From 35a89eaebe28921c12c9b5a05072d3b3f31ab00a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:48:38 +0100 Subject: [PATCH 001/475] Mod API 0.3: capability world for server mods (blocks, biomes) Server mods get interface world: get-block(dimension, x, y, z) reads a loaded chunk's block state as "minecraft:oak_log[axis=y]", get-biome its biome ("minecraft:plains"), and set-blocks(dimension, changes) sets up to 4096 blocks per call, checked at once, made as /setblock makes them (place_state: neighbours, light, block entities, every client told through the block log) after the call, before that dimension's next tick. Reads cost no copy: for a mod call the game lends Mods the levels' chunk maps (moved out with mem::take and put back after), only while a running mod has world. Writes for a level that isn't ticking (another dimension, in-frame) wait for it, up to 65,536. Test scripts get blocks (setup) and expect.blocks; cargo horizon-mod check reports a trial run's block writes. Example beacons: /beacon builds a glowstone pillar beside the player, /ground reads the block underfoot and the biome. parse_state is now a free function so the mod path shares it. Docs: docs/mods.md (the capability, script keys), docs/api/mods.md and the regenerated reference. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 + Cargo.toml | 2 +- crates/cargo-horizon-mod/src/check.rs | 5 + crates/horizon-client/src/game/levels.rs | 15 ++ crates/horizon-client/src/game/mods.rs | 15 +- .../horizon-client/src/game/render_control.rs | 61 ++++--- crates/horizon-client/src/game/server_mods.rs | 154 +++++++++++++++++- .../examples/beacons/Cargo.toml | 16 ++ .../examples/beacons/horizon-mod.toml | 8 + .../examples/beacons/src/lib.rs | 87 ++++++++++ .../examples/beacons/tests/beacons.toml | 22 +++ crates/horizon-mod-sdk/src/lib.rs | 1 + crates/horizon-mod-sdk/wit/horizon.wit | 28 ++++ crates/horizon-mods/src/manifest.rs | 14 +- crates/horizon-mods/src/runtime.rs | 98 ++++++++++- crates/horizon-mods/src/testing.rs | 92 ++++++++++- crates/horizon-mods/tests/sandbox.rs | 29 +++- docs/api/mods-reference.md | 24 ++- docs/api/mods.md | 12 +- docs/mods.md | 18 +- 20 files changed, 658 insertions(+), 50 deletions(-) create mode 100644 crates/horizon-mod-sdk/examples/beacons/Cargo.toml create mode 100644 crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml create mode 100644 crates/horizon-mod-sdk/examples/beacons/src/lib.rs create mode 100644 crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml diff --git a/Cargo.lock b/Cargo.lock index 0b8adf3ea..8c74b3b46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,13 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "beacons" +version = "0.1.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "bit-set" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 44ed89122..ffe744e65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/welcome", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/welcome", "crates/horizon-mod-sdk/examples/beacons", "xtask"] resolver = "2" [workspace.package] diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index 86ce9654d..4d271e2c1 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -242,6 +242,11 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { format!("{}: {text}", if error { "error" } else { "feedback" }) } Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")), + Output::SetBlocks { + dimension, blocks, .. + } => { + format!("set {} block(s) in {dimension}", blocks.len()) + } }); } match failed(&mods) { diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index f685200f2..09d5c0bff 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -106,6 +106,19 @@ pub(super) struct Levels { parked: HashMap, } +impl Levels { + /// The chunks of each level not in place (capability `world` lends + /// them to server mods for a call). + #[cfg_attr(not(feature = "mods"), allow(dead_code))] + pub(super) fn parked_chunks( + &mut self, + ) -> impl Iterator { + self.parked + .iter_mut() + .map(|(d, p)| (*d, &mut p.world.chunks)) + } +} + impl Game<'_> { /// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`). fn open_level(&self, dim: Dim) -> Result { @@ -295,6 +308,8 @@ impl Game<'_> { }; self.update_dedicated_streaming(); self.update_world(); + // What server mods set in this level since its last tick. + self.apply_mod_block_writes(); result = if paused { self.tick_lan(true); Ok(()) diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index b1ce4207c..111b797ad 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -49,6 +49,14 @@ pub(super) struct ModsState { /// What players did this tick, for server mods at its end. #[cfg(feature = "mods")] pub(super) events: Vec, + /// A running server mod has capability `world`: its calls get the + /// levels' chunks to read. + #[cfg(feature = "mods")] + pub(super) world: bool, + /// Blocks server mods set, by dimension, made before that level's next + /// tick. + #[cfg(feature = "mods")] + pub(super) block_writes: Vec<(String, horizon_mods::BlockChange)>, } impl ModsState { /// The chat's command tree: vanilla's, plus the running mods' commands. @@ -213,6 +221,7 @@ impl Game<'_> { /// The Mods page's rows, from what loaded. fn list_mods(&mut self) { self.mods.server = self.mods.mods.runs_server(); + self.mods.world = self.mods.mods.wants_world(); let dev = &self.mods.dev; self.settings.mods.installed = self .mods @@ -399,8 +408,10 @@ impl Game<'_> { } Text::literal(line).with_color(text::RED) } - server - @ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => { + server @ (Output::Tell { .. } + | Output::Broadcast { .. } + | Output::Reply { .. } + | Output::SetBlocks { .. }) => { self.route_server_output(host.as_deref_mut(), server); continue; } diff --git a/crates/horizon-client/src/game/render_control.rs b/crates/horizon-client/src/game/render_control.rs index 0a2f9b715..7c5ff4b2d 100644 --- a/crates/horizon-client/src/game/render_control.rs +++ b/crates/horizon-client/src/game/render_control.rs @@ -19,33 +19,7 @@ pub const HELP: &[&str] = &[ impl Game<'_> { /// Parse `name[k=v,...]` into a registry state (defaults for missing properties). fn parse_state(&self, text: &str) -> Result { - let (name, props) = match text.split_once('[') { - Some((n, rest)) => (n, rest.trim_end_matches(']')), - None => (text, ""), - }; - let name = if name.contains(':') { - name.to_owned() - } else { - format!("minecraft:{name}") - }; - let registry = &self.pack.assets.registry; - let default = registry - .default_state(&name) - .ok_or_else(|| format!("unknown block {name}"))?; - let mut state = registry - .state(default) - .ok_or("missing default state")? - .clone(); - for pair in props.split(',').filter(|p| !p.is_empty()) { - let (k, v) = pair - .split_once('=') - .ok_or_else(|| format!("bad property {pair}"))?; - if !state.properties.contains_key(k) { - return Err(format!("{name} has no property {k}").into()); - } - state.properties.insert(k.to_owned(), v.to_owned()); - } - Ok(registry.resolve(&state)?) + parse_state(self.pack.assets.registry, text) } pub(super) fn render_command(&mut self, words: &[&str]) -> Result> { let int = |i: usize| -> Result { @@ -137,3 +111,36 @@ impl Game<'_> { } } } + +/// Parse `name[k=v,...]` into a registry state (defaults for missing properties). +pub(super) fn parse_state( + registry: &horizon_world::registry::BlockRegistry, + text: &str, +) -> Result { + let (name, props) = match text.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']')), + None => (text, ""), + }; + let name = if name.contains(':') { + name.to_owned() + } else { + format!("minecraft:{name}") + }; + let default = registry + .default_state(&name) + .ok_or_else(|| format!("unknown block {name}"))?; + let mut state = registry + .state(default) + .ok_or("missing default state")? + .clone(); + for pair in props.split(',').filter(|p| !p.is_empty()) { + let (k, v) = pair + .split_once('=') + .ok_or_else(|| format!("bad property {pair}"))?; + if !state.properties.contains_key(k) { + return Err(format!("{name} has no property {k}").into()); + } + state.properties.insert(k.to_owned(), v.to_owned()); + } + Ok(registry.resolve(&state)?) +} diff --git a/crates/horizon-client/src/game/server_mods.rs b/crates/horizon-client/src/game/server_mods.rs index db88639eb..f5ee5cdc2 100644 --- a/crates/horizon-client/src/game/server_mods.rs +++ b/crates/horizon-client/src/game/server_mods.rs @@ -47,19 +47,72 @@ impl Game<'_> { ) -> bool { false } + pub(super) fn apply_mod_block_writes(&mut self) {} } #[cfg(feature = "mods")] mod run { use super::{Game, LanHost, ModEvent, Who}; + use horizon_client::chunk_map::ChunkMap; use horizon_client::{ command::args::GameMode as Mode, lan::data::component_nbt, text::{self, Text}, }; - use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer}; + use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer, WorldView}; use horizon_protocol::packets::play::{self, Clientbound as C}; - use horizon_world::registry::BlockStateId; + use horizon_world::registry::{BlockRegistry, BlockStateId}; + + /// Blocks queued for levels that don't tick (another dimension, in-frame) + /// are kept up to this many; the oldest go first. + const MAX_QUEUED_WRITES: usize = 1 << 16; + + /// Capability `world`'s view during a call: the levels' chunks, moved out + /// of the game for the call and back after it (no copy). + struct LentWorld { + registry: &'static BlockRegistry, + biomes: std::sync::Arc, + dims: Vec<(&'static str, ChunkMap)>, + } + impl WorldView for LentWorld { + fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option { + let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?; + let chunk = chunks.get(&(x >> 4, z >> 4))?; + Some(state_string(self.registry, chunk.block(x & 15, y, z & 15))) + } + fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option { + let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?; + let chunk = chunks.get(&(x >> 4, z >> 4))?; + Some(self.biomes.get(chunk.biome(x, y, z)).name.clone()) + } + fn has_dimension(&self, dimension: &str) -> bool { + matches!( + dimension, + "minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" + ) + } + fn check_state(&self, state: &str) -> Result<(), String> { + crate::game::render_control::parse_state(self.registry, state) + .map(drop) + .map_err(|e| format!("{state}: {e}")) + } + } + + /// `minecraft:oak_log[axis=y]`. + fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String { + let Some(s) = registry.state(state) else { + return "minecraft:air".into(); + }; + if s.properties.is_empty() { + return s.name.clone(); + } + let props: Vec = s + .properties + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + format!("{}[{}]", s.name, props.join(",")) + } /// `UUID.toString()`. fn uuid_string(u: u128) -> String { @@ -144,6 +197,7 @@ mod run { let players = self.server_players(None); let snapshot = self.mod_snapshot_with(players.clone()); self.mods.mods.set_snapshot(snapshot); + self.lend_world(); let before = std::mem::take(&mut self.mods.players); for p in before .iter() @@ -197,8 +251,80 @@ mod run { } } self.mods.mods.server_tick(self.ticks); + self.return_world(); self.mods.players = players; self.show_mod_output(); + self.apply_mod_block_writes(); + } + + /// Lends the levels' chunks to capability `world` for the calls up to + /// [`Game::return_world`], when a running server mod has it. + fn lend_world(&mut self) { + if !self.mods.world { + return; + } + let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))]; + for (dim, chunks) in self.levels.parked_chunks() { + dims.push((dim.id(), std::mem::take(chunks))); + } + let registry = self.pack.assets.registry; + let biomes = self.res.biomes().clone(); + self.mods.mods.lend_world(Box::new(LentWorld { + registry, + biomes, + dims, + })); + } + + /// Puts back the chunks [`Game::lend_world`] lent. + fn return_world(&mut self) { + let Some(view) = self.mods.mods.take_world() else { + return; + }; + let view: Box = view; + let Ok(lent) = view.downcast::() else { + return; + }; + let mut dims = lent.dims; + if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) { + self.world.chunks = dims.swap_remove(i).1; + } + for (dim, chunks) in self.levels.parked_chunks() { + if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) { + *chunks = dims.swap_remove(i).1; + } + } + } + + /// Makes the blocks server mods set in the level in place, in order, + /// as `/setblock` does. Those of other dimensions wait for their level. + pub(in crate::game) fn apply_mod_block_writes(&mut self) { + if self.mods.block_writes.is_empty() { + return; + } + let dim = self.dim.id(); + let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes) + .into_iter() + .partition(|(d, _)| d == dim); + self.mods.block_writes = later; + let registry = self.pack.assets.registry; + for (_, b) in now { + let state = match crate::game::render_control::parse_state(registry, &b.state) { + Ok(state) => state, + Err(e) => { + log::warn!("Horizon: a server mod set {}: {e}", b.state); + continue; + } + }; + if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) { + log::debug!( + "Horizon: a server mod's block at {} {} {}: {e:?}", + b.x, + b.y, + b.z + ); + } + } } /// A player broke (or placed) `state` at `pos`: the player acting now. @@ -240,8 +366,11 @@ mod run { }; let snapshot = self.mod_snapshot_with(players); self.mods.mods.set_snapshot(snapshot); + self.lend_world(); let owned = self.mods.mods.server_command(&me, line); + self.return_world(); self.show_mod_output(); + self.apply_mod_block_writes(); owned } @@ -266,10 +395,14 @@ mod run { }; let snapshot = self.mod_snapshot_with(players); self.mods.mods.set_snapshot(snapshot); - if !self.mods.mods.server_command(&me, line) { + self.lend_world(); + let owned = self.mods.mods.server_command(&me, line); + self.return_world(); + if !owned { return false; } self.route_mod_output(Some(host)); + self.apply_mod_block_writes(); true } @@ -349,6 +482,21 @@ mod run { self.tell_server_player(host.as_deref_mut(), &p.id, line); } } + Output::SetBlocks { + name, + dimension, + blocks, + } => { + let queued = &mut self.mods.block_writes; + queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b))); + if queued.len() > MAX_QUEUED_WRITES { + let over = queued.len() - MAX_QUEUED_WRITES; + queued.drain(..over); + log::warn!( + "Horizon: mod {name} set more blocks than wait for their level: {over} dropped" + ); + } + } _ => {} } } diff --git a/crates/horizon-mod-sdk/examples/beacons/Cargo.toml b/crates/horizon-mod-sdk/examples/beacons/Cargo.toml new file mode 100644 index 000000000..c001614e8 --- /dev/null +++ b/crates/horizon-mod-sdk/examples/beacons/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "beacons" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false +description = "Example Horizon server mod: /beacon lights a pillar beside you, /ground says what you stand on" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +horizon-mod-sdk = { path = "../.." } + +[lints] +workspace = true diff --git a/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml b/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml new file mode 100644 index 000000000..0c2004b5f --- /dev/null +++ b/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml @@ -0,0 +1,8 @@ +id = "beacons" +name = "Beacons" +version = "0.1.0" +description = "/beacon lights a pillar of glowstone beside you; /ground says what you are standing on, and in which biome." +authors = ["Horizon"] +api = "0.3" +side = "server" +capabilities = ["players", "world"] diff --git a/crates/horizon-mod-sdk/examples/beacons/src/lib.rs b/crates/horizon-mod-sdk/examples/beacons/src/lib.rs new file mode 100644 index 000000000..509431775 --- /dev/null +++ b/crates/horizon-mod-sdk/examples/beacons/src/lib.rs @@ -0,0 +1,87 @@ +//! Example Horizon server mod (`docs/mods.md`, "Server mods") with +//! capability `world`: `/beacon` lights a pillar of glowstone two blocks east +//! of the player, `/ground` reads the block under their feet and the biome. +use horizon_mod_sdk::server::{BlockChange, Player, Registration, ServerMod, players, world}; + +/// How tall `/beacon` builds. +const HEIGHT: i32 = 4; + +struct Beacons; + +/// The block the player stands in: their feet, floored. +fn feet(player: &Player) -> (i32, i32, i32) { + ( + player.x.floor() as i32, + player.y.floor() as i32, + player.z.floor() as i32, + ) +} + +/// The pillar beside `player`: `HEIGHT` blocks of glowstone, from their feet up. +fn pillar(player: &Player) -> Vec { + let (x, y, z) = feet(player); + (0..HEIGHT) + .map(|dy| BlockChange { + x: x + 2, + y: y + dy, + z, + state: "minecraft:glowstone".into(), + }) + .collect() +} + +impl ServerMod for Beacons { + fn init() -> Result<(Self, Registration), String> { + Ok(( + Beacons, + Registration::new().command("beacon").command("ground"), + )) + } + + fn command( + &mut self, + player: &Player, + name: &str, + _args: &str, + ) -> Result, String> { + let (x, y, z) = feet(player); + if name == "beacon" { + world::set_blocks(&player.dimension, &pillar(player))?; + players::broadcast(&format!( + "{} lit a beacon at {} {y} {z}", + player.name, + x + 2 + )); + return Ok(None); + } + let below = world::get_block(&player.dimension, x, y - 1, z) + .ok_or("The ground under you isn't loaded")?; + let biome = world::get_biome(&player.dimension, x, y, z).unwrap_or_default(); + Ok(Some(format!("You are standing on {below} in {biome}"))) + } +} + +horizon_mod_sdk::export_server_mod!(Beacons); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pillar_stands_two_blocks_east() { + let p = Player { + id: String::new(), + name: "Alex".into(), + x: -0.5, + y: 64.0, + z: 3.2, + dimension: "minecraft:overworld".into(), + game_mode: horizon_mod_sdk::server::GameMode::Survival, + health: 20.0, + }; + let blocks = pillar(&p); + assert_eq!(blocks.len(), HEIGHT as usize); + assert_eq!((blocks[0].x, blocks[0].y, blocks[0].z), (1, 64, 3)); + assert_eq!(blocks[3].y, 67); + } +} diff --git a/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml b/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml new file mode 100644 index 000000000..e75caeb75 --- /dev/null +++ b/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml @@ -0,0 +1,22 @@ +# /ground reads the block under the player and the biome; /beacon sets four blocks of glowstone two to the east. + +[[step]] +join = "Alex" +blocks = [{ x = 0, y = 63, z = 0, state = "minecraft:grass_block[snowy=false]" }] +command = "ground" +expect.feedback = "You are standing on minecraft:grass_block[snowy=false] in minecraft:plains" + +[[step]] +command = "beacon" +expect.broadcast = ["Alex lit a beacon at 2 64 0"] +expect.blocks = [ + { x = 2, y = 64, z = 0, state = "minecraft:glowstone" }, + { x = 2, y = 67, z = 0, state = "minecraft:glowstone" }, + { x = 2, y = 68, z = 0, state = "minecraft:air" }, +] + +# Anywhere the script set nothing is air. +[[step]] +player = { x = 10.5, z = 10.5 } +command = "ground" +expect.feedback = "You are standing on minecraft:air in minecraft:plains" diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index 1cffcc183..98f6a5ef4 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -166,6 +166,7 @@ pub mod server { pub use exports::horizon::mods::server_events::{BlockPos, Guest as __Guest, Registration}; pub use horizon::mods::players::{self, GameMode, Player}; + pub use horizon::mods::world::{self, BlockChange}; /// The server's calls into a mod. Only the hooks [`Registration`] asks /// for are called; the others may stay as their defaults. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 3a9027bba..f0dd1c596 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -144,6 +144,33 @@ interface players { broadcast: func(text: string); } +/// Capability `world`, server mods only (manifest `api = "0.3"`, +/// experimental): the blocks of the server's loaded chunks, and changes to +/// them through the server's own block updates. +interface %world { + record block-change { + x: s32, + y: s32, + z: s32, + /// `minecraft:oak_log[axis=y]`; properties left out take the block's + /// defaults, `minecraft:stone` alone is the default state. + state: string, + } + /// The block state at a position in a dimension (`minecraft:overworld`, + /// ...), as `minecraft:oak_log[axis=y]`, as of this call: none where the + /// server has no chunk loaded there. + get-block: func(dimension: string, x: s32, y: s32, z: s32) -> option; + /// The biome at a position (`minecraft:plains`, ...), as the server + /// stores it (one per 4 x 4 x 4 cell): none where no chunk is loaded. + get-biome: func(dimension: string, x: s32, y: s32, z: s32) -> option; + /// Sets blocks as `/setblock` does: the old block entity removed, + /// neighbours and shapes updated, light, and every player told. Every + /// state is checked now (an error queues nothing); the changes are made + /// in order after this call returns, before that dimension's next tick; + /// one in a chunk not loaded then is skipped. At most 4096 per call. + set-blocks: func(dimension: string, blocks: list) -> result<_, string>; +} + /// What the server calls in a server mod (manifest `api = "0.3"`, /// experimental). Only the hooks `init` asks for are ever called. The player /// in each event is as the server saw them when it happened. @@ -205,5 +232,6 @@ world horizon-server-mod { import log; import storage; import players; + import %world; export server-events; } diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 1caf2038f..ca7443186 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -80,19 +80,26 @@ pub enum Capability { Registries, /// The players on the server and messages to them (server mods). Players, + /// Blocks in the server's loaded chunks, and changes to them (server + /// mods; client mods later, where the game runs the world). + World, } impl Capability { - pub const ALL: [Capability; 6] = [ + pub const ALL: [Capability; 7] = [ Capability::GameRead, Capability::Chat, Capability::Hud, Capability::Storage, Capability::Registries, Capability::Players, + Capability::World, ]; /// Whether a server mod may have it: the others act on a local player. pub fn on_server(self) -> bool { - matches!(self, Capability::Storage | Capability::Players) + matches!( + self, + Capability::Storage | Capability::Players | Capability::World + ) } /// The WIT interface it grants, in `horizon:mods`. pub fn interface(self) -> &'static str { @@ -103,6 +110,7 @@ impl Capability { Capability::Storage => "storage", Capability::Registries => "registries", Capability::Players => "players", + Capability::World => "world", } } /// What the player reads before turning the mod on: "May ". @@ -114,6 +122,7 @@ impl Capability { Capability::Storage => "save its own settings to disk", Capability::Registries => "add blocks and items to your worlds", Capability::Players => "see who is on the server and message them", + Capability::World => "read and change the world's blocks", } } /// The capability a component import needs, `Ok(None)` for one every @@ -145,6 +154,7 @@ impl fmt::Display for Capability { Capability::Storage => "storage", Capability::Registries => "registries", Capability::Players => "players", + Capability::World => "world", }; f.write_str(name) } diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index e80dbdc9f..fa0998614 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -1,12 +1,13 @@ //! Loading and calling mods with wasmtime's component model. use crate::manifest::{Manifest, Package, Side}; use server_bindings::{ - HorizonServerMod, exports::horizon::mods::server_events::Registration as ServerRegistration, - horizon::mods::players, + HorizonServerMod, + exports::horizon::mods::server_events::Registration as ServerRegistration, + horizon::mods::{players, world as mod_world}, }; use std::{ path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, time::{Duration, Instant, SystemTime}, }; use wasmtime::{ @@ -25,6 +26,7 @@ mod server_bindings { wasmtime::component::bindgen!({ path: "../horizon-mod-sdk/wit", world: "horizon-server-mod", + additional_derives: [PartialEq], with: { "horizon:mods/log": super::bindings::horizon::mods::log, "horizon:mods/storage": super::bindings::horizon::mods::storage, @@ -43,6 +45,7 @@ use bindings::{ pub use server_bindings::{ exports::horizon::mods::server_events::BlockPos, horizon::mods::players::{GameMode, Player as ServerPlayer}, + horizon::mods::world::BlockChange, }; type ClientEvents = bindings::exports::horizon::mods::events::Guest; type ServerEvents = server_bindings::exports::horizon::mods::server_events::Guest; @@ -55,6 +58,8 @@ const FUEL_INIT: u64 = 2_000_000_000; const FUEL_TICK: u64 = 50_000_000; const FUEL_FRAME: u64 = 10_000_000; const FUEL_EVENT: u64 = 100_000_000; +/// Blocks a mod may set per `world.set-blocks` call. +pub const BLOCKS_PER_CALL: usize = 4096; /// Chat lines a mod may show per call, HUD lines and their length. const CHAT_PER_CALL: usize = 8; const HUD_LINES: usize = 16; @@ -121,8 +126,32 @@ pub enum Output { text: String, error: bool, }, + /// `world.set-blocks`: changes to make in `dimension`, in order, each + /// state checked by the [`WorldView`] lent for the call. + SetBlocks { + name: String, + dimension: String, + blocks: Vec, + }, } +/// The game's world as capability `world` reads it during a call: the +/// game lends one with [`Mods::lend_world`] and takes it back after. +pub trait WorldView: std::any::Any + Send { + /// The block state at a position, `minecraft:oak_log[axis=y]`; `None` + /// where no chunk of that dimension is loaded. + fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option; + /// The biome at a position, `minecraft:plains`; `None` as for blocks. + fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option; + /// Whether `dimension` is one the server has. + fn has_dimension(&self, dimension: &str) -> bool; + /// `Err` (why) for a state string that names no block state. + fn check_state(&self, state: &str) -> Result<(), String>; +} + +/// The view lent for the current calls, shared by every mod's store. +type WorldSlot = Arc>>>; + /// A mod's text panel, drawn every frame until it changes. #[derive(Clone, Debug)] pub struct Panel { @@ -199,6 +228,7 @@ struct State { /// needs its own budget. storage_budget: u64, limits: StoreLimits, + world: WorldSlot, } /// The installed mods and the engine running them. `Mods::default()` is @@ -209,9 +239,33 @@ pub struct Mods { entries: Vec, snapshot: Arc, out: Vec, + world: WorldSlot, } impl Mods { + /// Whether a running mod may use capability `world`: only then is a + /// view worth lending. + pub fn wants_world(&self) -> bool { + self.entries.iter().any(|e| { + e.status == Status::Running + && e.manifest + .as_ref() + .is_some_and(|m| m.capabilities.contains(&crate::Capability::World)) + }) + } + /// Lends the game's world to capability `world` for the calls until + /// [`Mods::take_world`]; with none lent, `get-block` finds nothing and + /// `set-blocks` checks only its count. + pub fn lend_world(&mut self, view: Box) { + if let Ok(mut slot) = self.world.lock() { + *slot = Some(view); + } + } + /// The view [`Mods::lend_world`] lent, back to the game. + pub fn take_world(&mut self) -> Option> { + self.world.lock().ok()?.take() + } + /// Reads every mod at `paths` and starts those `enabled` says are on, /// client and server mods alike. A command that `reserved` (vanilla's /// commands) or an earlier mod already uses is refused. @@ -281,6 +335,8 @@ impl Mods { .map_err(|e| e.to_string())?; players::add_to_linker::>(&mut linker, |s| s) .map_err(|e| e.to_string())?; + mod_world::add_to_linker::>(&mut linker, |s| s) + .map_err(|e| e.to_string())?; self.engine = Some((engine, linker)); } Ok(self.engine.as_ref().expect("made above")) @@ -318,6 +374,7 @@ impl Mods { .flat_map(|r| r.hooks.commands().iter().cloned()) .collect(); let snapshot = self.snapshot.clone(); + let world = self.world.clone(); let (engine, linker) = self.engine()?; let component = Component::new(engine, wasm).map_err(|e| format!("not a component: {e}"))?; @@ -344,6 +401,7 @@ impl Mods { .memory_size(MEMORY) .instances(16) .build(), + world, }; let mut store = Store::new(engine, state); store.limiter(|s| &mut s.limits); @@ -731,6 +789,40 @@ impl chat::Host for State { }); } } +impl mod_world::Host for State { + fn get_block(&mut self, dimension: String, x: i32, y: i32, z: i32) -> Option { + let view = self.world.lock().ok()?; + view.as_ref()?.block(&dimension, x, y, z) + } + fn get_biome(&mut self, dimension: String, x: i32, y: i32, z: i32) -> Option { + let view = self.world.lock().ok()?; + view.as_ref()?.biome(&dimension, x, y, z) + } + fn set_blocks(&mut self, dimension: String, blocks: Vec) -> Result<(), String> { + if blocks.len() > BLOCKS_PER_CALL { + return Err(format!( + "{} blocks: at most {BLOCKS_PER_CALL} per call", + blocks.len() + )); + } + if let Some(view) = self.world.lock().ok().as_ref().and_then(|v| v.as_ref()) { + if !view.has_dimension(&dimension) { + return Err(format!("no dimension {dimension}")); + } + for b in &blocks { + view.check_state(&b.state)?; + } + } + if !blocks.is_empty() { + self.out.push(Output::SetBlocks { + name: self.name.clone(), + dimension, + blocks, + }); + } + Ok(()) + } +} impl players::Host for State { fn online(&mut self) -> Vec { self.snapshot.players.clone() diff --git a/crates/horizon-mods/src/testing.rs b/crates/horizon-mods/src/testing.rs index 31ff27227..239642357 100644 --- a/crates/horizon-mods/src/testing.rs +++ b/crates/horizon-mods/src/testing.rs @@ -29,6 +29,54 @@ pub struct TestGame { /// Every name that joined, for their id. names: Vec, tick: u64, + /// Capability `world`'s Overworld: the blocks a script set or the mod + /// changed; every other block, and the other dimensions, are air. Every + /// biome is the dimension's plainest: plains, nether wastes, the end. + pub world: std::collections::BTreeMap<(i32, i32, i32), String>, +} + +/// The world a script's mod reads: [`TestGame::world`], copied for a step. +struct ScriptWorld(std::collections::BTreeMap<(i32, i32, i32), String>); + +impl crate::WorldView for ScriptWorld { + fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option { + let here = (dimension == "minecraft:overworld") + .then(|| self.0.get(&(x, y, z)).cloned()) + .flatten(); + Some(here.unwrap_or_else(|| "minecraft:air".into())) + } + fn biome(&self, dimension: &str, _x: i32, _y: i32, _z: i32) -> Option { + let biome = match dimension { + "minecraft:overworld" => "minecraft:plains", + "minecraft:the_nether" => "minecraft:nether_wastes", + "minecraft:the_end" => "minecraft:the_end", + _ => return None, + }; + Some(biome.into()) + } + fn has_dimension(&self, dimension: &str) -> bool { + matches!( + dimension, + "minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" + ) + } + fn check_state(&self, state: &str) -> Result<(), String> { + // No registry here: the shape only (the game checks the names). + let name = state.split('[').next().unwrap_or_default(); + let plain = |t: &str| { + !t.is_empty() + && t.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-/".contains(&b)) + }; + let ok = match name.split_once(':') { + Some((ns, path)) => plain(ns) && plain(path), + None => plain(name), + }; + let closed = !state.contains('[') || state.ends_with(']'); + (ok && closed) + .then_some(()) + .ok_or_else(|| format!("{state} is not a block state")) + } } /// Where a script starts: standing at 0.5 64 0.5 in the Overworld, facing @@ -71,6 +119,7 @@ impl TestGame { players: Vec::new(), names: Vec::new(), tick: 0, + world: Default::default(), }; copy_mod(hmod, ©)?; game.start()?; @@ -309,9 +358,10 @@ pub struct ClockSet { } /// One step. Its parts happen in this order: `reload`, `title-screen`, -/// `join`, `player`, `clock`, `ticks`, `frames`, `chat`, `command`, `break`, -/// `place`, `leave`; then `expect` is checked against what the mod showed -/// during the step. For a server mod, `player`, `chat`, `command`, `break` +/// `blocks`, `join`, `player`, `clock`, `ticks`, `frames`, `chat`, `command`, +/// `break`, `place`, `leave`; then the blocks the mod set are made, and +/// `expect` is checked against what the mod showed and changed during the +/// step. For a server mod, `player`, `chat`, `command`, `break` /// and `place` are the doing of the player named by `as`, or of the first /// player on. #[derive(Debug, Default, Deserialize)] @@ -338,6 +388,10 @@ pub struct Step { #[serde(rename = "break")] broken: Option, place: Option, + /// Blocks of the Overworld before the rest of the step (capability + /// `world`); every other block is air. + #[serde(default)] + blocks: Vec, #[serde(default)] expect: Expect, } @@ -360,6 +414,9 @@ pub struct Expect { /// The panel's lines (`[]`: no panel) and its corner. panel: Option>, corner: Option, + /// Blocks of the Overworld after the step, the mod's changes made + /// (capability `world`). + blocks: Option>, /// The mod is turned off during this step, for a reason holding this text. /// Without it, a mod that stops fails the test. stopped: Option, @@ -464,6 +521,11 @@ impl Step { game.player = None; game.clock = None; } + for b in &self.blocks { + game.world.insert((b.x, b.y, b.z), b.state.clone()); + } + game.mods + .lend_world(Box::new(ScriptWorld(game.world.clone()))); if let Some(name) = &self.join { game.join(name); } @@ -528,7 +590,19 @@ impl Step { { return Err(format!("{name} is not on the server")); } + game.mods.take_world(); let out = game.drain(); + for o in &out { + if let Output::SetBlocks { + dimension, blocks, .. + } = o + && dimension == "minecraft:overworld" + { + for b in blocks { + game.world.insert((b.x, b.y, b.z), b.state.clone()); + } + } + } let stopped = out.iter().find_map(|o| match o { Output::Stopped { reason, .. } => Some(reason.as_str()), _ => None, @@ -612,6 +686,18 @@ impl Step { .unwrap_or_default(); same("panel", want, &got)?; } + if let Some(want) = &e.blocks { + for b in want { + let got = game.world.get(&(b.x, b.y, b.z)); + let got = got.map_or("minecraft:air", String::as_str); + if got != b.state { + return Err(format!( + "block at {} {} {}: expected {}, got {got}", + b.x, b.y, b.z, b.state + )); + } + } + } if let Some(want) = &e.corner { let got = game.panel().map(|p| corner_name(p.corner).to_string()); same("corner", &Some(want.clone()), &got)?; diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index ca2b8896e..3647cca30 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -36,6 +36,8 @@ fn mods_dir() -> &'static Path { "-p", "welcome", "-p", + "beacons", + "-p", "horizon-mods-test-guest", "--target-dir", ]) @@ -56,6 +58,7 @@ fn mods_dir() -> &'static Path { let mods = [ ("coords_hud", example("coords-hud")), ("welcome", example("welcome")), + ("beacons", example("beacons")), ("horizon_mods_test_guest", GUEST_MANIFEST.to_string()), ]; for (name, manifest) in mods { @@ -412,7 +415,12 @@ capabilities = ["registries"] fn the_examples_test_scripts_pass() { let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../horizon-mod-sdk/examples"); let mut ran = 0; - for (example, hmod) in [("coords-hud", "coords_hud"), ("welcome", "welcome")] { + let examples_and_files = [ + ("coords-hud", "coords_hud"), + ("welcome", "welcome"), + ("beacons", "beacons"), + ]; + for (example, hmod) in examples_and_files { for entry in std::fs::read_dir(examples.join(example).join("tests")).unwrap() { let path = entry.unwrap().path(); let script = Script::parse(&std::fs::read_to_string(&path).unwrap()).unwrap(); @@ -423,7 +431,24 @@ fn the_examples_test_scripts_pass() { ran += 1; } } - assert_eq!(ran, 4); + assert_eq!(ran, 5); +} + +/// Capability `world` without a world lent (the registry's trial run): +/// `get-block` finds nothing, `set-blocks` checks its count and queues. +#[test] +fn a_server_mod_without_a_world_reads_nothing() { + let mut game = TestGame::load(&mods_dir().join("beacons.hmod")).unwrap(); + game.join("Alex"); + let alex = game.players[0].clone(); + assert!(game.player_command(&alex, "ground")); + let out = game.drain(); + assert!( + out.iter() + .any(|o| matches!(o, Output::Reply { text, error: true, .. } + if text == "The ground under you isn't loaded")), + "{out:?}" + ); } #[test] diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index f179e99cc..3aaf29d0c 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -13,6 +13,7 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`, | `storage` | [`storage`](#storage) | save its own settings to disk | | `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds | | `players` | [`players`](#players) | see who is on the server and message them | +| `world` | [`world`](#world) | read and change the world's blocks | ## World `horizon-mod` @@ -25,7 +26,7 @@ A client mod: runs in the player's game. A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs where the world is simulated, on the dedicated server, a LAN host and single player's own server. -- Imports (what the game offers): `log`, `storage`, `players` +- Imports (what the game offers): `log`, `storage`, `players`, `world` - Exports (what the mod provides): `server-events` ## `log` @@ -184,6 +185,27 @@ Capability `players`, server mods only (manifest `api = "0.3"`, experimental): t | `tell` | `func(player: string, text: string) -> bool` | A system message to one player, after the mod's name; false when no player with that id is online. Up to 8 messages per call. | | `broadcast` | `func(text: string)` | A system message to every player and the server's log, after the mod's name. Counts toward the same 8 per call. | +## `world` + +Access: capability `world`. + +Capability `world`, server mods only (manifest `api = "0.3"`, experimental): the blocks of the server's loaded chunks, and changes to them through the server's own block updates. + +### record `block-change` + +| Field | Type | | +| --- | --- | --- | +| `x` | `s32` | | +| `y` | `s32` | | +| `z` | `s32` | | +| `state` | `string` | `minecraft:oak_log[axis=y]`; properties left out take the block's defaults, `minecraft:stone` alone is the default state. | + +| Function | Signature | What it does | +| --- | --- | --- | +| `get-block` | `func(dimension: string, x: s32, y: s32, z: s32) -> option` | The block state at a position in a dimension (`minecraft:overworld`, ...), as `minecraft:oak_log[axis=y]`, as of this call: none where the server has no chunk loaded there. | +| `get-biome` | `func(dimension: string, x: s32, y: s32, z: s32) -> option` | The biome at a position (`minecraft:plains`, ...), as the server stores it (one per 4 x 4 x 4 cell): none where no chunk is loaded. | +| `set-blocks` | `func(dimension: string, blocks: list) -> result<_, string>` | Sets blocks as `/setblock` does: the old block entity removed, neighbours and shapes updated, light, and every player told. Every state is checked now (an error queues nothing); the changes are made in order after this call returns, before that dimension's next tick; one in a chunk not loaded then is skipped. At most 4096 per call. | + ## `server-events` Access: exported by the mod: the game calls it. diff --git a/docs/api/mods.md b/docs/api/mods.md index 5fb9d9c1e..76075e989 100644 --- a/docs/api/mods.md +++ b/docs/api/mods.md @@ -84,11 +84,11 @@ release. What real mods need next, in the order modpacks and servers need it: - **More for server mods** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): they run - on the server thread, a LAN host and the dedicated server today, with joins, chat, commands and players' block - breaking and placing; next entity events, game rules, server commands with typed arguments sent to clients' - command trees. -- **World and blocks**: read blocks, block states and biomes in loaded chunks; change them through the server's block - log, so clients and the parity harnesses see vanilla's updates. + on the server thread, a LAN host and the dedicated server today, with joins, chat, commands, players' block + breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock` does); next entity events, game rules, + server commands with typed arguments sent to clients' command trees. +- **World and blocks**: blocks, block states and biomes are there for server mods (`world`); next block entities, + and `world` for client mods (raycasts, single player and LAN hosts). - **Items and inventories**: the player's inventory, held item and open container; item stacks with components. - **Entities**: query and change entities near a position; spawn from a mod's own entity types (data first). - **Block entities and menus**: ticking machines with slots and their screens. @@ -102,6 +102,8 @@ What real mods need next, in the order modpacks and servers need it: ## Changelog +- API 0.3: capability `world` (`get-block`, `get-biome`, `set-blocks`); example `beacons`; test scripts' `blocks` and + `expect.blocks`. - Server mods run in the game: single player (server thread and in-frame), LAN hosts, the dedicated server. - API 0.3 (server mods, experimental): world `horizon-server-mod`, capability `players`, the `server-events` hooks; SDK `server::ServerMod`; example `welcome`. diff --git a/docs/mods.md b/docs/mods.md index f0df45994..19b05f9ec 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -278,6 +278,7 @@ A step's parts happen in this order: | --- | --- | | `reload = true` | stops the mod and starts it again, as a rebuild does in the game: its memory starts over, its `storage` stays | | `title-screen = true` | no player and no clock until a step sets them | +| `blocks = [{ x = 0, y = 63, z = 0, state = "minecraft:stone" }]` | server mods with `world`: those blocks of the Overworld, before the rest of the step (every other block is air, every biome the dimension's plainest: plains, nether wastes, the end) | | `player = { x = 3.0, ... }`, `clock = { day-time = 0 }` | changes those fields (`x y z yaw pitch health food dimension`, `game-time day-time`) | | `ticks = n`, `frames = n` | that many ticks (the game's tick counter and the clock move on), then frames 1/60 s apart | | `chat = " hi"` | a chat line the player receives | @@ -303,6 +304,7 @@ checked: | `feedback = "..."`, `error = "..."` | the command's answer, shown plain or in red | | `panel = ["..."]`, `corner = "top-left"` | its HUD panel's lines (`[]`: no panel) and corner | | `told = ["Alex: Welcome!"]`, `broadcast = ["..."]` | server mods: every `players.tell` (as `name: text`) and `players.broadcast`, in order | +| `blocks = [{ x = 2, y = 64, z = 0, state = "minecraft:glowstone" }]` | server mods with `world`: those blocks of the Overworld after the step, the mod's `set-blocks` made (the script checks a state's shape; the game checks its name) | | `stopped = "panic"` | the mod was turned off, for a reason holding this text; without it, a mod that traps, panics or overruns fails the test | Unknown keys are errors, so a typo never passes silently. The same runner is @@ -465,6 +467,7 @@ its component targets the WIT world `horizon-server-mod` instead of | `log` | always | as for client mods | | `storage` | `storage` | as for client mods, in the server's `horizon-mods/` | | `players` | `players` | `online()`: id, name, position, dimension, game mode, health of each player on; `tell(id, text)`, `broadcast(text)`: system messages after the mod's name, 8 per call | +| `world` | `world` | `get-block(dimension, x, y, z)`: the state in a loaded chunk, `minecraft:oak_log[axis=y]`; `get-biome(dimension, x, y, z)`: `minecraft:plains`, ...; `set-blocks(dimension, changes)`: up to 4096 per call, checked at once, made as `/setblock` makes them (neighbours, light, block entities, every player told) after the call, before that dimension's next tick | | Hook (export `server-events`) | When | |---|---| @@ -478,7 +481,20 @@ its component targets the WIT world `horizon-server-mod` instead of Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for client mods; `players` is for server mods. In Rust: `impl horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is -[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs). +[`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs), and with +`world`, [`beacons`](../crates/horizon-mod-sdk/examples/beacons/src/lib.rs). + +**Capability `world`.** Reads are of the server's loaded chunks as they are +during the call: the game lends a server mod's call the levels' chunk maps +(moved, not copied, and only while a running mod has `world`), so a read +costs a map lookup and no copy of the world. A dimension with no chunk loaded +at a position reads as none. Writes go through the same code as `/setblock`, +so block updates, light, block entities and every client (LAN guests, +dedicated server players) see them as vanilla's; the parity harnesses test +that code. Changes for a dimension whose level isn't ticking wait for it +(in-frame single player ticks only the player's), up to 65,536 blocks; a +change in a chunk that isn't loaded when it is made is skipped, as `/setblock` +refuses one. **Where they run.** The game that simulates the world starts the server mods that are on, and only that one: From c5bb51bb053a7f41acf7361f831091a7b54ce493 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:19:50 +0100 Subject: [PATCH 002/475] Mod API 0.3: typed server commands, in players' command trees A server mod's registration now lists each command's arguments: a name, a kind (word, text, integer with bounds, number, boolean, player, block-pos) and whether it is optional. The server adds them to the command tree every player receives, after vanilla's commands, so a player's chat box completes and colours them as vanilla's. When a player sends one, the server parses the line as vanilla's parsers do (horizon-mods' commands.rs: ~ coordinates from the player, @s/@p, integer bounds) and calls the mod's command hook with the values by name, or answers with vanilla's error without calling the mod. The hub keeps the server mods' commands as a root in the commands report's format and resends players' trees when they change (CommandTree:: packet_with); the game sets it when its mods start, stop or reload, or a hub opens. In-frame, the window's own chat box lists them by name. SDK: Registration::command_with(name, [Argument::...]), Argument builders, Args (text(), integer(), player(), block_pos() ...); command(name) is free text, an optional text argument. Examples: welcome's /mined [player], beacons' /beacon [height] and /ground [pos], with scripts for values and errors. Docs: docs/mods.md (typed commands, kinds table), docs/api/mods.md and the regenerated reference. Co-Authored-By: Claude Opus 5.5 --- crates/cargo-horizon-mod/src/check.rs | 2 +- crates/horizon-client/src/game/mods.rs | 5 +- crates/horizon-client/src/game/server_mods.rs | 74 ++++- crates/horizon-client/src/lan/commands.rs | 63 +++- crates/horizon-client/src/lan/hub.rs | 29 +- .../examples/beacons/horizon-mod.toml | 2 +- .../examples/beacons/src/lib.rs | 46 ++- .../examples/beacons/tests/beacons.toml | 19 +- .../examples/welcome/src/lib.rs | 25 +- .../examples/welcome/tests/commands.toml | 17 +- crates/horizon-mod-sdk/src/lib.rs | 135 +++++++- crates/horizon-mod-sdk/wit/horizon.wit | 68 +++- crates/horizon-mods/src/commands.rs | 301 ++++++++++++++++++ crates/horizon-mods/src/lib.rs | 2 + crates/horizon-mods/src/runtime.rs | 117 +++++-- crates/horizon-mods/src/testing.rs | 2 +- crates/horizon-mods/tests/sandbox.rs | 12 +- docs/api/mods-reference.md | 65 +++- docs/api/mods.md | 11 +- docs/mods.md | 36 ++- 20 files changed, 923 insertions(+), 108 deletions(-) create mode 100644 crates/horizon-mods/src/commands.rs diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index 4d271e2c1..7e3091805 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -183,7 +183,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { return Err(format!("it didn't start: {reason}")); } let client: Vec = mods.commands().map(str::to_owned).collect(); - let server: Vec = mods.server_commands().map(str::to_owned).collect(); + let server: Vec = mods.server_commands().map(|c| c.name.clone()).collect(); report.commands = [client.clone(), server.clone()].concat(); // A server mod plays with one player on, who joins, chats and leaves. let steve = ServerPlayer { diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 111b797ad..0c3d32a40 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -262,7 +262,10 @@ impl Game<'_> { } /// The chat's tree after the running mods changed. fn sync_mod_commands(&mut self) { - let mut names = self.mods.mods.commands().peekable(); + // In-frame, the window runs server mods too: their names, free text + // here (the server parses their arguments). + let server = self.mods.mods.server_commands().map(|c| c.name.as_str()); + let mut names = self.mods.mods.commands().chain(server).peekable(); self.mods.tree = names .peek() .is_some() diff --git a/crates/horizon-client/src/game/server_mods.rs b/crates/horizon-client/src/game/server_mods.rs index f5ee5cdc2..a5a4b0f3f 100644 --- a/crates/horizon-client/src/game/server_mods.rs +++ b/crates/horizon-client/src/game/server_mods.rs @@ -59,9 +59,12 @@ mod run { lan::data::component_nbt, text::{self, Text}, }; - use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer, WorldView}; + use horizon_mods::{ + ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView, + }; use horizon_protocol::packets::play::{self, Clientbound as C}; use horizon_world::registry::{BlockRegistry, BlockStateId}; + use serde_json::{Value, json}; /// Blocks queued for levels that don't tick (another dimension, in-frame) /// are kept up to this many; the oldest go first. @@ -98,6 +101,60 @@ mod run { } } + /// Server mods' commands as a root of the commands report + /// (`lan::commands`): a literal per command, then its arguments in a + /// chain, each node runnable where only optional arguments follow. + fn command_tree<'a>(commands: impl Iterator) -> Value { + let mut root = serde_json::Map::new(); + for c in commands { + let mut next: Option<(String, Value)> = None; + for (i, a) in c.arguments.iter().enumerate().rev() { + let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional); + let (parser, properties) = match a.kind { + ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})), + ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})), + ArgumentKind::Integer((min, max)) => { + let mut p = serde_json::Map::new(); + if let Some(min) = min { + p.insert("min".into(), min.into()); + } + if let Some(max) = max { + p.insert("max".into(), max.into()); + } + ("brigadier:integer", Value::Object(p)) + } + ArgumentKind::Number => ("brigadier:double", Value::Null), + ArgumentKind::Boolean => ("brigadier:bool", Value::Null), + ArgumentKind::Player => ( + "minecraft:entity", + json!({"amount": "single", "type": "players"}), + ), + ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null), + }; + let mut node = json!({ + "type": "argument", + "parser": parser, + "properties": properties, + "executable": executable, + }); + if let Some((name, child)) = next.take() { + node["children"] = json!({ name: child }); + } + next = Some((a.name.clone(), node)); + } + let executable = c.arguments.first().is_none_or(|a| a.optional); + let mut literal = json!({"type": "literal", "executable": executable}); + if let Some((name, child)) = next { + literal["children"] = json!({ name: child }); + } + root.insert(c.name.clone(), literal); + } + if root.is_empty() { + return Value::Null; + } + json!({"type": "root", "children": root}) + } + /// `minecraft:oak_log[axis=y]`. fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String { let Some(s) = registry.state(state) else { @@ -191,6 +248,7 @@ mod run { // A server has no frames to check its dev mods in. self.reload_dev_mods(); } + self.send_server_mod_commands(); if !self.mods.server { return; } @@ -257,6 +315,20 @@ mod run { self.apply_mod_block_writes(); } + /// Players' command trees list the running server mods' commands + /// after vanilla's, whenever they change (or a hub opens). + fn send_server_mod_commands(&mut self) { + let key = self.mods.mods.generation(); + let Some(host) = self.lan.as_mut() else { + return; + }; + if host.hub.mod_commands_key() == Some(key) { + return; + } + let tree = command_tree(self.mods.mods.server_commands()); + host.hub.set_mod_commands(key, tree); + } + /// Lends the levels' chunks to capability `world` for the calls up to /// [`Game::return_world`], when a running server mod has it. fn lend_world(&mut self) { diff --git a/crates/horizon-client/src/lan/commands.rs b/crates/horizon-client/src/lan/commands.rs index 71b7fa68d..c3e462348 100644 --- a/crates/horizon-client/src/lan/commands.rs +++ b/crates/horizon-client/src/lan/commands.rs @@ -86,6 +86,18 @@ impl CommandTree { /// The ClientboundCommandsPacket for a player with `level` permissions. pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands { + self.packet_with(level, dedicated, builtin, &Value::Null) + } + + /// [`CommandTree::packet`] with more commands after vanilla's: `extra` + /// is a root in the report's format (server mods' commands). + pub fn packet_with( + &self, + level: u8, + dedicated: bool, + builtin: &Builtin, + extra: &Value, + ) -> Commands { let mut nodes = vec![Node { name: String::new(), kind: 0, @@ -99,16 +111,18 @@ impl CommandTree { let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect(); let mut paths: HashMap, usize> = HashMap::new(); paths.insert(Vec::new(), 0); - fill( - &self.root, - 0, - &mut Vec::new(), - level, - dedicated, - &suggestions, - &mut nodes, - &mut paths, - ); + for root in [&self.root, extra] { + fill( + root, + 0, + &mut Vec::new(), + level, + dedicated, + &suggestions, + &mut nodes, + &mut paths, + ); + } // enumerateNodes: breadth first from the root, each node's children // (in registration order) then its redirect target. let mut ids = vec![None; nodes.len()]; @@ -365,4 +379,33 @@ mod tests { ); assert!(op.nodes[3].restricted); } + + #[test] + fn server_mods_commands_follow_vanillas() { + let report: Value = serde_json::from_str( + r#"{"type":"root","children":{"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}}}}"#, + ) + .unwrap(); + let mods: Value = serde_json::from_str( + r#"{"type":"root","children":{"beacon":{"type":"literal","executable":true,"children":{ + "height":{"type":"argument","parser":"brigadier:integer","properties":{"min":1,"max":64},"executable":true}}}}}"#, + ) + .unwrap(); + let tree = CommandTree { root: report }; + let packet = tree.packet_with(0, true, &Builtin::default(), &mods); + let names: Vec<_> = packet.nodes.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["", "me", "beacon", "action", "height"]); + let beacon = &packet.nodes[2]; + assert!(beacon.executable && !beacon.restricted); + assert_eq!(beacon.children, [4]); + let height = packet.nodes[4].parser.as_ref().unwrap(); + assert_eq!(height.1, vec![3, 0, 0, 0, 1, 0, 0, 0, 64]); + // No mods: vanilla's tree as it is. + assert_eq!( + tree.packet_with(0, true, &Builtin::default(), &Value::Null) + .nodes + .len(), + 3 + ); + } } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 761d31af4..2938e530b 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -174,6 +174,9 @@ pub struct Hub { entities: std::collections::HashMap, /// What one tick of entity tracking sends each guest, kept between ticks. entity_out: Vec>>, + /// Server mods' commands, a root in the commands report's format, sent + /// after vanilla's; the key the game last set them for. + mod_commands: (serde_json::Value, Option), } fn string(s: &str) -> Tag { @@ -320,6 +323,7 @@ impl Hub { entity_types: Default::default(), entities: Default::default(), entity_out: Vec::new(), + mod_commands: (serde_json::Value::Null, None), } } @@ -815,13 +819,36 @@ impl Hub { } } + /// The key [`Hub::set_mod_commands`] last had: the game compares it + /// with its server mods' each tick. + pub fn mod_commands_key(&self) -> Option { + self.mod_commands.1 + } + /// Server mods' commands changed: every player gets their tree again. + pub fn set_mod_commands(&mut self, key: u64, commands: serde_json::Value) { + let resend = self.mod_commands.0 != commands; + self.mod_commands = (commands, Some(key)); + if resend { + for i in 0..self.guests.len() { + if self.guests[i].placed { + self.send_commands(i); + } + } + } + } + /// Commands.sendCommands: the command tree for this player. fn send_commands(&self, i: usize) { let g = &self.guests[i]; let packet = match &self.sync.commands { Some(tree) => { let dedicated = self.config.dedicated && !self.config.integrated; - tree.packet(g.permission, dedicated, &self.sync.builtin) + tree.packet_with( + g.permission, + dedicated, + &self.sync.builtin, + &self.mod_commands.0, + ) } None => play::Commands { nodes: vec![play::CommandNode { diff --git a/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml b/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml index 0c2004b5f..5b58273c6 100644 --- a/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml +++ b/crates/horizon-mod-sdk/examples/beacons/horizon-mod.toml @@ -1,7 +1,7 @@ id = "beacons" name = "Beacons" version = "0.1.0" -description = "/beacon lights a pillar of glowstone beside you; /ground says what you are standing on, and in which biome." +description = "/beacon [height] lights a pillar of glowstone beside you; /ground [pos] says what you are standing on (or what is at pos), and in which biome." authors = ["Horizon"] api = "0.3" side = "server" diff --git a/crates/horizon-mod-sdk/examples/beacons/src/lib.rs b/crates/horizon-mod-sdk/examples/beacons/src/lib.rs index 509431775..4a4de5cc1 100644 --- a/crates/horizon-mod-sdk/examples/beacons/src/lib.rs +++ b/crates/horizon-mod-sdk/examples/beacons/src/lib.rs @@ -1,9 +1,12 @@ //! Example Horizon server mod (`docs/mods.md`, "Server mods") with -//! capability `world`: `/beacon` lights a pillar of glowstone two blocks east -//! of the player, `/ground` reads the block under their feet and the biome. -use horizon_mod_sdk::server::{BlockChange, Player, Registration, ServerMod, players, world}; +//! capability `world` and typed commands: `/beacon [height]` lights a +//! pillar of glowstone two blocks east of the player, `/ground [pos]` reads +//! the block under their feet (or at `pos`) and its biome. +use horizon_mod_sdk::server::{ + Args, Argument, BlockChange, Player, Registration, ServerMod, players, world, +}; -/// How tall `/beacon` builds. +/// How tall `/beacon` builds when no height is given. const HEIGHT: i32 = 4; struct Beacons; @@ -17,10 +20,10 @@ fn feet(player: &Player) -> (i32, i32, i32) { ) } -/// The pillar beside `player`: `HEIGHT` blocks of glowstone, from their feet up. -fn pillar(player: &Player) -> Vec { +/// The pillar beside `player`: `height` blocks of glowstone, from their feet up. +fn pillar(player: &Player, height: i32) -> Vec { let (x, y, z) = feet(player); - (0..HEIGHT) + (0..height) .map(|dy| BlockChange { x: x + 2, y: y + dy, @@ -34,7 +37,12 @@ impl ServerMod for Beacons { fn init() -> Result<(Self, Registration), String> { Ok(( Beacons, - Registration::new().command("beacon").command("ground"), + Registration::new() + .command_with( + "beacon", + [Argument::integer("height", Some(1), Some(64)).optional()], + ) + .command_with("ground", [Argument::block_pos("pos").optional()]), )) } @@ -42,11 +50,12 @@ impl ServerMod for Beacons { &mut self, player: &Player, name: &str, - _args: &str, + args: &Args, ) -> Result, String> { let (x, y, z) = feet(player); if name == "beacon" { - world::set_blocks(&player.dimension, &pillar(player))?; + let height = args.integer("height").unwrap_or(HEIGHT); + world::set_blocks(&player.dimension, &pillar(player, height))?; players::broadcast(&format!( "{} lit a beacon at {} {y} {z}", player.name, @@ -54,10 +63,17 @@ impl ServerMod for Beacons { )); return Ok(None); } - let below = world::get_block(&player.dimension, x, y - 1, z) - .ok_or("The ground under you isn't loaded")?; - let biome = world::get_biome(&player.dimension, x, y, z).unwrap_or_default(); - Ok(Some(format!("You are standing on {below} in {biome}"))) + let (bx, by, bz) = match args.block_pos("pos") { + Some(p) => (p.x, p.y, p.z), + None => (x, y - 1, z), + }; + let block = + world::get_block(&player.dimension, bx, by, bz).ok_or("That block isn't loaded")?; + let biome = world::get_biome(&player.dimension, bx, by, bz).unwrap_or_default(); + if args.block_pos("pos").is_some() { + return Ok(Some(format!("{bx} {by} {bz} is {block} in {biome}"))); + } + Ok(Some(format!("You are standing on {block} in {biome}"))) } } @@ -79,7 +95,7 @@ mod tests { game_mode: horizon_mod_sdk::server::GameMode::Survival, health: 20.0, }; - let blocks = pillar(&p); + let blocks = pillar(&p, HEIGHT); assert_eq!(blocks.len(), HEIGHT as usize); assert_eq!((blocks[0].x, blocks[0].y, blocks[0].z), (1, 64, 3)); assert_eq!(blocks[3].y, 67); diff --git a/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml b/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml index e75caeb75..bab4839f3 100644 --- a/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml +++ b/crates/horizon-mod-sdk/examples/beacons/tests/beacons.toml @@ -1,4 +1,4 @@ -# /ground reads the block under the player and the biome; /beacon sets four blocks of glowstone two to the east. +# /ground [pos] reads the block under the player (or at pos) and the biome; /beacon [height] sets glowstone two to the east. [[step]] join = "Alex" @@ -20,3 +20,20 @@ expect.blocks = [ player = { x = 10.5, z = 10.5 } command = "ground" expect.feedback = "You are standing on minecraft:air in minecraft:plains" + +# Typed arguments: a height, a position relative to the player. +[[step]] +player = { x = 5.5, y = 64.0, z = 5.5 } +command = "beacon 2" +expect.blocks = [ + { x = 7, y = 65, z = 5, state = "minecraft:glowstone" }, + { x = 7, y = 66, z = 5, state = "minecraft:air" }, +] + +[[step]] +command = "beacon 65" +expect.error = "Integer must not be more than 64, found 65" + +[[step]] +command = "ground ~2 ~1 ~" +expect.feedback = "7 65 5 is minecraft:glowstone in minecraft:plains" diff --git a/crates/horizon-mod-sdk/examples/welcome/src/lib.rs b/crates/horizon-mod-sdk/examples/welcome/src/lib.rs index c45712c88..e5a5ef13e 100644 --- a/crates/horizon-mod-sdk/examples/welcome/src/lib.rs +++ b/crates/horizon-mod-sdk/examples/welcome/src/lib.rs @@ -1,9 +1,10 @@ //! Example Horizon server mod (`docs/mods.md`, "Server mods"): greets each //! player who joins, announces a newcomer to everyone (the players it has //! seen are kept in `storage`), `/online` lists who is on and where, and -//! `/mined` counts the blocks the player broke since the server started. +//! `/mined [player]` counts the blocks a player broke since the server +//! started (typed arguments: the chat box completes the player's name). use horizon_mod_sdk::{ - server::{BlockPos, Player, Registration, ServerMod, players}, + server::{Args, Argument, BlockPos, Player, Registration, ServerMod, players}, storage, }; use std::collections::HashMap; @@ -25,8 +26,8 @@ impl ServerMod for Welcome { }) .unwrap_or_default(); let hooks = Registration::new() - .command("online") - .command("mined") + .command_with("online", []) + .command_with("mined", [Argument::player("player").optional()]) .on_joins() .on_blocks(); Ok(( @@ -55,7 +56,7 @@ impl ServerMod for Welcome { &mut self, player: &Player, name: &str, - _args: &str, + args: &Args, ) -> Result, String> { Ok(Some(match name { "online" => players::online() @@ -69,10 +70,16 @@ impl ServerMod for Welcome { }) .collect::>() .join(", "), - _ => { - let n = self.mined.get(&player.id).copied().unwrap_or(0); - format!("You broke {n} block(s)") - } + _ => match args.player("player") { + Some(other) if other.id != player.id => { + let n = self.mined.get(&other.id).copied().unwrap_or(0); + format!("{} broke {n} block(s)", other.name) + } + _ => { + let n = self.mined.get(&player.id).copied().unwrap_or(0); + format!("You broke {n} block(s)") + } + }, })) } diff --git a/crates/horizon-mod-sdk/examples/welcome/tests/commands.toml b/crates/horizon-mod-sdk/examples/welcome/tests/commands.toml index 5f0018346..a55bd421d 100644 --- a/crates/horizon-mod-sdk/examples/welcome/tests/commands.toml +++ b/crates/horizon-mod-sdk/examples/welcome/tests/commands.toml @@ -1,4 +1,4 @@ -# /online lists the players and their dimension; /mined counts the acting player's blocks. +# /online lists the players and their dimension; /mined [player] counts a player's blocks. [[step]] join = "Alex" @@ -29,3 +29,18 @@ expect.feedback = "You broke 2 block(s)" as = "Steve" command = "mined" expect.feedback = "You broke 0 block(s)" + +[[step]] +as = "Steve" +command = "mined alex" +expect.feedback = "Alex broke 2 block(s)" + +# The server parses the arguments before the mod: vanilla's errors. +[[step]] +as = "Steve" +command = "mined Herobrine" +expect.error = "No player was found" + +[[step]] +command = "online now" +expect.error = "Incorrect argument for command" diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index 98f6a5ef4..875113e94 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -164,7 +164,10 @@ pub mod server { }, }); - pub use exports::horizon::mods::server_events::{BlockPos, Guest as __Guest, Registration}; + pub use exports::horizon::mods::server_events::{ + Argument, ArgumentKind, ArgumentValue, BlockPos, Guest as __Guest, Registration, + ServerCommand, + }; pub use horizon::mods::players::{self, GameMode, Player}; pub use horizon::mods::world::{self, BlockChange}; @@ -181,13 +184,14 @@ pub mod server { fn player_left(&mut self, _player: &Player) {} /// A chat message a player sent, when [`Registration::chat`] is set. fn chat(&mut self, _player: &Player, _text: &str) {} - /// `/name args` typed by `player`. `Ok(Some(text))` is their - /// feedback, `Err(text)` their error in red. + /// `/name args` typed by `player`, parsed as the command's + /// arguments say ([`Registration::command_with`]). `Ok(Some(text))` + /// is their feedback, `Err(text)` their error in red. fn command( &mut self, _player: &Player, _name: &str, - _args: &str, + _args: &Args, ) -> Result, String> { Err("This command does nothing".into()) } @@ -207,9 +211,23 @@ pub mod server { blocks: false, } } - /// Adds `/name`. - pub fn command(mut self, name: &str) -> Self { - self.commands.push(name.into()); + /// Adds `/name`, taking free text ([`Args::text`]): one optional + /// `text` argument named `args`. + pub fn command(self, name: &str) -> Self { + self.command_with(name, [Argument::text("args").optional()]) + } + /// Adds `/name` with typed arguments (none: nothing after the name): + /// players' chat boxes complete and check them, and the server + /// parses them into [`Args`]. + pub fn command_with( + mut self, + name: &str, + arguments: impl IntoIterator, + ) -> Self { + self.commands.push(ServerCommand { + name: name.into(), + arguments: arguments.into_iter().collect(), + }); self } pub fn on_tick(mut self) -> Self { @@ -234,6 +252,105 @@ pub mod server { Self::new() } } + + impl Argument { + fn of(name: &str, kind: ArgumentKind) -> Self { + Self { + name: name.into(), + kind, + optional: false, + } + } + /// One word: letters, digits and `_ - . +`. + pub fn word(name: &str) -> Self { + Self::of(name, ArgumentKind::Word) + } + /// The rest of the line: only last. + pub fn text(name: &str) -> Self { + Self::of(name, ArgumentKind::Text) + } + /// A whole number, at least `min` and at most `max` when given. + pub fn integer(name: &str, min: Option, max: Option) -> Self { + Self::of(name, ArgumentKind::Integer((min, max))) + } + pub fn number(name: &str) -> Self { + Self::of(name, ArgumentKind::Number) + } + pub fn boolean(name: &str) -> Self { + Self::of(name, ArgumentKind::Boolean) + } + /// One online player: a name, `@s` or `@p`. + pub fn player(name: &str) -> Self { + Self::of(name, ArgumentKind::Player) + } + /// `x y z`, each one also `~` or `~n`. + pub fn block_pos(name: &str) -> Self { + Self::of(name, ArgumentKind::BlockPos) + } + /// May be left out; optional arguments come after the required ones. + pub fn optional(mut self) -> Self { + self.optional = true; + self + } + } + + /// A command's arguments: the line as typed, and the values the server + /// parsed from it, by argument name. + #[derive(Clone, Debug, Default)] + pub struct Args { + text: String, + values: Vec<(String, ArgumentValue)>, + } + impl Args { + pub fn new(text: String, values: Vec<(String, ArgumentValue)>) -> Self { + Self { text, values } + } + /// The line after the command's name, as typed. + pub fn text(&self) -> &str { + &self.text + } + /// The value of argument `name`; `None` when it was left out. + pub fn get(&self, name: &str) -> Option<&ArgumentValue> { + self.values.iter().find(|(n, _)| n == name).map(|(_, v)| v) + } + pub fn integer(&self, name: &str) -> Option { + match self.get(name)? { + ArgumentValue::Integer(n) => Some(*n), + _ => None, + } + } + pub fn number(&self, name: &str) -> Option { + match self.get(name)? { + ArgumentValue::Number(n) => Some(*n), + _ => None, + } + } + /// A `word` or `text` argument. + pub fn string(&self, name: &str) -> Option<&str> { + match self.get(name)? { + ArgumentValue::Word(s) | ArgumentValue::Text(s) => Some(s), + _ => None, + } + } + pub fn boolean(&self, name: &str) -> Option { + match self.get(name)? { + ArgumentValue::Boolean(b) => Some(*b), + _ => None, + } + } + pub fn player(&self, name: &str) -> Option<&Player> { + match self.get(name)? { + ArgumentValue::Player(p) => Some(p), + _ => None, + } + } + pub fn block_pos(&self, name: &str) -> Option { + match self.get(name)? { + ArgumentValue::BlockPos(p) => Some(*p), + _ => None, + } + } + } } /// Exports a [`ServerMod`](server::ServerMod) as the component's @@ -243,7 +360,7 @@ macro_rules! export_server_mod { ($t:ty) => { const _: () = { use ::std::cell::RefCell; - use $crate::server::{BlockPos, Player, Registration, ServerMod}; + use $crate::server::{Args, ArgumentValue, BlockPos, Player, Registration, ServerMod}; ::std::thread_local! { static STATE: RefCell> = const { RefCell::new(None) }; } @@ -277,8 +394,10 @@ macro_rules! export_server_mod { player: Player, name: String, args: String, + values: Vec<(String, ArgumentValue)>, ) -> Result, String> { let mut out = Err("The mod is not running".to_string()); + let args = Args::new(args, values); with(|m| out = m.command(&player, &name, &args)); out } diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index f0dd1c596..e8cea0d02 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -177,10 +177,61 @@ interface %world { interface server-events { use players.{player}; + record block-pos { + x: s32, + y: s32, + z: s32, + } + /// What an argument of a command takes. The server sends each command + /// and its arguments to players' games, whose chat box completes and + /// colours them as vanilla's; the server parses them before the mod's + /// `command` hook gets their values. + variant argument-kind { + /// One word: letters, digits and `_ - . +`. + word, + /// The rest of the line, spaces included: only last. + text, + /// A whole number, within the bounds given. + integer(tuple, option>), + /// A number with or without decimals. + number, + /// `true` or `false`. + boolean, + /// One online player: a name, or `@s` or `@p` (whoever typed it, + /// as vanilla picks for a player). + player, + /// A block position: three whole numbers, each one also `~` or + /// `~n`, relative to whoever typed it. + block-pos, + } + record argument { + /// Shown in the chat box's hint (``) and the key of its value. + name: string, + kind: argument-kind, + /// Optional arguments come after every required one. + optional: bool, + } + record server-command { + /// Without the slash. A name vanilla already uses is refused + /// (vanilla's command runs). + name: string, + /// In order; none: the command takes nothing after its name. Free + /// text is one optional `text` argument. + arguments: list, + } + /// An argument's value, parsed as its kind says. + variant argument-value { + word(string), + text(string), + integer(s32), + number(f64), + boolean(bool), + player(player), + block-pos(block-pos), + } record registration { - /// Commands players may type, without the slash. A name vanilla - /// already uses is refused (vanilla's command runs). - commands: list, + /// Commands players may type. + commands: list, /// Call `tick` every server tick (20 per second). tick: bool, /// Call `player-joined` and `player-left`. @@ -190,11 +241,6 @@ interface server-events { /// Call `block-broken` and `block-placed`. blocks: bool, } - record block-pos { - x: s32, - y: s32, - z: s32, - } /// Once, after loading. An error keeps the mod off and is logged. init: func() -> result; /// Each server tick, with the server's tick counter. @@ -206,8 +252,10 @@ interface server-events { /// A chat message a player sent, as plain text. chat: func(player: player, text: string); /// `/name args` typed by `player`: ok(some(text)) is their feedback, - /// err(text) their error in red. - command: func(player: player, name: string, args: string) -> result, string>; + /// err(text) their error in red. `values` holds each argument given, by + /// name, in order (an optional one left out isn't there); a line that + /// doesn't parse never reaches the mod: the player gets vanilla's error. + command: func(player: player, name: string, args: string, values: list>) -> result, string>; /// A player broke the block at `pos` in their dimension: `state` is what /// was there, as `minecraft:oak_log[axis=y]`. block-broken: func(player: player, pos: block-pos, state: string); diff --git a/crates/horizon-mods/src/commands.rs b/crates/horizon-mods/src/commands.rs new file mode 100644 index 000000000..4421b47ba --- /dev/null +++ b/crates/horizon-mods/src/commands.rs @@ -0,0 +1,301 @@ +//! Server mods' typed commands: an argument line parsed as the command's +//! arguments say, the way vanilla's Brigadier parsers read them, with +//! vanilla's error messages. The game sends the same arguments to players' +//! command trees, so their chat box checks them before the server does. +use crate::runtime::{Argument, ArgumentKind, ArgumentValue, BlockPos, ServerPlayer}; + +/// The values of `args`, typed by `player`, for `arguments` (in order; +/// optional ones left out aren't there), or the error the player reads. +pub fn parse( + arguments: &[Argument], + args: &str, + player: &ServerPlayer, + online: &[ServerPlayer], +) -> Result, String> { + let mut values = Vec::new(); + let mut rest = args; + for (i, argument) in arguments.iter().enumerate() { + if i > 0 && !rest.is_empty() { + rest = rest.strip_prefix(' ').ok_or( + "Expected whitespace to end one argument, but found trailing data".to_string(), + )?; + } + if rest.is_empty() { + if argument.optional { + break; + } + return Err("Unknown or incomplete command, see below for error".into()); + } + let (value, after) = one(&argument.kind, rest, player, online)?; + values.push((argument.name.clone(), value)); + rest = after; + } + if !rest.is_empty() { + return Err("Incorrect argument for command".into()); + } + Ok(values) +} + +/// StringReader.isAllowedInUnquotedString. +fn unquoted(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '+') +} + +/// The unquoted string at the start of `s`, and what follows it. +fn word(s: &str) -> (&str, &str) { + let end = s.find(|c: char| !unquoted(c)).unwrap_or(s.len()); + s.split_at(end) +} + +/// One argument from the start of `s`, and what follows it. +fn one<'a>( + kind: &ArgumentKind, + s: &'a str, + player: &ServerPlayer, + online: &[ServerPlayer], +) -> Result<(ArgumentValue, &'a str), String> { + Ok(match kind { + ArgumentKind::Word => { + let (w, rest) = word(s); + if w.is_empty() { + return Err("Expected string".into()); + } + (ArgumentValue::Word(w.into()), rest) + } + ArgumentKind::Text => (ArgumentValue::Text(s.into()), ""), + ArgumentKind::Integer((min, max)) => { + let (n, rest) = integer(s)?; + if let Some(min) = min.filter(|m| n < *m) { + return Err(format!("Integer must not be less than {min}, found {n}")); + } + if let Some(max) = max.filter(|m| n > *m) { + return Err(format!("Integer must not be more than {max}, found {n}")); + } + (ArgumentValue::Integer(n), rest) + } + ArgumentKind::Number => { + let (n, rest) = double(s)?; + (ArgumentValue::Number(n), rest) + } + ArgumentKind::Boolean => { + let (w, rest) = word(s); + let b = match w { + "true" => true, + "false" => false, + "" => return Err("Expected bool".into()), + _ => { + return Err(format!( + "Invalid boolean, expected 'true' or 'false' but found '{w}'" + )); + } + }; + (ArgumentValue::Boolean(b), rest) + } + ArgumentKind::Player => { + if let Some(selector) = s.strip_prefix('@') { + let (kind, rest) = word(selector); + return match kind { + // A player's @p is themselves (the nearest to where they are). + "s" | "p" => Ok((ArgumentValue::Player(player.clone()), rest)), + "a" | "e" | "r" | "n" => { + Err("Only one player is allowed, but the provided selector allows more than one".into()) + } + _ => Err(format!("Unknown selector type '@{kind}'")), + }; + } + let (name, rest) = word(s); + if name.is_empty() { + return Err("Invalid name or UUID".into()); + } + let found = online + .iter() + .find(|p| p.name.eq_ignore_ascii_case(name)) + .ok_or("No player was found")?; + (ArgumentValue::Player(found.clone()), rest) + } + ArgumentKind::BlockPos => { + let (x, rest) = coordinate(s, player.x)?; + let rest = rest + .strip_prefix(' ') + .ok_or("Incomplete (expected 3 coordinates)")?; + let (y, rest) = coordinate(rest, player.y)?; + let rest = rest + .strip_prefix(' ') + .ok_or("Incomplete (expected 3 coordinates)")?; + let (z, rest) = coordinate(rest, player.z)?; + (ArgumentValue::BlockPos(BlockPos { x, y, z }), rest) + } + }) +} + +/// StringReader.readInt. +fn integer(s: &str) -> Result<(i32, &str), String> { + let end = s + .find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.')) + .unwrap_or(s.len()); + let (n, rest) = s.split_at(end); + if n.is_empty() { + return Err("Expected integer".into()); + } + let n = n.parse().map_err(|_| format!("Invalid integer '{n}'"))?; + Ok((n, rest)) +} + +/// StringReader.readDouble. +fn double(s: &str) -> Result<(f64, &str), String> { + let end = s + .find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.')) + .unwrap_or(s.len()); + let (n, rest) = s.split_at(end); + if n.is_empty() { + return Err("Expected double".into()); + } + let n = n.parse().map_err(|_| format!("Invalid double '{n}'"))?; + Ok((n, rest)) +} + +/// WorldCoordinate.parseInt, then BlockPos.containing: `~` and `~n` from +/// `base`, a whole number as it is. +fn coordinate(s: &str, base: f64) -> Result<(i32, &str), String> { + if s.starts_with('^') { + return Err( + "Cannot mix world & local coordinates (everything must either use ^ or not)".into(), + ); + } + if let Some(offset) = s.strip_prefix('~') { + if offset.is_empty() || offset.starts_with(' ') { + return Ok((base.floor() as i32, offset)); + } + let (n, rest) = double(offset)?; + return Ok(((base + n).floor() as i32, rest)); + } + integer(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::GameMode; + + fn player(name: &str) -> ServerPlayer { + ServerPlayer { + id: format!("id-{name}"), + name: name.into(), + x: 10.7, + y: 64.0, + z: -3.2, + dimension: "minecraft:overworld".into(), + game_mode: GameMode::Survival, + health: 20.0, + } + } + fn arg(name: &str, kind: ArgumentKind, optional: bool) -> Argument { + Argument { + name: name.into(), + kind, + optional, + } + } + + #[test] + fn values_in_order_and_optional_ones_left_out() { + let alex = player("Alex"); + let online = [alex.clone(), player("Steve")]; + let args = [ + arg("who", ArgumentKind::Player, false), + arg("height", ArgumentKind::Integer((Some(1), Some(64))), true), + arg("note", ArgumentKind::Text, true), + ]; + let v = parse(&args, "steve 5 hello there", &alex, &online).unwrap(); + assert_eq!(v[0].0, "who"); + assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Steve")); + assert_eq!(v[1], ("height".into(), ArgumentValue::Integer(5))); + assert_eq!( + v[2], + ("note".into(), ArgumentValue::Text("hello there".into())) + ); + let v = parse(&args, "@s", &alex, &online).unwrap(); + assert_eq!(v.len(), 1); + assert!(matches!(&v[0].1, ArgumentValue::Player(p) if p.name == "Alex")); + } + + #[test] + fn vanilla_errors() { + let alex = player("Alex"); + let online = [alex.clone()]; + let height = [arg( + "height", + ArgumentKind::Integer((Some(1), Some(64))), + false, + )]; + let err = |a: &[Argument], s| parse(a, s, &alex, &online).unwrap_err(); + assert_eq!( + err(&height, "0"), + "Integer must not be less than 1, found 0" + ); + assert_eq!( + err(&height, "65"), + "Integer must not be more than 64, found 65" + ); + assert_eq!(err(&height, "x"), "Expected integer"); + assert_eq!(err(&height, "1.5"), "Invalid integer '1.5'"); + assert_eq!( + err(&height, ""), + "Unknown or incomplete command, see below for error" + ); + assert_eq!(err(&height, "3 4"), "Incorrect argument for command"); + let who = [arg("who", ArgumentKind::Player, false)]; + assert_eq!(err(&who, "Herobrine"), "No player was found"); + assert_eq!(err(&who, "@q"), "Unknown selector type '@q'"); + let flag = [arg("on", ArgumentKind::Boolean, false)]; + assert_eq!( + err(&flag, "yes"), + "Invalid boolean, expected 'true' or 'false' but found 'yes'" + ); + let pos = [arg("at", ArgumentKind::BlockPos, false)]; + assert_eq!(err(&pos, "1 2"), "Incomplete (expected 3 coordinates)"); + } + + #[test] + fn block_positions_relative_to_the_player() { + let alex = player("Alex"); + let pos = [arg("at", ArgumentKind::BlockPos, false)]; + let v = parse(&pos, "~ ~1 ~-0.5", &alex, &[]).unwrap(); + assert_eq!( + v[0].1, + ArgumentValue::BlockPos(BlockPos { + x: 10, + y: 65, + z: -4 + }) + ); + let v = parse(&pos, "-5 70 ~", &alex, &[]).unwrap(); + assert_eq!( + v[0].1, + ArgumentValue::BlockPos(BlockPos { + x: -5, + y: 70, + z: -4 + }) + ); + } + + #[test] + fn free_text_is_an_optional_text_argument() { + let alex = player("Alex"); + let free = [arg("args", ArgumentKind::Text, true)]; + assert_eq!(parse(&free, "", &alex, &[]), Ok(Vec::new())); + assert_eq!( + parse(&free, "anything at all", &alex, &[]), + Ok(vec![( + "args".into(), + ArgumentValue::Text("anything at all".into()) + )]) + ); + assert_eq!( + parse(&[], "more", &alex, &[]), + Err("Incorrect argument for command".into()) + ); + assert_eq!(parse(&[], "", &alex, &[]), Ok(Vec::new())); + } +} diff --git a/crates/horizon-mods/src/lib.rs b/crates/horizon-mods/src/lib.rs index 3c5fe74f6..d352f8c90 100644 --- a/crates/horizon-mods/src/lib.rs +++ b/crates/horizon-mods/src/lib.rs @@ -5,6 +5,8 @@ //! //! Nothing here runs unless a mod is installed and turned on: an empty //! [`Mods`] makes no engine and every hook returns at once. +#[cfg(feature = "runtime")] +pub mod commands; pub mod content; #[cfg(feature = "index")] pub mod index; diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index fa0998614..f4ab5c4dd 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -43,7 +43,9 @@ use bindings::{ horizon::mods::{chat, game, hud, log as mod_log, storage}, }; pub use server_bindings::{ - exports::horizon::mods::server_events::BlockPos, + exports::horizon::mods::server_events::{ + Argument, ArgumentKind, ArgumentValue, BlockPos, ServerCommand, + }, horizon::mods::players::{GameMode, Player as ServerPlayer}, horizon::mods::world::BlockChange, }; @@ -206,10 +208,10 @@ impl Hooks { Hooks::Client(..) => None, } } - fn commands(&self) -> &[String] { + fn commands(&self) -> Vec<&str> { match self { - Hooks::Client(_, r) => &r.commands, - Hooks::Server(_, r) => &r.commands, + Hooks::Client(_, r) => r.commands.iter().map(String::as_str).collect(), + Hooks::Server(_, r) => r.commands.iter().map(|c| c.name.as_str()).collect(), } } } @@ -240,9 +242,16 @@ pub struct Mods { snapshot: Arc, out: Vec, world: WorldSlot, + /// Moves on whenever a mod starts or stops: the game resends the server + /// mods' commands to players when it changes. + generation: u64, } impl Mods { + /// Changes whenever the running mods (so their commands) change. + pub fn generation(&self) -> u64 { + self.generation + } /// Whether a running mod may use capability `world`: only then is a /// view worth lending. pub fn wants_world(&self) -> bool { @@ -371,7 +380,8 @@ impl Mods { .iter() .filter_map(|e| e.run.as_ref()) .filter(|r| r.hooks.server().is_some() == server) - .flat_map(|r| r.hooks.commands().iter().cloned()) + .flat_map(|r| r.hooks.commands()) + .map(str::to_owned) .collect(); let snapshot = self.snapshot.clone(); let world = self.world.clone(); @@ -423,17 +433,30 @@ impl Mods { .map_err(|e| reason(&e))??; Hooks::Client(exports, r) }; - let commands = match &mut hooks { - Hooks::Client(_, r) => &mut r.commands, - Hooks::Server(_, r) => &mut r.commands, - }; - commands.retain(|c| { - let free = !reserved(c) && !taken.contains(c); + let free = |c: &str| { + let free = !reserved(c) && !taken.iter().any(|t| t == c); if !free { log::warn!("Horizon: mod {} cannot take /{c}: already a command", m.id); } free - }); + }; + match &mut hooks { + Hooks::Client(_, r) => r.commands.retain(|c| free(c)), + Hooks::Server(_, r) => { + r.commands.retain(|c| free(&c.name)); + for c in &mut r.commands { + if let Err(e) = check_arguments(&c.arguments) { + log::warn!( + "Horizon: mod {}'s /{}: {e}; it takes free text", + m.id, + c.name + ); + c.arguments.clear(); + } + } + } + } + self.generation += 1; self.out.append(&mut store.data_mut().out); Ok(Some(Running { store, hooks })) } @@ -537,6 +560,7 @@ impl Mods { }); entry.status = Status::Failed(reason); entry.run = None; + self.generation += 1; None } } @@ -561,7 +585,7 @@ impl Mods { fn command_owner(&self, server: bool, name: &str) -> Option { self.entries.iter().position(|e| { e.run.as_ref().is_some_and(|r| { - r.hooks.server().is_some() == server && r.hooks.commands().iter().any(|c| c == name) + r.hooks.server().is_some() == server && r.hooks.commands().contains(&name) }) }) } @@ -592,7 +616,7 @@ impl Mods { .iter() .filter_map(|e| e.run.as_ref()) .filter(move |r| r.hooks.server().is_some() == server) - .flat_map(|r| r.hooks.commands().iter().map(String::as_str)) + .flat_map(|r| r.hooks.commands()) } /// A server tick. @@ -635,9 +659,13 @@ impl Mods { |m, s| m.call_block_placed(s, player, pos, state), ); } - /// The commands running server mods own, for the server's command tree. - pub fn server_commands(&self) -> impl Iterator { - self.owned_commands(true) + /// The commands running server mods own and their arguments, for the + /// server's command tree. + pub fn server_commands(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|e| e.run.as_ref()?.hooks.server()) + .flat_map(|r| r.commands.iter()) } /// Runs `line` (without its slash), typed by `player`, if a server mod /// owns its command; its feedback is an [`Output::Reply`]. False when @@ -647,17 +675,31 @@ impl Mods { let Some(i) = self.command_owner(true, name) else { return false; }; - let result = self.call(i, FUEL_EVENT, |h, s| match h { - Hooks::Server(m, _) => m - .horizon_mods_server_events() - .call_command(s, player, name, args), - Hooks::Client(..) => Ok(Ok(None)), - }); let reply = |text, error| Output::Reply { player: player.id.clone(), text, error, }; + let arguments = self.entries[i] + .run + .as_ref() + .and_then(|r| r.hooks.server()) + .and_then(|r| r.commands.iter().find(|c| c.name == name)) + .map(|c| c.arguments.as_slice()) + .unwrap_or_default(); + let values = match crate::commands::parse(arguments, args, player, &self.snapshot.players) { + Ok(values) => values, + Err(e) => { + self.out.push(reply(e, true)); + return true; + } + }; + let result = self.call(i, FUEL_EVENT, |h, s| match h { + Hooks::Server(m, _) => m + .horizon_mods_server_events() + .call_command(s, player, name, args, &values), + Hooks::Client(..) => Ok(Ok(None)), + }); match result { Some(Ok(Some(text))) => self.out.push(reply(text, false)), Some(Err(text)) => self.out.push(reply(text, true)), @@ -688,6 +730,7 @@ impl Mods { continue; } any = true; + self.generation += 1; self.entries[i].modified = now; self.entries[i].run = None; let path = self.entries[i].path.clone(); @@ -713,6 +756,34 @@ impl Mods { } } +/// A command's arguments are well formed: names given and distinct, +/// optional ones last, free text only last. +fn check_arguments(arguments: &[Argument]) -> Result<(), String> { + for (i, a) in arguments.iter().enumerate() { + if a.name.is_empty() + || !a + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(format!("argument name '{}' isn't [A-Za-z0-9_]+", a.name)); + } + if arguments[..i].iter().any(|b| b.name == a.name) { + return Err(format!("two arguments are named {}", a.name)); + } + if i > 0 && arguments[i - 1].optional && !a.optional { + return Err(format!("{} is required after an optional argument", a.name)); + } + if a.kind == ArgumentKind::Text && i + 1 < arguments.len() { + return Err(format!( + "{} takes the rest of the line, so it must be last", + a.name + )); + } + } + Ok(()) +} + fn modified(path: &Path) -> Option { let meta = std::fs::metadata(path).ok()?; if meta.is_dir() { diff --git a/crates/horizon-mods/src/testing.rs b/crates/horizon-mods/src/testing.rs index 239642357..5a18d4cb8 100644 --- a/crates/horizon-mods/src/testing.rs +++ b/crates/horizon-mods/src/testing.rs @@ -250,7 +250,7 @@ impl TestGame { } /// The commands the mod registered. pub fn commands(&self) -> Vec { - let server = self.mods.server_commands(); + let server = self.mods.server_commands().map(|c| c.name.as_str()); self.mods .commands() .chain(server) diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 3647cca30..c9cd60bdc 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -446,7 +446,7 @@ fn a_server_mod_without_a_world_reads_nothing() { assert!( out.iter() .any(|o| matches!(o, Output::Reply { text, error: true, .. } - if text == "The ground under you isn't loaded")), + if text == "That block isn't loaded")), "{out:?}" ); } @@ -545,10 +545,12 @@ fn a_server_mod_runs_only_where_the_world_does() { let mut mods = Mods::load_for(Runs::Server, paths, |_| true, |c| c == "give"); assert!(mods.runs_server()); - assert_eq!( - mods.server_commands().collect::>(), - ["online", "mined"] - ); + let commands: Vec<_> = mods.server_commands().map(|c| c.name.as_str()).collect(); + assert_eq!(commands, ["online", "mined"]); + // `/mined [player]`: its argument travels with it, for the command tree. + let mined = mods.server_commands().nth(1).unwrap(); + assert_eq!(mined.arguments[0].kind, horizon_mods::ArgumentKind::Player); + assert!(mined.arguments[0].optional); assert_eq!(mods.commands().count(), 0, "not the window's chat commands"); let alex = alex(); mods.set_snapshot(Snapshot { diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 3aaf29d0c..7f0beeee6 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -216,16 +216,6 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On `type player = player` -### record `registration` - -| Field | Type | | -| --- | --- | --- | -| `commands` | `list` | Commands players may type, without the slash. A name vanilla already uses is refused (vanilla's command runs). | -| `tick` | `bool` | Call `tick` every server tick (20 per second). | -| `joins` | `bool` | Call `player-joined` and `player-left`. | -| `chat` | `bool` | Call `chat` for each chat message a player sends. | -| `blocks` | `bool` | Call `block-broken` and `block-placed`. | - ### record `block-pos` | Field | Type | | @@ -234,6 +224,59 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On | `y` | `s32` | | | `z` | `s32` | | +### variant `argument-kind` + +What an argument of a command takes. The server sends each command and its arguments to players' games, whose chat box completes and colours them as vanilla's; the server parses them before the mod's `command` hook gets their values. + +| Case | Payload | | +| --- | --- | --- | +| `word` | | One word: letters, digits and `_ - . +`. | +| `text` | | The rest of the line, spaces included: only last. | +| `integer` | `tuple, option>` | A whole number, within the bounds given. | +| `number` | | A number with or without decimals. | +| `boolean` | | `true` or `false`. | +| `player` | | One online player: a name, or `@s` or `@p` (whoever typed it, as vanilla picks for a player). | +| `block-pos` | | A block position: three whole numbers, each one also `~` or `~n`, relative to whoever typed it. | + +### record `argument` + +| Field | Type | | +| --- | --- | --- | +| `name` | `string` | Shown in the chat box's hint (``) and the key of its value. | +| `kind` | `argument-kind` | | +| `optional` | `bool` | Optional arguments come after every required one. | + +### record `server-command` + +| Field | Type | | +| --- | --- | --- | +| `name` | `string` | Without the slash. A name vanilla already uses is refused (vanilla's command runs). | +| `arguments` | `list` | In order; none: the command takes nothing after its name. Free text is one optional `text` argument. | + +### variant `argument-value` + +An argument's value, parsed as its kind says. + +| Case | Payload | | +| --- | --- | --- | +| `word` | `string` | | +| `text` | `string` | | +| `integer` | `s32` | | +| `number` | `f64` | | +| `boolean` | `bool` | | +| `player` | `player` | | +| `block-pos` | `block-pos` | | + +### record `registration` + +| Field | Type | | +| --- | --- | --- | +| `commands` | `list` | Commands players may type. | +| `tick` | `bool` | Call `tick` every server tick (20 per second). | +| `joins` | `bool` | Call `player-joined` and `player-left`. | +| `chat` | `bool` | Call `chat` for each chat message a player sends. | +| `blocks` | `bool` | Call `block-broken` and `block-placed`. | + | Function | Signature | What it does | | --- | --- | --- | | `init` | `func() -> result` | Once, after loading. An error keeps the mod off and is logged. | @@ -241,6 +284,6 @@ What the server calls in a server mod (manifest `api = "0.3"`, experimental). On | `player-joined` | `func(player: player)` | A player joined the server; they are in `players.online` already. | | `player-left` | `func(player: player)` | A player left the server; they are no longer in `players.online`. | | `chat` | `func(player: player, text: string)` | A chat message a player sent, as plain text. | -| `command` | `func(player: player, name: string, args: string) -> result, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. | +| `command` | `func(player: player, name: string, args: string, values: list>) -> result, string>` | `/name args` typed by `player`: ok(some(text)) is their feedback, err(text) their error in red. `values` holds each argument given, by name, in order (an optional one left out isn't there); a line that doesn't parse never reaches the mod: the player gets vanilla's error. | | `block-broken` | `func(player: player, pos: block-pos, state: string)` | A player broke the block at `pos` in their dimension: `state` is what was there, as `minecraft:oak_log[axis=y]`. | | `block-placed` | `func(player: player, pos: block-pos, state: string)` | A player placed `state` at `pos` in their dimension. | diff --git a/docs/api/mods.md b/docs/api/mods.md index 76075e989..6e7a87205 100644 --- a/docs/api/mods.md +++ b/docs/api/mods.md @@ -18,7 +18,7 @@ the player before the mod is turned on, and checked against the component's real | Hooks a mod exports | 5: `init`, `tick`, `frame`, `command`, `chat`; a server mod 8: `init`, `tick`, `player-joined`, `player-left`, `chat`, `command`, `block-broken`, `block-placed` | | Capabilities | 6: `game-read`, `chat`, `hud`, `storage`, `registries`, `players` | | Manifest `api` lines served | 3: `0.1` (code), `0.2` (content), `0.3` (server mods, experimental) | -| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (5 builders), `export_server_mod!` | +| SDK | the `Mod` trait (5 methods), `Registration` (4 builders), `export_mod!`, log helpers; `server::ServerMod` (8 methods), `server::Registration` (6 builders), `server::Argument`, `server::Args`, `export_server_mod!` | | `cargo horizon-mod` | `new`, `build`, `run`, `test`, `check` | | Example mods | `coords-hud` (code), `ruby-content` (content), `welcome` (server) | @@ -84,9 +84,9 @@ release. What real mods need next, in the order modpacks and servers need it: - **More for server mods** (`api = "0.3"`, [Server mods](../mods.md#server-mods-api-03-experimental)): they run - on the server thread, a LAN host and the dedicated server today, with joins, chat, commands, players' block - breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock` does); next entity events, game rules, - server commands with typed arguments sent to clients' command trees. + on the server thread, a LAN host and the dedicated server today, with joins, chat, typed commands in players' + command trees, players' block breaking and placing, and `world` (read blocks and biomes, set blocks as `/setblock` + does); next entity events, game rules, entity, item and block-state arguments. - **World and blocks**: blocks, block states and biomes are there for server mods (`world`); next block entities, and `world` for client mods (raycasts, single player and LAN hosts). - **Items and inventories**: the player's inventory, held item and open container; item stacks with components. @@ -102,6 +102,9 @@ What real mods need next, in the order modpacks and servers need it: ## Changelog +- API 0.3: typed server commands: `registration.commands` lists each command's arguments (`argument-kind`: word, + text, integer, number, boolean, player, block-pos), sent in players' command trees and parsed by the server; + `command` gets their values. SDK `Registration::command_with`, `Argument`, `Args`. - API 0.3: capability `world` (`get-block`, `get-biome`, `set-blocks`); example `beacons`; test scripts' `blocks` and `expect.blocks`. - Server mods run in the game: single player (server thread and in-frame), LAN hosts, the dedicated server. diff --git a/docs/mods.md b/docs/mods.md index 19b05f9ec..5d0378a34 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -475,7 +475,7 @@ its component targets the WIT world `horizon-server-mod` instead of | `tick(tick)` | each server tick | | `player-joined(player)`, `player-left(player)` | `registration.joins` | | `chat(player, text)` | `registration.chat`: each chat message a player sends | -| `command(player, name, args)` | `/name args` typed by a player, for a command it registered; `ok(some)` is that player's feedback, `err` their red error | +| `command(player, name, args, values)` | `/name args` typed by a player, for a command it registered, with its arguments' values (below); `ok(some)` is that player's feedback, `err` their red error | | `block-broken(player, pos, state)`, `block-placed(player, pos, state)` | `registration.blocks`, with the state as `minecraft:oak_log[axis=y]` | Capabilities that act on a local player (`game-read`, `chat`, `hud`) are for @@ -484,6 +484,31 @@ horizon_mod_sdk::server::ServerMod` and `export_server_mod!`; the example is [`welcome`](../crates/horizon-mod-sdk/examples/welcome/src/lib.rs), and with `world`, [`beacons`](../crates/horizon-mod-sdk/examples/beacons/src/lib.rs). +**Typed commands.** Each command in `registration.commands` lists its +arguments, in order: a name and a kind, required or optional (optional ones +last). The server adds them to the command tree every player receives, after +vanilla's commands, so a player's chat box completes and colours them as it +does vanilla's (player names, coordinates, bounds); when a player sends one, +the server parses the line the way vanilla's parsers do and gives the mod the +values, by argument name, or answers with vanilla's error (`Integer must not +be more than 64, found 65`, `No player was found`) without calling the mod. + +| Kind | Takes | Value | Chat box parser | +|---|---|---|---| +| `word` | one word: letters, digits, `_ - . +` | `word(string)` | `brigadier:string` (word) | +| `text` | the rest of the line, only last | `text(string)` | `brigadier:string` (greedy) | +| `integer(min, max)` | a whole number within the bounds given | `integer(s32)` | `brigadier:integer` | +| `number` | a number | `number(f64)` | `brigadier:double` | +| `boolean` | `true` or `false` | `boolean(bool)` | `brigadier:bool` | +| `player` | an online player's name, `@s` or `@p` (whoever typed it) | `player(player)` | `minecraft:entity` (one player) | +| `block-pos` | `x y z`, each a whole number, `~` or `~n` (from whoever typed it) | `block-pos(block-pos)` | `minecraft:block_pos` | + +A command with no arguments takes nothing after its name. In Rust, +`Registration::command_with("beacon", [Argument::integer("height", Some(1), +Some(64)).optional()])`, read with `args.integer("height")`; `command(name)` +alone takes free text, an optional `text` argument named `args` +(`args.text()` is the line as typed either way). + **Capability `world`.** Reads are of the server's loaded chunks as they are during the call: the game lends a server mod's call the levels' chunk maps (moved, not copied, and only while a running mod has `world`), so a read @@ -524,10 +549,11 @@ chat for the in-frame player and a system message for a connection; on, each of these points costs one branch (measured in the PR that added them). -**Not yet.** Clients aren't sent server mods' commands with vanilla's -command tree, so a player's chat box doesn't complete or colour them; typing -them works. Block events are a player's own breaking and placing, not -explosions, pistons or other mods. +**Not yet.** Block events are a player's own breaking and placing, not +explosions, pistons or other mods. Command arguments don't take entities, +items or block states yet, and `^` coordinates; in-frame single player's chat +box lists server mods' commands by name only (the server still parses their +arguments). ## Versions and compatibility From 574d65a03ca86775593d52472da4b1802f0a7d2a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:08:47 +0100 Subject: [PATCH 003/475] stability.md: server mods' row names world and typed commands Co-Authored-By: Claude Opus 5.5 --- docs/api/stability.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/stability.md b/docs/api/stability.md index 30d8c699c..f5b2da632 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -88,7 +88,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel | --- | --- | --- | | Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 | | Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` | -| Server mods: world `horizon-server-mod`, `players`, `server-events` | experimental | manifest `api = "0.3"` | +| Server mods: world `horizon-server-mod`, `players`, `server-events`, `world`, typed `server-command`s | experimental | manifest `api = "0.3"` | | Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | | | `cargo horizon-mod` (`new`, `build`, `run`), `horizon-mod-sdk` | preview | 0.1.0 | | Shader API | not started | | From a7ff22eea05de72275341d7346252fc5d2ecdbb6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 23:05:12 +0100 Subject: [PATCH 004/475] Give a server player's new body its effects at once after a level change A server's player gets a new body when it joins, respawns or changes level, but what the old body had been given by its effects (the applied attribute modifiers and the armor protection table) was kept in its per-player record. apply_effect_status then saw nothing to change, so after a trip to another dimension Speed kept its icon while the body's movement speed fell back from 0.14 to 0.10, until the effect was reapplied. Vanilla keeps the same ServerPlayer through ServerPlayer.teleport into another level, with its attributes and MobEffect modifiers. A new body now starts with nothing applied, and its effects are applied as soon as it is created, before the movement that brought it is handled. --- crates/horizon-client/src/game/lan_actions.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 9d4d868e0..41f92106c 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -250,8 +250,10 @@ impl Game<'_> { } let mut play = host.play.remove(&conn).unwrap_or_default(); if fresh { - // A new body (joined, respawned, changed level): nothing on it yet. + // A new body (joined, respawned, changed level): nothing on it yet, + // its effects' modifiers and armor protection included. play.equipped = None; + play.potions = Default::default(); } let g = &mut host.hub.guests[i]; // The guest's body. @@ -384,6 +386,12 @@ impl Game<'_> { self.apply_equipment(); play.equipped = Some(equipped); } + // The player's effects are on the new body before anything it does + // (the movement that brought it, a landing): vanilla keeps the same + // entity through a level change, with its attributes and effects. + if fresh { + self.apply_effect_status(); + } self.update_totem_ready(); Some(GuestSwap { conn, From 19a5098b140f4ca6eab01b0e73f5d8688cd52d85 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:03:41 +0100 Subject: [PATCH 005/475] Leave a respawned body's reset to the swap that builds it Respawning drops the guest's body, so the next swap builds a fresh one, which now clears what the old body was given (equipment, effects and armor protection) itself. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 41f92106c..00ea51de8 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1707,10 +1707,6 @@ impl Game<'_> { }; xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR); (g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total); - // A new body: nothing given to the old one carries over. - if let Some(play) = host.play.get_mut(&conn) { - play.potions = Default::default(); - } let clock_rate = self.network_clock_rate(); let ctx = TickCtx { world: &mut self.world, From f98528bbfc49fbe1c92deef00b953623903302c8 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:46:18 +0000 Subject: [PATCH 006/475] Fix evoker fangs rendering on server clients Track fang entities and deliver their start event to the existing client renderer. Exercise warmup, animation, authoritative positions and removal in the LAN harness. Co-Authored-By: OpenAI Codex GPT-6.1 Sol --- crates/horizon-client/src/game/control.rs | 3 + crates/horizon-client/src/game/fangs.rs | 42 ++++++++-- .../horizon-client/src/game/lan_entities.rs | 3 + crates/horizon-client/src/game/remote.rs | 1 + .../src/game/remote_projectiles.rs | 10 +++ tools/lan_e2e.py | 83 +++++++++++++++++++ 6 files changed, 137 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 7a632e308..6139ccd37 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -1023,6 +1023,9 @@ impl Game<'_> { "flashes": self.lightning_seen[1], "flash": self.flash}); // Primed TNT, falling blocks, projectiles, tridents and end crystals. status["projectiles"] = json!(self.projectiles_status()); + let mut fangs_vertices = Vec::new(); + self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices); + status["fangs_vertices"] = json!(fangs_vertices.len()); status["maps"] = json!(self.maps_status()); // The particles made so far, by type. status["particles"] = json!(self.particles.made()); diff --git a/crates/horizon-client/src/game/fangs.rs b/crates/horizon-client/src/game/fangs.rs index cdf2d17a5..9aa687032 100644 --- a/crates/horizon-client/src/game/fangs.rs +++ b/crates/horizon-client/src/game/fangs.rs @@ -7,12 +7,13 @@ use std::sync::Arc; #[derive(Clone, Debug)] pub(super) struct Fangs { - pos: [f64; 3], - yaw: f32, + pub id: u32, + pub pos: [f64; 3], + pub yaw: f32, warmup: i32, - life: i32, + pub life: i32, owner: u32, - started: bool, + pub started: bool, } #[derive(Default)] pub(super) struct FangState { @@ -21,6 +22,11 @@ pub(super) struct FangState { } impl Game<'_> { pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) { + self.next_entity += 1; + self.insert_fangs(self.next_entity, at, yaw, delay, owner); + } + + pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) { if self.fangs.model.is_none() { self.fangs.model = horizon_client::mobs::load_model( &self.options.resources, @@ -29,6 +35,7 @@ impl Game<'_> { .map(Arc::new); } self.fangs.list.push(Fangs { + id, pos: at, yaw, warmup: delay, @@ -37,6 +44,30 @@ impl Game<'_> { started: false, }); } + /// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack. + pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) { + if event == 4 + && let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id) + { + f.started = true; + self.audio.play_unshared( + "minecraft:entity.evoker_fangs.attack", + Some(f.pos), + 1.0, + 1.0, + ); + } + } + + /// `EvokerFangs.tick`: clients only advance animation, never damage or discard. + pub(super) fn tick_remote_fangs(&mut self) { + for f in &mut self.fangs.list { + if f.started { + f.life -= 1; + } + } + } + /// `EvokerFangs.tick`. pub(super) fn tick_fangs(&mut self) { let mut list = std::mem::take(&mut self.fangs.list); @@ -50,7 +81,8 @@ impl Game<'_> { } if !f.started { f.started = true; - self.audio.play( + self.lan_entity_event(f.id as i32, 4, f.pos); + self.audio.play_unshared( "minecraft:entity.evoker_fangs.attack", Some(f.pos), 1.0, diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index cd4ba2fb8..a389e6a99 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -290,6 +290,9 @@ impl Game<'_> { None => 0, }; let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z]; + for f in &self.fangs.list { + out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false)); + } // LightningBolt: each client draws and flashes its own. for b in self.bolts.iter().filter(|b| b.id != 0) { out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false)); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 20c307720..88f36bdf2 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2050,6 +2050,7 @@ impl Game<'_> { } } C::EntityEvent(p) => { + self.remote_fangs_event(p.id as u32, p.event); // LocalPlayer.setPermissions also refreshes an open World Options screen. if (24..=28).contains(&p.event) && self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 2ba4510ea..f033c5a8b 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -54,6 +54,7 @@ impl Game<'_> { let (yaw, pitch) = rotation; self.remove_remote_projectile(id); match kind { + "minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0), "minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt { id, position: at, @@ -220,6 +221,7 @@ impl Game<'_> { /// One tick of interpolation, as `tick_remote_objects`. pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) { + self.tick_remote_fangs(); // PrimedTnt.tick runs on a client too: it falls, bounces and burns its // fuse down (the swell and flash follow it) between the server's // updates (every 10 ticks), and a client discards it when the fuse @@ -311,6 +313,7 @@ impl Game<'_> { } pub(super) fn remove_remote_projectile(&mut self, id: u32) { + self.fangs.list.retain(|f| f.id != id); self.tnts.retain(|t| t.id != id); self.falling.retain(|f| f.id != id); self.creatures.projectiles.retain(|p| p.id != id); @@ -324,6 +327,7 @@ impl Game<'_> { /// Every one a server sent (a new connection, level or dimension). pub(super) fn clear_remote_projectiles(&mut self) { + self.fangs.list.clear(); self.tnts.clear(); self.falling.clear(); self.creatures.projectiles.clear(); @@ -340,6 +344,12 @@ impl Game<'_> { use serde_json::json; let at = |p: Vec3| [p.x, p.y, p.z]; let mut out = Vec::new(); + for f in &self.fangs.list { + out.push( + json!({"id": f.id, "kind": "minecraft:evoker_fangs", "position": f.pos, + "yaw": f.yaw, "started": f.started, "life": f.life}), + ); + } for t in &self.tnts { out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse})); } diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 804add98a..b4a89eb1d 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -6591,5 +6591,88 @@ CHECKS += ['replant'] CHECK_FNS['replant'] = check_replant + +def check_fangs(host, guest, out): + """EvokerFangs: authoritative spawn, event 4, client animation and server removal (#1482).""" + x, y, z = (int(v // 1) for v in pos_of(host)) + tag = 'lan_evoker_fangs' + selector = f'@e[type=minecraft:evoker,tag={tag},limit=1]' + paths = {g.name: {} for g in (host, guest)} + warmup = dict.fromkeys(paths, False) + drawn = dict.fromkeys(paths, False) + stopped = False + photographed = False + + def fangs(status): + return [p for p in status['projectiles'] if p['kind'] == 'minecraft:evoker_fangs'] + + try: + host('command', command='difficulty normal') + host('command', command='gamemode creative') + guest('command', command='gamemode survival') + guest('command', command='effect give @s minecraft:resistance 120 4 true') + host('command', command=f'fill {x - 10} {y - 1} {z - 10} {x + 10} {y - 1} {z + 18} minecraft:stone') + host('command', command=f'fill {x - 10} {y} {z - 10} {x + 10} {y + 8} {z + 18} minecraft:air') + host('command', command=f'tp @s {x + 4.5} {y} {z - 2.5}') + guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}') + guest.wait(10) + host('command', command=f'summon minecraft:evoker {x + 0.5} {y} {z + 9.5} ' + f'{{PersistenceRequired:1b,Tags:["{tag}"]}}') + for game in (host, guest): + game.raw('look-at', x + 0.5, y + 0.5, z + 5.5) + for _ in range(200): + guest.wait(2) + statuses = {g.name: g('status') for g in (host, guest)} + for name, status in statuses.items(): + live = fangs(status) + ids = [f['id'] for f in live] + assert all(ids) and len(ids) == len(set(ids)), f'{name}: invalid fang ids {ids}' + warmup[name] |= any(not f['started'] for f in live) + drawn[name] |= status['fangs_vertices'] > 0 + for f in live: + paths[name].setdefault(f['id'], []).append(f) + if paths['host'] and not stopped: + host('command', command=f'data merge entity {selector} {{NoAI:1b}}') + stopped = True + if (all(drawn.values()) and not photographed + and any(f['started'] and 8 <= f['life'] <= 16 + for f in fangs(statuses['guest']))): + host('command', command='tick freeze') + try: + for game in (host, guest): + game.raw('key', 'f1', 'tap') + try: + game.shot(out, 'evoker-fangs') + finally: + game.raw('key', 'f1', 'tap') + finally: + host('command', command='tick unfreeze') + photographed = True + if stopped and not any(fangs(s) for s in statuses.values()): + break + lab.write_json(out / 'evoker-fangs.json', {'paths': paths, 'warmup': warmup, 'drawn': drawn}) + assert all(warmup.values()), f'fang warmup not received: {warmup}' + assert all(drawn.values()), f'fang renderer produced no geometry: {drawn}' + assert paths['host'].keys() == paths['guest'].keys(), 'clients received different fang ids' + for id, samples in paths['host'].items(): + other = paths['guest'][id] + assert all(f['position'] == samples[0]['position'] for f in samples + other), \ + f'fang {id} is not at its authoritative position' + for name, frames in (('host', samples), ('guest', other)): + life = {f['life'] for f in frames if f['started']} + assert len(life) > 1, f'{name}: fang {id} did not animate: {frames}' + assert not any(fangs(g('status')) for g in (host, guest)), 'fangs survived server removal' + finally: + host('command', command='tick unfreeze') + host('command', command=f'kill @e[tag={tag}]') + guest('command', command='effect clear @s') + face_each_other(host, guest) + return {'fangs': len(paths['host']), 'warmup': warmup, 'drawn': drawn, 'removed': True} + + +CHECKS += ['fangs'] +CHECK_FNS.update(fangs=check_fangs) + + if __name__ == '__main__': main() From 9e3956282abf29f49ba9e0e1f0f04b3622a9b900 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 12:18:36 +0100 Subject: [PATCH 007/475] Send a deflected fireball's new direction to the player at once A ghast fireball or wind charge hit back by a player was turned on the server, but the player kept seeing it fly on until its next update ten ticks later. Mark the turn to be sent right away, as vanilla does with needsSync, and reset the acceleration power to 0.1 as an attack deflection does. --- crates/horizon-client/src/game/deflect.rs | 5 +++++ crates/horizon-client/src/game/lan.rs | 3 +++ crates/horizon-client/src/game/lan_entities.rs | 1 + crates/horizon-client/src/projectiles.rs | 4 ++++ 4 files changed, 13 insertions(+) diff --git a/crates/horizon-client/src/game/deflect.rs b/crates/horizon-client/src/game/deflect.rs index a08de87b3..088374a34 100644 --- a/crates/horizon-client/src/game/deflect.rs +++ b/crates/horizon-client/src/game/deflect.rs @@ -120,6 +120,11 @@ impl Game<'_> { p.guest = guest; // Not into the one who turned it on its way out. p.left_owner = false; + // `AbstractHurtingProjectile.onDeflection` by an attack, and the + // `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn + // at once, not at the fireball's next update ten ticks later. + p.acceleration = 0.1; + p.needs_sync = true; self.reset_attack_strength(); self.swing_arm(); let from = self diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50fa222e6..721fea5cd 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -912,6 +912,9 @@ impl Game<'_> { for m in &mut self.mobs { m.needs_sync = false; } + for p in &mut self.creatures.projectiles { + p.needs_sync = false; + } let entities = states.as_slice(); let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index cd4ba2fb8..a5c925a65 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -325,6 +325,7 @@ impl Game<'_> { let mut e = state(p.id, kind, at(p.position), p.yaw, false); e.pitch = p.pitch; e.velocity = at(p.velocity); + e.needs_sync = p.needs_sync; // Projectile.getAddEntityPacket: the owner's id. e.data = match p.guest.and_then(|conn| hub.guest(conn)) { Some(g) => g.entity_id, diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 219d59b6b..ef50c30a4 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -104,6 +104,9 @@ pub struct Projectile { pub arrow: Option>, /// `AbstractHurtingProjectile.accelerationPower`. pub acceleration: f64, + /// `Entity.needsSync`: a server tells its clients about the change this + /// tick instead of at the next update interval. + pub needs_sync: bool, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -168,6 +171,7 @@ impl Projectile { left_owner: false, bonus: Default::default(), acceleration: 0.1, + needs_sync: false, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( From f5f7448909e1dfe7db76ff0c9a565bc59f786967 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 12:31:40 +0100 Subject: [PATCH 008/475] Lingering potions leave an effect cloud A thrown lingering potion used to act as one weaker splash. It now leaves an area effect cloud like vanilla: radius 3, a 10 tick wait, 600 ticks, shrinking as it goes and by 0.5 each time it affects someone. Every 5 ticks it affects the mobs and players inside, with a 20 tick delay per entity, at a quarter of the duration (instant effects at half strength). Server players see it as an area_effect_cloud, like the dragon's breath clouds. --- crates/horizon-client/src/game/dragon.rs | 38 ++- .../horizon-client/src/game/lan_entities.rs | 10 +- crates/horizon-client/src/game/potions.rs | 248 ++++++++++++++++-- .../src/game/remote_projectiles.rs | 98 +++---- 4 files changed, 310 insertions(+), 84 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index e1880f9aa..c55e3ec9d 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -289,11 +289,11 @@ pub(super) enum FightWho { } /// A player in the End as the dragon sees it. #[derive(Clone, Copy, Debug)] -struct FightPlayer { - who: FightWho, - position: Vec3, - bounds: Aabb, - alive: bool, +pub(super) struct FightPlayer { + pub(super) who: FightWho, + pub(super) position: Vec3, + pub(super) bounds: Aabb, + pub(super) alive: bool, creative: bool, spectator: bool, } @@ -691,7 +691,7 @@ impl Game<'_> { } /// The players in the level the dragon sees (`level.players()`): the /// window's own, then every guest in the level. - fn fight_players(&self) -> Vec { + pub(super) fn fight_players(&self) -> Vec { let mut out = Vec::new(); if let Some(p) = self.player.as_ref() { out.push(FightPlayer { @@ -720,7 +720,7 @@ impl Game<'_> { } /// Run `f` as the fight's player `who` (a guest's turn, its knockback /// sent to its client). - fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) { + pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) { match who { FightWho::Host => f(self), FightWho::Guest(conn) => { @@ -1564,6 +1564,7 @@ impl Game<'_> { age: 0, duration: 200, from_dragon: true, + potion: None, }); } } @@ -2091,6 +2092,7 @@ impl Game<'_> { age: 0, duration: 600, from_dragon: false, + potion: None, }); self.audio.play( "minecraft:entity.dragon_fireball.explode", @@ -2123,6 +2125,10 @@ impl Game<'_> { let mut harmed: Vec<(FightWho, f32)> = Vec::new(); let mut clouds = std::mem::take(&mut self.breath_clouds); clouds.retain_mut(|c| { + // Lingering potions' clouds tick with the potions. + if c.potion.is_some() { + return true; + } c.age += 1; if c.age >= c.duration { return false; @@ -2250,8 +2256,22 @@ pub(super) struct Breath { pub(super) at: [f64; 3], pub(super) radius: f32, pub(super) age: i32, - duration: i32, - from_dragon: bool, + pub(super) duration: i32, + pub(super) from_dragon: bool, + /// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies. + pub(super) potion: Option, +} +impl Breath { + /// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a + /// lingering potion's). + pub(super) fn waiting(&self) -> bool { + self.age + < if self.potion.is_some() { + super::potions::LINGERING_WAIT + } else { + 20 + } + } } impl Game<'_> { /// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`. diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 574f5303e..d8e2a09a6 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -439,7 +439,7 @@ impl Game<'_> { } out.push(e); } - // The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS, + // The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS, // DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the // client draws them from these (`tick_remote_clouds`). let breath = self.network_particle( @@ -457,10 +457,14 @@ impl Game<'_> { for b in self.breath_clouds.iter().filter(|b| b.id != 0) { let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false); e.meta.push((8, serializer::FLOAT, Value::Float(b.radius))); - if b.age < 20 { + if b.waiting() { e.meta.push((9, serializer::BOOLEAN, Value::Bool(true))); } - if let Some(p) = &breath { + let particle = match &b.potion { + Some(p) => self.network_particle(&p.particle(), None), + None => breath.clone(), + }; + if let Some(p) = &particle { e.meta .push((10, serializer::PARTICLE, Value::Particle(p.clone()))); } diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 74c4ccd0c..bfffff5ee 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -2,7 +2,12 @@ //! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`), //! filling glass bottles (`BottleItem`), the per-tick effect application, //! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`). -use super::{Game, Screen, item_draw::owner_at}; +use super::{ + Game, Screen, + dragon::{Breath, FightPlayer, FightWho}, + item_draw::owner_at, + remote_projectiles::cloud_client_tick, +}; use horizon_assets::item_model::DisplayContext; use horizon_client::{ block_entity_render::vertex_light, @@ -12,8 +17,12 @@ use horizon_client::{ hud_layout, hud_overlays, inventory_effects, items::ItemStack, mesher::Vertex, + particles::{ParticleOptions, ParticleType}, +}; +use horizon_core::{ + geometry::{Aabb, Vec3}, + random::RandomSource, }; -use horizon_core::{geometry::Vec3, random::RandomSource}; /// A thrown splash or lingering potion in flight. #[derive(Clone, Debug)] @@ -27,6 +36,32 @@ pub(super) struct ThrownPotion { velocity: Vec3, age: u32, } +/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`. +pub(super) const LINGERING_WAIT: i32 = 10; +/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`. +const REAPPLICATION_DELAY: i32 = 20; +/// An entity a cloud has affected (`AreaEffectCloud.victims`). +#[derive(Clone, Copy, PartialEq)] +enum Victim { + Mob(u32), + Player(FightWho), +} +/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects +/// (at the lingering item's 0.25 duration scale) and who it has affected. +pub(super) struct PotionCloud { + effects: &'static [(&'static str, i32, i32)], + /// The potion's colour (`PotionContents.getColor`). + color: u32, + /// Each victim and the tick it can be affected again. + victims: Vec<(Victim, i32)>, +} +impl PotionCloud { + /// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour. + pub(super) fn particle(&self) -> ParticleOptions { + let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0; + ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0]) + } +} /// A mob's effect ids carry the `minecraft:` namespace. fn mob_effect_id(id: &str) -> Option<&'static str> { use std::{collections::HashMap, sync::OnceLock}; @@ -368,6 +403,7 @@ impl Game<'_> { self.tick_mob_effects(); if running { self.tick_thrown_potions(); + self.tick_potion_clouds(); } } /// `LivingEntity.tickEffects` (server side) for `self.player`, the local @@ -504,21 +540,20 @@ impl Game<'_> { let state = self.world .block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); - if self.res.facts().get(state).full_collision - || self.mobs.iter().any(|m| { - m.alive() - && (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125 - && (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125 - && p.y >= m.position.y - && p.y <= m.position.y + m.kind.height - }) - { - hit = Some(p); + let mob = self.mobs.iter().find(|m| { + m.alive() + && (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125 + && (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125 + && p.y >= m.position.y + && p.y <= m.position.y + m.kind.height + }); + if self.res.facts().get(state).full_collision || mob.is_some() { + hit = Some((p, mob.map(|m| m.position))); break; } } - if let Some(p) = hit { - landed.push((t.stack.clone(), p)); + if let Some((p, mob)) = hit { + landed.push((t.stack.clone(), p, mob)); return false; } t.position = next; @@ -537,8 +572,148 @@ impl Game<'_> { t.age < 1200 && t.position.y > -128.0 }); self.potions.thrown = thrown; - for (stack, at) in landed { - self.splash(&stack, at); + for (stack, at, mob) in landed { + self.splash(&stack, at, mob); + } + } + /// `AreaEffectCloud.tick` for the lingering potions' clouds: the client + /// tick's particles, then the server tick. + fn tick_potion_clouds(&mut self) { + if self.breath_clouds.iter().all(|c| c.potion.is_none()) { + return; + } + let players: Vec = self + .fight_players() + .into_iter() + .filter(|p| p.alive) + .collect(); + let mut clouds = std::mem::take(&mut self.breath_clouds); + clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players)); + self.breath_clouds.append(&mut clouds); + } + /// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its + /// wait it shrinks, and every 5 ticks it affects the living entities in + /// its box and radius that were not affected in the last 20 ticks, each + /// taking some of its radius. False once it is gone. + fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool { + c.age += 1; + let Some(potion) = c.potion.as_mut() else { + return true; + }; + if c.age - LINGERING_WAIT >= c.duration { + return false; + } + let waiting = c.age < LINGERING_WAIT; + cloud_client_tick( + &mut self.particles, + c.at, + c.radius, + waiting, + &potion.particle(), + ); + if waiting { + return true; + } + // setRadiusPerTick(-radius / duration). + c.radius += -3.0 / 600.0; + if c.radius < 0.5 { + return false; + } + if c.age % 5 != 0 { + return true; + } + potion.victims.retain(|(_, until)| c.age < *until); + let r = f64::from(c.radius); + let area = Aabb::new( + Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r), + Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r), + ); + let mut inside = Vec::new(); + for p in players.iter().filter(|p| p.bounds.intersects(area)) { + inside.push((Victim::Player(p.who), p.position.x, p.position.z)); + } + for m in self + .mobs + .iter() + .filter(|m| m.alive() && m.bounds().intersects(area)) + { + inside.push((Victim::Mob(m.id), m.position.x, m.position.z)); + } + let effects = potion.effects; + for (victim, x, z) in inside { + let (dx, dz) = (x - c.at[0], z - c.at[2]); + if potion.victims.iter().any(|(v, _)| *v == victim) + || dx * dx + dz * dz > f64::from(c.radius * c.radius) + { + continue; + } + potion.victims.push((victim, c.age + REAPPLICATION_DELAY)); + self.cloud_affect(victim, effects, c.at); + // setRadiusOnUse(-0.5). + c.radius -= 0.5; + if c.radius < 0.5 { + return false; + } + } + true + } + /// What a cloud puts on one entity: instant effects at half strength + /// (`applyInstantaneousEffect` with 0.5), the others for their duration + /// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`). + fn cloud_affect( + &mut self, + victim: Victim, + effects: &'static [(&'static str, i32, i32)], + at: [f64; 3], + ) { + let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1); + match victim { + Victim::Player(who) => self.as_fight_player(who, |g| { + for (id, duration, amp) in effects { + let Some(e) = brewing::effect(id) else { + continue; + }; + if e.instant { + if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) { + g.apply_effect_action(a); + } + } else { + g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp)); + } + } + }), + Victim::Mob(mob) => { + let Some(i) = self.mobs.iter().position(|m| m.id == mob) else { + return; + }; + // Undead invert healing and harming. + let inverted = self + .res + .stations() + .enchantments + .entity_tags + .get("minecraft:inverted_healing_and_harm") + .is_some_and(|t| t.contains(&self.mobs[i].kind.id)); + for (id, duration, amp) in effects { + let m = &mut self.mobs[i]; + match *id { + "instant_damage" | "instant_health" => { + let amount = + |base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32; + if (*id == "instant_damage") != inverted { + m.hurt(amount(6), Some(at), &mut self.world.random); + } else { + m.health = (m.health + amount(4)).min(m.kind.max_health); + } + } + _ => { + if let Some(name) = mob_effect_id(id) { + m.add_effect(name, scaled(*duration), *amp); + } + } + } + } + } } } /// `ThrownItemRenderer` for thrown splash and lingering potions and @@ -587,12 +762,7 @@ impl Game<'_> { } } /// A splash potion's effects on the player in place, by their distance. - fn splash_on_player( - &mut self, - effects: &'static [(&'static str, i32, i32)], - at: Vec3, - scale: f64, - ) { + fn splash_on_player(&mut self, effects: &'static [(&'static str, i32, i32)], at: Vec3) { if let Some(p) = self.player.as_ref() { let center = Vec3::new( p.position.x, @@ -610,7 +780,7 @@ impl Game<'_> { self.apply_effect_action(a); } } else { - let d = (intensity * f64::from(*duration) * scale + 0.5) as i32; + let d = (intensity * f64::from(*duration) + 0.5) as i32; if d > 20 && let Some(e) = brewing::effect(id) { @@ -621,8 +791,9 @@ impl Game<'_> { } } } - /// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash). - fn splash(&mut self, stack: &ItemStack, at: Vec3) { + /// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion` + /// for a lingering potion; `mob` is where it hit a mob. + fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option) { if &*stack.item == "minecraft:experience_bottle" { // ThrownExperienceBottle.onHit: the water-coloured splash (level // event 2002) and 3 to 11 experience. @@ -677,13 +848,34 @@ impl Game<'_> { ); self.particles.push(p); } - let scale = if lingering { 0.25 } else { 1.0 }; let Some(def) = stack.potion().and_then(brewing::potion) else { return; }; + if lingering { + // The cloud sits at the mob it hit (its feet), else the impact; + // radius 3, wait 10, 600 ticks, shrinking to nothing, and a + // potion with no effects leaves none. + if !def.effects.is_empty() { + let at = mob.unwrap_or(at); + self.breath_clouds.push(Breath { + id: 0, + at: [at.x, at.y, at.z], + radius: 3.0, + age: 0, + duration: 600, + from_dragon: false, + potion: Some(PotionCloud { + effects: def.effects, + color, + victims: Vec::new(), + }), + }); + } + return; + } // Every player in range (`ThrownSplashPotion.applySplash` over // `LivingEntity`s, the players among them). - self.for_each_player(|g| g.splash_on_player(def.effects, at, scale)); + self.for_each_player(|g| g.splash_on_player(def.effects, at)); // Mobs take the instant effects (undead invert healing and harming). let undead = &self.res.stations().enchantments.entity_tags; let mut hits = Vec::new(); @@ -706,7 +898,7 @@ impl Game<'_> { _ => { // `Potion.applySplash`: timed effects last in // proportion to how close the mob was. - let d = (intensity * f64::from(*duration) * scale + 0.5) as i32; + let d = (intensity * f64::from(*duration) + 0.5) as i32; if d > 20 && let Some(name) = mob_effect_id(id) { diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index bbb7b7cde..f6fff2ca2 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -23,6 +23,58 @@ fn default_cloud_particle() -> ParticleOptions { ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4]) } +/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles +/// near the middle; then about one per square block of the disc, drifting +/// (an entity effect stays still). +pub(super) fn cloud_client_tick( + particles: &mut horizon_client::particles::ParticleEngine, + at: [f64; 3], + radius: f32, + waiting: bool, + particle: &ParticleOptions, +) { + if waiting && particles.random().next_boolean() { + return; + } + let (count, spread) = if waiting { + (2, 0.2) + } else { + ( + (std::f32::consts::PI * radius * radius).ceil() as i32, + radius, + ) + }; + for _ in 0..count { + let r = particles.random(); + let angle = r.next_float() * std::f32::consts::TAU; + let distance = r.next_float().sqrt() * spread; + let at = [ + at[0] + f64::from(angle.cos() * distance), + at[1], + at[2] + f64::from(angle.sin() * distance), + ]; + let (options, velocity) = if particle.ty == ParticleType::EntityEffect { + let default = waiting && r.next_boolean(); + let options = if default { + default_cloud_particle() + } else { + particle.clone() + }; + (options, [0.0; 3]) + } else if waiting { + (particle.clone(), [0.0; 3]) + } else { + let v = [ + (0.5 - r.next_double()) * 0.15, + f64::from(0.01f32), + (0.5 - r.next_double()) * 0.15, + ]; + (particle.clone(), v) + }; + particles.add(&options, at, velocity); + } +} + /// The projectile a type id names, as Horizon models it. fn projectile_kind(kind: &str) -> Option { Some(match kind { @@ -380,52 +432,10 @@ impl Game<'_> { } } - /// `AreaEffectCloud.clientTick`: while waiting, now and then two - /// particles near the middle; then about one per square block of the - /// disc, drifting (an entity effect stays still). + /// `AreaEffectCloud.clientTick` for each cloud a server tracks. fn tick_remote_clouds(&mut self) { for c in &self.potions.clouds { - let r = self.particles.random(); - if c.waiting && r.next_boolean() { - continue; - } - let (count, spread) = if c.waiting { - (2, 0.2) - } else { - ( - (std::f32::consts::PI * c.radius * c.radius).ceil() as i32, - c.radius, - ) - }; - for _ in 0..count { - let r = self.particles.random(); - let angle = r.next_float() * std::f32::consts::TAU; - let distance = r.next_float().sqrt() * spread; - let at = [ - c.at[0] + f64::from(angle.cos() * distance), - c.at[1], - c.at[2] + f64::from(angle.sin() * distance), - ]; - let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect { - let default = c.waiting && r.next_boolean(); - let options = if default { - default_cloud_particle() - } else { - c.particle.clone() - }; - (options, [0.0; 3]) - } else if c.waiting { - (c.particle.clone(), [0.0; 3]) - } else { - let v = [ - (0.5 - r.next_double()) * 0.15, - f64::from(0.01f32), - (0.5 - r.next_double()) * 0.15, - ]; - (c.particle.clone(), v) - }; - self.particles.add(&options, at, velocity); - } + cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle); } } From 265965445d471e0dedba02d998362e8642443b78 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 12:34:59 +0100 Subject: [PATCH 009/475] Save the world when the game gets SIGTERM, Ctrl+C or SIGHUP, and disconnect on native quit --- crates/horizon-client/src/game.rs | 22 +++++++++++++++++++++ crates/horizon-threads/src/lib.rs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index cdbd72d7b..e8e6a6bcb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8207,6 +8207,10 @@ impl Game<'_> { } self.lan_close(false); self.save_game()?; + if let Some(mut remote) = self.remote.take() { + // Minecraft.disconnect closes the connection, so the host sees a clean leave. + remote.session.disconnect(); + } self.saved_for_exit = true; Ok(()) } @@ -11960,6 +11964,16 @@ impl ApplicationHandler for Game<'_> { } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 + && self.pending_save.as_ref().is_none_or(|save| !save.quit) + { + // SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close. + if let Err(error) = self.begin_closing_save(true) { + self.error = Some(error); + event_loop.exit(); + return; + } + } if self.pending_save.is_some() { match self.poll_closing_save() { Ok(true) => event_loop.exit(), @@ -13405,6 +13419,11 @@ impl Loading { } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 { + // No world is open yet: nothing to save. + event_loop.exit(); + return; + } if let Err(error) = self.poll_menu_resources() { self.error = Some(error); event_loop.exit(); @@ -13682,6 +13701,9 @@ pub fn run() -> Result<()> { { horizon_client::heartbeat::start(); } + if options.execution_role.is_client() { + horizon_threads::catch_stop_signals(); + } let started = Instant::now(); horizon_client::spans::enable(options.spikes.is_some()); let vanilla_root = options.resources.clone(); diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index 90c8d128e..ec98b1690 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -126,6 +126,38 @@ fn idle() -> bool { false } +/// How many stop requests (Ctrl-C, SIGTERM, SIGHUP) arrived since `catch_stop_signals`. +pub fn stop_requests() -> u32 { + STOP_REQUESTS.load(std::sync::atomic::Ordering::SeqCst) +} + +static STOP_REQUESTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Count stop requests from outside instead of dying on them: vanilla's "Client Shutdown Thread" +/// (`Main.java`) halts the integrated server when the JVM shuts down, and the JVM does that on +/// these signals, so a killed game still saves. The caller's event loop turns the first request +/// into a normal quit; a second one (the save is stuck) ends the process at once. +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] +pub fn catch_stop_signals() { + extern "C" fn handle(_: libc::c_int) { + if STOP_REQUESTS.fetch_add(1, Ordering::SeqCst) >= 1 { + // SAFETY: _exit is async-signal-safe; 130 is the shell's code for a Ctrl-C death. + unsafe { libc::_exit(130) } + } + } + let handler = handle as extern "C" fn(libc::c_int); + for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] { + // SAFETY: the handler only touches an atomic and calls _exit, both async-signal-safe. + unsafe { + libc::signal(signal, handler as libc::sighandler_t); + } + } +} + +/// Windows and the other systems keep their default: a closed window is a `CloseRequested`. +#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))] +pub fn catch_stop_signals() {} + #[cfg(test)] mod tests { /// The hint holds on the platforms that take it. From 09078949d784d48ae928c59038cb79674a86a538 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 12:36:29 +0100 Subject: [PATCH 010/475] Send a guest's explosion to the players once their turn ends An end crystal (or minecart) hit by a player on the server thread blew up with sound and damage but no explosion particles. The blast runs inside the guest's turn, while the host holds the players, so lan_explode had no hub to send the explosion packet through and dropped it. Queue it like a wind burst and send it when the turn is over. --- crates/horizon-client/src/game/creatures.rs | 4 ++++ crates/horizon-client/src/game/lan.rs | 3 ++- .../horizon-client/src/game/lan_particles.rs | 18 +++++++++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..db6fca100 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -45,6 +45,9 @@ pub(super) struct Creatures { pub projectiles: Vec, /// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs). pub wind_bursts: Vec, + /// Explosion packets from a guest's turn, waiting for it to end + /// (centre, radius, blocks hit; game/lan_particles.rs). + pub lan_explosions: Vec<([f64; 3], f32, usize)>, /// Ticks left of the elder guardian's apparition (MobAppearanceParticle). pub elder_apparition: i32, /// The wandering trader spawner's clock. @@ -1839,6 +1842,7 @@ impl Game<'_> { _ => self.projectile_hit(&p, hit)?, } } + self.flush_lan_explosions(); self.flush_wind_bursts() } /// `projectile_hit` for a projectile the guest `conn` shot, run as them; diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50fa222e6..29c405674 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1044,7 +1044,8 @@ impl Game<'_> { drop(holding); lan::flush_all(host.hub.guests.iter().map(|g| &g.sender)); self.lan = Some(host); - // A mace's wind burst from a guest's attack. + // A blast from a guest's attack, and a mace's wind burst. + self.flush_lan_explosions(); let _ = self.flush_wind_bursts(); } diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 0e35392bf..d58f18f5f 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -194,7 +194,16 @@ impl Game<'_> { /// `ServerLevel.explode`'s packet to each player within 64 blocks. The /// sound and the push reach them on their own (the level's sounds, the /// motion a hurt player is sent), so the packet carries neither. - pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) { + pub(super) fn lan_explode(&mut self, center: [f64; 3], radius: f32, block_count: usize) { + // In a guest's turn (their hit on an end crystal or a minecart) the + // host holds the players (`as_guest`): the packet waits for the turn + // to end so that it reaches every player, the guest too. + if self.lan_acting.is_some() && self.lan.is_none() { + self.creatures + .lan_explosions + .push((center, radius, block_count)); + return; + } let emitter = if radius < 2.0 { "minecraft:explosion" } else { @@ -208,6 +217,13 @@ impl Game<'_> { } } + /// The explosions guests' turns put off. + pub(super) fn flush_lan_explosions(&mut self) { + for (center, radius, block_count) in std::mem::take(&mut self.creatures.lan_explosions) { + self.lan_explode(center, radius, block_count); + } + } + /// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters, /// no block particles, and for each player it pushed the push /// (`ServerExplosion.hitPlayers`), which only that client applies, as From 3287a74d64e64ac8b3aa976276987b9b4b93f83f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:00:40 +0100 Subject: [PATCH 011/475] Send fence knots to server players so leads tied to fences are drawn --- crates/horizon-client/src/game.rs | 8 ++++++ crates/horizon-client/src/game/lan.rs | 4 +-- .../horizon-client/src/game/lan_entities.rs | 28 +++++++++++++++++++ crates/horizon-client/src/game/remote.rs | 10 +++++-- .../horizon-client/src/game/remote_objects.rs | 16 +++++++++-- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index cdbd72d7b..0134dd9a1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1459,6 +1459,12 @@ struct Game<'a> { /// Map textures waiting for the next HUD batch to upload them. map_patches: Vec, text_displays: std::collections::HashMap, + /// The fence knots a server tracks to this client, by entity id, at their + /// block (`LeashFenceKnotEntity`). + remote_knots: HashMap, + /// The entity ids a hosted server gives the knots its mobs hang on, by + /// block, while a lead is tied there. + knot_ids: HashMap<[i32; 3], u32>, text_display_vertices: usize, /// Player skin downloads, and which skin each atlas skin slot holds /// (least recently shown first). @@ -12291,6 +12297,8 @@ impl<'a> Game<'a> { dragon_model: prepared.dragon_model, map_patches: Vec::new(), text_displays: Default::default(), + remote_knots: Default::default(), + knot_ids: Default::default(), text_display_vertices: 0, skin_downloads: RefCell::new(horizon_client::skins::Downloader::new( std::env::current_dir() diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50fa222e6..c698e969e 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -819,7 +819,7 @@ impl Game<'_> { out.clear(); for m in self.mobs.iter().filter(|m| !m.removed) { // The lead's holder: the player the mob sees (a guest, else the - // host) or another mob. A fence knot isn't an entity here. + // host), another mob or a fence knot (game/lan_entities.rs). let leash = m.extra.leash.and_then(|h| match h { Holder::Player => mob_players .get(&m.id) @@ -827,7 +827,7 @@ impl Game<'_> { .map(|g| g.entity_id) .or(host_entity), Holder::Mob(id) => Some(id as i32), - Holder::Knot(_) => None, + Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32), }); let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str()); e.position = [m.position.x, m.position.y, m.position.z]; diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index cd4ba2fb8..e161b9a2b 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -225,6 +225,16 @@ impl Game<'_> { } out.push(e); } + for (p, &id) in &self.knot_ids { + // LeashFenceKnotEntity.getAddEntityPacket: the fence's block. + out.push(state( + id, + "minecraft:leash_knot", + p.map(f64::from), + 0.0, + false, + )); + } for p in &self.paintings { let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false); // Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its @@ -260,6 +270,24 @@ impl Game<'_> { /// Gives the entities Horizon keeps without an id one (`Entity.id`, from /// the same counter as the rest) before they are first tracked. pub(super) fn lan_assign_ids(&mut self) { + // LeashFenceKnotEntity: one per fence post with a lead on it + // (`notifyLeasheeRemoved` discards it once the last lead is gone). + let knots: Vec<[i32; 3]> = self + .mobs + .iter() + .filter(|m| !m.removed) + .filter_map(|m| match m.extra.leash { + Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p), + _ => None, + }) + .collect(); + self.knot_ids.retain(|p, _| knots.contains(p)); + for p in knots { + self.knot_ids.entry(p).or_insert_with(|| { + self.next_entity += 1; + self.next_entity + }); + } let next = &mut self.next_entity; let mut give = |id: &mut u32| { if *id == 0 { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5651647b7..849d6dfab 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1808,7 +1808,8 @@ impl Game<'_> { } C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers), // ClientPacketListener.handleEntityLinkPacket: who holds a mob's - // lead (this player, another player or a mob), for the rope. + // lead (this player, another player, a mob or a fence knot), for + // the rope. C::SetEntityLink(p) => { let me = self.remote.as_ref().map(|r| r.entity_id); if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) { @@ -1816,7 +1817,12 @@ impl Game<'_> { m.extra.leash = match p.dest { 0 => None, d if Some(d) == me => Some(Holder::Player), - d => Some(Holder::Mob(d as u32)), + d => match self.remote_knots.get(&(d as u32)) { + Some(&block) => Some(Holder::Knot(block)), + // Not a known knot yet: kept by id until the knot + // arrives (game/remote_objects.rs). + None => Some(Holder::Mob(d as u32)), + }, }; } } diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index 251d277f3..3f39072e9 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -1,9 +1,10 @@ -//! Boats, minecarts, armor stands, item frames and paintings on a server's +//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's //! client (`ClientPacketListener.handleAddEntity`, `handleSetEntityData`, //! `handleSetEquipment`): the server simulates them, the client places them //! where it says and interpolates their moves like a mob's. use super::Game; use horizon_client::entities::{ArmorStand, ItemFrame, Painting}; +use horizon_client::mobs::leash::Holder; use horizon_core::geometry::Vec3; use horizon_protocol::entity_data::Value; use std::sync::Arc; @@ -24,7 +25,17 @@ impl Game<'_> { let at = Vec3::new(position[0], position[1], position[2]); let block = position.map(|v| v.floor() as i32); self.remove_remote_object(id); - if kind == "minecraft:text_display" { + if kind == "minecraft:leash_knot" { + // A lead that came before its knot waits as a mob holder with the + // knot's id (handleEntityLinkPacket's delayed holder); now it can + // be tied to the fence. + self.remote_knots.insert(id, block); + for m in &mut self.mobs { + if m.extra.leash == Some(Holder::Mob(id)) { + m.extra.leash = Some(Holder::Knot(block)); + } + } + } else if kind == "minecraft:text_display" { self.text_displays.insert( id as i32, super::text_displays::RemoteTextDisplay::new(position, yaw), @@ -292,6 +303,7 @@ impl Game<'_> { pub(super) fn remove_remote_object(&mut self, id: u32) { self.text_displays.remove(&(id as i32)); + self.remote_knots.remove(&id); self.carts.retain(|c| c.id != id); self.boats.retain(|b| b.id != id); self.armor_stands.retain(|a| a.id != id); From 5c0f7142f36cad8e1a1e7adffe3c6c5902328edb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:03:44 +0100 Subject: [PATCH 012/475] Draw the level events the client dropped: lava and torch smoke, drips, chorus, sniffer eggs, dragon fireball burst --- crates/horizon-client/src/game.rs | 51 ++++- crates/horizon-client/src/game/dragon.rs | 15 +- .../src/game/level_particles.rs | 205 ++++++++++++++++-- crates/horizon-client/src/particles.rs | 13 +- 4 files changed, 261 insertions(+), 23 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index cdbd72d7b..f042bc28d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2800,6 +2800,34 @@ impl Game<'_> { pos, data, } => self.composter_fill_event(pos, data == 1), + // LevelEventHandler 1504: the stalactite's drip particle. + LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos), + // LevelEventHandler 2006: the dragon fireball's burst. + LevelEvent::LevelEvent { + id: 2006, + pos, + data, + } => self.dragon_fireball_burst(pos, data), + // LevelEventHandler 2009: a wet sponge drying. + LevelEvent::LevelEvent { id: 2009, pos, .. } => { + self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud) + } + // LevelEventHandler 2012: a turtle egg laid. + LevelEvent::LevelEvent { + id: 2012, + pos, + data, + } => self.turtle_egg_particles(pos, data), + // LevelEventHandler 2014: a straw bed destroyed, with no sound. + LevelEvent::LevelEvent { + id: 2014, + pos, + data, + } => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)), + // LevelEventHandler 3009: a sniffer egg cracking. + LevelEvent::LevelEvent { id: 3009, pos, .. } => { + self.sniffer_egg_crack_particles(pos) + } LevelEvent::LevelEvent { id: id @ 3011..=3021, pos, @@ -2815,6 +2843,16 @@ impl Game<'_> { 1501 => Some(("minecraft:block.lava.extinguish", 0.5)), 1009 => Some(("minecraft:block.fire.extinguish", 0.5)), 1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)), + 1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)), + 1034 => Some(("minecraft:block.chorus_flower.death", 1.0)), + 1046 => Some(( + "minecraft:block.pointed_dripstone.drip_lava_into_cauldron", + 2.0, + )), + 1047 => Some(( + "minecraft:block.pointed_dripstone.drip_water_into_cauldron", + 2.0, + )), 1030 => Some(("minecraft:block.anvil.use", 1.0)), 1029 => Some(("minecraft:block.anvil.destroy", 1.0)), 1031 => Some(("minecraft:block.anvil.land", 0.3)), @@ -2826,12 +2864,12 @@ impl Game<'_> { }; if let Some((name, volume)) = sound { let pitch = match id { - 1009 => { + 1009 | 1501 | 1502 => { 2.6 + (self.world.random.next_float() - self.world.random.next_float()) * 0.8 } - 1029 | 1030 | 1042 | 1043 | 1044 => { + 1029 | 1030 | 1042 | 1043 | 1044 | 1046 | 1047 => { self.world.random.next_float() * 0.1 + 0.9 } _ => 1.0, @@ -2844,6 +2882,15 @@ impl Game<'_> { self.audio.play(name, c, volume, pitch); } } + // LevelEventHandler 1501 and 1502 draw their smoke after the sound. + match id { + 1501 => self.puffs_above_block( + pos, + horizon_client::particles::ParticleType::LargeSmoke, + ), + 1502 => self.torch_burnout_smoke(pos), + _ => {} + } } LevelEvent::Sound { pos, diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 8c316deeb..c83018c82 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2089,11 +2089,16 @@ impl Game<'_> { duration: 600, from_dragon: false, }); - self.audio.play( - "minecraft:entity.dragon_fireball.explode", - Some([next.x, next.y, next.z]), - 1.0, - 1.0, + // DragonFireball.onHit: level event 2006 (the burst of dragon + // breath and the explosion sound) at its block position. + self.level_event_at( + 2006, + [ + next.x.floor() as i32, + next.y.floor() as i32, + next.z.floor() as i32, + ], + 1, ); return false; } diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index f12aedc5b..e6593e7b9 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -1,11 +1,13 @@ //! Level events whose client handler (`LevelEventHandler`) spawns particles //! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a //! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax -//! off (3004) and scraping (3005). +//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon +//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009. use super::Game; use horizon_client::particles::{self, ParticleOptions, ParticleType}; -use horizon_core::{position::BlockPos, random::RandomSource}; +use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource}; use horizon_world::level::LevelEvent; +use horizon_world::registry::BlockStateId; impl Game<'_> { pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) { @@ -65,24 +67,42 @@ impl Game<'_> { } else { // ParticleUtils.spawnParticleInBlock: up to the // block's shape (a full block when air). - let inside = self.world.block(at[0], at[1], at[2]); - let height = if facts.is_air(inside) { - 1.0 - } else { - facts - .get(inside) - .outline - .iter() - .map(|b| b[4]) - .fold(0.0, f64::max) - }; - (at, count, 0.5, height, true) + (at, count, 0.5, self.block_shape_top(at), true) } } None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false), None => return, }; - let options = ParticleOptions::simple(ParticleType::HappyVillager); + self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager); + } + /// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air. + fn block_shape_top(&self, at: [i32; 3]) -> f64 { + let facts = self.res.facts(); + let inside = self.world.block(at[0], at[1], at[2]); + if facts.is_air(inside) { + 1.0 + } else { + facts + .get(inside) + .outline + .iter() + .map(|b| b[4]) + .fold(0.0, f64::max) + } + } + /// `ParticleUtils.spawnParticles`: `count` slow particles spread over + /// `xz` blocks around `at` and `height` up, only above solid ground + /// unless floating ones are allowed. + fn spawn_particles( + &mut self, + at: [i32; 3], + count: i32, + xz: f64, + height: f64, + floating: bool, + ty: ParticleType, + ) { + let options = ParticleOptions::simple(ty); for _ in 0..count.max(0) { let r = self.particles.level_random(); let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02); @@ -101,6 +121,161 @@ impl Game<'_> { } } } + /// `LevelEventHandler` 2012 (a turtle egg laid on sand): + /// `ParticleUtils.spawnParticleInBlock` with happy villager sparks. + pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) { + let at = [pos.x, pos.y, pos.z]; + let height = self.block_shape_top(at); + self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager); + } + /// `LevelEventHandler` 3009 (a sniffer egg cracking on moss): + /// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`. + pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) { + let mut spawned = Vec::new(); + particles::on_block_faces_between( + self.particles.level_random(), + [pos.x, pos.y, pos.z], + 3, + 6, + |p, v| spawned.push((p, v)), + ); + let options = ParticleOptions::simple(ParticleType::EggCrack); + for (p, v) in spawned { + self.particles.add(&options, p, v); + } + } + /// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried): + /// eight puffs just above the block, drawn after the sound. + pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) { + let options = ParticleOptions::simple(ty); + for _ in 0..8 { + let r = self.particles.level_random(); + let x = f64::from(pos.x) + r.next_double(); + let z = f64::from(pos.z) + r.next_double(); + self.particles + .add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]); + } + } + /// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke + /// puffs inside the block, drawn after the sound. + pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) { + let options = ParticleOptions::simple(ParticleType::Smoke); + for _ in 0..5 { + let r = self.particles.level_random(); + let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2; + let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2; + let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2; + self.particles.add(&options, [x, y, z], [0.0; 3]); + } + } + /// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath + /// particles thrown outwards, then the explosion sound when `data` is 1. + pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) { + for _ in 0..200 { + let r = self.particles.level_random(); + let dist = r.next_float() * 4.0; + let angle = r.next_float() * std::f32::consts::TAU; + let vx = f64::from(trig::cos(f64::from(angle)) * dist); + let vy = 0.01 + r.next_double() * 0.5; + let vz = f64::from(trig::sin(f64::from(angle)) * dist); + let options = ParticleOptions { + scale: dist, + ..ParticleOptions::simple(ParticleType::DragonBreath) + }; + self.particles.add( + &options, + [ + f64::from(pos.x) + vx * 0.1, + f64::from(pos.y) + 0.3, + f64::from(pos.z) + vz * 0.1, + ], + [vx, vy, vz], + ); + } + if data == 1 { + let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5); + let pitch = self.world.random.next_float() * 0.1 + 0.9; + self.audio.play( + "minecraft:entity.dragon_fireball.explode", + Some(at), + 1.0, + pitch, + ); + } + } + /// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down. + fn is_stalactite(&self, state: BlockStateId) -> bool { + self.res.facts().get(state).has_class("SpeleothemBlock") + && self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("vertical_direction")) + .is_some_and(|d| d == "down") + } + /// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at + /// the tip `pos`. The fluid above the stalactite's root picks the drip; + /// with none, the dimension's default (lava in the Nether). + pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) { + let tip_state = self.world.block(pos.x, pos.y, pos.z); + if !self.is_stalactite(tip_state) { + return; + } + // findRootBlock: up through the same stalactite, at most 11 steps. + let tip_name = self.res.facts().get(tip_state).name.clone(); + let mut root = None; + for step in 1..11 { + let y = pos.y + step; + if !(horizon_client::chunk::MIN_Y + ..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT) + .contains(&y) + { + break; + } + let state = self.world.block(pos.x, y, pos.z); + if self.res.facts().get(state).name != tip_name { + root = Some(y); + break; + } + if !self.is_stalactite(state) { + break; + } + } + let Some(root_y) = root else { + return; + }; + let above = self.world.block(pos.x, root_y + 1, pos.z); + let above_facts = self.res.facts().get(above); + let nether = self.dim == super::Dim::Nether; + let fluid = if &*above_facts.name == "minecraft:mud" && !nether { + horizon_client::blocks::Fluid::Water + } else { + above_facts.fluid + }; + let ty = match fluid { + horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater, + horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava, + horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava, + horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater, + }; + let offset = self + .pack + .context + .tables + .offset(tip_state, pos.x, pos.z) + .unwrap_or([0.0; 3]); + // The tip shape starts 5/16 up; the particle sits a pixel below that. + self.particles.add( + &ParticleOptions::simple(ty), + [ + f64::from(pos.x) + 0.5 + offset[0], + f64::from(pos.y) + 5.0 / 16.0 - 0.0625, + f64::from(pos.z) + 0.5 + offset[2], + ], + [0.0; 3], + ); + } /// `ServerLevel.globalLevelEvent`: heard by every player on the server /// (an end portal opening, the dragon's death roar): here, and sent to /// every guest whatever their dimension. diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index fa9dc9c6e..3099069ba 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -751,6 +751,17 @@ fn next_double_between(random: &mut impl RandomSource, min: f64, max: f64) -> f6 pub fn on_block_faces( random: &mut impl RandomSource, pos: [i32; 3], + emit: impl FnMut([f64; 3], [f64; 3]), +) { + on_block_faces_between(random, pos, 3, 5, emit); +} +/// `ParticleUtils.spawnParticlesOnBlockFaces` with `UniformInt.of(min, max)` +/// particles per face (3009 sniffer egg cracks use 3 to 6). +pub fn on_block_faces_between( + random: &mut impl RandomSource, + pos: [i32; 3], + min: i32, + max: i32, mut emit: impl FnMut([f64; 3], [f64; 3]), ) { const FACES: [[i32; 3]; 6] = [ @@ -762,7 +773,7 @@ pub fn on_block_faces( [1, 0, 0], ]; for face in FACES { - let count = random.next_int_bound(5 - 3 + 1).unwrap_or(0) + 3; + let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min; for _ in 0..count { // getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55. let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5)); From 2e91bd84e9ee9f0ddbca7bd9e53bdc7edd9ca0f7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:05:47 +0100 Subject: [PATCH 013/475] Send villager, taming, love, spawn poof and snowball entity events to server clients Single player on the server thread drew these particles only into the server's own engine. The server now sends the entity event byte where vanilla does (20, 12/13/14, 6/7, 18 and 3), and the client draws it in handleEntityEvent's way. --- crates/horizon-client/src/game/allays.rs | 2 +- crates/horizon-client/src/game/creatures.rs | 47 ++++++++++--------- crates/horizon-client/src/game/lan.rs | 10 ++++ crates/horizon-client/src/game/llamas.rs | 6 +++ crates/horizon-client/src/game/mounts.rs | 21 ++++++--- crates/horizon-client/src/game/nautili.rs | 9 ++-- crates/horizon-client/src/game/remote.rs | 31 ++++++++++++ crates/horizon-client/src/game/spawner.rs | 11 ++++- crates/horizon-client/src/game/villagers.rs | 35 ++++++++++---- crates/horizon-client/src/mobs.rs | 5 +- .../horizon-client/src/mobs/villager_brain.rs | 15 ++++-- 11 files changed, 145 insertions(+), 47 deletions(-) diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index bed835663..a1d2ba1df 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -160,7 +160,7 @@ impl Game<'_> { } // Allay.duplicateAllay: entity event 18, three spawnHeartParticle. let id = self.mobs[index].id; - self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY); + self.mob_event_particles(id, 18); self.audio .play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0); self.consume_held(); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..0d7b1f1bc 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1105,12 +1105,17 @@ impl Game<'_> { } } MobEvent::AroundSelf { + mob, + event, kind, feet, width, height, burst, - } => self.particles_around(kind, feet, [width, height], burst), + } => { + self.lan_entity_event(mob as i32, event, feet); + self.particles_around(kind, feet, [width, height], burst) + } MobEvent::ParticleAt { kind, at, velocity } => { if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) { let options = horizon_client::particles::ParticleOptions::simple(ty); @@ -1302,6 +1307,8 @@ impl Game<'_> { q.extra.in_love = 0; } let q = self.mobs.iter().find(|m| m.id == partner).cloned(); + // Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent. + self.mob_event_particles(parent, 18); // Sniffer.spawnChildFromBreeding: an egg instead of a baby. if p.species() == Species::Sniffer { self.lay_sniffer_egg(&p); @@ -2148,6 +2155,7 @@ impl Game<'_> { }; m.damage(amount, source); } + self.thrown_break_event(p.id, atp); self.item_burst(atp, "minecraft:snowball"); } (ProjectileKind::Egg, hit) => { @@ -2173,6 +2181,7 @@ impl Game<'_> { } } } + self.thrown_break_event(p.id, atp); self.item_burst(atp, "minecraft:egg"); } (ProjectileKind::EnderPearl, hit) => { @@ -2402,10 +2411,17 @@ impl Game<'_> { } Ok(()) } + /// `Snowball`/`ThrownEgg.onHit`: entity event 3 for the guests, before the + /// projectile is discarded (the clients lose it when the tracker next runs). + fn thrown_break_event(&mut self, id: u32, at: [f64; 3]) { + if id != 0 { + self.lan_entity_event(id as i32, 3, at); + } + } /// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the /// thrown item where it broke, at rest for a snowball, scattered by the /// client entity's random (the particle engine's here) for an egg. - fn item_burst(&mut self, at: [f64; 3], item: &str) { + pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) { let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1)); let egg = item != "minecraft:snowball"; for _ in 0..8 { @@ -2662,14 +2678,8 @@ impl Game<'_> { m.ai.target = None; m.extra.sitting = true; } - // TamableAnimal.spawnTamingParticles. - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); if tamed { let view = self.mob_entity(&self.mobs[index]); self.trigger_entity("minecraft:tame_animal", "entity", &view); @@ -2736,13 +2746,8 @@ impl Game<'_> { m.extra.sitting = true; } eat = eating_sound(m, &mut self.world.random); - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); if tamed { let view = self.mob_entity(&self.mobs[index]); self.trigger_entity("minecraft:tame_animal", "entity", &view); @@ -2779,13 +2784,8 @@ impl Game<'_> { m.extra.tame = true; m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1])); } - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); if tamed { let view = self.mob_entity(&self.mobs[index]); self.trigger_entity("minecraft:tame_animal", "entity", &view); @@ -2803,6 +2803,11 @@ impl Game<'_> { Some(sound) => { consume = true; eat = sound; + // Animal.setInLove: entity event 18 (a baby that + // only grew is not in love). + if self.mobs[index].extra.in_love > 0 { + self.mob_event_particles(self.mobs[index].id, 18); + } } // Animal.mobInteract still consumes the client click when // an adult cannot breed, without swinging or taking food. diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50fa222e6..0f946161f 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -913,6 +913,16 @@ impl Game<'_> { m.needs_sync = false; } let entities = states.as_slice(); + // An entity that left the level since the last tick (a snowball + // that hit) is dropped by the tracker below: its event goes out + // first, as `Level.broadcastEntityEvent` precedes the discard. + let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events) + .into_iter() + .partition(|(id, ..)| !entities.iter().any(|e| e.id == *id)); + self.lan_entity_events = later; + for (id, event, at) in gone { + host.hub.entity_event(id, event, at); + } let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { let clock_rate = self.network_clock_rate(); diff --git a/crates/horizon-client/src/game/llamas.rs b/crates/horizon-client/src/game/llamas.rs index 1ce518f31..080343952 100644 --- a/crates/horizon-client/src/game/llamas.rs +++ b/crates/horizon-client/src/game/llamas.rs @@ -25,11 +25,13 @@ impl Game<'_> { }; let m = &mut self.mobs[index]; let mut used = false; + let mut loved = false; if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 { m.extra.in_love = 600; m.extra.love_player = true; used = true; + loved = true; } if m.health < m.attrs.max_health { m.health = (m.health + heal).min(m.attrs.max_health); @@ -57,6 +59,10 @@ impl Game<'_> { if !used { return false; } + // Animal.setInLove: entity event 18. + if loved { + self.mob_event_particles(self.mobs[index].id, 18); + } self.llama_sound(index, "entity.llama.eat", 1.0); self.consume_held(); true diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index fa4bbc209..60c6d143c 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -215,10 +215,12 @@ impl Game<'_> { return false; } let mut used = false; + let mut loved = false; if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 { m.extra.in_love = 600; m.extra.love_player = true; used = true; + loved = true; } if m.health < m.attrs.max_health && heal > 0.0 { m.health = (m.health + heal).min(m.attrs.max_health); @@ -238,6 +240,7 @@ impl Game<'_> { // eating(): the mouth opens and the horse eats. m.anim.v[2] = 1.0; let at = m.center(); + let id = m.id; let sound = match m.species() { Species::Camel => "minecraft:entity.camel.eat", Species::CamelHusk => "minecraft:entity.camel_husk.eat", @@ -247,6 +250,10 @@ impl Game<'_> { }; let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0; self.audio.play(sound, Some(at), 1.0, pitch); + // Animal.setInLove: entity event 18. + if loved { + self.mob_event_particles(id, 18); + } self.consume_held(); true } @@ -452,14 +459,16 @@ impl Game<'_> { m.extra.owner = owner; m.persistent = true; // AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true). - self.particles_around_mob( - mob, - "heart", - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(mob, 7); } } - MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(), + MobEvent::Buck { mob } => { + if self.riding == Some(mob) { + self.dismount_mount(); + } + // RunAroundLikeCrazyGoal.tick: entity event 6, smoke. + self.mob_event_particles(mob, 6); + } _ => {} } } diff --git a/crates/horizon-client/src/game/nautili.rs b/crates/horizon-client/src/game/nautili.rs index e7f16ca18..5e384db98 100644 --- a/crates/horizon-client/src/game/nautili.rs +++ b/crates/horizon-client/src/game/nautili.rs @@ -85,13 +85,8 @@ impl Game<'_> { m.ai.target = None; } let sound = nautilus::eat_sound(m); - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); self.nautilus_sound(index, sound); if tamed { let view = self.mob_entity(&self.mobs[index]); @@ -145,6 +140,8 @@ impl Game<'_> { let sound = nautilus::eat_sound(m); self.use_nautilus_food(item); self.nautilus_sound(index, sound); + // Animal.setInLove: entity event 18. + self.mob_event_particles(self.mobs[index].id, 18); return true; } false diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5651647b7..2fe1a3344 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2072,6 +2072,37 @@ impl Game<'_> { if p.event == super::totem::TOTEM_EVENT { self.remote_totem(p.id); } + // Mob.handleEntityEvent 20: the spawn poof. + if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) { + self.poof_particles(p.id as u32); + } + // Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and + // 7, Animal and Allay 18: the particles about the mob. + if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) { + self.entity_event_particles(p.id as u32, p.event); + } + // Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where + // the projectile is (the server removes it right after). + if p.event == 3 { + let crumbs = self + .creatures + .projectiles + .iter() + .find(|pr| pr.id == p.id as u32) + .and_then(|pr| { + let item = match pr.kind { + horizon_client::projectiles::ProjectileKind::Snowball => { + "minecraft:snowball" + } + horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg", + _ => return None, + }; + Some(([pr.position.x, pr.position.y, pr.position.z], item)) + }); + if let Some((at, item)) = crumbs { + self.item_burst(at, item); + } + } } C::Animate(p) => self.remote_animate(p.id, p.action), // ClientPacketListener.handleSwingAnimation: the entity swings diff --git a/crates/horizon-client/src/game/spawner.rs b/crates/horizon-client/src/game/spawner.rs index 538cb39e7..f2835446f 100644 --- a/crates/horizon-client/src/game/spawner.rs +++ b/crates/horizon-client/src/game/spawner.rs @@ -257,8 +257,17 @@ impl Game<'_> { let Some(m) = self.mobs.iter().find(|m| m.id == id) else { return; }; - let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height())); + let at = m.position; self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]); + self.poof_particles(id); + } + /// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about + /// mob `id`. + pub(super) fn poof_particles(&mut self, id: u32) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height())); let options = ParticleOptions::simple(ParticleType::Poof); for _ in 0..20 { let r = self.particles.level_random(); diff --git a/crates/horizon-client/src/game/villagers.rs b/crates/horizon-client/src/game/villagers.rs index 3cfe8aa1d..092faaadf 100644 --- a/crates/horizon-client/src/game/villagers.rs +++ b/crates/horizon-client/src/game/villagers.rs @@ -116,7 +116,7 @@ impl Game<'_> { // VillagerMakeLove: entity event 13 for the partner, then the parent. for i in [ib, ia] { let id = self.mobs[i].id; - self.villager_particles(id, "angry_villager"); + self.mob_event_particles(id, 13); } return; }; @@ -141,12 +141,31 @@ impl Game<'_> { child.extra.bed = Some(bed); } // VillagerMakeLove.giveBirth: entity event 12 for the child. - self.villager_particles(id, "heart"); + self.mob_event_particles(id, 12); } - /// `Villager.handleEntityEvent` 12, 13, 14 and 42: - /// `AbstractVillager.addParticlesAroundSelf`. - pub(super) fn villager_particles(&mut self, id: u32, kind: &'static str) { - self.particles_around_mob(id, kind, horizon_client::particles::Burst::VILLAGER); + /// `Level.broadcastEntityEvent` of an event whose `handleEntityEvent` + /// draws particles about the mob (villager 12, 13 and 14, taming 6 and 7, + /// love 18): the guests get the byte, and this game draws it as its own + /// client would. + pub(super) fn mob_event_particles(&mut self, id: u32, event: i8) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + self.lan_entity_event(id as i32, event, at); + self.entity_event_particles(id, event); + } + /// `handleEntityEvent(event)`'s particles about mob `id` + /// (`Villager`, `TamableAnimal`, `AbstractHorse`, `Animal`, `Allay`). + pub(super) fn entity_event_particles(&mut self, id: u32, event: i8) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + if let Some((kind, burst)) = + horizon_client::particles::entity_event_burst(&m.kind.id, event as u8) + { + self.particles_around_mob(id, kind, burst); + } } /// `particles::around_self` about mob `id`. pub(super) fn particles_around_mob( @@ -283,7 +302,7 @@ impl Game<'_> { } // Villager.setLastHurtByMob: entity event 13. for id in angry { - self.villager_particles(id, "angry_villager"); + self.mob_event_particles(id, 13); } } /// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a @@ -338,7 +357,7 @@ impl Game<'_> { gossip::REPUTATION_CHANGE_PER_TRADE, ); // Villager.customServerAiStep after a trade: entity event 14. - self.villager_particles(id, "happy_villager"); + self.mob_event_particles(id, 14); } /// `Villager.getPlayerReputation` of the local player. pub(super) fn villager_reputation(&self, id: u32) -> i32 { diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..e8fa6eb91 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1011,8 +1011,11 @@ pub enum MobEvent { at: [f64; 3], count: u32, }, - /// Client particles about a mob's box (`particles::around_self`). + /// Client particles about a mob's box (`particles::around_self`) and the + /// entity event `event` that shows them, sent for mob `mob`. AroundSelf { + mob: u32, + event: i8, kind: &'static str, feet: [f64; 3], width: f64, diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 7b311a9e0..339318a06 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -2905,11 +2905,18 @@ impl B { let mut bodies = Vec::new(); if let Some(t) = cx.w.target_info(target) { let (min, max) = (t.bounds.min_position(), t.bounds.max_position()); - bodies.push((t.position, max.x - min.x, max.y - min.y)); + bodies.push((partner, t.position, max.x - min.x, max.y - min.y)); } - bodies.push((m.position, f64::from(m.width()), f64::from(m.height()))); - for (feet, width, height) in bodies { + bodies.push(( + m.id, + m.position, + f64::from(m.width()), + f64::from(m.height()), + )); + for (mob, feet, width, height) in bodies { cx.events.push(MobEvent::AroundSelf { + mob, + event: 12, kind: "heart", feet: [feet.x, feet.y, feet.z], width, @@ -3580,6 +3587,8 @@ fn pois_scan_order(_m: &Mob, found: &[(Poi, [i32; 3])]) -> Vec { /// `AbstractVillager.addParticlesAroundSelf(HAPPY_VILLAGER)` on the client. fn happy_particles(m: &Mob, cx: &mut Bx) { cx.events.push(MobEvent::AroundSelf { + mob: m.id, + event: 14, kind: "happy_villager", feet: [m.position.x, m.position.y, m.position.z], width: f64::from(m.width()), From 4825e537ccc51d123bcc0f97d777e3330a570f52 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:05:51 +0100 Subject: [PATCH 014/475] Tilt a server player's camera towards the hit The server never sent ClientboundHurtAnimationPacket, and the client ignored the yaw it carried, so a player on a server got the flash but the camera never tilted towards where the hit came from. ServerPlayer.indicateDamage now runs where the hit's knockback is dealt (and with no direction for hits that have none), the packet goes out when the guest's turn ends, and the client keeps the yaw as Player.hurtDir for GameRenderer.bobHurt. --- crates/horizon-client/src/game/creatures.rs | 4 +++ crates/horizon-client/src/game/fov.rs | 2 +- crates/horizon-client/src/game/lan_actions.rs | 7 +++++ crates/horizon-client/src/game/remote.rs | 3 ++- crates/horizon-client/src/health.rs | 27 +++++++++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..2f80be926 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1528,6 +1528,10 @@ impl Game<'_> { // hurt cooldown (a stronger hit there only adds its difference). if o.knockback { let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z); + // dealDefaultKnockback's `indicateDamage`, unless blocked. + if o.blocked <= 0.0 { + player.health.indicate_damage(xd, zd, player.yaw); + } self.knock_back_player(f64::from(0.4_f32), xd, zd); if let Some(p) = self.player.as_mut() && let Some(r) = p.hurt_log.last_mut() diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 95e2e1e6e..364f89189 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -114,7 +114,7 @@ impl Game<'_> { living: Some(HurtBob { hurt_time: p.health.hurt_animation_time as f32 - partial, hurt_duration: hurt.hurt_duration, - hurt_dir: 0.0, + hurt_dir: p.health.hurt_dir, death_time: self.death_time().map(|t| t as f32 + partial), }), player: Some(( diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..e6869650b 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -650,6 +650,13 @@ impl Game<'_> { g.sprinting = false; } m.sprinting = false; + // ServerPlayer.indicateDamage: the hit's direction to the player. + if let Some(yaw) = m.health.indicated_dir.take() { + g.send(&C::HurtAnimation(play::HurtAnimation { + id: g.entity_id, + yaw, + })); + } let teleported = std::mem::take(&mut m.teleported); let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum(); g.motion = Some(Box::new(m)); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5651647b7..c4571c3a9 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2042,8 +2042,9 @@ impl Game<'_> { } C::HurtAnimation(p) => { if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { + // Player.animateHurt: the tilt turns around the yaw. if let Some(pl) = self.player.as_mut() { - pl.health.animate_hurt(); + pl.health.animate_hurt_dir(p.yaw); } } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.hurt_time = 10; diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 25c719bfb..b9670d065 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -163,6 +163,12 @@ pub struct PlayerHealth { pub hurt: State, /// Client hurt animation; fully blocked hits only refresh the server's hurt state. pub hurt_animation_time: i32, + /// `Player.hurtDir` on a client: the yaw `ClientboundHurtAnimationPacket` + /// gave, which `GameRenderer.bobHurt` tilts the camera around. + pub hurt_dir: f32, + /// `ServerPlayer.hurtDir` of a hit the player's client hasn't been told + /// of yet (`indicateDamage`). + pub indicated_dir: Option, pub dead: bool, pub sync_velocity: bool, pub game_time: i64, @@ -213,6 +219,8 @@ impl PlayerHealth { last_stamp: 0, }, hurt_animation_time: 0, + hurt_dir: 0.0, + indicated_dir: None, dead: false, sync_velocity: false, game_time: 0, @@ -260,6 +268,18 @@ impl PlayerHealth { self.hurt.hurt_time = 10; self.hurt_animation_time = 10; } + /// `LivingEntity.animateHurt(yaw)` on a player: the tilt restarts around + /// `yaw` (`Player.animateHurt` keeps it as `hurtDir`). + pub fn animate_hurt_dir(&mut self, yaw: f32) { + self.animate_hurt(); + self.hurt_dir = yaw; + } + /// `ServerPlayer.indicateDamage`: the hit's direction relative to where + /// the player looks, kept for the packet to its client. + pub fn indicate_damage(&mut self, xd: f64, zd: f64, yaw: f32) { + use horizon_core::geometry::trig::{RAD_TO_DEG, atan2}; + self.indicated_dir = Some((atan2(zd, xd) * f64::from(RAD_TO_DEG) - f64::from(yaw)) as f32); + } /// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current /// health starts the hurt animation; `damageCooldownTime` (20 on a drop, /// 10 on a gain) is what lets `Hud.extractPlayerHealth` blink the hearts. @@ -348,6 +368,13 @@ impl PlayerMotion { if outcome.hurt { h.hurt_animation_time = h.hurt.hurt_time; } + // `dealDefaultKnockback` ends in `indicateDamage` unless blocked: a + // hit with no direction of its own (`xd` and `zd` zero) is sent as + // the opposite of the player's yaw. A hit that has a direction + // replaces this (`mob_hits_player`). + if outcome.knockback && outcome.blocked <= 0.0 { + h.indicate_damage(0.0, 0.0, self.yaw); + } let after = h.health() + h.absorption(); if self.hurt_log.len() >= HURT_LOG { self.hurt_log.remove(0); From 6bc8a8b8899fc5be2e6c03485db8b6b9b47ff05d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:09:45 +0100 Subject: [PATCH 015/475] Send sweep, damage indicator and fishing particles to a server's players Vanilla sends these with ServerLevel.sendParticles, so every player nearby sees them. Horizon drew them only in the host window's own particle engine, which a window on a server (and a guest next to the host) never sees. One helper now sends a ClientboundLevelParticlesPacket with the same count, offset and speed to each player within 32 blocks. While the host holds the players (a guest's turn, a host hit on a guest) the packet waits and goes out when the tick ends. --- crates/horizon-client/src/game.rs | 22 +++++-- crates/horizon-client/src/game/creatures.rs | 9 +++ crates/horizon-client/src/game/fishing.rs | 2 + crates/horizon-client/src/game/lan.rs | 2 + .../horizon-client/src/game/lan_particles.rs | 61 +++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index cdbd72d7b..765354a0f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7455,13 +7455,15 @@ impl Game<'_> { } /// The visual and sound part of `Player.doSweepAttack` after a successful hit. pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) { - self.particles.add( - &horizon_client::particles::ParticleOptions::simple( - horizon_client::particles::ParticleType::SweepAttack, - ), - at, - [0.0; 3], + let sweep = horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::SweepAttack, ); + self.particles.add(&sweep, at, [0.0; 3]); + // Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`: + // the offset is the one from the player to the particle. + let origin = from.unwrap_or(at); + let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]]; + self.lan_send_particles(&sweep, at, spread, 0.0, 0); self.audio .play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0); } @@ -7625,6 +7627,14 @@ impl Game<'_> { 0.2, (actual * 0.5) as u32, ); + // The players a server serves are sent them too. + self.lan_send_particles( + &indicator, + [feet[0], feet[1] + height * 0.5, feet[2]], + [0.1, 0.0, 0.1], + 0.2, + (actual * 0.5) as u32, + ); } self.last_hurt_by_player .insert(self.mobs[index].id, self.lan_acting); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..2fd9f588c 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -49,6 +49,15 @@ pub(super) struct Creatures { pub elder_apparition: i32, /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, + /// Particles from a guest's turn, waiting for it to end + /// (`lan_send_particles`: options, centre, spread, speed, count). + pub lan_particles: Vec<( + horizon_client::particles::ParticleOptions, + [f64; 3], + [f64; 3], + f64, + u32, + )>, } struct ProjWorld<'a, 'w> { w: &'a MobWorld<'w>, diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index d454df406..b62822beb 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -416,6 +416,8 @@ impl Game<'_> { let options = ParticleOptions::simple(kind); self.particles .send_particles(&options, at, spread, speed, count); + // The players a server serves are sent them too. + self.lan_send_particles(&options, at, spread, speed, count); } /// `FishingHook.tick`. pub(super) fn tick_fishing(&mut self) { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50fa222e6..af89844c6 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1046,6 +1046,8 @@ impl Game<'_> { self.lan = Some(host); // A mace's wind burst from a guest's attack. let _ = self.flush_wind_bursts(); + // Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes). + self.flush_lan_particles(); } fn lan_guest_joined( diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 0e35392bf..b1790686e 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -191,6 +191,67 @@ impl Game<'_> { } } + /// `ServerLevel.sendParticles` for the players a server serves: one + /// `ClientboundLevelParticlesPacket` to each player in the level whose + /// block is within 32 blocks of `at`. The window's own player is not + /// sent it (the caller draws it in this window). While the host holds + /// the players (a guest's turn through `as_guest`, a host's hit on a + /// guest) the packet waits for the turn to end so that it reaches every + /// player, the guest too. + pub(super) fn lan_send_particles( + &mut self, + options: &ParticleOptions, + at: [f64; 3], + spread: [f64; 3], + speed: f64, + count: u32, + ) { + if self.lan.is_none() { + // The server is running (its sounds are captured for the guests). + if self.audio.capture.is_some() { + self.creatures + .lan_particles + .push((options.clone(), at, spread, speed, count)); + } + return; + } + let Some(particle) = self.network_particle(options, None) else { + return; + }; + let Some(host) = self.lan.as_ref() else { + return; + }; + let body = C::LevelParticles(play::LevelParticles { + particle, + override_limiter: false, + always_show: false, + pos: at, + dist: spread.map(|d| d as f32), + max_speed: [speed as f32; 3], + count: count as i32, + randomization: 0, + }) + .encode(); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + // BlockPos.closerToCenterThan(at, 32): from the centre of the + // player's block. + let d: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if d < 32.0 * 32.0 { + g.sender.send(body.clone()); + } + } + } + + /// The particles guests' turns put off. + pub(super) fn flush_lan_particles(&mut self) { + for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles) + { + self.lan_send_particles(&options, at, spread, speed, count); + } + } + /// `ServerLevel.explode`'s packet to each player within 64 blocks. The /// sound and the push reach them on their own (the level's sounds, the /// motion a hurt player is sent), so the packet carries neither. From 8697f3fd3f91a8e6f48d5ca19882413b7a55ba2b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:13:32 +0100 Subject: [PATCH 016/475] Furnaces: drying a wet sponge fills the bucket in the fuel slot --- crates/horizon-client/src/containers.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index d13c5e45b..b0e034e5b 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -639,6 +639,18 @@ impl Furnace { None => self.items[2] = Some(recipe.result.clone()), } *self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1; + // AbstractFurnaceBlockEntity.burn: drying a wet sponge fills + // the bucket in the fuel slot. + if self.items[0] + .as_ref() + .is_some_and(|i| &*i.item == "minecraft:wet_sponge") + && self.items[1] + .as_ref() + .is_some_and(|f| &*f.item == "minecraft:bucket") + { + self.items[1] = + Some(ItemStack::new("minecraft:water_bucket", 1)); + } if let Some(i) = self.items[0].as_mut() { i.count -= 1; if i.count == 0 { From 2f6f0e3e0f22c91d1cf7205563e2574d75d6f570 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Mon, 26 Oct 2026 13:16:35 +0100 Subject: [PATCH 017/475] Launcher: Game arguments edits at the caret The field only appended and popped the last character, and Space typed nothing on Windows (it arrives without a character). A shared one-line field, launcher/src/line_edit.rs, now holds the text, caret, selection and undo history: arrows, Home/End, Ctrl+arrows by word, Shift to select, mouse click, drag, double and triple click, Ctrl+A/C/X/V and Ctrl+Z / Ctrl+Y. --- launcher/src/line_edit.rs | 672 ++++++++++++++++++++++++++++++++++++++ launcher/src/main.rs | 53 ++- 2 files changed, 692 insertions(+), 33 deletions(-) create mode 100644 launcher/src/line_edit.rs diff --git a/launcher/src/line_edit.rs b/launcher/src/line_edit.rs new file mode 100644 index 000000000..0647f5861 --- /dev/null +++ b/launcher/src/line_edit.rs @@ -0,0 +1,672 @@ +//! The one-line text field every launcher input shares: the state (text, caret, selection, +//! undo), the keyboard (`handle`) and what it draws (`content`). A page keeps its own box and +//! its Enter/Escape; this is what is inside it. Positions count characters, not bytes. + +use super::*; +use gpui::{ + black, canvas, Bounds, Font, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels, Point, + TextRun, +}; +use std::{cell::RefCell, rc::Rc}; + +/// What a key did to the field. +#[derive(Debug, PartialEq)] +pub(super) enum Key { + Enter, + Escape, + /// Not the field's: Tab (focus), shortcuts. + Pass, + /// The caret or the selection moved; the text is the same. + Moved, + /// The text changed. + Edited, + /// Ctrl/Cmd-V: `handle` inserts the clipboard's first line. + Paste, + /// Ctrl/Cmd-C: `handle` puts this on the clipboard. + Copy(String), + /// Ctrl/Cmd-X: the text changed; `handle` puts this on the clipboard. + Cut(String), +} + +/// What the last edit was, so a run of typing undoes as one step. +#[derive(Clone, Copy, PartialEq)] +enum Run { + None, + Typing, + Deleting, + Other, +} + +/// Where the text was drawn last frame, and how: what turns a mouse position into a character. +struct Area { + bounds: Bounds, + font: Font, + size: Pixels, + mask: bool, +} + +struct Snap { + text: String, + caret: usize, +} + +pub(super) struct LineEdit { + text: String, + caret: usize, + /// The other end of the selection; equal to `caret` when nothing is selected. + anchor: usize, + scroll: usize, + max: usize, + /// How many characters the box shows. + visible: usize, + undo: Vec, + redo: Vec, + run: Run, + area: Rc>>, + /// A drag that started in this field is on. + dragging: bool, +} + +const HISTORY: usize = 100; + +impl LineEdit { + pub(super) fn new(text: String, max: usize, visible: usize) -> Self { + let caret = text.chars().count(); + let mut e = LineEdit { + text, + caret, + anchor: caret, + scroll: 0, + max, + visible, + undo: Vec::new(), + redo: Vec::new(), + run: Run::None, + area: Rc::default(), + dragging: false, + }; + e.reveal(); + e + } + + pub(super) fn text(&self) -> &str { + &self.text + } + + pub(super) fn select_all(&mut self) { + self.anchor = 0; + self.caret = self.len(); + } + + /// The character under `pos`, from where the text was drawn last frame. + fn index_at(&self, window: &Window, pos: Point) -> Option { + let area = self.area.borrow(); + let a = area.as_ref()?; + let shown: String = self + .text + .chars() + .skip(self.scroll) + .take(self.visible) + .collect(); + let shown = if a.mask { + "•".repeat(shown.chars().count()) + } else { + shown + }; + let run = TextRun { + len: shown.len(), + font: a.font.clone(), + color: black(), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window + .text_system() + .shape_line(shown.clone().into(), a.size, &[run], None); + let at = line + .closest_index_for_x(pos.x - a.bounds.origin.x) + .min(shown.len()); + Some(self.scroll + shown[..at].chars().count()) + } + + fn select_word(&mut self, at: usize) { + self.anchor = self.word(at, false); + self.caret = self.word(at, true).max(self.anchor); + self.run = Run::None; + } + + fn len(&self) -> usize { + self.text.chars().count() + } + + fn byte(&self, at: usize) -> usize { + self.text + .char_indices() + .nth(at) + .map_or(self.text.len(), |(i, _)| i) + } + + fn range(&self) -> (usize, usize) { + (self.caret.min(self.anchor), self.caret.max(self.anchor)) + } + + fn selected(&self) -> String { + let (lo, hi) = self.range(); + self.text.chars().skip(lo).take(hi - lo).collect() + } + + fn snap(&self) -> Snap { + Snap { + text: self.text.clone(), + caret: self.caret, + } + } + + /// Runs an edit and remembers the text it replaced for undo, unless it changed nothing + /// (typing a "t" over a selected "t") or continues a run. A run of typing is one step per + /// word: `split` starts a new one. + fn edit(&mut self, run: Run, split: bool, f: impl FnOnce(&mut Self)) { + let prev = self.snap(); + let prev_run = self.run; + f(self); + if self.text == prev.text { + self.run = prev_run; + return; + } + if run == Run::Other || run != prev_run || split { + if self.undo.len() == HISTORY { + self.undo.remove(0); + } + self.undo.push(prev); + } + self.redo.clear(); + self.run = run; + } + + fn restore(&mut self, s: Snap) { + self.text = s.text; + self.caret = s.caret; + self.anchor = s.caret; + self.run = Run::None; + } + + fn undo(&mut self) { + if let Some(s) = self.undo.pop() { + self.redo.push(self.snap()); + self.restore(s); + } + } + + fn redo(&mut self) { + if let Some(s) = self.redo.pop() { + self.undo.push(self.snap()); + self.restore(s); + } + } + + fn delete_selection(&mut self) -> bool { + let (lo, hi) = self.range(); + if lo == hi { + return false; + } + self.text.replace_range(self.byte(lo)..self.byte(hi), ""); + self.caret = lo; + self.anchor = lo; + true + } + + fn put(&mut self, s: &str) { + self.delete_selection(); + let room = self.max.saturating_sub(self.len()); + let s: String = s.chars().filter(|c| !c.is_control()).take(room).collect(); + self.text.insert_str(self.byte(self.caret), &s); + self.caret += s.chars().count(); + self.anchor = self.caret; + } + + /// Replaces the selection with `s` (control characters dropped), up to the length limit. + pub(super) fn insert(&mut self, s: &str) { + self.edit(Run::Other, false, |e| e.put(s)); + self.reveal(); + } + + fn move_to(&mut self, at: usize, extend: bool) { + self.caret = at.min(self.len()); + if !extend { + self.anchor = self.caret; + } + self.run = Run::None; + } + + /// The start of the word before `from`, or the end of the one after it. + fn word(&self, from: usize, forward: bool) -> usize { + let chars: Vec = self.text.chars().collect(); + let mut i = from; + if forward { + while i < chars.len() && chars[i].is_whitespace() { + i += 1; + } + while i < chars.len() && !chars[i].is_whitespace() { + i += 1; + } + } else { + while i > 0 && chars[i - 1].is_whitespace() { + i -= 1; + } + while i > 0 && !chars[i - 1].is_whitespace() { + i -= 1; + } + } + i + } + + /// Applies a key press. `command` is Ctrl, or Cmd on macOS. + pub(super) fn key( + &mut self, + key: &str, + key_char: Option<&str>, + command: bool, + shift: bool, + ) -> Key { + let (lo, hi) = self.range(); + let out = match key { + "enter" => Key::Enter, + "escape" => Key::Escape, + "left" if !shift && lo != hi && !command => { + self.move_to(lo, false); + Key::Moved + } + "right" if !shift && lo != hi && !command => { + self.move_to(hi, false); + Key::Moved + } + "left" => { + let to = if command { + self.word(self.caret, false) + } else { + self.caret.saturating_sub(1) + }; + self.move_to(to, shift); + Key::Moved + } + "right" => { + let to = if command { + self.word(self.caret, true) + } else { + self.caret + 1 + }; + self.move_to(to, shift); + Key::Moved + } + "home" => { + self.move_to(0, shift); + Key::Moved + } + "end" => { + self.move_to(self.len(), shift); + Key::Moved + } + "backspace" | "delete" => { + let run = if lo == hi { Run::Deleting } else { Run::Other }; + self.edit(run, false, |e| { + if !e.delete_selection() { + let (from, to) = match (key, command) { + ("backspace", false) => (e.caret.saturating_sub(1), e.caret), + ("backspace", true) => (e.word(e.caret, false), e.caret), + (_, false) => (e.caret, (e.caret + 1).min(e.len())), + (_, true) => (e.caret, e.word(e.caret, true)), + }; + e.anchor = from; + e.caret = to; + e.delete_selection(); + } + }); + Key::Edited + } + "a" if command => { + self.select_all(); + self.run = Run::None; + Key::Moved + } + "z" if command && shift => { + self.redo(); + Key::Edited + } + "z" if command => { + self.undo(); + Key::Edited + } + "y" if command => { + self.redo(); + Key::Edited + } + "c" if command => Key::Copy(self.selected()), + "x" if command => { + let cut = self.selected(); + self.edit(Run::Other, false, |e| { + e.delete_selection(); + }); + Key::Cut(cut) + } + "v" if command => Key::Paste, + _ if command => Key::Pass, + // Not every platform gives Space a character. + "space" => { + let run = if lo == hi { Run::Typing } else { Run::Other }; + self.edit(run, false, |e| e.put(" ")); + Key::Edited + } + _ => match key_char { + Some(ch) if !ch.is_empty() && !ch.chars().any(char::is_control) => { + let starts_word = !ch.starts_with(char::is_whitespace) + && self + .text + .chars() + .nth(self.caret.wrapping_sub(1)) + .is_some_and(char::is_whitespace); + let run = if lo == hi { Run::Typing } else { Run::Other }; + self.edit(run, starts_word, |e| e.put(ch)); + Key::Edited + } + _ => Key::Pass, + }, + }; + self.reveal(); + out + } + + /// Scrolls the field so the caret shows. + fn reveal(&mut self) { + if self.caret < self.scroll { + self.scroll = self.caret; + } else if self.caret > self.scroll + self.visible { + self.scroll = self.caret - self.visible; + } + } + + /// What shows: the text before the selection, the selection, the text after, and whether the + /// caret sits before the selection (otherwise after it). `whole` shows all of it selected. + fn view(&self, whole: bool) -> (String, String, String, bool) { + let (lo, hi) = if whole { (0, self.len()) } else { self.range() }; + let end = self.scroll + self.visible; + let part = |a: usize, b: usize| -> String { + let (a, b) = (a.max(self.scroll), b.min(end)); + self.text + .chars() + .skip(a) + .take(b.saturating_sub(a)) + .collect() + }; + ( + part(0, lo), + part(lo, hi), + part(hi, usize::MAX), + self.caret == lo, + ) + } +} + +/// The keyboard in a field: moves the caret, edits, copies and pastes. Never returns Paste, Copy +/// or Cut (they are done); Enter, Escape and Pass are the page's. +pub(super) fn handle(edit: &mut LineEdit, ev: &KeyDownEvent, cx: &mut App) -> Key { + let k = &ev.keystroke; + let command = k.modifiers.control || k.modifiers.platform; + match edit.key(&k.key, k.key_char.as_deref(), command, k.modifiers.shift) { + Key::Paste => { + if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) { + edit.insert(text.lines().next().unwrap_or_default().trim()); + } + Key::Edited + } + Key::Copy(s) => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(s)); + Key::Moved + } + Key::Cut(s) => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(s)); + Key::Edited + } + other => other, + } +} + +/// Puts mouse handling on a field's box: a click puts the caret in (Shift extends the selection), +/// a drag selects, a double click selects a word and a triple click the line. +pub(super) fn mouse( + el: E, + cx: &mut Context, + edit: impl Fn(&mut T) -> &mut LineEdit + Clone + 'static, +) -> E { + let get = edit.clone(); + el.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, ev: &MouseDownEvent, window, cx| { + let e = get(this); + if let Some(at) = e.index_at(window, ev.position) { + match ev.click_count { + 1 => e.move_to(at, ev.modifiers.shift), + 2 => e.select_word(at), + _ => e.select_all(), + } + e.dragging = true; + cx.notify(); + } + }), + ) + .on_mouse_move(cx.listener(move |this, ev: &MouseMoveEvent, window, cx| { + let e = edit(this); + if !ev.dragging() { + e.dragging = false; + } else if e.dragging { + if let Some(at) = e.index_at(window, ev.position) { + e.move_to(at, true); + cx.notify(); + } + } + })) +} + +/// What a field draws inside its box: the placeholder while empty and unfocused, else the text +/// with its selection and caret. `mask` shows dots (a token); `whole` shows all selected. +pub(super) fn content( + edit: &LineEdit, + t: &theme::Theme, + focused: bool, + whole: bool, + mask: bool, + placeholder: &'static str, +) -> Div { + if edit.text.is_empty() && !focused { + return div().text_color(t.muted).child(placeholder); + } + let (before, selected, after, caret_first) = edit.view(whole); + let text = |s: String| { + let s = if mask { + "•".repeat(s.chars().count()) + } else { + s + }; + div().flex_none().whitespace_nowrap().child(s) + }; + let caret = || div().flex_none().w(px(1.)).h(px(14.)).bg(t.ink); + let area = edit.area.clone(); + div() + .relative() + .flex() + .items_center() + .text_color(t.ink) + .child(text(before)) + .when(focused && caret_first, |d| d.child(caret())) + .when(!selected.is_empty(), |d| { + d.child( + text(selected) + .rounded(px(2.)) + .bg(t.accent.opacity(theme::SELECTION)), + ) + }) + .when(focused && !caret_first, |d| d.child(caret())) + .child(text(after)) + .child( + // Records where the text sits and in what font, for the mouse. + canvas( + move |bounds, window, _| { + let style = window.text_style(); + *area.borrow_mut() = Some(Area { + bounds, + font: style.font(), + size: style.font_size.to_pixels(window.rem_size()), + mask, + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(text: &str) -> LineEdit { + LineEdit::new(text.into(), 1024, 38) + } + + fn typed(e: &mut LineEdit, s: &str) { + for c in s.chars() { + let key = if c == ' ' { "space" } else { "x" }; + let ch = c.to_string(); + e.key(key, (c != ' ').then_some(ch.as_str()), false, false); + } + } + + #[test] + fn spaces_type_and_the_caret_moves_and_edits_in_the_middle() { + let mut e = edit(""); + typed(&mut e, "--name Steve"); + assert_eq!(e.text(), "--name Steve"); + for _ in 0..5 { + e.key("left", None, false, false); + } + typed(&mut e, "X"); + assert_eq!(e.text(), "--name XSteve"); + e.key("backspace", None, false, false); + e.key("delete", None, false, false); + assert_eq!(e.text(), "--name teve"); + e.key("home", None, false, false); + e.key("delete", None, false, false); + assert_eq!(e.text(), "-name teve"); + } + + #[test] + fn undo_steps_back_a_run_at_a_time_and_redo_returns() { + let mut e = edit(""); + typed(&mut e, "--name Steve"); + e.key("home", None, false, false); + e.key("delete", None, false, false); + e.key("delete", None, false, false); + assert_eq!(e.text(), "name Steve"); + e.key("z", None, true, false); + assert_eq!(e.text(), "--name Steve"); + // Typing undoes a word at a time. + e.key("z", None, true, false); + assert_eq!(e.text(), "--name "); + e.key("z", None, true, false); + assert_eq!(e.text(), ""); + e.key("z", None, true, false); + assert_eq!(e.text(), ""); + e.key("y", None, true, false); + assert_eq!(e.text(), "--name "); + e.key("y", None, true, false); + assert_eq!(e.text(), "--name Steve"); + e.key("z", None, true, false); + e.key("z", None, true, true); + assert_eq!(e.text(), "--name Steve"); + // A paste is a step of its own, and typing after an undo drops the redo. + e.key("end", None, false, false); + e.insert("!"); + e.key("z", None, true, false); + assert_eq!(e.text(), "--name Steve"); + typed(&mut e, "?"); + e.key("y", None, true, false); + assert_eq!(e.text(), "--name Steve?"); + } + + #[test] + fn selection_is_replaced_copied_and_cut() { + let mut e = edit("--name Steve"); + e.key("a", None, true, false); + assert_eq!( + e.key("c", None, true, false), + Key::Copy("--name Steve".into()) + ); + e.key("home", None, false, false); + for _ in 0..2 { + e.key("right", None, false, true); + } + assert_eq!(e.key("x", None, true, false), Key::Cut("--".into())); + assert_eq!(e.text(), "name Steve"); + e.key("a", None, true, false); + e.key("y", Some("y"), false, false); + assert_eq!(e.text(), "y"); + e.insert("a\nb c"); + assert_eq!(e.text(), "yab c"); + } + + #[test] + fn words_and_the_length_limit() { + let mut e = edit("one two three"); + e.key("backspace", None, true, false); + assert_eq!(e.text(), "one two "); + e.key("left", None, true, false); + e.key("delete", None, true, false); + assert_eq!(e.text(), "one "); + let mut e = LineEdit::new("abc".into(), 4, 38); + e.insert("defg"); + assert_eq!(e.text(), "abcd"); + assert_eq!(e.key("tab", None, false, false), Key::Pass); + assert_eq!(e.key("q", None, true, false), Key::Pass); + assert_eq!(e.key("enter", Some("\n"), false, false), Key::Enter); + assert_eq!(e.key("escape", None, false, false), Key::Escape); + } + + #[test] + fn an_edit_that_changes_nothing_is_not_an_undo_step() { + let mut e = edit("test"); + e.key("left", None, false, false); + e.key("end", None, false, false); + e.key("left", None, false, true); + e.key("t", Some("t"), false, false); + assert_eq!(e.text(), "test"); + e.key("delete", None, false, false); + e.key("home", None, false, false); + e.key("backspace", None, false, false); + e.key("z", None, true, false); + assert_eq!(e.text(), "test"); + e.insert(""); + e.key("x", Some("x"), false, false); + assert_eq!(e.text(), "xtest"); + e.key("z", None, true, false); + assert_eq!(e.text(), "test"); + } + + #[test] + fn a_selected_text_is_replaced_by_what_is_typed() { + let mut e = edit("My server"); + e.select_all(); + e.key("f", Some("F"), false, false); + assert_eq!(e.text(), "F"); + } + + #[test] + fn the_view_follows_the_caret() { + let mut e = edit(&"é".repeat(100)); + let (before, sel, after, _) = e.view(false); + assert_eq!((before.chars().count(), sel.len(), after.len()), (38, 0, 0)); + e.key("home", None, false, false); + assert_eq!(e.view(false).2.chars().count(), 38); + assert_eq!(e.view(true).1.chars().count(), 38); + } +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 7d778b6b9..0632a51ca 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -13,6 +13,7 @@ mod console; mod cull; mod game_tools; mod internet; +mod line_edit; mod markdown; mod mcp; mod modpacks_page; @@ -183,7 +184,7 @@ struct Launcher { friend_focus: FocusHandle, joining: Option, /// Settings' Game arguments field: the line, whether it has focus, and its focus handle. - args_input: String, + args_input: line_edit::LineEdit, args_focused: bool, args_focus: FocusHandle, /// Opt-in data collection: the player's answer and Settings' section (`collection`). @@ -298,7 +299,7 @@ impl Launcher { install_error: None, confirm_remove: None, picker_open: false, - args_input: settings.game_args.clone(), + args_input: line_edit::LineEdit::new(settings.game_args.clone(), 1024, 38), args_focused: false, args_focus: cx.focus_handle(), settings, @@ -2615,46 +2616,32 @@ impl Launcher { .font_family(theme::MONO) .text_size(px(12.)) .cursor_text() - .child(if self.args_input.is_empty() && !focused { - div() - .text_color(t.muted) - .child("For example --server-thread") - } else { - // The end of a long line stays in view while typing. - let chars: Vec = self.args_input.chars().collect(); - let shown: String = chars[chars.len().saturating_sub(44)..].iter().collect(); - div().text_color(t.ink).child(shown) - }) - .when(focused, |d| d.child(div().w(px(1.)).h(px(14.)).bg(t.ink))) + .child(line_edit::content( + &self.args_input, + &t, + focused, + false, + false, + "For example --server-thread", + )) .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { window.focus(&this.args_focus); cx.notify(); })) - .on_key_down(cx.listener(|this, ev: &KeyDownEvent, _, cx| { - let k = &ev.keystroke; - let paste = k.key == "v" && (k.modifiers.control || k.modifiers.platform); - match k.key.as_str() { - "backspace" => { - this.args_input.pop(); + .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { + match line_edit::handle(&mut this.args_input, ev, cx) { + line_edit::Key::Pass => return, + line_edit::Key::Escape => window.blur(), + line_edit::Key::Edited => { + this.settings.game_args = this.args_input.text().to_string(); + this.settings.save(); } - _ if paste => { - if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) { - this.args_input.push_str(text.trim()); - } - } - _ if !k.modifiers.control && !k.modifiers.platform => { - if let Some(ch) = &k.key_char { - this.args_input.push_str(ch); - } - } - _ => return, + _ => {} } - this.args_input.truncate(1024); - this.settings.game_args = this.args_input.clone(); - this.settings.save(); cx.stop_propagation(); cx.notify(); })); + let field = line_edit::mouse(field, cx, |this| &mut this.args_input); index_row( n, "Game arguments", From e2781f7939dfb4451f356a60d6513e25a4a271fa Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:20:35 +0100 Subject: [PATCH 018/475] Apply suspicious stew effects when the stew is eaten --- crates/horizon-client/src/game/consume.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/horizon-client/src/game/consume.rs b/crates/horizon-client/src/game/consume.rs index 93d919911..37d27eeb8 100644 --- a/crates/horizon-client/src/game/consume.rs +++ b/crates/horizon-client/src/game/consume.rs @@ -57,6 +57,17 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.burp", self.feet(), 0.5, pitch); } + // SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is + // added as a `MobEffectInstance(effect, duration)` (amplifier 0). + if let Some(data) = stack.extra.as_deref() { + for (id, duration) in &data.stew_effects { + if let Some(def) = horizon_client::brewing::effect(id) { + self.add_player_effect(horizon_client::effects::EffectInstance::new( + def.id, *duration, 0, + )); + } + } + } for effect in &consumable.effects { self.apply_consume_effect(effect); } From 30fa41ddbd4e85258e36a00f75e692976b7f3775 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:20:37 +0100 Subject: [PATCH 019/475] Make the fall, drowning, fire and freeze damage game rules work --- .../examples/probe/scene_motion_probe.rs | 1 + crates/horizon-client/src/game/chat.rs | 5 +++++ crates/horizon-client/src/health.rs | 19 +++++++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 21977eeab..523bd28a4 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -351,6 +351,7 @@ pub fn main() -> Result<()> { horizon_world::food::Difficulty::Hard, ][op["difficulty"].as_u64().unwrap() as usize], natural_regeneration: op["regenerate"].as_bool().unwrap(), + ..Default::default() }; } "food" => { diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 5d3919c8b..e19e3669f 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -763,6 +763,11 @@ impl Game<'_> { pub(super) fn sync_rules(&mut self) { self.world.health_rules.natural_regeneration = self.ui.rules.bool("natural_health_regeneration"); + // Player.isInvulnerableTo: the damage-type rules. + self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage"); + self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage"); + self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage"); + self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage"); use horizon_world::food::Difficulty; self.world.health_rules.difficulty = match self.ui.difficulty { 0 => Difficulty::Peaceful, diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 25c719bfb..cf7397bf8 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -29,12 +29,22 @@ pub(crate) type State = HurtState; pub struct HealthRules { pub difficulty: Difficulty, pub natural_regeneration: bool, + /// The `drowning_damage`, `fall_damage`, `fire_damage` and + /// `freeze_damage` rules `Player.isInvulnerableTo` reads. + pub drowning_damage: bool, + pub fall_damage: bool, + pub fire_damage: bool, + pub freeze_damage: bool, } impl Default for HealthRules { fn default() -> Self { Self { difficulty: Difficulty::Normal, natural_regeneration: true, + drowning_damage: true, + fall_damage: true, + fire_damage: true, + freeze_damage: true, } } } @@ -604,8 +614,13 @@ impl ImmunityWorld for Environment<'_> { fn enchantment_immunity(&mut self) -> io::Result { Ok(false) } - fn damage_rule(&mut self, _: DamageRule) -> io::Result { - Ok(true) + fn damage_rule(&mut self, rule: DamageRule) -> io::Result { + Ok(match rule { + DamageRule::Drowning => self.rules.drowning_damage, + DamageRule::Fall => self.rules.fall_damage, + DamageRule::Fire => self.rules.fire_damage, + DamageRule::Freeze => self.rules.freeze_damage, + }) } fn changing_dimension(&mut self) -> io::Result { Ok(false) From f252941ac15a075b982d892b0c5d6924818a5a62 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:20:39 +0100 Subject: [PATCH 020/475] Charge melee exhaustion only when the hit hurts --- crates/horizon-client/src/game.rs | 12 ++++++---- crates/horizon-client/src/game/lan_actions.rs | 8 +++++-- crates/horizon-client/src/game/lan_pvp.rs | 23 ++++++++++++------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..fbd6fd240 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7552,11 +7552,6 @@ impl Game<'_> { let sweep = self.sweep_attack_position(scale, crit); let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale); self.attack_ticks = 0; - if let Some(p) = self.player.as_mut() - && !self.creative - { - p.cause_food_exhaustion(0.1); - } let player = self.player.as_ref(); let from = player.map(|p| [p.position.x, p.position.y, p.position.z]); if knockback_attack { @@ -7651,6 +7646,13 @@ impl Game<'_> { } } self.weapon_hit(index); + // Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt + // (inside `if (wasHurt)`), after damageStatsAndHearts. + if let Some(p) = self.player.as_mut() + && !self.creative + { + p.cause_food_exhaustion(0.1); + } } else { self.audio .play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..7cba4c162 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1468,9 +1468,13 @@ impl Game<'_> { if let Some(victim) = Self::pvp_victim(host, conn, id) { if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) && self.pvp_land(host, victim, &strike) - && strike.stops_sprint() { - self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback()); + self.as_guest(host, conn, |g| { + g.pvp_exhaustion(); + if strike.stops_sprint() { + g.stop_sprint_after_extra_knockback(); + } + }); } return; } diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 467c4537c..7b59681ed 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -82,9 +82,11 @@ impl Game<'_> { }; if let Some(strike) = self.pvp_strike() && self.pvp_land(&mut host, Victim::Guest(conn), &strike) - && strike.stops_sprint() { - self.stop_sprint_after_extra_knockback(); + self.pvp_exhaustion(); + if strike.stops_sprint() { + self.stop_sprint_after_extra_knockback(); + } } self.lan = Some(host); true @@ -102,8 +104,18 @@ impl Game<'_> { .map(|g| Victim::Guest(g.conn)) } + /// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker + /// (the host, or a guest swapped in) only once the hit hurt the victim. + pub(super) fn pvp_exhaustion(&mut self) { + if let Some(p) = self.player.as_mut() + && !self.creative + { + p.cause_food_exhaustion(0.1); + } + } + /// The attacker's side of `Player.attack` (the host, or a guest swapped - /// in): the damage, the charge reset, the exhaustion and the swing. + /// in): the damage, the charge reset and the swing. pub(super) fn pvp_strike(&mut self) -> Option { let p = self.player.as_ref()?; let from = [p.position.x, p.position.y, p.position.z]; @@ -112,11 +124,6 @@ impl Game<'_> { let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale); let (xd, zd) = self.extra_knockback_direction(); self.attack_ticks = 0; - if let Some(p) = self.player.as_mut() - && !self.creative - { - p.cause_food_exhaustion(0.1); - } self.swing_arm(); Some(Strike { amount, From a8205dd80dc43779ec22d213e412ef97805553c6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:21:45 +0100 Subject: [PATCH 021/475] Infested blocks release their silverfish when broken --- crates/horizon-client/src/game.rs | 15 +++++++++++++++ crates/horizon-client/src/game/spawner.rs | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..e5fe51b7c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3396,6 +3396,21 @@ impl Game<'_> { { self.hive_broken_by_player(pos, hive, honey, facing); } + // InfestedBlock.spawnAfterBreak: its silverfish comes out unless the + // tool has silk touch (#prevents_infested_spawns) or blocks don't drop. + if facts.name.starts_with("minecraft:infested_") + && !silk + && self.ui.rules.bool("block_drops") + { + let at = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]), + f64::from(pos[2]) + 0.5, + ]; + if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) { + self.mob_poof(id); + } + } // SpawnerBlock.spawnAfterBreak pops its experience whatever the tool. let xp = if silk && &*facts.name != "minecraft:spawner" { 0 diff --git a/crates/horizon-client/src/game/spawner.rs b/crates/horizon-client/src/game/spawner.rs index 538cb39e7..5dd907afb 100644 --- a/crates/horizon-client/src/game/spawner.rs +++ b/crates/horizon-client/src/game/spawner.rs @@ -253,7 +253,7 @@ impl Game<'_> { self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } /// `LivingEntity.makePoofParticles` (entity event 20) for a mob. - fn mob_poof(&mut self, id: u32) { + pub(super) fn mob_poof(&mut self, id: u32) { let Some(m) = self.mobs.iter().find(|m| m.id == id) else { return; }; From 6c7a90cd7e2f2627a06744392db5882de81660a0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:21:59 +0100 Subject: [PATCH 022/475] Let chickens lay eggs and players get snow and chorus flowers from loot tables Gift loot (chicken eggs, sniffer seeds) is now rolled with the mob as the loot context's this entity, and chicken variants are known to loot predicates, so each chicken lays its own egg colour. A block a player breaks now has the player as this entity, so snow layers drop snowballs and chorus flowers drop themselves. --- crates/horizon-client/src/game.rs | 9 ++-- crates/horizon-client/src/game/creatures.rs | 51 +++++++++++++++++---- crates/horizon-client/src/game/explosion.rs | 1 + crates/horizon-client/src/items.rs | 45 ++++++++++++++++-- crates/horizon-client/src/mobs.rs | 4 +- crates/horizon-client/src/mobs/drops.rs | 3 ++ crates/horizon-client/src/mobs/farm.rs | 1 + crates/horizon-client/src/mobs/sniffer.rs | 1 + crates/horizon-world/src/loot.rs | 5 ++ 9 files changed, 103 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..7fd2bd1ba 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2159,8 +2159,9 @@ impl Game<'_> { ticks: 0, }); } - /// `Block.getDrops`: the block's loot table, drawing from its server random - /// sequence (`LootContext.Builder.create`), else the level random. + /// `Block.getDrops` for a block a player breaks: the block's loot table + /// with the player as `this`, drawing from its server random sequence + /// (`LootContext.Builder.create`), else the level random. fn block_loot_at( &mut self, pos: [i32; 3], @@ -2174,9 +2175,9 @@ impl Game<'_> { let random = self.rand_sequences.get(&sequence, self.level.seed); self.res .items() - .block_drops_with(registry, state, held, &dynamic, random) + .block_drops_by_player(registry, state, held, &dynamic, random) } - None => self.res.items().block_drops_with( + None => self.res.items().block_drops_by_player( registry, state, held, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..88abe39a1 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -834,6 +834,47 @@ impl Game<'_> { } } } + /// `LivingEntity.dropFromGiftLootTable`: the gift table from its random + /// sequence with the mob as `this` (a chicken's egg depends on its + /// variant); the loot data of the game's resources decides, and without it + /// the items' table engine (no entity) rolls it. + fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) { + use horizon_client::mobs::drops; + use horizon_world::loot::EntityLootParams; + let loot = horizon_client::structure_blocks::loot_data(&self.options.resources); + let this = self + .mobs + .iter() + .find(|m| m.id == mob) + .map(|m| drops::loot_entity(m, None)); + let stacks = match (loot, this) { + (Some(loot), Some(this)) if loot.has_table(table) => { + let params = EntityLootParams { + this, + ..EntityLootParams::default() + }; + let seed = self.level.seed; + match loot.random_sequence(table) { + Some(id) => { + let random = self.rand_sequences.get(id, seed); + loot.roll_entity(table, random, ¶ms) + .iter() + .filter(|s| !s.is_empty()) + .map(from_loot) + .collect() + } + None => Vec::new(), + } + } + _ => self + .res + .items() + .loot_table(table, false, None, &mut self.world.random), + }; + for d in stacks { + self.spawn_item(d, at, [0.0, 0.2, 0.0], 10); + } + } pub(super) fn handle_mob_events(&mut self, events: Vec) -> Result<()> { for e in events { match e { @@ -1215,15 +1256,7 @@ impl Game<'_> { m.ai.aggressive |= aggressive; } } - MobEvent::GiftLoot { table, at } => { - let drops = - self.res - .items() - .loot_table(table, false, None, &mut self.world.random); - for d in drops { - self.spawn_item(d, at, [0.0, 0.2, 0.0], 10); - } - } + MobEvent::GiftLoot { mob, table, at } => self.drop_gift_loot(mob, table, at), } } Ok(()) diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..4324c5863 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -620,6 +620,7 @@ impl Game<'_> { &mut self.world.random, Some((&mut self.rand_sequences, self.level.seed)), decay, + false, ); for mut stack in drops { if &*stack.item == "minecraft:air" || stack.count == 0 { diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index 8ac450bcb..32a84e7d7 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -796,6 +796,7 @@ impl Items { held: None, killed_by_player: false, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -860,10 +861,26 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, ) -> Vec { - self.block_drops_exploded(registry, state, held, dynamic, random, sequences, None) + self.block_drops_exploded( + registry, state, held, dynamic, random, sequences, None, false, + ) + } + /// `block_drops_with` for a block a player breaks (`Block.playerDestroy`): + /// the player is the loot context's `this` entity, so snow and chorus + /// flower tables see an entity. + pub fn block_drops_by_player( + &self, + registry: &BlockRegistry, + state: BlockStateId, + held: Option<&ItemStack>, + dynamic: &[ItemStack], + random: &mut impl RandomSource, + ) -> Vec { + self.block_drops_exploded(registry, state, held, dynamic, random, None, None, true) } /// `block_drops_full` for a block an explosion destroys: `explosion` - /// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`). + /// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`); + /// `breaker` is whether an entity (the player) broke the block. #[allow(clippy::too_many_arguments)] pub fn block_drops_exploded( &self, @@ -874,8 +891,10 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, explosion: Option, + breaker: bool, ) -> Vec { - let drops = self.block_drops_raw(registry, state, held, random, sequences, explosion); + let drops = + self.block_drops_raw(registry, state, held, random, sequences, explosion, breaker); if !drops.iter().any(|d| d.item.starts_with(DYNAMIC)) { return drops; } @@ -890,6 +909,7 @@ impl Items { }) .collect() } + #[allow(clippy::too_many_arguments)] fn block_drops_raw( &self, registry: &BlockRegistry, @@ -898,6 +918,7 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, explosion_radius: Option, + breaker: bool, ) -> Vec { let Some(block) = registry.state(state) else { return vec![]; @@ -931,6 +952,7 @@ impl Items { held, killed_by_player: false, explosion_radius, + this_entity: breaker, }; return ctx.evaluate(table, random); } @@ -941,6 +963,7 @@ impl Items { held, killed_by_player: false, explosion_radius, + this_entity: breaker, }; ctx.evaluate(table, random) } @@ -964,6 +987,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -996,6 +1020,7 @@ impl Items { held: None, killed_by_player: false, explosion_radius: None, + this_entity: false, }; match table.get("random_sequence").and_then(Value::as_str) { Some(sequence) => ctx.evaluate(table, sequences.get(sequence, seed)), @@ -1021,6 +1046,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1043,6 +1069,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1084,6 +1111,8 @@ struct LootContext<'a> { killed_by_player: bool, /// `LootContextParams.EXPLOSION_RADIUS`: an explosion that decays drops. explosion_radius: Option, + /// `LootContextParams.THIS_ENTITY` is set (a player broke the block). + this_entity: bool, } impl LootContext<'_> { /// Level of an enchantment on the tool / killer's weapon. @@ -1244,6 +1273,15 @@ impl LootContext<'_> { }; random.next_float() < chance } + // `this` with no checks (snow, chorus flower) holds when an entity + // broke the block (LootItemEntityPropertyCondition needs the entity). + "minecraft:entity_properties" + if self.this_entity + && c["entity"].as_str() == Some("this") + && c["predicate"].as_object().is_none_or(|p| p.is_empty()) => + { + true + } // Entity flags, equipment and damage-source predicates: none hold // for an unenchanted player kill without fire or lightning. "minecraft:entity_properties" | "minecraft:damage_source_properties" => false, @@ -1571,6 +1609,7 @@ mod tests { held, killed_by_player: block.is_empty(), explosion_radius: None, + this_entity: false, } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..b37841adc 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1126,8 +1126,10 @@ pub enum MobEvent { target: Who, aggressive: bool, }, - /// Drop the loot of a gift table (chicken eggs, armadillo scutes). + /// Drop the loot of a gift table (chicken eggs, sniffer seeds) with the + /// mob as `this` (`LivingEntity.dropFromGiftLootTable`). GiftLoot { + mob: u32, table: &'static str, at: [f64; 3], }, diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index e5ccfd283..f8431fd65 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -350,6 +350,9 @@ pub fn loot_entity(m: &Mob, vehicle: Option) -> LootEntity { .to_owned(), ); } + if s == S::Chicken { + e.chicken_variant = super::finalize::variant_component(m).map(|(_, id)| id); + } e.powered = s == S::Creeper && m.extra.charged; e.equipment = SLOTS .iter() diff --git a/crates/horizon-client/src/mobs/farm.rs b/crates/horizon-client/src/mobs/farm.rs index 83d5ac1f5..87951b0e2 100644 --- a/crates/horizon-client/src/mobs/farm.rs +++ b/crates/horizon-client/src/mobs/farm.rs @@ -80,6 +80,7 @@ pub fn chicken_ai_step(m: &mut Mob, _w: &MobWorld, events: &mut Vec) { let at = [m.position.x, m.position.y, m.position.z]; events.push(MobEvent::Sound("entity.chicken.egg", at, 1.0, pitch)); events.push(MobEvent::GiftLoot { + mob: m.id, table: "minecraft:gameplay/chicken_lay", at, }); diff --git a/crates/horizon-client/src/mobs/sniffer.rs b/crates/horizon-client/src/mobs/sniffer.rs index 6979171b8..c0c8d8155 100644 --- a/crates/horizon-client/src/mobs/sniffer.rs +++ b/crates/horizon-client/src/mobs/sniffer.rs @@ -94,6 +94,7 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec) { if current == DIGGING && now(m) == m.extra.ints[3] { let h = head_block(m); events.push(MobEvent::GiftLoot { + mob: m.id, table: "minecraft:gameplay/sniffer_digging", at: [f64::from(h[0]), f64::from(h[1]), f64::from(h[2])], }); diff --git a/crates/horizon-world/src/loot.rs b/crates/horizon-world/src/loot.rs index 96ca5bdee..ca7ff9e56 100644 --- a/crates/horizon-world/src/loot.rs +++ b/crates/horizon-world/src/loot.rs @@ -150,6 +150,8 @@ pub struct LootEntity { pub captain: Option, /// A frog's variant id (`minecraft:warm`). pub frog_variant: Option, + /// A chicken's variant id (`minecraft:warm`). + pub chicken_variant: Option, /// A charged creeper (`Creeper.isPowered`). pub powered: bool, /// Worn and held items by `EquipmentSlot` name (mainhand, offhand, @@ -851,6 +853,9 @@ impl Context<'_, R> { "minecraft:frog/variant" => { e.frog_variant.is_some() && want.as_str() == e.frog_variant.as_deref() } + "minecraft:chicken/variant" => { + e.chicken_variant.is_some() && want.as_str() == e.chicken_variant.as_deref() + } _ => false, }), "minecraft:type_specific/sheep" => e From 23d089aa7c5bf68f0a5ab830bf2ae3264a636954 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:25:30 +0100 Subject: [PATCH 023/475] Match vanilla animal breeding rules: rabbits breed, damage ends love, horse and wolf checks, breeding XP rule Rabbits get BreedGoal and TemptGoal with rabbit food. Damage to an animal resets love. Horses, donkeys and llamas need canParent (not ridden, tame, adult, full health) for both parents, a sitting wolf is no partner, and the breeding experience orb needs the mob_drops game rule. --- crates/horizon-client/src/game/creatures.rs | 17 ++++++++++++----- crates/horizon-client/src/mobs.rs | 10 ++++++++++ crates/horizon-client/src/mobs/goals.rs | 20 ++++++++++++++------ crates/horizon-client/src/mobs/rabbit.rs | 11 ++++++++++- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..d8fdb9104 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1117,7 +1117,7 @@ impl Game<'_> { self.particles.add(&options, at, velocity); } } - MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount), + MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount), MobEvent::AxolotlSupportPlayer => { let previous = self .potions @@ -1261,6 +1261,13 @@ impl Game<'_> { m.extra.egg_time = life; } } + /// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the + /// `mob_drops` game rule is on. + fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) { + if self.ui.rules.bool("mob_drops") { + self.spawn_orbs(at, amount); + } + } /// Animal.spawnChildFromBreeding fn breed( &mut self, @@ -1354,7 +1361,7 @@ impl Game<'_> { } self.world.random = r; let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } if p.species() == Species::Cat { @@ -1375,7 +1382,7 @@ impl Game<'_> { }; } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } if p.species() == Species::Wolf { @@ -1397,7 +1404,7 @@ impl Game<'_> { }; } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } // Nautilus.getBreedOffspring: a tame parent's baby is tame too. @@ -1413,7 +1420,7 @@ impl Game<'_> { } } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); } /// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian: /// mining fatigue III for five minutes unless a stronger or longer one diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..479683664 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1243,6 +1243,10 @@ pub struct Seen { /// `MAX_HEALTH`, `JUMP_STRENGTH` and `MOVEMENT_SPEED` base values (a /// horse's breeding stock). pub stats: [f64; 3], + /// `Wolf.isInSittingPose`. + pub sitting: bool, + /// `Entity.isVehicle` or `isPassenger`. + pub ridden: bool, } impl Mob { pub fn burning_time(&self) -> f64 { @@ -1315,6 +1319,8 @@ impl Mob { m.extra.vector[0], m.attrs.speed, ], + sitting: m.extra.sitting, + ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(), } } /// `EntityType.getCategory`'s name. @@ -1752,6 +1758,10 @@ impl Mob { self.health -= amount; self.hurt_time = 10; } + // Animal.actuallyHurt: damage taken ends love mode (`resetLove`). + if drops::is_animal(self.species()) { + self.extra.in_love = 0; + } self.last_damage = Some((source, i64::from(self.age))); if let Some(who) = source.attacker { self.ai.last_hurt_by = Some(who); diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..a784464d8 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1771,11 +1771,16 @@ impl Goal for Breed { } let llama = super::llama::is_llama(species); let equine = matches!(species, Species::Horse | Species::Donkey) || llama; - if equine && !m.extra.tame { - return false; - } - // Llama.canParent: not ridden, and at full health. - if llama && (m.rider.is_some() || m.health < m.attrs.max_health) { + // AbstractHorse.canParent: tame, an adult, not a vehicle or passenger + // and at full health. + if equine + && (!m.extra.tame + || m.is_baby() + || m.rider.is_some() + || m.extra.rider.is_some() + || m.extra.mount.is_some() + || m.health < m.attrs.max_health) + { return false; } self.partner = @@ -1788,7 +1793,10 @@ impl Goal for Breed { && s.in_love && !s.baby && (!tamable || s.tame) - && (!equine || s.tame) + // Wolf.canMate: a sitting wolf is no partner. + && !(species == Species::Wolf && s.sitting) + // AbstractHorse.canParent of the partner. + && (!equine || s.tame && !s.ridden && s.health >= s.stats[0] as f32) && (!sniffer || super::sniffer::can_mate_state(s.variant)) }) .map(|s| s.id) diff --git a/crates/horizon-client/src/mobs/rabbit.rs b/crates/horizon-client/src/mobs/rabbit.rs index a2ab6e95c..ac4c3850f 100644 --- a/crates/horizon-client/src/mobs/rabbit.rs +++ b/crates/horizon-client/src/mobs/rabbit.rs @@ -1,5 +1,9 @@ //! Rabbit hopping controls and the clocks consumed by `RabbitModel`. -use super::{Mob, MobEvent, MobWorld, control::Operation}; +use super::{ + Mob, MobEvent, MobWorld, + control::Operation, + goals::{Breed, Tempt}, +}; use horizon_core::{ geometry::{Vec3, trig}, random::RandomSource, @@ -14,7 +18,12 @@ const IDLE_TIMEOUT: usize = 3; const CLIENT_JUMP_TICKS: usize = 4; const CLIENT_JUMP_DURATION: usize = 5; +/// Rabbit.registerGoals after the generic panic and stroll: BreedGoal and +/// TemptGoal with rabbit food. pub fn register(m: &mut Mob) { + let food = m.kind.food.clone(); + m.ai.goals.add(2, Breed::new(0.8)); + m.ai.goals.add(3, Tempt::new(1.0, food, false)); m.ai.mov.kind = super::control::MoveKind::Custom; m.ai.nav.can_float = true; m.extra.ints[IDLE_TIMEOUT] = m.random.next_int_bound(40).unwrap_or(0) + 180; From ebf3c1567cf408783e6adc08d76106b30fb82f20 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:34:31 +0100 Subject: [PATCH 024/475] parity: follow code into new crates when choosing harnesses Two changes so `parity.py --changed` keeps picking the right harnesses once code moves into a crate of its own: - A harness recorded before the move names the old paths. A changed Rust file of a crate the recorded crates link (their path dependencies, transitively) but don't name now counts, and the reach search decides. Before, such a file matched no record and selected nothing. - A library's root opens once reached code names the crate (`horizon_world::`, `pub use horizon_command as command`), instead of always. Every crate root was open, so any item of a new crate whose name matched a name used anywhere counted. On main the selection is the same for 15 of 16 sampled files; for horizon-world/src/nbt.rs one harness that reached it only through a name match drops out. Co-Authored-By: Claude Opus 5.5 --- tests/test_parity_reach.py | 20 +++++++++++++++++ tools/parity.py | 2 +- tools/parity_reach.py | 45 +++++++++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/tests/test_parity_reach.py b/tests/test_parity_reach.py index 07e3df9e3..0e20f9174 100644 --- a/tests/test_parity_reach.py +++ b/tests/test_parity_reach.py @@ -111,6 +111,26 @@ class ReachTests(unittest.TestCase): self.assertIn("crates/a/src/display.rs", full) self.assertTrue(graph.results) + def test_a_module_moved_to_a_new_crate_counts_through_its_reexport(self): + # used.rs moves to crate b after the harness was recorded; a re-exports it under its old path. Crate c is + # a dependency nothing reached names: its items stay out even where their names match. + (self.root / "crates/a/src/used.rs").unlink() + workspace(self.root, { + "crates/a/Cargo.toml": (self.root / "crates/a/Cargo.toml").read_text() + + '\n[dependencies]\nb = { path = "../b" }\nc = { path = "../c" }\n', + "crates/a/src/lib.rs": "pub use b::used;\npub mod unused;\nmod methods;\nmod display;\n", + "crates/b/Cargo.toml": '[package]\nname = "b"\n', + "crates/b/src/lib.rs": "pub mod used;\n", + "crates/b/src/used.rs": "pub struct Thing;\npub fn helper() -> u32 { 1 }\n", + "crates/c/Cargo.toml": '[package]\nname = "c"\n', + "crates/c/src/lib.rs": "pub fn helper() -> u32 { 3 }\n", + }) + changed = {"crates/b/src/used.rs", "crates/c/src/lib.rs"} + self.assertEqual(parity_reach.unrecorded(self.root, self.dependencies, changed), changed) + kept = self.narrowed(changed=changed) + self.assertIn("crates/b/src/used.rs", kept) + self.assertNotIn("crates/c/src/lib.rs", kept) + def test_unknown_probes_keep_everything(self): self.assertEqual(set(self.narrowed(artifacts=[])), set(self.dependencies)) self.assertEqual(set(self.narrowed(artifacts=[["example", "missing", "release", ["-p", "a"], True]])), diff --git a/tools/parity.py b/tools/parity.py index dbff8c146..6cc61cfb4 100644 --- a/tools/parity.py +++ b/tools/parity.py @@ -1274,7 +1274,7 @@ def affected_harnesses(found, records, changed, reach=True, base=None): affected.add(name) continue plain = {path.split(KEEP)[0] for path in dependencies} - hit = plain & changed + hit = (plain & changed) | parity_reach.unrecorded(ROOT, plain, changed) if not hit: continue if graph is None or any(not path.startswith("crates/") for path in hit): diff --git a/tools/parity_reach.py b/tools/parity_reach.py index dc61f6215..b5be2a192 100644 --- a/tools/parity_reach.py +++ b/tools/parity_reach.py @@ -7,8 +7,8 @@ of the workspace's current sources whose nodes are items: - everything in an entry is reached, and so is everything in a Rust file the module tree doesn't place (an `include!`d source); -- a module is open when it is a crate root, holds reached code, or is named in a path (`name::`, `use ...name`) by - reached code while its parent is open; +- a module is open when it holds reached code, or is named in a path (`name::`, `use ...name`) by reached code while + its parent is open; a library's root once reached code names its crate (`horizon_world`); - a free item (fn, struct, enum, union, trait, type, const, static, an item-level macro call's items) is reached when reached code in its own module or below it mentions its name, or when its module is open and reached code names it in a path or an import, or when its module is glob-imported (`use m::*`) and its name is mentioned; @@ -608,8 +608,8 @@ class SourceGraph: try: for path in universe: - if path.endswith("/src/lib.rs") or path in entries or path not in self.parent: - open_module(path, "crate root or entry") + if path in entries or path not in self.parent and not path.endswith("/src/lib.rs"): + open_module(path, "entry, or a file no module places") for node in seeds: reach(node, "entry" if node[0] in entries else "always counted") for path in entries & universe: @@ -624,6 +624,12 @@ class SourceGraph: label = (item.get("names") or [item["kind"]])[0] if member is None else item["members"][member]["name"] source = sets_of(node, item) why = f"{path} ({label})" + # A library's root opens once reached code names the crate (`horizon_world::`, `use horizon_command + # as command`): its items are out of reach until then. + for word in (source["mentions"] | source["paths"]) & idents.keys(): + root = f"crates/{idents[word]}/src/lib.rs" + if root in universe and root not in opened: + open_module(root, f"{why} names {word}") # Plain mentions reach items of this file and of the modules above it. ancestor, new = path, source["mentions"] while ancestor is not None and new: @@ -709,6 +715,35 @@ def is_workspace_rust(path): return path.startswith("crates/") and path.endswith(".rs") and path.count("/") >= 2 +PATH_DEPENDENCY = re.compile(r'path\s*=\s*"\.\./([\w-]+)"') + + +@functools.cache +def _path_dependencies(root, crate): + manifest = Path(root) / "crates" / crate / "Cargo.toml" + text = manifest.read_text(encoding="utf-8") if manifest.is_file() else "" + return frozenset(PATH_DEPENDENCY.findall(text)) + + +def linked_crates(root, crates): + """The workspace crates (folders under crates/) that crates link, themselves included: their path dependencies, + transitively. A harness recorded before code moved to a new crate links that crate without naming its files.""" + found, queue = set(crates), list(crates) + while queue: + for dependency in _path_dependencies(str(root), queue.pop()): + if dependency not in found: + found.add(dependency) + queue.append(dependency) + return found + + +def unrecorded(root, plain, changed): + """Changed workspace Rust files of crates a harness's recorded crates link but its record doesn't name.""" + crates = {crate_of(path) for path in plain if path.startswith("crates/") and path.count("/") >= 2} + linked = linked_crates(root, crates) - crates + return {path for path in changed if is_workspace_rust(path) and crate_of(path) in linked} + + DECLARATION_LINE = re.compile(rf"\s*(?:pub(?:\([^)]*\))?\s+)?mod\s+({IDENT})\s*;") @@ -758,7 +793,7 @@ def narrow(dependencies, artifacts, graph, changed=(), stop=frozenset()): plain = [dependency.split("#keep:")[0] for dependency in dependencies] crates = {crate_of(path) for path in plain if path.startswith("crates/") and path.count("/") >= 2} rust = {path for path in plain if is_workspace_rust(path)} - added = {path for path in changed if is_workspace_rust(path) and crate_of(path) in crates + added = {path for path in changed if is_workspace_rust(path) and crate_of(path) in linked_crates(graph.root, crates) and path not in rust and (graph.root / path).is_file()} entries = graph.entries(artifacts, rust) if entries is None: From 0a116d95234f09d1cd5867a0ee371e097e37ed5a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:34:31 +0100 Subject: [PATCH 025/475] Split horizon-command and horizon-client-core out of horizon-client A pure move, the first slice of splitting the client lib into crates: - horizon-command: the command/ module (Brigadier port, vanilla tree, argument types, selectors), as is. - horizon-client-core: what command needs from the lib, with no other dependency inside it: text, lang, snbt, nbt_path, component, scoreboard, bossbar, gamerules, block_entity_nbt, fastutil_order; plus items moved out of lib modules that sit in the cycle, unchanged: ParticleType (particles.rs), the key display name (keybinds.rs), SOUND_SOURCES (settings.rs), uuid_text/uuid_from_text (save.rs), Registries/RegistryIds and parse_identifier (command/args.rs). horizon-client re-exports every module and item under its old path (`horizon_client::text`, `crate::command::Registries`, `particles::ParticleType`...), so no caller changes. Inside the moved files only paths changed (`crate::snbt` -> `horizon_client_core::snbt` in command, `crate::command::args::parse_identifier` -> `crate::identifier::parse_identifier`...), `sign_side` became pub, lang.rs includes the lang files from horizon-client's assets, and gamerules' test that saves a Level moved to save.rs (it needs the client's save). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 +- Cargo.lock | 19 ++ Cargo.toml | 2 +- crates/horizon-client-core/Cargo.toml | 14 + .../src/block_entity_nbt.rs | 2 +- .../src/bossbar.rs | 0 .../src/component.rs | 2 +- .../src/fastutil_order.rs | 0 .../src/gamerules.rs | 23 -- crates/horizon-client-core/src/identifier.rs | 18 ++ crates/horizon-client-core/src/key_display.rs | 38 +++ .../src/lang.rs | 8 +- crates/horizon-client-core/src/lib.rs | 23 ++ .../src/nbt_path.rs | 0 .../horizon-client-core/src/particle_type.rs | 209 +++++++++++++++ crates/horizon-client-core/src/registries.rs | 188 +++++++++++++ .../src/scoreboard.rs | 4 +- .../src/snbt.rs | 0 .../horizon-client-core/src/sound_source.rs | 7 + .../src/text.rs | 2 +- crates/horizon-client-core/src/uuid.rs | 24 ++ crates/horizon-client/Cargo.toml | 2 + crates/horizon-client/src/keybinds.rs | 36 +-- crates/horizon-client/src/lib.rs | 24 +- crates/horizon-client/src/particles.rs | 207 +-------------- crates/horizon-client/src/save.rs | 47 ++-- crates/horizon-client/src/settings.rs | 6 +- crates/horizon-command/Cargo.toml | 15 ++ .../command => horizon-command/src}/args.rs | 248 ++---------------- .../command => horizon-command/src}/block.rs | 0 .../command => horizon-command/src}/codec.rs | 2 +- .../src}/component.rs | 4 +- .../command => horizon-command/src}/item.rs | 4 +- .../mod.rs => horizon-command/src/lib.rs} | 9 +- .../src}/network.rs | 0 .../src}/packrat.rs | 2 +- .../src}/selector.rs | 6 +- .../command => horizon-command/src}/snbt.rs | 0 .../command => horizon-command/src}/tests.rs | 0 .../src}/tree_data.rs | 0 .../src}/tree_execute.rs | 0 .../src}/tree_extra.rs | 0 .../src}/tree_misc.rs | 4 +- .../src}/tree_scoreboard.rs | 0 .../src}/tree_world.rs | 2 +- .../src}/vanilla.rs | 2 +- .../src}/vanilla_order.rs | 0 docs/architecture.md | 11 +- docs/version-ports.md | 2 +- tools/generate_command_order.py | 4 +- tools/port_regen.py | 2 +- tools/port_report.py | 2 +- tools/test_lore_tooltip_parity.py | 4 +- tools/test_resource_pack_parity.py | 2 +- 54 files changed, 678 insertions(+), 562 deletions(-) create mode 100644 crates/horizon-client-core/Cargo.toml rename crates/{horizon-client => horizon-client-core}/src/block_entity_nbt.rs (99%) rename crates/{horizon-client => horizon-client-core}/src/bossbar.rs (100%) rename crates/{horizon-client => horizon-client-core}/src/component.rs (99%) rename crates/{horizon-client => horizon-client-core}/src/fastutil_order.rs (100%) rename crates/{horizon-client => horizon-client-core}/src/gamerules.rs (90%) create mode 100644 crates/horizon-client-core/src/identifier.rs create mode 100644 crates/horizon-client-core/src/key_display.rs rename crates/{horizon-client => horizon-client-core}/src/lang.rs (96%) create mode 100644 crates/horizon-client-core/src/lib.rs rename crates/{horizon-client => horizon-client-core}/src/nbt_path.rs (100%) create mode 100644 crates/horizon-client-core/src/particle_type.rs create mode 100644 crates/horizon-client-core/src/registries.rs rename crates/{horizon-client => horizon-client-core}/src/scoreboard.rs (99%) rename crates/{horizon-client => horizon-client-core}/src/snbt.rs (100%) create mode 100644 crates/horizon-client-core/src/sound_source.rs rename crates/{horizon-client => horizon-client-core}/src/text.rs (99%) create mode 100644 crates/horizon-client-core/src/uuid.rs create mode 100644 crates/horizon-command/Cargo.toml rename crates/{horizon-client/src/command => horizon-command/src}/args.rs (87%) rename crates/{horizon-client/src/command => horizon-command/src}/block.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/codec.rs (99%) rename crates/{horizon-client/src/command => horizon-command/src}/component.rs (98%) rename crates/{horizon-client/src/command => horizon-command/src}/item.rs (99%) rename crates/{horizon-client/src/command/mod.rs => horizon-command/src/lib.rs} (99%) rename crates/{horizon-client/src/command => horizon-command/src}/network.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/packrat.rs (99%) rename crates/{horizon-client/src/command => horizon-command/src}/selector.rs (99%) rename crates/{horizon-client/src/command => horizon-command/src}/snbt.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/tests.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/tree_data.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/tree_execute.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/tree_extra.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/tree_misc.rs (99%) rename crates/{horizon-client/src/command => horizon-command/src}/tree_scoreboard.rs (100%) rename crates/{horizon-client/src/command => horizon-command/src}/tree_world.rs (99%) rename crates/{horizon-client/src/command => horizon-command/src}/vanilla.rs (99%) rename crates/{horizon-client/src/command => horizon-command/src}/vanilla_order.rs (100%) diff --git a/AGENTS.md b/AGENTS.md index dcd4ca595..24dc38b8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se | Crate | Owns | |---|---| -| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) | +| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) | +| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` | +| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) | | `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage | | `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) | | `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` | @@ -117,16 +119,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end | Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` | | Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` | | Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` | -| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` | +| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` | | UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` | | Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) | | LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) | | Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` | | Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` | -| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` | +| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` | | Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` | | Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` | -| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` | +| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` | | Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) | | Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` | | Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` | diff --git a/Cargo.lock b/Cargo.lock index 0e93bba0e..1d3b52a3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,6 +1365,8 @@ dependencies = [ "glam", "horizon-assets", "horizon-bench", + "horizon-client-core", + "horizon-command", "horizon-core", "horizon-dualsense", "horizon-friends", @@ -1395,6 +1397,23 @@ dependencies = [ "zip", ] +[[package]] +name = "horizon-client-core" +version = "0.1.0" +dependencies = [ + "horizon-world", + "serde_json", +] + +[[package]] +name = "horizon-command" +version = "0.1.0" +dependencies = [ + "horizon-client-core", + "horizon-protocol", + "horizon-world", +] + [[package]] name = "horizon-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e4628f962..181ab1832 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-client-core", "crates/horizon-command", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] resolver = "2" [workspace.package] diff --git a/crates/horizon-client-core/Cargo.toml b/crates/horizon-client-core/Cargo.toml new file mode 100644 index 000000000..8809a5216 --- /dev/null +++ b/crates/horizon-client-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "horizon-client-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version = "1.90" +description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name" + +[dependencies] +horizon-world = { path = "../horizon-world" } +serde_json = { version = "1.0.151", features = ["raw_value"] } + +[lints] +workspace = true diff --git a/crates/horizon-client/src/block_entity_nbt.rs b/crates/horizon-client-core/src/block_entity_nbt.rs similarity index 99% rename from crates/horizon-client/src/block_entity_nbt.rs rename to crates/horizon-client-core/src/block_entity_nbt.rs index 54d230eb4..93f290515 100644 --- a/crates/horizon-client/src/block_entity_nbt.rs +++ b/crates/horizon-client-core/src/block_entity_nbt.rs @@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool { matches!(tag, Tag::IntArray(v) if v.len() == 3) } /// The loaded `SignText` of one side, or the empty text. -pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag { +pub fn sign_side(tag: Option<&Tag>) -> Tag { let empty = || string(""); let mut messages: Vec = match tag.and_then(|t| t.get("messages")) { Some(Tag::List(list)) => list.iter().filter_map(component).collect(), diff --git a/crates/horizon-client/src/bossbar.rs b/crates/horizon-client-core/src/bossbar.rs similarity index 100% rename from crates/horizon-client/src/bossbar.rs rename to crates/horizon-client-core/src/bossbar.rs diff --git a/crates/horizon-client/src/component.rs b/crates/horizon-client-core/src/component.rs similarity index 99% rename from crates/horizon-client/src/component.rs rename to crates/horizon-client-core/src/component.rs index fced49898..5b828b305 100644 --- a/crates/horizon-client/src/component.rs +++ b/crates/horizon-client-core/src/component.rs @@ -252,7 +252,7 @@ pub fn to_text_with( } else if let Some(sel) = o.get("selector").and_then(Value::as_str) { resolve(sel) } else if let Some(key) = o.get("keybind").and_then(Value::as_str) { - crate::keybinds::display_name(key) + crate::key_display::display_name(key) } else { Text::empty() }; diff --git a/crates/horizon-client/src/fastutil_order.rs b/crates/horizon-client-core/src/fastutil_order.rs similarity index 100% rename from crates/horizon-client/src/fastutil_order.rs rename to crates/horizon-client-core/src/fastutil_order.rs diff --git a/crates/horizon-client/src/gamerules.rs b/crates/horizon-client-core/src/gamerules.rs similarity index 90% rename from crates/horizon-client/src/gamerules.rs rename to crates/horizon-client-core/src/gamerules.rs index 7198fd202..6715e01ec 100644 --- a/crates/horizon-client/src/gamerules.rs +++ b/crates/horizon-client-core/src/gamerules.rs @@ -292,27 +292,4 @@ mod tests { assert_eq!(GameRules::load(&dir), rules); let _ = fs::remove_dir_all(dir); } - /// The world's save (Level.write) writes the rules as they are now, and a - /// world opened again reads them back (#74). - #[test] - fn level_save_keeps_changed_rules() { - let dir = - std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - let mut level = crate::save::Level::default(); - level.write(&dir).unwrap(); - // Rules changed while playing, then saved and loaded again. - let mut rules = GameRules::load(&dir); - assert_eq!(rules, GameRules::default()); - assert!(rules.set("advance_time", Value::Bool(false))); - assert!(rules.set("advance_weather", Value::Bool(false))); - assert!(rules.set("random_tick_speed", Value::Int(0))); - level.game_rules = rules.by_name(); - level.write(&dir).unwrap(); - assert_eq!(GameRules::load(&dir), rules); - let read = crate::save::Level::read(&dir).unwrap(); - assert_eq!(read.game_rules, rules.by_name()); - assert_eq!(read.game_rule("advance_time"), 0); - let _ = fs::remove_dir_all(dir); - } } diff --git a/crates/horizon-client-core/src/identifier.rs b/crates/horizon-client-core/src/identifier.rs new file mode 100644 index 000000000..1e76f7731 --- /dev/null +++ b/crates/horizon-client-core/src/identifier.rs @@ -0,0 +1,18 @@ +//! Resource identifiers (`Identifier`). + +/// Identifier.parse: `ns:path` or `path`, validated. +pub fn parse_identifier(raw: &str) -> Option { + let (ns, path) = match raw.split_once(':') { + Some(("", path)) => ("minecraft", path), + Some((ns, path)) => (ns, path), + None => ("minecraft", raw), + }; + let ns_ok = ns != ".." + && ns + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-')); + let path_ok = path.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/') + }); + (ns_ok && path_ok).then(|| format!("{ns}:{path}")) +} diff --git a/crates/horizon-client-core/src/key_display.rs b/crates/horizon-client-core/src/key_display.rs new file mode 100644 index 000000000..fc57c97bd --- /dev/null +++ b/crates/horizon-client-core/src/key_display.rs @@ -0,0 +1,38 @@ +//! Key display names (`InputConstants.Key.getDisplayName`), shared by key +//! mappings and `keybind` text components. +use crate::text::Text; + +/// InputConstants.Key.getDisplayName: one-character keys as the upper-case +/// character, others by translation. +pub fn display_name(key: &str) -> Text { + if let Some(rest) = key.strip_prefix("key.keyboard.") + && rest.chars().count() == 1 + { + let c = rest.to_uppercase(); + return Text::literal(c); + } + let symbol = match key { + "key.keyboard.minus" => Some("-"), + "key.keyboard.equal" => Some("="), + "key.keyboard.left.bracket" => Some("["), + "key.keyboard.right.bracket" => Some("]"), + "key.keyboard.backslash" => Some("\\"), + "key.keyboard.semicolon" => Some(";"), + "key.keyboard.apostrophe" => Some("'"), + "key.keyboard.grave.accent" => Some("`"), + "key.keyboard.comma" => Some(","), + "key.keyboard.period" => Some("."), + "key.keyboard.slash" => Some("/"), + _ => None, + }; + if let Some(s) = symbol { + return Text::literal(s); + } + if let Some(n) = key.strip_prefix("key.mouse.") + && n.parse::().is_ok() + && !crate::lang::has(key) + { + return Text::translate("key.mouse", &[Text::literal(n)]); + } + Text::translate(key, &[]) +} diff --git a/crates/horizon-client/src/lang.rs b/crates/horizon-client-core/src/lang.rs similarity index 96% rename from crates/horizon-client/src/lang.rs rename to crates/horizon-client-core/src/lang.rs index cb96cc373..6213cedc8 100644 --- a/crates/horizon-client/src/lang.rs +++ b/crates/horizon-client-core/src/lang.rs @@ -42,8 +42,12 @@ struct Bundled; impl ResourceSource for Bundled { fn read(&self, path: &str) -> Option> { let body = match path { - "assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"), - "assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"), + "assets/horizon/lang/en_us.json" => { + include_str!("../../horizon-client/assets/horizon/lang/en_us.json") + } + "assets/horizon/lang/ru_ru.json" => { + include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json") + } _ => return None, }; Some(body.as_bytes().to_vec()) diff --git a/crates/horizon-client-core/src/lib.rs b/crates/horizon-client-core/src/lib.rs new file mode 100644 index 000000000..ca40141d1 --- /dev/null +++ b/crates/horizon-client-core/src/lib.rs @@ -0,0 +1,23 @@ +//! The client's lowest layer, below commands and gameplay: chat text and +//! translations, SNBT and NBT paths, text components, the scoreboard, boss +//! bars, game rules, block entity NBT tables, and the registry, particle and +//! sound source tables commands name. horizon-client re-exports each module +//! under its old path (`horizon_client::text`, ...). +#![forbid(unsafe_code)] +pub mod block_entity_nbt; +pub mod bossbar; +pub mod component; +pub mod fastutil_order; +pub mod gamerules; +pub mod identifier; +pub mod key_display; +pub mod lang; +pub mod nbt_path; +pub mod particle_type; +pub mod registries; +pub mod scoreboard; +pub mod snbt; +pub mod sound_source; +pub mod text; +pub mod uuid; +pub type Result = std::result::Result>; diff --git a/crates/horizon-client/src/nbt_path.rs b/crates/horizon-client-core/src/nbt_path.rs similarity index 100% rename from crates/horizon-client/src/nbt_path.rs rename to crates/horizon-client-core/src/nbt_path.rs diff --git a/crates/horizon-client-core/src/particle_type.rs b/crates/horizon-client-core/src/particle_type.rs new file mode 100644 index 000000000..98de2380b --- /dev/null +++ b/crates/horizon-client-core/src/particle_type.rs @@ -0,0 +1,209 @@ +//! Particle types (`ParticleTypes`): the vanilla particle types with a client +//! implementation, by registry name, and their per-type flags. + +macro_rules! particle_types { + ($($variant:ident = $name:literal),* $(,)?) => { + /// Vanilla particle types with a client implementation here. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum ParticleType { $($variant),* } + impl ParticleType { + pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*]; + /// Registry path (`minecraft:`), also the sprite-set file name. + pub fn name(self) -> &'static str { + match self { $(ParticleType::$variant => $name),* } + } + pub fn from_name(name: &str) -> Option { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + match name { $($name => Some(ParticleType::$variant),)* _ => None } + } + } + }; +} +particle_types! { + Block = "block", + BlockCrumble = "block_crumble", + Trail = "trail", + DustPillar = "dust_pillar", + FallingDust = "falling_dust", + Smoke = "smoke", + LargeSmoke = "large_smoke", + WhiteSmoke = "white_smoke", + Flame = "flame", + SoulFireFlame = "soul_fire_flame", + CopperFireFlame = "copper_fire_flame", + SmallFlame = "small_flame", + Lava = "lava", + DrippingWater = "dripping_water", + FallingWater = "falling_water", + DrippingLava = "dripping_lava", + FallingLava = "falling_lava", + LandingLava = "landing_lava", + DrippingHoney = "dripping_honey", + FallingHoney = "falling_honey", + LandingHoney = "landing_honey", + FallingNectar = "falling_nectar", + FallingSporeBlossom = "falling_spore_blossom", + SporeBlossomAir = "spore_blossom_air", + DrippingObsidianTear = "dripping_obsidian_tear", + FallingObsidianTear = "falling_obsidian_tear", + LandingObsidianTear = "landing_obsidian_tear", + DrippingDripstoneWater = "dripping_dripstone_water", + FallingDripstoneWater = "falling_dripstone_water", + DrippingDripstoneLava = "dripping_dripstone_lava", + FallingDripstoneLava = "falling_dripstone_lava", + Splash = "splash", + Rain = "rain", + Bubble = "bubble", + SulfurBubbles = "sulfur_bubbles", + BubblePop = "bubble_pop", + BubbleColumnUp = "bubble_column_up", + CurrentDown = "current_down", + Underwater = "underwater", + Portal = "portal", + ReversePortal = "reverse_portal", + Enchant = "enchant", + Nautilus = "nautilus", + VaultConnection = "vault_connection", + Crit = "crit", + EnchantedHit = "enchanted_hit", + DamageIndicator = "damage_indicator", + Heart = "heart", + AngryVillager = "angry_villager", + HappyVillager = "happy_villager", + Composter = "composter", + Mycelium = "mycelium", + Dolphin = "dolphin", + EggCrack = "egg_crack", + Poof = "poof", + Explosion = "explosion", + ExplosionEmitter = "explosion_emitter", + CampfireCosySmoke = "campfire_cosy_smoke", + CampfireSignalSmoke = "campfire_signal_smoke", + Note = "note", + Dust = "dust", + TotemOfUndying = "totem_of_undying", + Firework = "firework", + EndRod = "end_rod", + Ash = "ash", + WhiteAsh = "white_ash", + CrimsonSpore = "crimson_spore", + WarpedSpore = "warped_spore", + CherryLeaves = "cherry_leaves", + PaleOakLeaves = "pale_oak_leaves", + TintedLeaves = "tinted_leaves", + Effect = "effect", + InstantEffect = "instant_effect", + EntityEffect = "entity_effect", + Witch = "witch", + Infested = "infested", + Cloud = "cloud", + Sneeze = "sneeze", + Snowflake = "snowflake", + Soul = "soul", + SculkSoul = "sculk_soul", + DragonBreath = "dragon_breath", + Gust = "gust", + SmallGust = "small_gust", + GustEmitterLarge = "gust_emitter_large", + GustEmitterSmall = "gust_emitter_small", + DustColorTransition = "dust_color_transition", + SculkCharge = "sculk_charge", + SculkChargePop = "sculk_charge_pop", + Shriek = "shriek", + SonicBoom = "sonic_boom", + Vibration = "vibration", + SweepAttack = "sweep_attack", + Glow = "glow", + WaxOn = "wax_on", + WaxOff = "wax_off", + ElectricSpark = "electric_spark", + Scrape = "scrape", + SquidInk = "squid_ink", + GlowSquidInk = "glow_squid_ink", + Item = "item", + ItemSlime = "item_slime", + ItemSnowball = "item_snowball", + ItemCobweb = "item_cobweb", + SulfurCubeGoo = "sulfur_cube_goo", + Flash = "flash", + Firefly = "firefly", + TrialSpawnerDetection = "trial_spawner_detection", + TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous", + TrialOmen = "trial_omen", + RaidOmen = "raid_omen", + OminousSpawning = "ominous_spawning", + Spit = "spit", + Fishing = "fishing", + DustPlume = "dust_plume", + RedPoplarLeaves = "red_poplar_leaves", + OrangePoplarLeaves = "orange_poplar_leaves", + YellowPoplarLeaves = "yellow_poplar_leaves", + PauseMobGrowth = "pause_mob_growth", + ResetMobGrowth = "reset_mob_growth", + BlockMarker = "block_marker", +} +impl ParticleType { + /// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`): + /// spawned regardless of distance. + pub fn override_limiter(self) -> bool { + matches!( + self, + Self::BlockMarker + | Self::DamageIndicator + | Self::ExplosionEmitter + | Self::Explosion + | Self::Gust + | Self::GustEmitterLarge + | Self::GustEmitterSmall + | Self::SonicBoom + | Self::SculkCharge + | Self::SculkChargePop + | Self::Vibration + | Self::Poof + | Self::Spit + | Self::SquidInk + | Self::SweepAttack + | Self::CampfireCosySmoke + | Self::CampfireSignalSmoke + | Self::GlowSquidInk + | Self::Glow + | Self::WaxOn + | Self::WaxOff + | Self::ElectricSpark + | Self::Scrape + | Self::TrialSpawnerDetection + | Self::TrialSpawnerDetectionOminous + | Self::VaultConnection + | Self::OminousSpawning + ) + } + /// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class + /// hardcodes the translucent layer, drawn after translucent terrain. + /// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their + /// layer from the sprite instead (`Layer.bySprite`); see the `translucent` + /// sprite set on the engine. + pub fn translucent_layer(self) -> bool { + match self { + // CampfireSmokeParticle. + Self::CampfireCosySmoke | Self::CampfireSignalSmoke + // PlayerCloudParticle. + | Self::Cloud | Self::Sneeze + // SpellParticle. + | Self::Effect | Self::InstantEffect | Self::EntityEffect + | Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen + // EmissiveRisingParticle. + | Self::Soul | Self::SculkSoul + // SculkChargeParticle, SculkChargePopParticle, ShriekParticle, + // VibrationSignalParticle, FireflyParticle, OverlayParticle. + | Self::SculkCharge | Self::SculkChargePop | Self::Shriek + | Self::Vibration | Self::Firefly | Self::Flash + // SimpleAnimatedParticle and its subclasses: EndRodParticle, + // SparkParticle (firework), SquidInkParticle, TotemParticle. + | Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk + | Self::TotemOfUndying + // FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque. + | Self::VaultConnection => true, + _ => false, + } + } +} diff --git a/crates/horizon-client-core/src/registries.rs b/crates/horizon-client-core/src/registries.rs new file mode 100644 index 000000000..310115cf8 --- /dev/null +++ b/crates/horizon-client-core/src/registries.rs @@ -0,0 +1,188 @@ +//! Registry contents and source state that commands validate and complete +//! arguments against (what the server's registry access holds). +use std::collections::BTreeMap; + +/// Registry contents and source state used to validate and complete arguments. +#[derive(Clone, Debug, Default)] +pub struct Registries { + /// Permission level of the command source. + pub permission: u8, + pub items: Vec, + pub item_tags: Vec, + /// Block id -> (property, allowed values). + pub blocks: BTreeMap)>>, + pub block_tags: Vec, + /// Entity types that `summon` can create. + pub summonable: Vec, + /// Every entity type (selector `type=`). + pub entity_types: Vec, + pub effects: Vec, + pub enchantments: Vec, + pub structures: Vec, + pub biomes: Vec, + pub dimensions: Vec, + pub clocks: Vec, + pub timelines: Vec, + pub time_markers: Vec, + pub advancements: Vec, + /// Game rule name -> integer-valued. + pub game_rules: Vec<(String, bool)>, + pub players: Vec, + /// Scoreboard objective names, team names and tracked score holders. + pub objectives: Vec, + pub teams: Vec, + pub tracked: Vec, + /// Loaded datapack functions (`ns:path`) and function tags (`ns:path`). + pub functions: Vec, + /// `/data ... storage` ids. + pub storages: Vec, + /// Boss bar ids and datapack predicate ids. + pub bossbars: Vec, + pub predicates: Vec, + pub item_modifiers: Vec, + pub schedules: Vec, + pub stopwatches: Vec, + pub recipes: Vec, + pub sounds: Vec, + pub damage_types: Vec, + pub attributes: Vec, + pub loot_tables: Vec, + pub templates: Vec, + pub random_sequences: Vec, + pub datapacks_enabled: Vec, + pub datapacks_disabled: Vec, + pub function_tags: Vec, + /// The block the source is looking at (relevant coordinates). + pub target_block: Option<[i32; 3]>, + /// Every registry's element and tag ids (`minecraft:item` -> ...), as + /// the server's registry access and reloadable registries hold them. + pub by_registry: BTreeMap, + /// Blocks whose default state has a block entity. + pub block_entities: std::collections::BTreeSet, + /// Block tag -> member blocks. + pub block_tag_members: BTreeMap>, + /// Data component types with a codec (not transient). + pub persistent_components: Vec, + /// Recipes `/recipe` can give (not special). + pub learnable_recipes: Vec, + /// Advancement id -> criterion names. + pub advancement_criteria: BTreeMap>, + /// World clock -> time markers shown in commands. + pub clock_time_markers: BTreeMap>, + /// Trigger objectives the source may use (`/trigger` suggestions). + pub trigger_objectives: Vec, + /// Objectives with the `trigger` criterion -> holder -> whether its score is locked. + pub trigger_scores: BTreeMap>, +} +/// The ids and tag ids of one registry. +#[derive(Clone, Debug, Default)] +pub struct RegistryIds { + pub ids: Vec, + pub tags: Vec, +} +const EMPTY: &[String] = &[]; +impl Registries { + /// Fill what the game did not build itself from vanilla's exported + /// registries (`command-registries.json`, written by + /// `VanillaCommandParseOracle registries`): built-in registries such as + /// mob effects, data component types and dialogs, block entities and tag + /// members. Registries that data packs reload stay with the game. + pub fn fill_from_export(&mut self, r: &serde_json::Value) { + fn strings(v: &serde_json::Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() + } + let fill = |field: &mut Vec, key: &str| { + if field.is_empty() { + *field = strings(&r[key]); + } + }; + fill(&mut self.effects, "effects"); + fill(&mut self.enchantments, "enchantments"); + fill(&mut self.attributes, "attributes"); + fill(&mut self.damage_types, "damage_types"); + fill(&mut self.templates, "templates"); + fill(&mut self.persistent_components, "persistent_components"); + fill(&mut self.learnable_recipes, "learnable_recipes"); + const RELOADED: [&str; 5] = [ + "minecraft:advancement", + "minecraft:recipe", + "minecraft:loot_table", + "minecraft:predicate", + "minecraft:item_modifier", + ]; + for (name, entry) in r["by_registry"].as_object().into_iter().flatten() { + if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) { + continue; + } + self.by_registry.insert( + name.clone(), + RegistryIds { + ids: strings(&entry["ids"]), + tags: strings(&entry["tags"]), + }, + ); + } + if self.block_entities.is_empty() { + self.block_entities = strings(&r["block_entities"]).into_iter().collect(); + } + if self.block_tag_members.is_empty() { + for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() { + self.block_tag_members.insert(tag.clone(), strings(members)); + } + } + if self.clock_time_markers.is_empty() { + for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() { + self.clock_time_markers + .insert(clock.clone(), strings(markers)); + } + } + if self.advancement_criteria.is_empty() { + for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() { + self.advancement_criteria + .insert(id.clone(), strings(criteria)); + } + } + } + /// The element ids of a registry (sorted). + pub fn ids(&self, registry: &str) -> &[String] { + if let Some(r) = self.by_registry.get(registry) { + return &r.ids; + } + match registry { + "minecraft:item" => &self.items, + "minecraft:entity_type" => &self.entity_types, + "minecraft:mob_effect" => &self.effects, + "minecraft:enchantment" => &self.enchantments, + "minecraft:worldgen/structure" => &self.structures, + "minecraft:worldgen/biome" => &self.biomes, + "minecraft:world_clock" => &self.clocks, + "minecraft:timeline" => &self.timelines, + "minecraft:attribute" => &self.attributes, + "minecraft:damage_type" => &self.damage_types, + "minecraft:sound_event" => &self.sounds, + "minecraft:predicate" => &self.predicates, + "minecraft:item_modifier" => &self.item_modifiers, + "minecraft:loot_table" => &self.loot_tables, + "minecraft:recipe" => &self.recipes, + "minecraft:advancement" => &self.advancements, + _ => EMPTY, + } + } + /// The tag ids of a registry (sorted). + pub fn tags(&self, registry: &str) -> &[String] { + if let Some(r) = self.by_registry.get(registry) { + return &r.tags; + } + match registry { + "minecraft:item" => &self.item_tags, + "minecraft:block" => &self.block_tags, + _ => EMPTY, + } + } +} diff --git a/crates/horizon-client/src/scoreboard.rs b/crates/horizon-client-core/src/scoreboard.rs similarity index 99% rename from crates/horizon-client/src/scoreboard.rs rename to crates/horizon-client-core/src/scoreboard.rs index 4796be51a..3e820e4eb 100644 --- a/crates/horizon-client/src/scoreboard.rs +++ b/crates/horizon-client-core/src/scoreboard.rs @@ -2,9 +2,9 @@ //! `ObjectiveCriteria`, `NumberFormat`) and its saved data //! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`). use crate::{ - command::Registries, component, fastutil_order::{HashSetOrder, OpenOrder}, + registries::Registries, text::{self, Style, Text}, }; use horizon_world::nbt::Tag; @@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) { }) .collect::>>(), Tag::String(s) => { - crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from) + crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from) } _ => None, }; diff --git a/crates/horizon-client/src/snbt.rs b/crates/horizon-client-core/src/snbt.rs similarity index 100% rename from crates/horizon-client/src/snbt.rs rename to crates/horizon-client-core/src/snbt.rs diff --git a/crates/horizon-client-core/src/sound_source.rs b/crates/horizon-client-core/src/sound_source.rs new file mode 100644 index 000000000..d181d6ac5 --- /dev/null +++ b/crates/horizon-client-core/src/sound_source.rs @@ -0,0 +1,7 @@ +//! Sound sources (`SoundSource`), by name. + +/// SoundSource names in vanilla order. +pub const SOUND_SOURCES: [&str; 11] = [ + "master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient", + "voice", "ui", +]; diff --git a/crates/horizon-client/src/text.rs b/crates/horizon-client-core/src/text.rs similarity index 99% rename from crates/horizon-client/src/text.rs rename to crates/horizon-client-core/src/text.rs index 4c04c9da5..0a2b6eadb 100644 --- a/crates/horizon-client/src/text.rs +++ b/crates/horizon-client-core/src/text.rs @@ -67,7 +67,7 @@ pub struct Style { } /// A style's `font` field: an identifier (`alt` is `minecraft:alt`). pub fn font_id(v: &Value) -> Option { - crate::command::args::parse_identifier(v.as_str()?) + crate::identifier::parse_identifier(v.as_str()?) } impl Style { pub fn color(c: u32) -> Self { diff --git a/crates/horizon-client-core/src/uuid.rs b/crates/horizon-client-core/src/uuid.rs new file mode 100644 index 000000000..4ede9b141 --- /dev/null +++ b/crates/horizon-client-core/src/uuid.rs @@ -0,0 +1,24 @@ +//! UUIDs as vanilla's four-int form, to and from their text form. + +pub fn uuid_text(u: [i32; 4]) -> String { + let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect(); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} +pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> { + let hex: String = s.chars().filter(|c| *c != '-').collect(); + if hex.len() != 32 { + return None; + } + let mut out = [0i32; 4]; + for (i, v) in out.iter_mut().enumerate() { + *v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32; + } + Some(out) +} diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index d7c6d812b..923a48a34 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -9,6 +9,8 @@ description = "Horizon native development client" [dependencies] horizon-assets = { path = "../horizon-assets" } +horizon-client-core = { path = "../horizon-client-core" } +horizon-command = { path = "../horizon-command" } horizon-core = { path = "../horizon-core" } # The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority). horizon-threads = { path = "../horizon-threads" } diff --git a/crates/horizon-client/src/keybinds.rs b/crates/horizon-client/src/keybinds.rs index a59aa627c..fb0d04344 100644 --- a/crates/horizon-client/src/keybinds.rs +++ b/crates/horizon-client/src/keybinds.rs @@ -2,7 +2,6 @@ //! defaults, `key_:key.keyboard.x` persistence, display names, and the //! translation of rebound physical keys back to the default keys the game //! logic listens for. -use crate::text::Text; use std::collections::BTreeMap; use winit::{event::MouseButton, keyboard::KeyCode}; @@ -225,40 +224,7 @@ pub fn mouse_button(name: &str) -> Option { _ => return None, }) } -/// InputConstants.Key.getDisplayName: one-character keys as the upper-case -/// character, others by translation. -pub fn display_name(key: &str) -> Text { - if let Some(rest) = key.strip_prefix("key.keyboard.") - && rest.chars().count() == 1 - { - let c = rest.to_uppercase(); - return Text::literal(c); - } - let symbol = match key { - "key.keyboard.minus" => Some("-"), - "key.keyboard.equal" => Some("="), - "key.keyboard.left.bracket" => Some("["), - "key.keyboard.right.bracket" => Some("]"), - "key.keyboard.backslash" => Some("\\"), - "key.keyboard.semicolon" => Some(";"), - "key.keyboard.apostrophe" => Some("'"), - "key.keyboard.grave.accent" => Some("`"), - "key.keyboard.comma" => Some(","), - "key.keyboard.period" => Some("."), - "key.keyboard.slash" => Some("/"), - _ => None, - }; - if let Some(s) = symbol { - return Text::literal(s); - } - if let Some(n) = key.strip_prefix("key.mouse.") - && n.parse::().is_ok() - && !crate::lang::has(key) - { - return Text::translate("key.mouse", &[Text::literal(n)]); - } - Text::translate(key, &[]) -} +pub use horizon_client_core::key_display::display_name; /// Current bindings by mapping name. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 6bf40d1f0..232904b7a 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -19,13 +19,11 @@ pub mod biome; pub mod block_entities; pub mod block_entity_anim; pub mod block_entity_item; -pub mod block_entity_nbt; pub mod block_entity_render; pub mod block_transform; pub mod blocks; pub mod border_render; pub mod boss_overlay; -pub mod bossbar; pub mod breaking; pub mod brewing; pub mod bundle; @@ -38,10 +36,8 @@ pub mod clipboard; pub mod clock; pub mod clouds; pub mod collisions; -pub mod command; pub mod command_block; pub mod command_storage; -pub mod component; pub mod confirm_screen; pub mod container_screen; pub mod containers; @@ -70,7 +66,6 @@ pub mod extras; pub mod extras_hud; pub mod falling_block; pub mod fast_hash; -pub mod fastutil_order; pub mod fire_render; pub mod first_person; pub mod flatgen; @@ -79,7 +74,6 @@ pub mod font; pub mod friends_screen; pub mod game_mode_switcher; pub mod gamepad; -pub mod gamerules; pub mod gfx; pub mod gizmos; pub mod gossip; @@ -108,7 +102,6 @@ pub mod keybinds; pub mod keymapping; pub mod lan; pub mod landing; -pub mod lang; pub mod level_loading; pub mod light; pub mod locator_bar; @@ -130,7 +123,6 @@ pub mod mouse; pub mod moved_extras; pub mod multiplayer; pub mod music; -pub mod nbt_path; pub mod net; pub mod nether_portal; pub mod offline_name; @@ -159,7 +151,6 @@ pub mod recipe_book; pub mod recipe_book_screen; pub mod reconnect; pub mod save; -pub mod scoreboard; pub mod scoreboard_cmd; pub mod screen_draw; pub mod screen_effects; @@ -172,7 +163,6 @@ pub mod shader_pack_list; pub mod shadows; pub mod signs; pub mod skins; -pub mod snbt; pub mod sound_engine; pub mod sound_events; pub mod spans; @@ -185,7 +175,6 @@ pub mod structure_blocks; pub mod tab_list; pub mod telemetry; pub mod testgen; -pub mod text; pub mod text_display; pub mod tick_chunk_membership; pub mod ticket_levels; @@ -205,4 +194,15 @@ pub mod world; pub mod world_spawn; pub mod worldborder; pub mod zip; -pub type Result = std::result::Result>; +pub use horizon_client_core::Result; +pub use horizon_client_core::block_entity_nbt; +pub use horizon_client_core::bossbar; +pub use horizon_client_core::component; +pub use horizon_client_core::fastutil_order; +pub use horizon_client_core::gamerules; +pub use horizon_client_core::lang; +pub use horizon_client_core::nbt_path; +pub use horizon_client_core::scoreboard; +pub use horizon_client_core::snbt; +pub use horizon_client_core::text; +pub use horizon_command as command; diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index fa9dc9c6e..17064c163 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -23,212 +23,7 @@ use std::{ path::Path, }; -macro_rules! particle_types { - ($($variant:ident = $name:literal),* $(,)?) => { - /// Vanilla particle types with a client implementation here. - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub enum ParticleType { $($variant),* } - impl ParticleType { - pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*]; - /// Registry path (`minecraft:`), also the sprite-set file name. - pub fn name(self) -> &'static str { - match self { $(ParticleType::$variant => $name),* } - } - pub fn from_name(name: &str) -> Option { - let name = name.strip_prefix("minecraft:").unwrap_or(name); - match name { $($name => Some(ParticleType::$variant),)* _ => None } - } - } - }; -} -particle_types! { - Block = "block", - BlockCrumble = "block_crumble", - Trail = "trail", - DustPillar = "dust_pillar", - FallingDust = "falling_dust", - Smoke = "smoke", - LargeSmoke = "large_smoke", - WhiteSmoke = "white_smoke", - Flame = "flame", - SoulFireFlame = "soul_fire_flame", - CopperFireFlame = "copper_fire_flame", - SmallFlame = "small_flame", - Lava = "lava", - DrippingWater = "dripping_water", - FallingWater = "falling_water", - DrippingLava = "dripping_lava", - FallingLava = "falling_lava", - LandingLava = "landing_lava", - DrippingHoney = "dripping_honey", - FallingHoney = "falling_honey", - LandingHoney = "landing_honey", - FallingNectar = "falling_nectar", - FallingSporeBlossom = "falling_spore_blossom", - SporeBlossomAir = "spore_blossom_air", - DrippingObsidianTear = "dripping_obsidian_tear", - FallingObsidianTear = "falling_obsidian_tear", - LandingObsidianTear = "landing_obsidian_tear", - DrippingDripstoneWater = "dripping_dripstone_water", - FallingDripstoneWater = "falling_dripstone_water", - DrippingDripstoneLava = "dripping_dripstone_lava", - FallingDripstoneLava = "falling_dripstone_lava", - Splash = "splash", - Rain = "rain", - Bubble = "bubble", - SulfurBubbles = "sulfur_bubbles", - BubblePop = "bubble_pop", - BubbleColumnUp = "bubble_column_up", - CurrentDown = "current_down", - Underwater = "underwater", - Portal = "portal", - ReversePortal = "reverse_portal", - Enchant = "enchant", - Nautilus = "nautilus", - VaultConnection = "vault_connection", - Crit = "crit", - EnchantedHit = "enchanted_hit", - DamageIndicator = "damage_indicator", - Heart = "heart", - AngryVillager = "angry_villager", - HappyVillager = "happy_villager", - Composter = "composter", - Mycelium = "mycelium", - Dolphin = "dolphin", - EggCrack = "egg_crack", - Poof = "poof", - Explosion = "explosion", - ExplosionEmitter = "explosion_emitter", - CampfireCosySmoke = "campfire_cosy_smoke", - CampfireSignalSmoke = "campfire_signal_smoke", - Note = "note", - Dust = "dust", - TotemOfUndying = "totem_of_undying", - Firework = "firework", - EndRod = "end_rod", - Ash = "ash", - WhiteAsh = "white_ash", - CrimsonSpore = "crimson_spore", - WarpedSpore = "warped_spore", - CherryLeaves = "cherry_leaves", - PaleOakLeaves = "pale_oak_leaves", - TintedLeaves = "tinted_leaves", - Effect = "effect", - InstantEffect = "instant_effect", - EntityEffect = "entity_effect", - Witch = "witch", - Infested = "infested", - Cloud = "cloud", - Sneeze = "sneeze", - Snowflake = "snowflake", - Soul = "soul", - SculkSoul = "sculk_soul", - DragonBreath = "dragon_breath", - Gust = "gust", - SmallGust = "small_gust", - GustEmitterLarge = "gust_emitter_large", - GustEmitterSmall = "gust_emitter_small", - DustColorTransition = "dust_color_transition", - SculkCharge = "sculk_charge", - SculkChargePop = "sculk_charge_pop", - Shriek = "shriek", - SonicBoom = "sonic_boom", - Vibration = "vibration", - SweepAttack = "sweep_attack", - Glow = "glow", - WaxOn = "wax_on", - WaxOff = "wax_off", - ElectricSpark = "electric_spark", - Scrape = "scrape", - SquidInk = "squid_ink", - GlowSquidInk = "glow_squid_ink", - Item = "item", - ItemSlime = "item_slime", - ItemSnowball = "item_snowball", - ItemCobweb = "item_cobweb", - SulfurCubeGoo = "sulfur_cube_goo", - Flash = "flash", - Firefly = "firefly", - TrialSpawnerDetection = "trial_spawner_detection", - TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous", - TrialOmen = "trial_omen", - RaidOmen = "raid_omen", - OminousSpawning = "ominous_spawning", - Spit = "spit", - Fishing = "fishing", - DustPlume = "dust_plume", - RedPoplarLeaves = "red_poplar_leaves", - OrangePoplarLeaves = "orange_poplar_leaves", - YellowPoplarLeaves = "yellow_poplar_leaves", - PauseMobGrowth = "pause_mob_growth", - ResetMobGrowth = "reset_mob_growth", - BlockMarker = "block_marker", -} -impl ParticleType { - /// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`): - /// spawned regardless of distance. - pub fn override_limiter(self) -> bool { - matches!( - self, - Self::BlockMarker - | Self::DamageIndicator - | Self::ExplosionEmitter - | Self::Explosion - | Self::Gust - | Self::GustEmitterLarge - | Self::GustEmitterSmall - | Self::SonicBoom - | Self::SculkCharge - | Self::SculkChargePop - | Self::Vibration - | Self::Poof - | Self::Spit - | Self::SquidInk - | Self::SweepAttack - | Self::CampfireCosySmoke - | Self::CampfireSignalSmoke - | Self::GlowSquidInk - | Self::Glow - | Self::WaxOn - | Self::WaxOff - | Self::ElectricSpark - | Self::Scrape - | Self::TrialSpawnerDetection - | Self::TrialSpawnerDetectionOminous - | Self::VaultConnection - | Self::OminousSpawning - ) - } - /// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class - /// hardcodes the translucent layer, drawn after translucent terrain. - /// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their - /// layer from the sprite instead (`Layer.bySprite`); see the `translucent` - /// sprite set on the engine. - pub fn translucent_layer(self) -> bool { - match self { - // CampfireSmokeParticle. - Self::CampfireCosySmoke | Self::CampfireSignalSmoke - // PlayerCloudParticle. - | Self::Cloud | Self::Sneeze - // SpellParticle. - | Self::Effect | Self::InstantEffect | Self::EntityEffect - | Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen - // EmissiveRisingParticle. - | Self::Soul | Self::SculkSoul - // SculkChargeParticle, SculkChargePopParticle, ShriekParticle, - // VibrationSignalParticle, FireflyParticle, OverlayParticle. - | Self::SculkCharge | Self::SculkChargePop | Self::Shriek - | Self::Vibration | Self::Firefly | Self::Flash - // SimpleAnimatedParticle and its subclasses: EndRodParticle, - // SparkParticle (firework), SquidInkParticle, TotemParticle. - | Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk - | Self::TotemOfUndying - // FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque. - | Self::VaultConnection => true, - _ => false, - } - } -} +pub use horizon_client_core::particle_type::ParticleType; /// `ParticleOptions` with the parameters of the option classes used here. #[derive(Clone, Debug)] diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 0c353bb38..6f4082ec3 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -1988,28 +1988,7 @@ fn write_saved_data(world: &Path, name: &str, data: Tag) -> Result<()> { Tag::compound([("data", data), ("DataVersion", Tag::Int(DATA_VERSION))]), ) } -pub fn uuid_text(u: [i32; 4]) -> String { - let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect(); - format!( - "{}-{}-{}-{}-{}", - &hex[0..8], - &hex[8..12], - &hex[12..16], - &hex[16..20], - &hex[20..32] - ) -} -pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> { - let hex: String = s.chars().filter(|c| *c != '-').collect(); - if hex.len() != 32 { - return None; - } - let mut out = [0i32; 4]; - for (i, v) in out.iter_mut().enumerate() { - *v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32; - } - Some(out) -} +pub use horizon_client_core::uuid::{uuid_from_text, uuid_text}; /// A random (version 4) UUID as vanilla's four-int form. pub fn random_uuid() -> [i32; 4] { let t = std::time::SystemTime::now() @@ -4382,4 +4361,28 @@ mod tests { }; assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3)); } + /// The world's save (Level.write) writes the rules as they are now, and a + /// world opened again reads them back (#74). + #[test] + fn level_save_keeps_changed_rules() { + use crate::gamerules::{GameRules, Value}; + let dir = + std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut level = super::Level::default(); + level.write(&dir).unwrap(); + // Rules changed while playing, then saved and loaded again. + let mut rules = GameRules::load(&dir); + assert_eq!(rules, GameRules::default()); + assert!(rules.set("advance_time", Value::Bool(false))); + assert!(rules.set("advance_weather", Value::Bool(false))); + assert!(rules.set("random_tick_speed", Value::Int(0))); + level.game_rules = rules.by_name(); + level.write(&dir).unwrap(); + assert_eq!(GameRules::load(&dir), rules); + let read = super::Level::read(&dir).unwrap(); + assert_eq!(read.game_rules, rules.by_name()); + assert_eq!(read.game_rule("advance_time"), 0); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/crates/horizon-client/src/settings.rs b/crates/horizon-client/src/settings.rs index 74c428923..2308794d3 100644 --- a/crates/horizon-client/src/settings.rs +++ b/crates/horizon-client/src/settings.rs @@ -5,11 +5,7 @@ use crate::keybinds::KeyBinds; use crate::options_file::{OptionsFile, V}; use std::{fs, path::Path}; -/// SoundSource names in vanilla order. -pub const SOUND_SOURCES: [&str; 11] = [ - "master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient", - "voice", "ui", -]; +pub use horizon_client_core::sound_source::SOUND_SOURCES; /// Per-source volumes for the mixer (soundCategory_* except master). static SOURCE_VOLUMES: std::sync::RwLock<[f32; 11]> = std::sync::RwLock::new([1.0; 11]); diff --git a/crates/horizon-command/Cargo.toml b/crates/horizon-command/Cargo.toml new file mode 100644 index 000000000..10191ed6f --- /dev/null +++ b/crates/horizon-command/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "horizon-command" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version = "1.90" +description = "Commands (Brigadier): the vanilla command tree, argument types, selectors, parsing, suggestions and the network form of the tree" + +[dependencies] +horizon-client-core = { path = "../horizon-client-core" } +horizon-protocol = { path = "../horizon-protocol" } +horizon-world = { path = "../horizon-world" } + +[lints] +workspace = true diff --git a/crates/horizon-client/src/command/args.rs b/crates/horizon-command/src/args.rs similarity index 87% rename from crates/horizon-client/src/command/args.rs rename to crates/horizon-command/src/args.rs index ccced67a7..9d24d672f 100644 --- a/crates/horizon-client/src/command/args.rs +++ b/crates/horizon-command/src/args.rs @@ -1,192 +1,8 @@ //! Argument types (Brigadier's and Minecraft's `ArgumentType`s): parsing with //! the vanilla error messages and completion suggestions. use super::{CmdResult, CommandError, Context, Reader, SuggestionsBuilder}; -use std::collections::BTreeMap; -/// Registry contents and source state used to validate and complete arguments. -#[derive(Clone, Debug, Default)] -pub struct Registries { - /// Permission level of the command source. - pub permission: u8, - pub items: Vec, - pub item_tags: Vec, - /// Block id -> (property, allowed values). - pub blocks: BTreeMap)>>, - pub block_tags: Vec, - /// Entity types that `summon` can create. - pub summonable: Vec, - /// Every entity type (selector `type=`). - pub entity_types: Vec, - pub effects: Vec, - pub enchantments: Vec, - pub structures: Vec, - pub biomes: Vec, - pub dimensions: Vec, - pub clocks: Vec, - pub timelines: Vec, - pub time_markers: Vec, - pub advancements: Vec, - /// Game rule name -> integer-valued. - pub game_rules: Vec<(String, bool)>, - pub players: Vec, - /// Scoreboard objective names, team names and tracked score holders. - pub objectives: Vec, - pub teams: Vec, - pub tracked: Vec, - /// Loaded datapack functions (`ns:path`) and function tags (`ns:path`). - pub functions: Vec, - /// `/data ... storage` ids. - pub storages: Vec, - /// Boss bar ids and datapack predicate ids. - pub bossbars: Vec, - pub predicates: Vec, - pub item_modifiers: Vec, - pub schedules: Vec, - pub stopwatches: Vec, - pub recipes: Vec, - pub sounds: Vec, - pub damage_types: Vec, - pub attributes: Vec, - pub loot_tables: Vec, - pub templates: Vec, - pub random_sequences: Vec, - pub datapacks_enabled: Vec, - pub datapacks_disabled: Vec, - pub function_tags: Vec, - /// The block the source is looking at (relevant coordinates). - pub target_block: Option<[i32; 3]>, - /// Every registry's element and tag ids (`minecraft:item` -> ...), as - /// the server's registry access and reloadable registries hold them. - pub by_registry: BTreeMap, - /// Blocks whose default state has a block entity. - pub block_entities: std::collections::BTreeSet, - /// Block tag -> member blocks. - pub block_tag_members: BTreeMap>, - /// Data component types with a codec (not transient). - pub persistent_components: Vec, - /// Recipes `/recipe` can give (not special). - pub learnable_recipes: Vec, - /// Advancement id -> criterion names. - pub advancement_criteria: BTreeMap>, - /// World clock -> time markers shown in commands. - pub clock_time_markers: BTreeMap>, - /// Trigger objectives the source may use (`/trigger` suggestions). - pub trigger_objectives: Vec, - /// Objectives with the `trigger` criterion -> holder -> whether its score is locked. - pub trigger_scores: BTreeMap>, -} -/// The ids and tag ids of one registry. -#[derive(Clone, Debug, Default)] -pub struct RegistryIds { - pub ids: Vec, - pub tags: Vec, -} -const EMPTY: &[String] = &[]; -impl Registries { - /// Fill what the game did not build itself from vanilla's exported - /// registries (`command-registries.json`, written by - /// `VanillaCommandParseOracle registries`): built-in registries such as - /// mob effects, data component types and dialogs, block entities and tag - /// members. Registries that data packs reload stay with the game. - pub fn fill_from_export(&mut self, r: &serde_json::Value) { - fn strings(v: &serde_json::Value) -> Vec { - v.as_array() - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default() - } - let fill = |field: &mut Vec, key: &str| { - if field.is_empty() { - *field = strings(&r[key]); - } - }; - fill(&mut self.effects, "effects"); - fill(&mut self.enchantments, "enchantments"); - fill(&mut self.attributes, "attributes"); - fill(&mut self.damage_types, "damage_types"); - fill(&mut self.templates, "templates"); - fill(&mut self.persistent_components, "persistent_components"); - fill(&mut self.learnable_recipes, "learnable_recipes"); - const RELOADED: [&str; 5] = [ - "minecraft:advancement", - "minecraft:recipe", - "minecraft:loot_table", - "minecraft:predicate", - "minecraft:item_modifier", - ]; - for (name, entry) in r["by_registry"].as_object().into_iter().flatten() { - if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) { - continue; - } - self.by_registry.insert( - name.clone(), - RegistryIds { - ids: strings(&entry["ids"]), - tags: strings(&entry["tags"]), - }, - ); - } - if self.block_entities.is_empty() { - self.block_entities = strings(&r["block_entities"]).into_iter().collect(); - } - if self.block_tag_members.is_empty() { - for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() { - self.block_tag_members.insert(tag.clone(), strings(members)); - } - } - if self.clock_time_markers.is_empty() { - for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() { - self.clock_time_markers - .insert(clock.clone(), strings(markers)); - } - } - if self.advancement_criteria.is_empty() { - for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() { - self.advancement_criteria - .insert(id.clone(), strings(criteria)); - } - } - } - /// The element ids of a registry (sorted). - pub fn ids(&self, registry: &str) -> &[String] { - if let Some(r) = self.by_registry.get(registry) { - return &r.ids; - } - match registry { - "minecraft:item" => &self.items, - "minecraft:entity_type" => &self.entity_types, - "minecraft:mob_effect" => &self.effects, - "minecraft:enchantment" => &self.enchantments, - "minecraft:worldgen/structure" => &self.structures, - "minecraft:worldgen/biome" => &self.biomes, - "minecraft:world_clock" => &self.clocks, - "minecraft:timeline" => &self.timelines, - "minecraft:attribute" => &self.attributes, - "minecraft:damage_type" => &self.damage_types, - "minecraft:sound_event" => &self.sounds, - "minecraft:predicate" => &self.predicates, - "minecraft:item_modifier" => &self.item_modifiers, - "minecraft:loot_table" => &self.loot_tables, - "minecraft:recipe" => &self.recipes, - "minecraft:advancement" => &self.advancements, - _ => EMPTY, - } - } - /// The tag ids of a registry (sorted). - pub fn tags(&self, registry: &str) -> &[String] { - if let Some(r) = self.by_registry.get(registry) { - return &r.tags; - } - match registry { - "minecraft:item" => &self.item_tags, - "minecraft:block" => &self.block_tags, - _ => EMPTY, - } - } -} +pub use horizon_client_core::registries::{Registries, RegistryIds}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Resource { @@ -637,22 +453,7 @@ pub fn read_identifier(reader: &mut Reader) -> CmdResult { } } } -/// Identifier.parse: `ns:path` or `path`, validated. -pub fn parse_identifier(raw: &str) -> Option { - let (ns, path) = match raw.split_once(':') { - Some(("", path)) => ("minecraft", path), - Some((ns, path)) => (ns, path), - None => ("minecraft", raw), - }; - let ns_ok = ns != ".." - && ns - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-')); - let path_ok = path.chars().all(|c| { - c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/') - }); - (ns_ok && path_ok).then(|| format!("{ns}:{path}")) -} +pub use horizon_client_core::identifier::parse_identifier; pub fn normalize(id: &str) -> String { match id.split_once(':') { Some(("", path)) => format!("minecraft:{path}"), @@ -1220,7 +1021,10 @@ impl ArgType { reader.cursor = start; return Err(CommandError::tr_at( "argument.float.low", - &[&crate::snbt::java_float(*min), &crate::snbt::java_float(v)], + &[ + &horizon_client_core::snbt::java_float(*min), + &horizon_client_core::snbt::java_float(v), + ], reader, )); } @@ -1228,7 +1032,10 @@ impl ArgType { reader.cursor = start; return Err(CommandError::tr_at( "argument.float.big", - &[&crate::snbt::java_float(*max), &crate::snbt::java_float(v)], + &[ + &horizon_client_core::snbt::java_float(*max), + &horizon_client_core::snbt::java_float(v), + ], reader, )); } @@ -1241,8 +1048,8 @@ impl ArgType { return Err(CommandError::tr_at( "argument.double.low", &[ - &crate::snbt::java_double(*min), - &crate::snbt::java_double(v), + &horizon_client_core::snbt::java_double(*min), + &horizon_client_core::snbt::java_double(v), ], reader, )); @@ -1252,8 +1059,8 @@ impl ArgType { return Err(CommandError::tr_at( "argument.double.big", &[ - &crate::snbt::java_double(*max), - &crate::snbt::java_double(v), + &horizon_client_core::snbt::java_double(*max), + &horizon_client_core::snbt::java_double(v), ], reader, )); @@ -1597,7 +1404,7 @@ impl ArgType { return Err(CommandError::tr_at(key, &[&m], reader)); } let text = &reader.string[start..reader.cursor]; - match crate::component::parse_snbt(text) { + match horizon_client_core::component::parse_snbt(text) { Ok((v, _)) => Arg::Str(v.to_string()), Err(_) => Arg::Str(text.to_owned()), } @@ -1645,7 +1452,7 @@ impl ArgType { } let id = &reader.string[start..reader.cursor]; // ObjectiveCriteria.byName: statistics resolve to their canonical name. - match crate::scoreboard::criteria_by_name(id, reg) { + match horizon_client_core::scoreboard::criteria_by_name(id, reg) { Ok(Some(canonical)) => Arg::Str(canonical), Ok(None) => { reader.cursor = start; @@ -1667,7 +1474,7 @@ impl ArgType { } ArgType::ScoreboardSlot => { let name = reader.read_unquoted_string(); - if !crate::scoreboard::display_slot_valid(name) { + if !horizon_client_core::scoreboard::display_slot_valid(name) { return Err(CommandError::tr_at( "argument.scoreboardDisplaySlot.invalid", &[&name], @@ -1695,7 +1502,7 @@ impl ArgType { } ArgType::TeamColor => { let name = reader.read_unquoted_string(); - if crate::scoreboard::team_color_rgb(name).is_none() { + if horizon_client_core::scoreboard::team_color_rgb(name).is_none() { return Err(CommandError::tr_at( "argument.color.invalid", &[&name], @@ -1743,7 +1550,7 @@ impl ArgType { let id = read_identifier(reader)?; let known = reg.ids("minecraft:particle_type"); let exists = if known.is_empty() { - crate::particles::ParticleType::from_name(&id).is_some() + horizon_client_core::particle_type::ParticleType::from_name(&id).is_some() } else { known.contains(&id) }; @@ -1931,15 +1738,16 @@ impl ArgType { } ArgType::Team => builder.suggest_matching(reg.teams.iter().map(String::as_str)), ArgType::Criteria => { - let mut ids: Vec = crate::scoreboard::CUSTOM_CRITERIA + let mut ids: Vec = horizon_client_core::scoreboard::CUSTOM_CRITERIA .iter() .map(|s| (*s).to_owned()) .collect(); - for c in crate::scoreboard::team_colors() { + for c in horizon_client_core::scoreboard::team_colors() { ids.push(format!("teamkill.{c}")); ids.push(format!("killedByTeam.{c}")); } - let stat = |ty: &str, key: &str| crate::scoreboard::stat_criteria(ty, key); + let stat = + |ty: &str, key: &str| horizon_client_core::scoreboard::stat_criteria(ty, key); ids.extend(reg.blocks.keys().map(|b| stat("mined", b))); for ty in ["crafted", "used", "broken", "picked_up", "dropped"] { ids.extend(reg.items.iter().map(|i| stat(ty, i))); @@ -1948,14 +1756,14 @@ impl ArgType { ids.extend(reg.entity_types.iter().map(|e| stat(ty, e))); } ids.extend( - crate::scoreboard::CUSTOM_STATS + horizon_client_core::scoreboard::CUSTOM_STATS .iter() .map(|c| stat("custom", &format!("minecraft:{c}"))), ); builder.suggest_matching(ids.iter().map(String::as_str)); } ArgType::ScoreboardSlot => { - let slots = crate::scoreboard::display_slots(); + let slots = horizon_client_core::scoreboard::display_slots(); builder.suggest_matching(slots.iter().map(String::as_str)); } ArgType::Operation => builder.suggest_matching(OPERATIONS), @@ -1963,7 +1771,7 @@ impl ArgType { ArgType::Particle => { let known = reg.ids("minecraft:particle_type"); if known.is_empty() { - let names: Vec = crate::particles::ParticleType::ALL + let names: Vec = horizon_client_core::particle_type::ParticleType::ALL .iter() .map(|t| format!("minecraft:{}", t.name())) .collect(); @@ -1999,7 +1807,9 @@ impl ArgType { suggest_resources(builder, reg.ids("minecraft:slot_source"), ""); } ArgType::ResourceOrId(registry) => suggest_resources(builder, reg.ids(registry), ""), - ArgType::TeamColor => builder.suggest_matching(crate::scoreboard::team_colors()), + ArgType::TeamColor => { + builder.suggest_matching(horizon_client_core::scoreboard::team_colors()) + } ArgType::Anchor => builder.suggest_matching(["eyes", "feet"]), ArgType::Component | ArgType::Style | ArgType::NbtTag => super::snbt::suggest(builder), ArgType::Swizzle | ArgType::Message | ArgType::Nbt => {} diff --git a/crates/horizon-client/src/command/block.rs b/crates/horizon-command/src/block.rs similarity index 100% rename from crates/horizon-client/src/command/block.rs rename to crates/horizon-command/src/block.rs diff --git a/crates/horizon-client/src/command/codec.rs b/crates/horizon-command/src/codec.rs similarity index 99% rename from crates/horizon-client/src/command/codec.rs rename to crates/horizon-command/src/codec.rs index 7c4d26682..40f5c1ce0 100644 --- a/crates/horizon-client/src/command/codec.rs +++ b/crates/horizon-command/src/codec.rs @@ -6,7 +6,7 @@ use super::{CmdResult, CommandError, Reader}; use horizon_world::nbt::{Compound, JavaString, Tag}; pub fn snbt(t: &Tag) -> String { - crate::snbt::to_snbt(t) + horizon_client_core::snbt::to_snbt(t) } pub fn not_map(t: &Tag) -> String { format!("Not a map: {}", snbt(t)) diff --git a/crates/horizon-client/src/command/component.rs b/crates/horizon-command/src/component.rs similarity index 98% rename from crates/horizon-client/src/command/component.rs rename to crates/horizon-command/src/component.rs index 15a8fa6a0..2933afeca 100644 --- a/crates/horizon-client/src/command/component.rs +++ b/crates/horizon-command/src/component.rs @@ -164,7 +164,7 @@ fn color(t: &Tag) -> R { _ => partial(format!("Invalid color value: {s}")), }; } - if crate::text::named_color(&s).is_some() { + if horizon_client_core::text::named_color(&s).is_some() { Ok(()) } else { partial(format!("Invalid color name: {s}")) @@ -278,7 +278,7 @@ fn content(kind: &str, map: &Compound) -> bool { "nbt" => { let path_ok = get(map, "nbt") .and_then(string_of) - .is_some_and(|p| crate::nbt_path::NbtPath::parse(&p).is_ok()); + .is_some_and(|p| horizon_client_core::nbt_path::NbtPath::parse(&p).is_ok()); let source = if let Some(e) = get(map, "entity") { selector_string(e) } else if let Some(b) = get(map, "block") { diff --git a/crates/horizon-client/src/command/item.rs b/crates/horizon-command/src/item.rs similarity index 99% rename from crates/horizon-client/src/command/item.rs rename to crates/horizon-command/src/item.rs index f2abc91ef..da9c4b8b0 100644 --- a/crates/horizon-client/src/command/item.rs +++ b/crates/horizon-command/src/item.rs @@ -72,9 +72,9 @@ pub fn decode_component(id: &str, t: &Tag) -> Result<(), String> { } else { format!("minecraft:{id}") }; - let known = crate::block_entity_nbt::TYPES + let known = horizon_client_core::block_entity_nbt::TYPES .iter() - .chain(&crate::block_entity_nbt::NEWEST) + .chain(&horizon_client_core::block_entity_nbt::NEWEST) .any(|k| *k == id); if known { Ok(()) diff --git a/crates/horizon-client/src/command/mod.rs b/crates/horizon-command/src/lib.rs similarity index 99% rename from crates/horizon-client/src/command/mod.rs rename to crates/horizon-command/src/lib.rs index a61c8c08d..838c8de87 100644 --- a/crates/horizon-client/src/command/mod.rs +++ b/crates/horizon-command/src/lib.rs @@ -3,6 +3,7 @@ //! relevant child and keeps the best result, completion suggestions, smart //! usage text and the vanilla error messages. Argument types live in //! [`args`]; the vanilla command tree in [`vanilla`]. +#![forbid(unsafe_code)] pub mod args; pub mod block; pub mod codec; @@ -21,8 +22,8 @@ pub mod tree_world; pub mod vanilla; mod vanilla_order; -use crate::text::Text; pub use args::{Arg, ArgType, Registries}; +use horizon_client_core::text::Text; /// The command id of a mod's command (`Dispatcher::with_mod_commands`). pub const MOD_COMMAND: &str = "mod"; @@ -623,8 +624,10 @@ impl Suggests { for id in ®.summonable { if args::resource_matches(&contents, id) { let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id)); - let name = - crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', "."))); + let name = horizon_client_core::lang::get(&format!( + "entity.{ns}.{}", + path.replace('/', ".") + )); builder.suggest_tooltip(id.clone(), name); } } diff --git a/crates/horizon-client/src/command/network.rs b/crates/horizon-command/src/network.rs similarity index 100% rename from crates/horizon-client/src/command/network.rs rename to crates/horizon-command/src/network.rs diff --git a/crates/horizon-client/src/command/packrat.rs b/crates/horizon-command/src/packrat.rs similarity index 99% rename from crates/horizon-client/src/command/packrat.rs rename to crates/horizon-command/src/packrat.rs index 42f42d9f4..ebe9df09d 100644 --- a/crates/horizon-client/src/command/packrat.rs +++ b/crates/horizon-command/src/packrat.rs @@ -402,7 +402,7 @@ impl<'g, V: Value> State<'g, V> { Reason::Error(e) => e.clone(), }; } - CommandError::new(crate::text::Text::literal("Failed to parse")) + CommandError::new(horizon_client_core::text::Text::literal("Failed to parse")) } /// CachedParseState.parse: a named rule, memoised per position. pub fn parse(&mut self, rule: RuleId) -> Option { diff --git a/crates/horizon-client/src/command/selector.rs b/crates/horizon-command/src/selector.rs similarity index 99% rename from crates/horizon-client/src/command/selector.rs rename to crates/horizon-command/src/selector.rs index eb4e637a4..777124f60 100644 --- a/crates/horizon-client/src/command/selector.rs +++ b/crates/horizon-command/src/selector.rs @@ -687,7 +687,7 @@ impl<'r, 'a> SelectorParser<'r, 'a> { fn fill_selectors(builder: &mut SuggestionsBuilder) { for (s, tip) in SELECTOR_KINDS { - builder.suggest_tooltip(s, crate::lang::get(tip)); + builder.suggest_tooltip(s, horizon_client_core::lang::get(tip)); } } @@ -785,7 +785,9 @@ impl<'r, 'a> SelectorParser<'r, 'a> { if self.can_use(o) && o.starts_with(&lower) { b.suggest_tooltip( format!("{o}="), - crate::lang::get(&format!("argument.entity.options.{o}.description")), + horizon_client_core::lang::get(&format!( + "argument.entity.options.{o}.description" + )), ); } } diff --git a/crates/horizon-client/src/command/snbt.rs b/crates/horizon-command/src/snbt.rs similarity index 100% rename from crates/horizon-client/src/command/snbt.rs rename to crates/horizon-command/src/snbt.rs diff --git a/crates/horizon-client/src/command/tests.rs b/crates/horizon-command/src/tests.rs similarity index 100% rename from crates/horizon-client/src/command/tests.rs rename to crates/horizon-command/src/tests.rs diff --git a/crates/horizon-client/src/command/tree_data.rs b/crates/horizon-command/src/tree_data.rs similarity index 100% rename from crates/horizon-client/src/command/tree_data.rs rename to crates/horizon-command/src/tree_data.rs diff --git a/crates/horizon-client/src/command/tree_execute.rs b/crates/horizon-command/src/tree_execute.rs similarity index 100% rename from crates/horizon-client/src/command/tree_execute.rs rename to crates/horizon-command/src/tree_execute.rs diff --git a/crates/horizon-client/src/command/tree_extra.rs b/crates/horizon-command/src/tree_extra.rs similarity index 100% rename from crates/horizon-client/src/command/tree_extra.rs rename to crates/horizon-command/src/tree_extra.rs diff --git a/crates/horizon-client/src/command/tree_misc.rs b/crates/horizon-command/src/tree_misc.rs similarity index 99% rename from crates/horizon-client/src/command/tree_misc.rs rename to crates/horizon-command/src/tree_misc.rs index a66fd5f37..1bcd880bb 100644 --- a/crates/horizon-client/src/command/tree_misc.rs +++ b/crates/horizon-command/src/tree_misc.rs @@ -230,12 +230,12 @@ fn bossbar(d: &mut Dispatcher, root: usize) { let name = d.argument(name_l, "name", ArgType::Component); d.executes(name, "bossbar.set.name"); let color = d.literal(id, "color"); - for c in crate::bossbar::COLORS { + for c in horizon_client_core::bossbar::COLORS { let l = d.literal(color, c); d.executes(l, "bossbar.set.color"); } let style = d.literal(id, "style"); - for s in crate::bossbar::STYLES { + for s in horizon_client_core::bossbar::STYLES { let l = d.literal(style, s); d.executes(l, "bossbar.set.style"); } diff --git a/crates/horizon-client/src/command/tree_scoreboard.rs b/crates/horizon-command/src/tree_scoreboard.rs similarity index 100% rename from crates/horizon-client/src/command/tree_scoreboard.rs rename to crates/horizon-command/src/tree_scoreboard.rs diff --git a/crates/horizon-client/src/command/tree_world.rs b/crates/horizon-command/src/tree_world.rs similarity index 99% rename from crates/horizon-client/src/command/tree_world.rs rename to crates/horizon-command/src/tree_world.rs index 0bd2edd3b..86ebba0ca 100644 --- a/crates/horizon-client/src/command/tree_world.rs +++ b/crates/horizon-command/src/tree_world.rs @@ -7,7 +7,7 @@ use super::{ args::{ArgType, integer}, vanilla::{ENTITIES, ENTITY, GAMEMASTERS, PLAYERS}, }; -use crate::settings::SOUND_SOURCES; +use horizon_client_core::sound_source::SOUND_SOURCES; fn float(min: f32) -> ArgType { ArgType::Float { min, max: f32::MAX } diff --git a/crates/horizon-client/src/command/vanilla.rs b/crates/horizon-command/src/vanilla.rs similarity index 99% rename from crates/horizon-client/src/command/vanilla.rs rename to crates/horizon-command/src/vanilla.rs index 2ce892077..37ec1c3de 100644 --- a/crates/horizon-client/src/command/vanilla.rs +++ b/crates/horizon-command/src/vanilla.rs @@ -5,7 +5,7 @@ use super::{ Dispatcher, args::{ArgType, Resource, integer}, }; -use crate::gamerules::{RULES, Value}; +use horizon_client_core::gamerules::{RULES, Value}; /// Permission levels (Commands.LEVEL_*). pub const ALL: u8 = 0; diff --git a/crates/horizon-client/src/command/vanilla_order.rs b/crates/horizon-command/src/vanilla_order.rs similarity index 100% rename from crates/horizon-client/src/command/vanilla_order.rs rename to crates/horizon-command/src/vanilla_order.rs diff --git a/docs/architecture.md b/docs/architecture.md index 0350bd1ea..7646ccc17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,8 +5,9 @@ counted only for the game loop because nearly every gameplay change touches it). organised and how to build and test are in [AGENTS.md](../AGENTS.md). Paths: `L/` = `crates/horizon-client/src/`, `G/` = `crates/horizon-client/src/game/`, `W/` = -`crates/horizon-world/src/`. Checks: a bare name is a harness (`python3 tools/parity.py `, script -`tools/test__parity.py`); `lan_e2e ` is `python3 tools/lan_e2e.py --check `. +`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`. +Checks: a bare name is a harness (`python3 tools/parity.py `, script `tools/test__parity.py`); +`lan_e2e ` is `python3 tools/lan_e2e.py --check `. For the full set of harnesses a file can affect, ask the runner: `printf '%s\n' > /tmp/p.txt && python3 tools/parity.py --changed-files /tmp/p.txt --list`. @@ -17,7 +18,7 @@ file selects ~103. |---|---|---|---| | 1 | Game loop, tick order, frame pacing (554) | `L/game.rs` (`fn tick`, `fn tick_world`, `fn render`, `about_to_wait`), `L/clock.rs`, `L/window.rs`, `L/spans.rs` | game_loop, frame_limiter, interaction_input, tick | | 2 | Mobs in the game loop, spawning (166) | `G/creatures.rs`, `G/natural_spawning.rs`, `L/mobs/spawning.rs`, `L/mobs/spawn_rules.rs`, `L/mobs/spawn_selection.rs`, `L/mobs/finalize.rs`, `G/spawner.rs`, `G/trial_spawner.rs` | natural_spawn, spawn_rules, finalize_spawn, spawner, trial_spawner, mob_ai | -| 3 | World save and load (163) | `L/save.rs`, `G/saving.rs`, `G/persist.rs` (entities), `L/entity_nbt.rs`, `L/block_entity_nbt.rs`, `W/region.rs`, `W/nbt.rs` | save_roundtrip, world_reopen, world, datafix | +| 3 | World save and load (163) | `L/save.rs`, `G/saving.rs`, `G/persist.rs` (entities), `L/entity_nbt.rs`, `K/block_entity_nbt.rs`, `W/region.rs`, `W/nbt.rs` | save_roundtrip, world_reopen, world, datafix | | 4 | World generation (153) | `W/worldgen.rs`, `W/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `placement/`), `L/vanillagen.rs` | chunk_generation, worldgen, server_world_gen, world_spawn, feature_graph, feature_placement, decoration and the other worldgen harnesses | | 5 | Multiplayer client, packets in (145) | `G/remote.rs` (`fn handle_packet`, `fn tick_remote`), `G/remote_*.rs`, `L/net.rs` | dedicated_server, server_entities, lan_e2e (all) | | 6 | Agent control, horizon-ctl (145) | `L/control.rs`, `L/bin/horizon-ctl/`, `G/control.rs`, `G/agent.rs`, `G/events.rs`, `G/ui_control.rs` | `python3 tools/agent_control_e2e.py`; `cargo test -p horizon-client --bin horizon-ctl` | @@ -32,7 +33,7 @@ file selects ~103. | 15 | Items and item use (96) | `L/items.rs`, `L/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect | | 16 | Options and settings screens (93) | `L/options_ui.rs`, `G/options.rs`, `L/settings.rs`, `L/options_file.rs`, `L/graphics_ui.rs`, `L/graphics.rs` | options, toggle_keys | | 17 | HUD, chat, tab list (93) | `L/hud.rs`, `L/hud_layout.rs`, `L/hud_overlays.rs`, `L/chat.rs`, `G/chat.rs`, `L/tab_list.rs`, `L/locator_bar.rs`, `G/locator_bar.rs` | hud, tab_list, locator_bar, chat_screen, secure_chat | -| 18 | Command execution (88) | `G/commands.rs`, `G/commands/*.rs`, `L/scoreboard_cmd.rs`, `L/data_cmd.rs`, `G/functions.rs` | command_exec (corpus `tools/command_exec_corpus.py`), scoreboard, server_ops_feedback, lan_e2e commands | +| 18 | Command execution (88) | `G/commands.rs`, `G/commands/*.rs`, `L/scoreboard_cmd.rs`, `L/data_cmd.rs`, `K/scoreboard.rs`, `G/functions.rs` | command_exec (corpus `tools/command_exec_corpus.py`), scoreboard, server_ops_feedback, lan_e2e commands | | 19 | Chunk streaming, light, tickets (85) | `L/streaming.rs`, `L/world.rs`, `L/chunk.rs`, `L/chunk_map.rs`, `L/light.rs`, `L/ticket_levels.rs`, `L/tick_chunk_membership.rs`, `G/world_loading.rs` | light, chunk_status, random_tick_sections; `cargo test -p horizon-client --test streaming_meshes` | | 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `L/entity.wgsl` | entity_model | | 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers | @@ -52,6 +53,6 @@ file selects ~103. | 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser | | 36 | Audio and music (38) | `L/audio.rs`, `L/sound_engine.rs`, `L/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle | | 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) | -| 38 | Command parsing (34) | `L/command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`) | command_parse (corpus `tools/command_parse_corpus.py`) | +| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) | | 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `tools/perf_bench.py`, `L/telemetry.rs`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) | | 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet | diff --git a/docs/version-ports.md b/docs/version-ports.md index 623e3c082..4ecad210f 100644 --- a/docs/version-ports.md +++ b/docs/version-ports.md @@ -43,7 +43,7 @@ to players. Players still prepare their own copy from their official launcher (` | `crates/horizon-client/src/lan/tracking.rs` | `generate_entity_tracking.py` | decompiled `EntityTypes` | | `crates/horizon-client/src/lan/command_suggestions.rs` | `generate_command_suggestions.py` | `VanillaCommandSuggestionsExporter` | | `crates/horizon-client/src/lan/command_order.rs` | `generate_lan_command_order.py` | vanilla's command tree | - | `crates/horizon-client/src/command/vanilla_order.rs` | `generate_command_order.py` | command-parse harness export | + | `crates/horizon-command/src/vanilla_order.rs` | `generate_command_order.py` | command-parse harness export | | `crates/horizon-world/src/datafix/block_state_data.txt` | `gen_block_state_data.py` | decompiled `BlockStateData` | | `crates/horizon-world/src/datafix/flat_tables.rs` | `gen_flat_tables.py` | 1.13 flattening fixers (never change) | diff --git a/tools/generate_command_order.py b/tools/generate_command_order.py index ab78b3ada..e7e1175ae 100644 --- a/tools/generate_command_order.py +++ b/tools/generate_command_order.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate crates/horizon-client/src/command/vanilla_order.rs: the order of +"""Generate crates/horizon-command/src/vanilla_order.rs: the order of the children of each node of vanilla's command tree where Horizon's tree registers them in a different order. Brigadier keeps children in registration order, and /help (smart usage) lists them that way. @@ -19,7 +19,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("vanilla", type=Path) parser.add_argument("rust", type=Path) - parser.add_argument("--out", type=Path, default=ROOT / "crates/horizon-client/src/command/vanilla_order.rs") + parser.add_argument("--out", type=Path, default=ROOT / "crates/horizon-command/src/vanilla_order.rs") args = parser.parse_args() vanilla = json.loads(args.vanilla.read_text()) rust = json.loads(args.rust.read_text()) diff --git a/tools/port_regen.py b/tools/port_regen.py index 8ce639f21..4d69b0986 100644 --- a/tools/port_regen.py +++ b/tools/port_regen.py @@ -251,7 +251,7 @@ GENERATORS = [ ("lan_command_order", ["crates/horizon-client/src/lan/command_order.rs"], lambda root, version, store, build: [python(), "tools/generate_lan_command_order.py"]), ("command_suggestions", ["crates/horizon-client/src/lan/command_suggestions.rs"], gen_suggestions), - ("vanilla_command_order", ["crates/horizon-client/src/command/vanilla_order.rs"], gen_vanilla_order), + ("vanilla_command_order", ["crates/horizon-command/src/vanilla_order.rs"], gen_vanilla_order), ] diff --git a/tools/port_report.py b/tools/port_report.py index 9006576dd..f8e81b6ee 100644 --- a/tools/port_report.py +++ b/tools/port_report.py @@ -40,7 +40,7 @@ GENERATORS = [ lambda d: bool(d["commands"]["added"] or d["commands"]["removed"] or d["commands"]["changed"])), ("crates/horizon-client/src/lan/command_order.rs", "python3 tools/generate_lan_command_order.py", lambda d: bool(d["commands"]["added"] or d["commands"]["removed"])), - ("crates/horizon-client/src/command/vanilla_order.rs", "python3 tools/generate_command_order.py", + ("crates/horizon-command/src/vanilla_order.rs", "python3 tools/generate_command_order.py", lambda d: bool(d["commands"]["added"] or d["commands"]["removed"])), ("crates/horizon-world/src/datafix/block_state_data.txt", "python3 tools/gen_block_state_data.py", lambda d: "net/minecraft/util/datafix/fixes/BlockStateData" in d["classes"]["changed"]), diff --git a/tools/test_lore_tooltip_parity.py b/tools/test_lore_tooltip_parity.py index d262a3542..e6869f7e9 100644 --- a/tools/test_lore_tooltip_parity.py +++ b/tools/test_lore_tooltip_parity.py @@ -35,7 +35,7 @@ if __name__ == '__main__': sources=[ lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', lab.ROOT / 'crates/horizon-client/src/item_data.rs', - lab.ROOT / 'crates/horizon-client/src/text.rs', - lab.ROOT / 'crates/horizon-client/src/component.rs', + lab.ROOT / 'crates/horizon-client-core/src/text.rs', + lab.ROOT / 'crates/horizon-client-core/src/component.rs', ], expected_lines=len(CASES)) diff --git a/tools/test_resource_pack_parity.py b/tools/test_resource_pack_parity.py index 2d9ae2a85..a935e0b34 100644 --- a/tools/test_resource_pack_parity.py +++ b/tools/test_resource_pack_parity.py @@ -129,7 +129,7 @@ def main(): "Pack discovery, overlays, filters and the resource stacks vanilla merges.", sources=[lab.ROOT / "crates/horizon-client/src/packs.rs", lab.ROOT / "crates/horizon-client/src/sound_events.rs", - lab.ROOT / "crates/horizon-client/src/lang.rs"]) + lab.ROOT / "crates/horizon-client-core/src/lang.rs"]) total += len(QUERIES) print(f"{total} resource pack queries match the pinned client over {len(SELECTIONS)} selections") From aec3b7e2624ea88f8d5ccd2019a9b13e03700e04 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:43:51 +0100 Subject: [PATCH 026/475] Scale mob damage to players with the difficulty Damage types marked when_caused_by_living_non_player now scale when a mob (or the owner of its projectile, or an exploding mob) caused the hit. --- crates/horizon-client/src/game/creatures.rs | 6 ++++- crates/horizon-client/src/game/explosion.rs | 4 +++- crates/horizon-client/src/health.rs | 26 +++++++++++++++++++-- crates/horizon-client/src/hunger.rs | 1 + 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..e27e6b175 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1509,15 +1509,19 @@ impl Game<'_> { if let Some(a) = attacker.or(owner) { self.note_attacker(damage_type, a); } + // DamageSource.scalesWithDifficulty: a mob (or its projectile's + // owner) caused the hit, not a player. + let living_cause = attacker.or(owner).is_some(); let mut outcome = horizon_client::health::BlockOutcome::default(); if let Some(player) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(damage_type) - && let Ok(o) = player.hurt_blocking( + && let Ok(o) = player.hurt_caused_by( &self.motion.health, self.world.health_rules, id, amount, blocking, + living_cause, ) && { outcome = o; diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..8800e29d8 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -296,12 +296,14 @@ impl Game<'_> { if !creative && !spectator && hurtable - && let Ok(o) = p.hurt_blocking( + && let Ok(o) = p.hurt_caused_by( &self.motion.health, self.world.health_rules, id, amount, blocking, + // The exploding mob is the causing entity. + exploding.is_some(), ) { outcome = o; diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 25c719bfb..4f7824b25 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -295,6 +295,20 @@ impl PlayerMotion { source: DamageTypeId, amount: f32, blocking: Option, + ) -> Result { + self.hurt_caused_by(resources, rules, source, amount, blocking, false) + } + /// `hurt_blocking` from a source whose causing entity is a living + /// non-player (`living_cause`), which the `when_caused_by_living_non_player` + /// damage types scale with the difficulty. + pub fn hurt_caused_by( + &mut self, + resources: &HealthResources, + rules: HealthRules, + source: DamageTypeId, + amount: f32, + blocking: Option, + living_cause: bool, ) -> Result { if self.client_side { return Ok(BlockOutcome::default()); @@ -315,6 +329,7 @@ impl PlayerMotion { invulnerable, rules, source, + living_cause, events: &mut self.events, dead: &mut h.dead, sync_velocity: &mut h.sync_velocity, @@ -390,6 +405,8 @@ pub(crate) struct Environment<'a> { pub(crate) invulnerable: bool, pub(crate) rules: HealthRules, pub(crate) source: DamageSource<'a, ()>, + /// The causing entity is a living non-player (a mob, or its projectile's owner). + pub(crate) living_cause: bool, pub(crate) events: &'a mut Vec, pub(crate) dead: &'a mut bool, pub(crate) sync_velocity: &'a mut bool, @@ -773,8 +790,13 @@ impl PlayerHurtWorld for Environment<'_> { Ok(()) } fn scales_with_difficulty(&mut self, _: &mut State) -> io::Result { - Ok(self.source.definition().scaling == Scaling::Always) - } // no causing entity + // DamageSource.scalesWithDifficulty. + Ok(match self.source.definition().scaling { + Scaling::Never => false, + Scaling::Always => true, + Scaling::LivingNonPlayer => self.living_cause, + }) + } fn difficulty(&mut self, _: &mut State) -> io::Result { Ok(self.rules.difficulty) } diff --git a/crates/horizon-client/src/hunger.rs b/crates/horizon-client/src/hunger.rs index 15eac144f..34f414d50 100644 --- a/crates/horizon-client/src/hunger.rs +++ b/crates/horizon-client/src/hunger.rs @@ -151,6 +151,7 @@ impl FoodWorld for TickWorld<'_> { .damage .source::<()>(id, None, None, None) .expect("validated starvation source"), + living_cause: false, events: self.events, dead: self.dead, sync_velocity: self.sync_velocity, From d0af8430948c1f277a7f3e1b574dd2c7311babb8 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:44:16 +0100 Subject: [PATCH 027/475] Daylight detectors follow the sky and update when used A daylight detector never changed its power: nothing ran its update. It now has a block entity with the ticker vanilla has, and updates its power every 20 game ticks from the sky light, the sky darkening and the sun angle. Using it to flip inverted mode recomputes the power at once. --- crates/horizon-client/src/containers.rs | 16 ++++++++-- crates/horizon-client/src/game.rs | 18 +++++++++-- .../src/game/block_entity_ticks.rs | 15 +++++++++- crates/horizon-client/src/save.rs | 4 ++- crates/horizon-world/src/level/interact.rs | 1 + crates/horizon-world/src/level/mod.rs | 22 ++++++++++++++ crates/horizon-world/src/level/redstone.rs | 30 ++++++++++++++++++- 7 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index d13c5e45b..895ea5769 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -85,6 +85,9 @@ pub enum BlockEntity { CommandBlock(Box), /// `SkullBlockEntity` of every skull and head block. Skull(Skull), + /// `DaylightDetectorBlockEntity`: no data, only the ticker + /// (`DaylightDetectorBlock.tickEntity`). + DaylightDetector, } /// `SkullBlockEntity`: the player profile the head shows (kept as loaded: /// a compound, or a string when it names a player only), the note block @@ -295,6 +298,9 @@ impl BlockEntity { if is_skull(name) { return Some(Self::Skull(Skull::default())); } + if name == "minecraft:daylight_detector" { + return Some(Self::DaylightDetector); + } if name == "minecraft:beacon" { return Some(Self::Beacon(Box::new(Beacon { last_check_y: i32::MIN / 2, @@ -341,6 +347,7 @@ impl BlockEntity { Self::Beehive(_) => "minecraft:beehive", Self::CommandBlock(_) => "minecraft:command_block", Self::Skull(_) => "minecraft:skull", + Self::DaylightDetector => "minecraft:daylight_detector", } } pub fn contents(&self) -> Vec { @@ -357,7 +364,8 @@ impl BlockEntity { | Self::Vault(_) | Self::Beehive(_) | Self::CommandBlock(_) - | Self::Skull(_) => Vec::new(), + | Self::Skull(_) + | Self::DaylightDetector => Vec::new(), Self::Lectern(l) => l.book.iter().cloned().collect(), Self::Sculk(_) => Vec::new(), Self::Banner(_) => Vec::new(), @@ -375,7 +383,8 @@ impl BlockEntity { | Self::Lectern(_) | Self::Sculk(_) | Self::Banner(_) - | Self::Skull(_) => &[], + | Self::Skull(_) + | Self::DaylightDetector => &[], Self::Crafter(c) => &c.items, Self::CreakingHeart(_) | Self::Spawner(_) @@ -396,7 +405,8 @@ impl BlockEntity { | Self::Lectern(_) | Self::Sculk(_) | Self::Banner(_) - | Self::Skull(_) => &mut [], + | Self::Skull(_) + | Self::DaylightDetector => &mut [], Self::Crafter(c) => &mut c.items, Self::CreakingHeart(_) | Self::Spawner(_) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..4c866a203 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -532,6 +532,8 @@ struct GameLevel<'w> { events: &'w mut Vec, dimension: LevelDimension, darken: i32, + /// The environment of the level's dimension (`SUN_ANGLE`). + environment: &'w Environment, raining: bool, thundering: bool, player_box: Option<([f64; 3], [f64; 3])>, @@ -759,6 +761,11 @@ impl LevelAccess for GameLevel<'_> { fn sky_darken(&self) -> i32 { self.darken } + fn sun_angle(&self, _pos: BlockPos) -> f32 { + self.environment + .sample(self.world.day_time, None, None) + .sun_angle + } fn is_raining(&self) -> bool { self.raining } @@ -2469,6 +2476,11 @@ impl Game<'_> { events: &mut self.level_events, dimension, darken, + environment: match dimension { + LevelDimension::Overworld => self.res.environment(), + LevelDimension::Nether => self.res.nether_environment(), + LevelDimension::End => self.res.end_environment(), + }, raining: rain > 0.2, thundering: thunder > 0.9, player_box, @@ -5278,7 +5290,8 @@ impl Game<'_> { | BlockEntity::Vault(_) | BlockEntity::Beehive(_) | BlockEntity::CommandBlock(_) - | BlockEntity::Skull(_), + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector, ) => {} None => { v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i)); @@ -5548,7 +5561,8 @@ impl Game<'_> { | BlockEntity::Vault(_) | BlockEntity::Beehive(_) | BlockEntity::CommandBlock(_) - | BlockEntity::Skull(_), + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector, ) => {} None if self.mount_active() => self.draw_mount_background(batch, ox, oy), None => { diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index e48c8f398..3ddf58281 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -10,6 +10,7 @@ use horizon_client::{ block_entities::{Pos, Ticking}, containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS}, }; +use horizon_core::position::BlockPos; use horizon_world::registry::BlockStateId; impl Game<'_> { @@ -56,6 +57,7 @@ impl Game<'_> { Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos), Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?, Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?, + Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos), _ => {} } } @@ -92,7 +94,8 @@ impl Game<'_> { | "minecraft:vault" | "minecraft:beehive" | "minecraft:bee_nest" - | "minecraft:brewing_stand" => Ticking::Yes, + | "minecraft:brewing_stand" + | "minecraft:daylight_detector" => Ticking::Yes, "minecraft:jukebox" => { let record = registry .state(state) @@ -113,6 +116,16 @@ impl Game<'_> { let levels = self.tickets.levels(); move |x, z| levels.block_ticking_at(x, z) } + /// `DaylightDetectorBlock.tickEntity` of the detector at `pos`. + fn tick_daylight_detector(&mut self, pos: Pos) { + // It updates every 20 game ticks (the behaviour checks this too). + if self.world.game_time % 20 != 0 { + return; + } + let at = BlockPos::new(pos.0, pos.1, pos.2); + self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at)); + let _ = self.process_level_events(); + } /// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and /// its block's `lit` following it. fn tick_furnace(&mut self, pos: Pos) -> Result<()> { diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 0c353bb38..f848277b4 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -569,6 +569,7 @@ pub fn encode_block_entity( BlockEntity::Vault(v) => entries.extend(v.save()), BlockEntity::Beehive(h) => entries.extend(h.to_nbt()), BlockEntity::CommandBlock(c) => entries.extend(c.save()), + BlockEntity::DaylightDetector => {} } Tag::compound(entries) } @@ -724,7 +725,8 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain | BlockEntity::Lectern(_) | BlockEntity::Banner(_) | BlockEntity::CreakingHeart(_) - | BlockEntity::Skull(_) => {} + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector => {} BlockEntity::Spawner(s) => **s = crate::mobs::spawner::Spawner::load(tag), BlockEntity::TrialSpawner(s) => **s = crate::mobs::trial_spawner::TrialSpawner::load(tag), BlockEntity::Vault(v) => **v = crate::vault::Vault::load(tag), diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 7b2195c34..11d4d36e1 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -195,6 +195,7 @@ impl Sim<'_, L> { let next = table.cycle(state, p.inverted); self.set_block(pos, next, 2); self.game_event(pos, "minecraft:block_change"); + self.daylight_detector_update_signal(pos, next); Success } Class::Cake => { diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index 3831efbd6..342ad6f2f 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -319,6 +319,10 @@ pub trait LevelAccess { fn sky_darken(&self) -> i32 { 0 } + /// `EnvironmentAttributes.SUN_ANGLE` at `pos`, in degrees (0 at noon). + fn sun_angle(&self, _pos: BlockPos) -> f32 { + 0.0 + } fn is_raining(&self) -> bool { false } @@ -749,6 +753,24 @@ impl Behaviours { ) { self.sim(level, ticks).entity_inside(pos); } + /// `DaylightDetectorBlock.tickEntity` of the daylight detector at `pos`: + /// its signal follows the sky every 20 game ticks. The detector's ticker + /// exists only where the dimension has sky light. + pub fn tick_daylight_detector( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + ) { + if level.dimension() == Dimension::Nether || level.game_time() % 20 != 0 { + return; + } + let mut sim = self.sim(level, ticks); + let state = sim.state(pos); + if sim.b.data(state).is(classes::Class::DaylightDetector) { + sim.daylight_detector_update_signal(pos, state); + } + } /// `BigDripleafBlock.entityInside` for an entity whose box is inside /// `pos`: whether it is on the ground and its feet's y (`canEntityTilt`). pub fn entity_on_big_dripleaf( diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 92f9059e5..5c7bb5cc9 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -11,7 +11,10 @@ use super::{ }; use crate::registry::BlockStateId; use horizon_core::{ - geometry::Direction, position::BlockPos, random::RandomSource, ticks::TickPriority, + geometry::{Direction, trig}, + position::BlockPos, + random::RandomSource, + ticks::TickPriority, }; const SIGNAL_ORDER: [Direction; 6] = [ @@ -892,6 +895,31 @@ impl Sim<'_, L> { } self.game_event(pos, "minecraft:block_deactivate"); } + /// `DaylightDetectorBlock.updateSignalStrength`: the effective sky light + /// at the detector, shaped by the sun's angle (or inverted), as its + /// power. + pub(crate) fn daylight_detector_update_signal(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + let mut target = (self.level.sky_light(pos) - self.level.sky_darken()).max(0); + if b.table.bool(state, b.props.inverted) { + target = 15 - target; + } else if target > 0 { + let mut sun_angle = self.level.sun_angle(pos) * trig::DEG_TO_RAD; + let offset = if sun_angle < std::f32::consts::PI { + 0.0 + } else { + std::f32::consts::TAU + }; + sun_angle += (offset - sun_angle) * 0.2; + // Math.round(float). + let scaled = target as f32 * trig::cos(f64::from(sun_angle)); + target = (f64::from(scaled) + 0.5).floor() as i32; + } + let target = target.clamp(0, 15); + if b.table.int(state, b.props.power) != target { + self.set_block_and_update(pos, b.table.set_int(state, b.props.power, target)); + } + } /// `LeverBlock.pull`. pub(crate) fn lever_pull(&mut self, pos: BlockPos, state: BlockStateId) { let b = self.b; From c4b9067d6df0f685d1390dbe7fdad341742032e0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:45:54 +0100 Subject: [PATCH 028/475] Infinity only saves plain arrows, not tipped or spectral ones --- crates/horizon-client/src/game.rs | 8 ++++---- crates/horizon-client/src/game/enchant_effects.rs | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..5dda8c67f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -9105,7 +9105,10 @@ impl Game<'_> { }; // BowItem.shootProjectile: shootFromRotation(power * 3, inaccuracy 1). let p = Vec3::new(eye[0], eye[1] - 0.1, eye[2]); - let (power_bonus, infinity) = self.bow_enchantments(); + let ammo = arrow_slot + .and_then(|i| self.stack_in(i).cloned()) + .unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1)); + let (power_bonus, infinity) = self.bow_enchantments(&ammo.item); let kind = horizon_client::projectiles::ProjectileKind::Arrow { base_damage: 2.0 + power_bonus, critical: power >= 1.0, @@ -9119,9 +9122,6 @@ impl Game<'_> { // ProjectileWeaponItem.useAmmo: creative and Infinity draw without // using the ammo, so the arrow is CREATIVE_ONLY; else setOwner(player) // lets it be picked up. - let ammo = arrow_slot - .and_then(|i| self.stack_in(i).cloned()) - .unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1)); if let Some(a) = arrow.arrow.as_mut() { a.set_item(&horizon_client::arrows::drawn_ammo( &ammo, diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index aa9f75e06..b9fe56ebd 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -388,7 +388,9 @@ impl Game<'_> { } } /// Bow enchantments at release: (extra base damage, infinity keeps the arrow). - pub(super) fn bow_enchantments(&mut self) -> (f64, bool) { + /// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the + /// `ammo_use` conditions as the tool, so Infinity only frees plain arrows. + pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) { let Some(bow) = self.inventory[self.selected].clone() else { return (0.0, false); }; @@ -407,7 +409,7 @@ impl Game<'_> { 0.0, ); let ammo_ctx = EffectContext { - tool: Some("minecraft:arrow"), + tool: Some(ammo), items: Some(self.res.items()), ..Default::default() }; From 65bebdfc49cd86f09ba18bd2eeb2f536c0816ad9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:47:18 +0100 Subject: [PATCH 029/475] Make a sword sweep hurt the mobs next to the target The sweep only drew the particle and played the sound. Mobs in the sweep box now take the sweep damage (with Sweeping Edge) and 0.4 knockback. --- crates/horizon-client/src/game.rs | 64 ++++++++++++++++++++++++++ crates/horizon-client/src/game/mace.rs | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..4e795c3c5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7465,6 +7465,69 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0); } + /// The damage and knockback part of `Player.doSweepAttack` after a sweep + /// hit on the mob at `target`: every other living mob in its box grown by + /// (1, 0.25, 1) and within the player's reach, but the player's allies + /// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the + /// weapon's enchantments, scaled by the attack strength, and is pushed 0.4 + /// along the player's yaw. + pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) { + let Some(p) = self.player.as_ref() else { + return; + }; + let from = [p.position.x, p.position.y, p.position.z]; + // `baseDamage` of `Player.attack` before a critical hit (a sweep has + // none) and the enchantments: the attack damage attribute with the + // strength and weakness effects, scaled by the attack strength. + let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage); + let base_damage = (hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8); + // The enchantment's attribute modifier on the attribute's 0 to 1 range. + let ratio = self + .res + .stations() + .enchantments + .attribute_add( + self.inventory[self.selected].as_ref(), + "minecraft:sweeping_damage_ratio", + ) + .clamp(0.0, 1.0) as f32; + let sweep_damage = 1.0 + ratio * base_damage; + let hit_box = self.mobs[target] + .bounds() + .inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0)); + let reach = if self.creative { 5.0 } else { 3.0 }; + let (uuid, allies) = ( + self.level.player_uuid.or(Some([0, 0, 0, 1])), + self.player_allies(), + ); + let nearby: Vec = self + .mobs + .iter() + .enumerate() + .filter(|(i, m)| { + *i != target + && m.alive() + && !m.removed + && !allies.contains(&m.id) + && !mace::ally_of_player(m, uuid) + && m.bounds().intersects(hit_box) + && m.position.distance_squared(p.position) < reach * reach + }) + .map(|(i, _)| i) + .collect(); + let (xd, zd) = self.extra_knockback_direction(); + for i in nearby { + let kind = self.mobs[i].kind.id.clone(); + let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:")); + let damage = (sweep_damage + self.enchant_damage_bonus(&kind)) * scale; + let source = self.attack_source(from); + if self.hurt_by_player(i, damage, source) { + self.mobs[i].knockback(0.4, xd, zd); + self.last_hurt_by_player + .insert(self.mobs[i].id, self.lan_acting); + } + } + } /// `Player.attack`'s sounds when a vehicle takes the hit (it always does, /// and can't be hit critically): knockback when sprinting at full /// strength, then strong or weak. Before `swing_arm` resets the charge. @@ -7638,6 +7701,7 @@ impl Game<'_> { // Player.playServerSideSound: at the player. if let Some(at) = sweep { self.sweep_attack(at, from); + self.sweep_damage(index, scale); } else { self.audio.play(sound, from, 1.0, 1.0); } diff --git a/crates/horizon-client/src/game/mace.rs b/crates/horizon-client/src/game/mace.rs index 8ad5e76c3..34c968980 100644 --- a/crates/horizon-client/src/game/mace.rs +++ b/crates/horizon-client/src/game/mace.rs @@ -24,7 +24,7 @@ const KNOCKBACK_POWER: f32 = 0.7; /// `Entity.isAlliedTo` between the player (`player` its UUID) and a mob /// without teams: `TamableAnimal.considersEntityAsAlly` (a pet it tamed) and /// `Allay.considersEntityAsAlly` (an allay that likes it). -fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { +pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { let tamable = matches!( m.species(), Species::Wolf From d68ab989c01e54033e498e8cde9cab84611749d9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:47:50 +0100 Subject: [PATCH 030/475] Let the fishing bobber hook dropped items, add owner luck, count only fish as fish caught --- crates/horizon-client/src/game/fishing.rs | 99 +++++++++++++++-------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index d454df406..aba0d04cf 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -154,37 +154,54 @@ impl Game<'_> { let mut damage = 0; if let State::Hooked(id) = hook.state { // pullEntity: 0.1 of the way to the owner. + let d = Vec3::new( + owner.x - hook.position.x, + owner.y - hook.position.y, + owner.z - hook.position.z, + ); + let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1); + let mut hooked_item = false; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { - let d = Vec3::new( - owner.x - hook.position.x, - owner.y - hook.position.y, - owner.z - hook.position.z, - ); - m.velocity = Vec3::new( - m.velocity.x + d.x * 0.1, - m.velocity.y + d.y * 0.1, - m.velocity.z + d.z * 0.1, - ); + m.velocity = pulled(m.velocity); + } else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) { + item.velocity = pulled(item.velocity); + hooked_item = true; } - damage = 5; + // `hookedIn instanceof ItemEntity ? 3 : 5`. + damage = if hooked_item { 3 } else { 5 }; } else if hook.nibble > 0 { let biome = self.biome_name_at(hook.position); let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources) else { return damage; }; + // `withLuck(this.luck + owner.getLuck())`: the rod's Luck of the Sea + // plus the owner's luck attribute (the Luck and Bad Luck effects). + let owner_luck = self + .player + .as_mut() + .and_then(|p| p.attribute("minecraft:luck").ok()) + .unwrap_or(0.0) as f32; let mut random = self.world.random.clone(); let items = loot.roll( "minecraft:gameplay/fishing", &mut random, - hook.luck as f32, + hook.luck as f32 + owner_luck, biome.as_deref(), Some(hook.open_water), ); self.world.random = random; - // FishingHook.retrieve: fish_caught for a catch. - self.stats.custom("fish_caught", 1); for stack in items { + // FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes. + if self + .res + .items() + .item_tags + .get("minecraft:fishes") + .is_some_and(|t| t.contains(stack.item.as_str())) + { + self.stats.custom("fish_caught", 1); + } let mut item = ItemStack { item: Arc::from(stack.item.as_str()), count: stack.count.max(1) as u32, @@ -457,35 +474,47 @@ impl Game<'_> { self.hook = Some(h); return; } - // checkCollision: hook the first living mob it touches. + // checkCollision: hook the first living mob it touches, or (via + // `canHitEntity`) a dropped item. let bb = bounds(h.position); let (lo, hi) = (bb.min_position(), bb.max_position()); - if let Some(m) = self.mobs.iter().find(|m| { - let mb = m.bounds(); - let (a, z) = (mb.min_position(), mb.max_position()); - m.alive() - && a.x < hi.x - && lo.x < z.x - && a.y < hi.y - && lo.y < z.y - && a.z < hi.z - && lo.z < z.z - }) { - h.state = State::Hooked(m.id); + let touches = |b: Aabb| { + let (a, z) = (b.min_position(), b.max_position()); + a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z + }; + let hit = self + .mobs + .iter() + .find(|m| m.alive() && touches(m.bounds())) + .map(|m| m.id) + .or_else(|| { + self.dropped + .iter() + .find(|i| touches(i.bounds())) + .map(|i| i.id) + }); + if let Some(id) = hit { + h.state = State::Hooked(id); h.velocity = Vec3::ZERO; self.hook = Some(h); return; } } State::Hooked(id) => { - match self.mobs.iter().find(|m| m.id == id && m.alive()) { - Some(m) => { - h.position = Vec3::new( - m.position.x, - m.position.y + f64::from(m.height()) * 0.8, - m.position.z, - ) - } + // updateHookedEntity: ride the hooked mob or item at 0.8 of its height. + let hooked = self + .mobs + .iter() + .find(|m| m.id == id && m.alive()) + .map(|m| (m.position, f64::from(m.height()))) + .or_else(|| { + self.dropped + .iter() + .find(|i| i.id == id) + .map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH)) + }); + match hooked { + Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z), None => h.state = State::Flying, } self.hook = Some(h); From 48c289f775c5d544714963f556e9ed521bb814bf Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:47:53 +0100 Subject: [PATCH 031/475] Make Channeling summon lightning when a trident hits A thrown trident with Channeling now calls down a lightning bolt in a thunderstorm, both on a mob it hurts (if the mob can see the sky) and on a lightning rod it hits (if the rod can see the sky), with the thunder sound. --- crates/horizon-client/src/game/trident.rs | 85 ++++++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index c31094596..f072a0ae1 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -1,7 +1,8 @@ //! Tridents: `TridentItem.releaseUsing` (throw after 10 ticks, or Riptide in //! water or rain) and `ThrownTrident` (arrow flight with 0.99 water inertia, //! 8 damage plus Impaling, the reversed bounce off a hit, sticking, Loyalty's -//! return to the owner, pickup) with `ThrownTridentRenderer`. +//! return to the owner, pickup) with `ThrownTridentRenderer`, and Channeling's +//! lightning on a hit (`channeling.json`). use super::{Game, view_vector}; use horizon_client::{ collisions::SceneCollisions, @@ -10,7 +11,8 @@ use horizon_client::{ mobs::{Damage, DamageKind, Who, anim::Poses}, }; use horizon_core::{ - geometry::{Aabb, Vec3}, + geometry::{Aabb, Vec3, traverse_blocks}, + position::BlockPos, random::RandomSource, }; @@ -154,6 +156,69 @@ impl Game<'_> { p.auto_spin_attack_dmg = 0.0; } } + /// The `summon_entity` and `play_sound` effects of `channeling.json`: in a + /// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`) + /// gets a lightning bolt and the thunder sound. + fn channel_lightning(&mut self, at: [f64; 3]) { + // Level.isThundering: the thunder level, in dimensions with weather. + if self.dim != super::Dim::Overworld || self.world.weather.levels(1.0).1 <= 0.9 { + return; + } + // BlockAndLightGetter.canSeeSky: full sky light. + let (x, y, z) = ( + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ); + if self.world.light(x, y, z) >> 4 != 15 { + return; + } + self.spawn_bolt(at, false); + self.audio + .play("minecraft:item.trident.thunder", Some(at), 5.0, 1.0); + } + /// `minecraft:hit_block` of Channeling (`AbstractArrow.hitBlockEnchantmentEffects`): + /// the block the trident's path first hits, if it is a lightning rod, with + /// the hit location clamped into the block (`BlockPos.clampLocationWithin`). + fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3) { + let to = Vec3::new( + from.x + velocity.x, + from.y + velocity.y, + from.z + velocity.z, + ); + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + // ClipContext.Block.COLLIDER with Fluid.NONE. + let hit = traverse_blocks(from, to, 1024, |p: BlockPos| { + let state = self.world.block(p.x, p.y, p.z); + if self.res.facts().is_air(state) { + return None; + } + let shape = self + .collision + .collision_shape(&mut scene, state, [p.x, p.y, p.z]) + .ok()?; + shape.clip(from, to, p) + }); + let Some(hit) = hit.ok().flatten() else { + return; + }; + let state = self.world.block(hit.block.x, hit.block.y, hit.block.z); + if !self.res.facts().get(state).name.ends_with("lightning_rod") { + return; + } + let edge = f64::from(1.0e-5f32); + let clamp = + |v: f64, origin: i32| v.clamp(f64::from(origin) + edge, f64::from(origin) + 1.0 - edge); + self.channel_lightning([ + clamp(hit.location.x, hit.block.x), + clamp(hit.location.y, hit.block.y), + clamp(hit.location.z, hit.block.z), + ]); + } /// `TridentItem.releaseUsing`. pub(super) fn release_trident(&mut self, ticks: u32) { if ticks < 10 { @@ -311,9 +376,10 @@ impl Game<'_> { }); let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 }; t.dealt_damage = true; + let mut hurt_at = None; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { let from = Some([t.position.x, t.position.y, t.position.z]); - m.damage( + let hurt = m.damage( damage, Damage { kind: DamageKind::Arrow, @@ -321,6 +387,15 @@ impl Game<'_> { from, }, ); + if hurt { + hurt_at = Some([m.position.x, m.position.y, m.position.z]); + } + } + // `minecraft:post_attack` of Channeling, on the victim. + if let Some(at) = hurt_at + && t.stack.enchantment_level("minecraft:channeling") > 0 + { + self.channel_lightning(at); } self.audio.play( "minecraft:item.trident.hit", @@ -341,6 +416,7 @@ impl Game<'_> { subject: None, }; if let Ok(moved) = scene.resolve(bounds(t.position), t.velocity, 0.0, false) { + let (from, flight) = (t.position, t.velocity); let blocked = (moved.x - t.velocity.x).abs() > 1e-7 || (moved.y - t.velocity.y).abs() > 1e-7 || (moved.z - t.velocity.z).abs() > 1e-7; @@ -350,6 +426,9 @@ impl Game<'_> { t.position.z + moved.z, ); if blocked { + if t.stack.enchantment_level("minecraft:channeling") > 0 { + self.channel_rod_hit(from, flight); + } t.in_ground = true; t.velocity = Vec3::ZERO; self.audio.play( From 4d0116762c9a360490cc5c65510f9fd5b552b8b9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:48:08 +0100 Subject: [PATCH 032/475] Apply Unbreaking to thrown tridents and stop Riptide while riding Throwing a trident now wears it through the same durability path as Riptide, so Unbreaking applies, and it still never breaks the trident. Riptide no longer launches a player who is riding something. --- crates/horizon-client/src/game.rs | 18 ++++++++++++++++++ crates/horizon-client/src/game/trident.rs | 13 +++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..390befe40 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8660,6 +8660,24 @@ impl Game<'_> { } } } + /// `ItemStack.hurtWithoutBreaking`: Unbreaking applies, but the stack stops at + /// one point of durability left instead of breaking. + fn hurt_held_without_breaking(&mut self, amount: u32) { + if self.creative { + return; + } + let Some(held) = self.held().cloned() else { + return; + }; + let amount = self.durability_loss(&held, amount); + let max = self.res.items().get(&held.item).map_or(0, |i| i.max_damage); + if amount == 0 || max == 0 { + return; + } + if let Some(stack) = self.held_mut().as_mut() { + stack.damage = (stack.damage + amount).min(max - 1); + } + } /// Block tool use: distinguish success, failure and fall-through for hand feedback. fn use_tool( &mut self, diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index c31094596..af77ac50f 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -171,10 +171,11 @@ impl Game<'_> { .as_ref() .map(|p| [p.position.x, p.position.y, p.position.z]); if riptide > 0 { - if !self.in_water_or_rain() { + // `isInWaterOrRain() && !isPassenger()`. + if !self.in_water_or_rain() || self.riding.is_some() { return; } - self.damage_held(1); + self.hurt_held_without_breaking(1); // trident_spin_attack_strength: 1.5 + 0.75 per level above the first. let strength = 1.5 + 0.75 * (riptide as f32 - 1.0); let d = view_vector(self.yaw, self.pitch); @@ -195,12 +196,8 @@ impl Game<'_> { self.audio.play(&sound, at, 1.0, 1.0); return; } - // hurtWithoutBreaking, then the thrown copy. - if !self.creative - && let Some(s) = self.held_mut().as_mut() - { - s.damage += 1; - } + // hurtWithoutBreaking (Unbreaking applies), then the thrown copy. + self.hurt_held_without_breaking(1); let thrown = self.held().cloned().unwrap_or(stack); if !self.creative { *self.held_mut() = None; From 02f222728d7d182d278879fb20e7a9b9d5ccc16d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:48:13 +0100 Subject: [PATCH 033/475] Respect mobGriefing for mob explosions and endermen Mob explosions (creepers, ghast fireballs, wither skulls and the wither spawn blast) now keep blocks when mobGriefing is off, and follow the mob_explosion_drop_decay rule when it is on. Endermen no longer pick up or put down blocks with mobGriefing off. --- crates/horizon-client/src/game.rs | 19 +++++++++++-------- crates/horizon-client/src/mobs/enderman.rs | 14 ++++++++++---- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..3e9a419c3 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7657,15 +7657,18 @@ impl Game<'_> { } } /// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step. - /// A mob explosion (`Level.ExplosionInteraction.MOB`: drop decay with the - /// default mobGriefing rule). + /// A mob explosion (`ServerLevel.explode` with `ExplosionInteraction.MOB`: + /// blocks are kept without mobGriefing, else the mob_explosion_drop_decay + /// rule picks decay). fn explode(&mut self, center: [f64; 3], radius: f32) -> Result<()> { - self.server_explode( - center, - radius, - explosion::BlockInteraction::DestroyWithDecay, - false, - ) + let interaction = if !self.ui.rules.bool("mob_griefing") { + explosion::BlockInteraction::Keep + } else if self.ui.rules.bool("mob_explosion_drop_decay") { + explosion::BlockInteraction::DestroyWithDecay + } else { + explosion::BlockInteraction::Destroy + }; + self.server_explode(center, radius, interaction, false) } /// Copies of a save batch carrying its live block entities and save data. fn with_entities<'a>( diff --git a/crates/horizon-client/src/mobs/enderman.rs b/crates/horizon-client/src/mobs/enderman.rs index c5ed01680..3ccd963a2 100644 --- a/crates/horizon-client/src/mobs/enderman.rs +++ b/crates/horizon-client/src/mobs/enderman.rs @@ -331,8 +331,11 @@ impl Goal for TakeBlock { fn flags(&self) -> u8 { 0 } - fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { - m.extra.carried.is_none() && m.random.next_int_bound(reduced(20)).unwrap_or(1) == 0 + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // EndermanTakeBlockGoal.canUse: needs mobGriefing, checked before the draw. + m.extra.carried.is_none() + && cx.w.mob_griefing + && m.random.next_int_bound(reduced(20)).unwrap_or(1) == 0 } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let x = (m.position.x - 2.0 + m.random.next_double() * 4.0).floor() as i32; @@ -379,8 +382,11 @@ impl Goal for LeaveBlock { fn flags(&self) -> u8 { 0 } - fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { - m.extra.carried.is_some() && m.random.next_int_bound(reduced(2000)).unwrap_or(1) == 0 + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // EndermanLeaveBlockGoal.canUse: needs mobGriefing, checked before the draw. + m.extra.carried.is_some() + && cx.w.mob_griefing + && m.random.next_int_bound(reduced(2000)).unwrap_or(1) == 0 } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let Some(carried) = m.extra.carried else { From 48c0f39e9677ff52e6027362c4c468cf73360e1b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:48:15 +0100 Subject: [PATCH 034/475] Farmers make bread and compost seeds when they work at a composter A farmer working at its composter turns wheat into bread (three wheat for one loaf, up to three loaves, while it holds at most 36 bread) and puts spare wheat and beetroot seeds into the composter, emptying a full one first, as WorkAtComposter does. Other professions are unchanged. --- crates/horizon-client/src/game/composter.rs | 77 +++++++++++++++++++ crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/game/villagers.rs | 32 ++++++++ crates/horizon-client/src/mobs.rs | 7 ++ crates/horizon-client/src/mobs/villager.rs | 18 +++++ .../horizon-client/src/mobs/villager_brain.rs | 16 +++- crates/horizon-world/src/level/interact.rs | 71 +++++++++++++---- 7 files changed, 208 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/game/composter.rs b/crates/horizon-client/src/game/composter.rs index 3f57229ad..4013155ad 100644 --- a/crates/horizon-client/src/game/composter.rs +++ b/crates/horizon-client/src/game/composter.rs @@ -5,6 +5,7 @@ use super::{Game, commands::compute::ComputeContext}; use horizon_client::{ items::ItemStack, + mobs::villager, particles::{ParticleOptions, ParticleType}, }; use horizon_core::{position::BlockPos, random::RandomSource}; @@ -86,6 +87,82 @@ impl Game<'_> { self.composter_add(pos, &layers); (stack.count > 0).then_some(stack) } + /// `WorkAtComposter.compostItems` for the villager at `mob`: a full + /// composter is emptied, then up to 20 wheat or beetroot seeds go in + /// (keeping 10 of each kind) until the composter reaches level 7. Level + /// event 1500 plays once, at the end. + pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) { + let at = BlockPos::new(pos[0], pos[1], pos[2]); + let Some(mut before) = self.composter_level(pos) else { + return; + }; + if before == 8 { + self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at)); + let _ = self.process_level_events(); + before = 0; + } + let mut remaining = 20; + let mut amounts: Vec<(std::sync::Arc, i32)> = Vec::new(); + let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len()); + 'slots: for slot in (0..slots).rev() { + if remaining <= 0 { + break; + } + let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone(); + if !matches!( + &*stack.item, + "minecraft:wheat_seeds" | "minecraft:beetroot_seeds" + ) { + continue; + } + let known = amounts.iter().position(|(item, _)| *item == stack.item); + let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32; + match known { + Some(k) => amounts[k].1 = total, + None => amounts.push((stack.item.clone(), total)), + } + let uses = (total - 10).min(remaining).min(stack.count as i32); + if uses <= 0 { + continue; + } + remaining -= uses; + for _ in 0..uses { + self.villager_insert_item(mob, slot, &stack, pos); + if self.composter_level(pos) == Some(7) { + break 'slots; + } + } + } + let after = self.composter_level(pos).unwrap_or(before); + self.with_level(|b, level, ticks| { + b.composter_fill_effects(level, ticks, at, after != before) + }); + let _ = self.process_level_events(); + } + /// `ComposterBlock.insertItem` with a villager's stack: below level 7 a + /// compostable item is used up and may add a layer. + fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) { + if self.composter_level(pos).is_none_or(|level| level >= 7) { + return; + } + let Some(layers) = self.compostable(stack) else { + return; + }; + let context = ComputeContext { + block: Some(pos), + target: None, + }; + let layers = self.provider_int(&layers, &context, 0).unwrap_or(0); + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers)); + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + let _ = self.process_level_events(); + let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory; + inventory[slot].count -= 1; + if inventory[slot].count == 0 { + inventory.remove(slot); + } + } /// `ComposterBlock.handleFill` (level event 1500) on the client: the /// fill sound and ten composter particles above the compost. pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..58e032b78 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1195,6 +1195,7 @@ impl Game<'_> { partner, child, } => self.villager_birth(parent, partner, child), + MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos), MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed), MobEvent::Firework { at, color, flight } => { self.villager_firework(at, color, flight) diff --git a/crates/horizon-client/src/game/villagers.rs b/crates/horizon-client/src/game/villagers.rs index 3cfe8aa1d..c04b18604 100644 --- a/crates/horizon-client/src/game/villagers.rs +++ b/crates/horizon-client/src/game/villagers.rs @@ -21,6 +21,38 @@ impl Game<'_> { fn item_max_stack(&self, item: &str) -> u32 { self.res.items().get(item).map_or(64, |i| i.max_stack) } + /// `WorkAtComposter.useWorkstation`: at a composter the farmer makes + /// bread, then composts seeds. + pub(super) fn villager_work_at_composter(&mut self, id: u32, pos: [i32; 3]) { + let Some(i) = self.villager_index(id) else { + return; + }; + if self.composter_level(pos).is_none() { + return; + } + self.villager_make_bread(i); + self.villager_compost_items(i, pos); + } + /// `WorkAtComposter.makeBread`: up to three loaves from nine wheat while + /// the villager holds at most 36 bread; what does not fit drops. + fn villager_make_bread(&mut self, i: usize) { + let max_stack = self.item_max_stack("minecraft:bread"); + let e = villager::ext_mut(&mut self.mobs[i]); + if e.count_item("minecraft:bread") > 36 { + return; + } + let loaves = (e.count_item("minecraft:wheat") / 3).min(3); + if loaves == 0 { + return; + } + e.remove_item("minecraft:wheat", loaves * 3); + let left = e.add_item(ItemStack::new("minecraft:bread", loaves), max_stack); + if let Some(rest) = left { + // spawnAtLocation(level, stack, 0.5F) + let p = self.mobs[i].position; + self.spawn_item(rest, [p.x, p.y + 0.5, p.z], [0.0, 0.2, 0.0], 10); + } + } /// `BehaviorUtils.throwItem`: toss a stack from a villager's hand toward /// a point. fn villager_throw(&mut self, from: Vec3, eye: f64, to: Vec3, stack: ItemStack) { diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..c51352f40 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1098,6 +1098,13 @@ pub enum MobEvent { partner: u32, child: Option<([i32; 3], Option)>, }, + /// `WorkAtComposter.useWorkstation`: a farmer at its composter makes + /// bread and composts seeds (the game applies it to the villager's + /// inventory and the block). + WorkAtComposter { + mob: u32, + pos: [i32; 3], + }, /// A villager asks whether enough neighbours want an iron golem. GolemCheck { mob: u32, diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index 575fafaed..608882a1f 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -446,6 +446,24 @@ impl Ext { } (stack.count > 0).then_some(stack) } + /// `SimpleContainer.removeItemType`: take up to `count` of the item from + /// the first stacks that hold it. + pub fn remove_item(&mut self, item: &str, mut count: u32) { + let mut slot = 0; + while slot < self.inventory.len() && count > 0 { + let s = &mut self.inventory[slot]; + if &*s.item == item { + let n = s.count.min(count); + s.count -= n; + count -= n; + if s.count == 0 { + self.inventory.remove(slot); + continue; + } + } + slot += 1; + } + } pub fn count_item(&self, item: &str) -> u32 { self.inventory .iter() diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 7b311a9e0..72581360f 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -1242,6 +1242,8 @@ enum B { WorkAtPoi { t: Timed, last_check: i64, + /// `WorkAtComposter` (a farmer's package) rather than `WorkAtPoi`. + composter: bool, }, StrollAroundPoi { running: bool, @@ -1635,7 +1637,11 @@ impl B { *birth = now + 275 + i64::from(m.random.next_int_bound(50).unwrap_or(0)); true } - B::WorkAtPoi { t, last_check } => { + B::WorkAtPoi { + t, + last_check, + composter, + } => { let mm = mem(m); let Some(site) = mm.job_site.value() else { return false; @@ -1656,6 +1662,13 @@ impl B { mm.last_worked_at_poi.set(now); mm.look_target.set(Tracker::Block(site)); play_work_sound(m, cx); + // WorkAtComposter.useWorkstation, before the restock. + if *composter { + cx.events.push(MobEvent::WorkAtComposter { + mob: m.id, + pos: site, + }); + } restock_if_due(m, cx.w); true } @@ -3775,6 +3788,7 @@ fn packages(baby: bool, seed: i64, farmer: bool) -> Vec { B::WorkAtPoi { t: timed(60, 60), last_check: 0, + composter: farmer, }, 7, ), diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 7b2195c34..a1f786184 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -227,19 +227,7 @@ impl Sim<'_, L> { if table.int(state, p.level) != 8 { return Pass; } - self.player_effect( - pos, - state, - PlayerEffect::DropItem("minecraft:bone_meal".into()), - ); - self.set_block_and_update(pos, table.set_int(state, p.level, 0)); - self.game_event(pos, "minecraft:block_change"); - self.emit(LevelEvent::Sound { - pos, - sound: "minecraft:block.composter.empty".into(), - volume: 1.0, - pitch: 1.0, - }); + self.composter_extract_produce(pos, state); Success } Class::CaveVines | Class::CaveVinesPlant => { @@ -437,6 +425,25 @@ impl Sim<'_, L> { /// `ComposterBlock.addLayer` once its `compostable` provider gave /// `layers`: the new state (the old one when no layer is added). + /// `ComposterBlock.extractProduce`: the bone meal pops out, the composter + /// empties and its sound plays. + fn composter_extract_produce(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + self.player_effect( + pos, + state, + PlayerEffect::DropItem("minecraft:bone_meal".into()), + ); + self.set_block_and_update(pos, b.table.set_int(state, b.props.level, 0)); + self.game_event(pos, "minecraft:block_change"); + self.emit(LevelEvent::Sound { + pos, + sound: "minecraft:block.composter.empty".into(), + volume: 1.0, + pitch: 1.0, + }); + } + fn composter_add_layer(&mut self, pos: BlockPos, layers: i32) -> BlockStateId { let b = self.b; let state = self.state(pos); @@ -499,6 +506,44 @@ impl Behaviours { next } + /// `ComposterBlock.addLayer` without level event 1500, for a villager + /// that composts several items and sends the event once at the end + /// (`WorkAtComposter.compostItems`). The new state. + pub fn composter_layer( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + layers: i32, + ) -> BlockStateId { + self.sim(level, ticks).composter_add_layer(pos, layers) + } + + /// `ComposterBlock.extractProduce` by a villager (`WorkAtComposter`). + pub fn composter_extract( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + ) { + let mut sim = self.sim(level, ticks); + let state = sim.state(pos); + sim.composter_extract_produce(pos, state); + } + + /// Level event 1500 (`WorkAtComposter.spawnComposterFillEffects`): data 1 + /// when the composter's state changed. + pub fn composter_fill_effects( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + changed: bool, + ) { + self.sim(level, ticks) + .level_event(1500, pos, i32::from(changed)); + } + /// `ComposterBlock.empty`: the hopper output container took the bone meal. pub fn composter_empty( &self, From 02e5c728c3e886ba29dd4c28c3745c4f83fcd966 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:49:57 +0100 Subject: [PATCH 035/475] Pick the skeleton bow interval by difficulty Skeletons now shoot every 20 ticks on Hard and every 40 otherwise (bogged and parched: 50 and 70) instead of always using the normal interval. --- crates/horizon-client/src/mobs/undead.rs | 32 +++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index e77a37641..efff2b73e 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -136,12 +136,13 @@ pub fn register(m: &mut Mob) { } // The AbstractSkeleton constructor's reassessWeaponGoal, after // registerGoals; the weapon itself comes with finalizeSpawn. - let interval = if matches!(species, S::Bogged | S::Parched) { - 70 + // getHardAttackInterval / getAttackInterval. + let (hard, normal) = if matches!(species, S::Bogged | S::Parched) { + (50, 70) } else { - 40 + (20, 40) }; - m.ai.goals.add(4, SkeletonWeapon::new(interval)); + m.ai.goals.add(4, SkeletonWeapon::new(hard, normal)); } _ => {} } @@ -524,13 +525,20 @@ pub struct SkeletonWeapon { bow: BowAttack, melee: MeleeAttack, use_bow: bool, + /// The weapon the goal was last reassessed for. + assessed: Option, + hard_interval: i32, + normal_interval: i32, } impl SkeletonWeapon { - pub fn new(bow_interval: i32) -> Self { + pub fn new(hard_interval: i32, normal_interval: i32) -> Self { Self { - bow: BowAttack::new(1.0, bow_interval, 15.0), + bow: BowAttack::new(1.0, normal_interval, 15.0), melee: MeleeAttack::new(1.2, false), use_bow: false, + assessed: None, + hard_interval, + normal_interval, } } } @@ -556,6 +564,18 @@ impl Goal for SkeletonWeapon { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { // The goal registered for the weapon in hand. self.use_bow = holding_bow(m); + // reassessWeaponGoal: a bow gets the hard interval only on Hard; it + // is picked again when the weapon changes (the first check after + // spawning or loading stands for finalizeSpawn and + // readAdditionalSaveData). + if self.assessed != Some(self.use_bow) { + self.assessed = Some(self.use_bow); + self.bow.interval_min = if cx.w.difficulty.hard { + self.hard_interval + } else { + self.normal_interval + }; + } if self.use_bow { self.bow.can_use(m, cx) } else { From 8178ec3dae58d4d4f08afcfcf24ff08d56081c30 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:51:46 +0100 Subject: [PATCH 036/475] Let a fire charge light a creeper A creeper is now lit by any item in the creeper_igniters tag, not only flint and steel. A fire charge is used up and plays its own sound; flint and steel still loses durability. --- crates/horizon-client/src/game/creatures.rs | 30 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 119fe2a1e..02ee16236 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2524,6 +2524,19 @@ impl Game<'_> { } return Ok(true); } + // Creeper.mobInteract: any item in #creeper_igniters, damaged when it + // is damageable and used up otherwise. + let creeper_igniter = self + .res + .items() + .item_tags + .get("minecraft:creeper_igniters") + .is_some_and(|t| t.contains(&*item)); + let damageable = self + .res + .items() + .get(&item) + .is_some_and(|i| i.max_damage > 0); let m = &mut self.mobs[index]; if !m.alive() { return Ok(false); @@ -2764,10 +2777,21 @@ impl Game<'_> { consume = true; sound = Some("entity.iron_golem.repair"); } - Species::Creeper if item == "minecraft:flint_and_steel" => { + Species::Creeper if creeper_igniter => { m.extra.flag = true; - sound = Some("item.flintandsteel.use"); - self.damage_held_tool(); + // FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE, + // at the creeper's feet with a pitch from its own random. + let event = if item == "minecraft:fire_charge" { + "item.firecharge.use" + } else { + "item.flintandsteel.use" + }; + eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8)); + if damageable { + self.damage_held_tool(); + } else { + consume = true; + } } // Parrot.mobInteract: parrot food may tame a wild one (one time in // ten); a parrot never takes it as Animal food (isFood is false). From 5d30776b07610197935f14ea820ecbd2d4b94a04 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:51:56 +0100 Subject: [PATCH 037/475] Make Piercing crossbow arrows pass through entities Crossbow arrows now get their pierce level from the Piercing enchantment, go through that many entities plus one without sticking, and are discarded at the next one. Arrows without Piercing behave as before. Pillager arrows now use the pierce level their weapon already carried. --- crates/horizon-client/src/arrows.rs | 35 ++++++++++++++++++++-- crates/horizon-client/src/game/crossbow.rs | 16 ++++++++++ crates/horizon-client/src/projectiles.rs | 11 +++---- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 30b501011..9f584899e 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -156,7 +156,11 @@ pub struct ArrowState { /// `pickupItemStack`: what picking the arrow up gives. pub pickup_item: crate::items::ItemStack, pub no_physics: bool, + /// `AbstractArrow.getPierceLevel`. pub pierce: u8, + /// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone + /// through (cleared when it sticks in a block). + pub pierced: Vec, /// `Entity.remainingFireTicks`. pub fire_ticks: i32, pub fall_distance: f64, @@ -199,6 +203,7 @@ impl ArrowState { pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1), no_physics: false, pierce: 0, + pierced: Vec::new(), fire_ticks: 0, fall_distance: 0.0, fluids: EntityFluidInteraction::new([WATER, LAVA]), @@ -431,10 +436,19 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity match outcome { EntityHit::Hurt => { ticker.hit_sound(); - ticker.st.removed = true; + // A piercing arrow flies on (`getPierceLevel() <= 0` discards). + if ticker.st.pierce == 0 { + ticker.st.removed = true; + } } EntityHit::Refused => ticker.turn_back(), - EntityHit::Deflected => {} + EntityHit::Deflected => { + // The deflection comes before `onHitEntity` adds the entity to + // `piercingIgnoreEntityIds`. + if ticker.st.pierce > 0 { + ticker.st.pierced.pop(); + } + } } ticker.finish(); p.stuck = st.in_ground; @@ -639,7 +653,8 @@ impl Ticker<'_, '_, '_, '_> { self.st.life = 0; } /// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the - /// block (piercing is not modelled). + /// block. A piercing arrow that hits an entity here goes on from it next + /// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick. fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit { let block = self.scene.clip(from, to); let end = block.map_or(to, |b| b.0); @@ -653,6 +668,10 @@ impl Ticker<'_, '_, '_, '_> { if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) { continue; } + // canHitEntity: not an entity a piercing arrow has gone through. + if self.st.pierced.contains(who) { + continue; + } if let Some(at) = self.entity_hit(b, from, end, margin) { // Sorted by the distance to the entity's position (the // stable sort keeps the first of equals). @@ -667,6 +686,15 @@ impl Ticker<'_, '_, '_, '_> { self.p.position = next; self.apply_effects_from_blocks(from, next); if let Some((who, at, _)) = best { + // AbstractArrow.onHitEntity: past pierce level + 1 entities the + // arrow is discarded before it hurts the next one. + if self.st.pierce > 0 { + if self.st.pierced.len() > usize::from(self.st.pierce) { + self.st.removed = true; + return Hit::None; + } + self.st.pierced.push(who); + } return Hit::Entity { who, at }; } let Some((at, pos, face)) = block else { @@ -752,6 +780,7 @@ impl Ticker<'_, '_, '_, '_> { self.st.shake = 7; self.p.set_critical(false); self.st.pierce = 0; + self.st.pierced.clear(); } /// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`. fn may_interact(&self) -> bool { diff --git a/crates/horizon-client/src/game/crossbow.rs b/crates/horizon-client/src/game/crossbow.rs index 70ccba8d3..bc49c4b90 100644 --- a/crates/horizon-client/src/game/crossbow.rs +++ b/crates/horizon-client/src/game/crossbow.rs @@ -6,6 +6,7 @@ //! finds the ammo for bows and crossbows. use super::{Game, shield::OFFHAND_SLOT, view_vector}; use horizon_client::{ + enchant::EffectContext, items::ItemStack, mobs::Who, projectiles::{Projectile, ProjectileKind}, @@ -152,6 +153,20 @@ impl Game<'_> { }; let charged = std::mem::take(&mut bow.data_mut().charged); bow.tidy(); + let weapon = bow.clone(); + // AbstractArrow's constructor: `getPiercingCount` of the weapon. + let pierce = self + .res + .stations() + .enchantments + .modify_value( + Some(&weapon), + "minecraft:projectile_piercing", + &EffectContext::default(), + &mut self.world.random, + 0.0, + ) + .clamp(0.0, 127.0) as u8; let count = charged.len(); // getShootingPower: a rocket among them slows every shot. let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) { @@ -204,6 +219,7 @@ impl Game<'_> { if let Some(a) = arrow.arrow.as_mut() { a.set_item(ammo); a.set_owner(Some(Who::Player)); + a.pierce = pierce; } arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0); self.creatures.projectiles.push(arrow); diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 219d59b6b..8a709067d 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -186,12 +186,13 @@ impl Projectile { self } /// The shooter's bow enchantments; Flame spawns the arrow burning for 100 - /// seconds (`projectile_spawned` ignite). + /// seconds (`projectile_spawned` ignite), Piercing sets its pierce level. pub fn set_bonus(&mut self, bonus: crate::mobs::gear::ArrowBonus) { - if bonus.flame - && let Some(a) = self.arrow.as_mut() - { - a.ignite_for_seconds(100.0); + if let Some(a) = self.arrow.as_mut() { + if bonus.flame { + a.ignite_for_seconds(100.0); + } + a.pierce = bonus.pierce.min(127) as u8; } self.bonus = bonus; } From 688d86db7cc05196a00e9e91c09741e0e123540c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:51:58 +0100 Subject: [PATCH 038/475] Villagers look for a child's bed in a full 48 block sphere and only take one they can reach The search only covered 8 blocks up and down. It now uses the POI index over the whole sphere, and a bed only counts when the villager has a path to it, as VillagerMakeLove.takeVacantBed does. --- crates/horizon-client/src/mobs/villager.rs | 24 ------------------- .../horizon-client/src/mobs/villager_brain.rs | 24 ++++++++++++++----- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index 575fafaed..83ca28b71 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -752,30 +752,6 @@ pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) { } m.extra.left_handed = random.next_float() < 0.05; } -/// `PoiManager.take(HOME, ..., 48)`: the nearest bed head not `claimed` -/// (`free_head`: a bed head that is not occupied). -pub fn take_vacant_bed( - claimed: &[[i32; 3]], - from: [i32; 3], - mut free_head: impl FnMut(i32, i32, i32) -> bool, -) -> Option<[i32; 3]> { - let mut best: Option<(i32, [i32; 3])> = None; - for y in from[1] - 8..=from[1] + 8 { - for z in from[2] - 48..=from[2] + 48 { - for x in from[0] - 48..=from[0] + 48 { - let d = (x - from[0]).pow(2) + (y - from[1]).pow(2) + (z - from[2]).pow(2); - if d <= 48 * 48 - && best.is_none_or(|(b, _)| d < b) - && free_head(x, y, z) - && !claimed.contains(&[x, y, z]) - { - best = Some((d, [x, y, z])); - } - } - } - } - best.map(|(_, p)| p) -} /// `Villager.canBreed`: enough food, awake and an adult. pub fn can_breed(m: &Mob) -> bool { m.species() == Species::Villager diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 7b311a9e0..1e39e1da2 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -3396,8 +3396,10 @@ fn breeding_possible(m: &mut Mob, w: &MobWorld) -> bool { && super::villager::can_breed(m) && w.seen(id).is_some_and(|s| s.in_love) } -/// `VillagerMakeLove.takeVacantBed`: a bed head no villager has claimed. -fn take_bed_for_child(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { +/// `VillagerMakeLove.takeVacantBed`: `PoiManager.take(HOME, ..., 48)` over a +/// sphere of 48 blocks, the nearest bed head no villager has claimed that the +/// villager can walk to (`canReach`: a path that gets there). +fn take_bed_for_child(m: &mut Mob, w: &MobWorld) -> Option<[i32; 3]> { let claimed: Vec<[i32; 3]> = w .others .iter() @@ -3405,10 +3407,20 @@ fn take_bed_for_child(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { .filter_map(|s| s.bed) .chain(m.extra.bed) .collect(); - let p = m.position; - let from = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]; - super::villager::take_vacant_bed(&claimed, from, |x, y, z| { - w.prop(x, y, z, "part") == Some("head") && w.prop(x, y, z, "occupied") != Some("true") + let from = block_of(m.position); + let mut beds: Vec<[i32; 3]> = homes_in_range(m, w, 48) + .into_iter() + .filter(|p| { + w.prop(p[0], p[1], p[2], "part") == Some("head") + && w.prop(p[0], p[1], p[2], "occupied") != Some("true") + && !claimed.contains(p) + }) + .collect(); + // Nearest first; equally near beds stay in scan order. + beds.sort_by_key(|p| dist_sqr(*p, from)); + beds.into_iter().find(|&p| { + m.create_path_to(w, p, Poi::Home.valid_range()) + .is_some_and(|path| path.reached) }) } /// `BehaviorUtils.lockGazeAndWalkToEachOther`: this villager's half, and From a489d65ab08bce13daa18a1970baedc1468fbae1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:52:43 +0100 Subject: [PATCH 039/475] Arrows press wooden buttons and keep them pressed Wooden buttons ignored arrows: a pressed button always released after its delay. A button whose type allows arrows is now pressed when an arrow (or a thrown trident) is inside its shape, and it stays pressed, scheduling the release again, while one still is. Arrows now also run the entityInside check on the blocks they pass through each tick. --- crates/horizon-client/src/arrows.rs | 23 ++++++ crates/horizon-client/src/game.rs | 45 ++++++++++-- crates/horizon-world/src/level/hooks.rs | 7 ++ crates/horizon-world/src/level/mod.rs | 3 + crates/horizon-world/src/level/redstone.rs | 82 +++++++++++++++++++--- 5 files changed, 148 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 30b501011..f6d011e93 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -362,6 +362,29 @@ fn bounds(at: Vec3) -> Aabb { Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w), ) } +/// The bounding box of an arrow, or a thrown trident, at `at`. +pub fn arrow_bounds(at: Vec3) -> Aabb { + bounds(at) +} +/// The blocks an arrow that moved from `from` to `to` was inside +/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for +/// the blocks that react to it (`entityInside`). +pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec { + let deflated = bounds(to).deflate(Vec3::new( + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + )); + let mut inside = Vec::new(); + let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| { + if iteration >= 16 { + return false; + } + inside.push(p); + true + }); + inside +} /// `Projectile.lerpRotation`. fn lerp_rotation(mut old: f32, new: f32) -> f32 { while new - old < -180.0 { diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..8a21ec3ad 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -540,6 +540,8 @@ struct GameLevel<'w> { player_collision: Option, /// Minecart boxes (detector rails). cart_boxes: Vec<([f64; 3], [f64; 3])>, + /// Arrow and thrown trident boxes (wooden buttons). + arrow_boxes: Vec<([f64; 3], [f64; 3])>, /// The other entities that keep blocks from being placed into them. blockers: Blockers<'w>, biomes: &'w Biomes, @@ -827,8 +829,9 @@ impl LevelAccess for GameLevel<'_> { fn is_ticking_chunk(&self, chunk: horizon_core::position::ChunkPos) -> bool { self.tickets.block_ticking(chunk.x, chunk.z) && ticking_chunk(self.world, chunk.x, chunk.z) } - /// Entities that trigger pressure plates, tripwires and detector rails: - /// the players (the host and the LAN guests, mobs) and the minecarts. + /// Entities that trigger pressure plates, tripwires, detector rails and + /// wooden buttons: the players (the host and the LAN guests, mobs) and + /// the minecarts, and for `EntityQuery::Arrows` the arrows. fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize { let hit = |(lo, hi): &([f64; 3], [f64; 3])| { area.intersects(Aabb::new( @@ -837,6 +840,7 @@ impl LevelAccess for GameLevel<'_> { )) }; let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count(); + let arrows = || self.arrow_boxes.iter().filter(|b| hit(b)).count(); let players = || { self.player_box .iter() @@ -846,6 +850,7 @@ impl LevelAccess for GameLevel<'_> { }; match query { EntityQuery::Minecart => carts(), + EntityQuery::Arrows => arrows(), EntityQuery::Mobs => players(), EntityQuery::Everything => players() + carts(), } @@ -2445,6 +2450,18 @@ impl Game<'_> { ) }) .collect(); + let arrow_boxes = self + .creatures + .projectiles + .iter() + .filter(|p| p.arrow.is_some()) + .map(|p| p.position) + .chain(self.tridents.iter().map(|t| t.position)) + .map(|at| { + let b = horizon_client::arrows::arrow_bounds(at); + (b.min_position().components(), b.max_position().components()) + }) + .collect(); let guest_boxes = self.lan_block_trigger_boxes(); let dimension = match self.dim { Dim::Overworld => LevelDimension::Overworld, @@ -2462,6 +2479,7 @@ impl Game<'_> { }; let mut level = GameLevel { cart_boxes, + arrow_boxes, blockers, world: &mut self.world, facts: self.res.facts(), @@ -2591,8 +2609,9 @@ impl Game<'_> { .copied() .filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)) .collect(); - // Entity.checkInsideBlocks for the plates, tripwires and detector rails - // under the players (the host and the LAN guests) and the minecarts. + // Entity.checkInsideBlocks for the plates, tripwires, detector rails and + // buttons under the players (the host and the LAN guests), the minecarts + // and the arrows. let mut inside = Vec::new(); let guests = self.lan_block_trigger_boxes(); let boxes = self @@ -2624,6 +2643,24 @@ impl Game<'_> { } } } + // AbstractArrow.applyEffectsFromBlocks: the blocks every arrow passed + // through this tick (a wooden button is pressed by one inside it), and + // the ones a thrown trident is in. + for p in self + .creatures + .projectiles + .iter() + .filter(|p| p.arrow.is_some()) + { + inside.extend(horizon_client::arrows::blocks_inside( + p.previous, p.position, + )); + } + for t in &self.tridents { + inside.extend(horizon_client::arrows::blocks_inside( + t.position, t.position, + )); + } // BigDripleafBlock.entityInside also asks whether the player stands on // the ground and how high its feet are: the leaves its box's feet // layer is inside (the host and the LAN guests). diff --git a/crates/horizon-world/src/level/hooks.rs b/crates/horizon-world/src/level/hooks.rs index 7f9e8d19a..f716449f5 100644 --- a/crates/horizon-world/src/level/hooks.rs +++ b/crates/horizon-world/src/level/hooks.rs @@ -447,6 +447,13 @@ impl Sim<'_, L> { if !b.table.bool(state, b.props.powered) { self.detector_rail_check_pressed(pos, state); } + } else if data.is(Class::Button) { + // ButtonBlock.entityInside: a wooden button an arrow is inside. + if data.set_type.as_ref().is_some_and(|s| s.arrows) + && !b.table.bool(state, b.props.powered) + { + self.button_check_pressed(pos, state); + } } else if data.is(Class::TripWire) && !b.table.bool(state, b.props.powered) && !self.ticks.has_block_tick(pos, b.block(state)) diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index 3831efbd6..afba55028 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -205,6 +205,9 @@ pub enum EntityQuery { Mobs, /// Minecarts only (detector rails). Minecart, + /// Arrows only (`AbstractArrow`, thrown tridents included): the entities + /// a wooden button senses. + Arrows, } /// Side effects reported to the caller, in vanilla call order. diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 92f9059e5..0230c76f6 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -879,18 +879,84 @@ impl Sim<'_, L> { } self.game_event(pos, "minecraft:block_activate"); } - /// `ButtonBlock.checkPressed` with no arrows present. + /// `ButtonBlock.tick`. pub(crate) fn button_tick(&mut self, pos: BlockPos, state: BlockStateId) { let b = self.b; - if !b.table.bool(state, b.props.powered) { - return; + if b.table.bool(state, b.props.powered) { + self.button_check_pressed(pos, state); } - self.set_block_and_update(pos, b.table.set_bool(state, b.props.powered, false)); - self.attached_update_neighbours(pos, state); - if let Some(set) = b.data(state).set_type.clone() { - self.sound(pos, &set.button_off, 1.0, 1.0); + } + /// `ButtonBlock.checkPressed`: a button whose type allows arrows is + /// pressed while an arrow is inside its shape, and stays pressed (the + /// release is scheduled again) as long as one is. + pub(crate) fn button_check_pressed(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + let data = b.data(state); + let should_be_pressed = data.set_type.as_ref().is_some_and(|s| s.arrows) + && self + .level + .count_entities(self.button_bounds(pos, state), super::EntityQuery::Arrows) + > 0; + let was_pressed = b.table.bool(state, b.props.powered); + if should_be_pressed != was_pressed { + self.set_block_and_update( + pos, + b.table.set_bool(state, b.props.powered, should_be_pressed), + ); + self.attached_update_neighbours(pos, state); + if let Some(set) = data.set_type.clone() { + let sound = if should_be_pressed { + &set.button_on + } else { + &set.button_off + }; + self.sound(pos, sound, 1.0, 1.0); + } + self.game_event( + pos, + if should_be_pressed { + "minecraft:block_activate" + } else { + "minecraft:block_deactivate" + }, + ); } - self.game_event(pos, "minecraft:block_deactivate"); + if should_be_pressed { + self.schedule_tick(pos, b.block(state), data.ticks_to_stay_pressed); + } + } + /// `ButtonBlock.getShape(...).bounds().move(pos)`: a 6 by 4 plate on the + /// face it is attached to, 2 pixels thick (1 when pressed). + fn button_bounds(&self, pos: BlockPos, state: BlockStateId) -> horizon_core::geometry::Aabb { + use horizon_core::geometry::{Aabb, Vec3}; + let b = self.b; + let thickness = if b.table.bool(state, b.props.powered) { + 1.0 / 16.0 + } else { + 2.0 / 16.0 + }; + let attached = self.face_attached_connected(state).opposite().normal(); + let thick = attached.iter().position(|&n| n != 0).unwrap_or(1); + // The 6 pixel side lies across the button's facing. + let wide = if b.facing(state).normal()[2] != 0 { + 0 + } else { + 2 + }; + let tall = 3 - thick - wide; + let (mut lo, mut hi) = ([0.0; 3], [1.0; 3]); + if attached[thick] > 0 { + lo[thick] = 1.0 - thickness; + } else { + hi[thick] = thickness; + } + (lo[wide], hi[wide]) = (5.0 / 16.0, 11.0 / 16.0); + (lo[tall], hi[tall]) = (6.0 / 16.0, 10.0 / 16.0); + let origin = [f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)]; + Aabb::new( + Vec3::new(origin[0] + lo[0], origin[1] + lo[1], origin[2] + lo[2]), + Vec3::new(origin[0] + hi[0], origin[1] + hi[1], origin[2] + hi[2]), + ) } /// `LeverBlock.pull`. pub(crate) fn lever_pull(&mut self, pos: BlockPos, state: BlockStateId) { From b752a821b1f501b78cdf200362ecd9975e277ad7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:53:21 +0100 Subject: [PATCH 040/475] Wear armor when cactus, fire, lava, campfires and other environment damage hurt the player --- .../examples/probe/scene_motion_probe.rs | 2 + crates/horizon-client/src/game.rs | 52 ++++++++++++++++--- crates/horizon-client/src/game/creatures.rs | 1 - crates/horizon-client/src/game/explosion.rs | 3 -- .../horizon-client/src/game/falling_damage.rs | 10 +--- crates/horizon-client/src/game/fireworks.rs | 10 +--- crates/horizon-client/src/game/lan_actions.rs | 1 + crates/horizon-client/src/health.rs | 16 +++++- crates/horizon-client/src/items.rs | 6 +++ 9 files changed, 73 insertions(+), 28 deletions(-) diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 21977eeab..6153e098b 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> { .iter() .filter_map(|e| match e { MotionEvent::Fall(e) => Some(e.trace()), + // The oracle's player wears no armor; the game applies this one. + MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None, MotionEvent::Health(e) => Some(e.trace()), MotionEvent::Snowflake { position, velocity } => Some(format!( "snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}", diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..d3b378a76 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4343,6 +4343,7 @@ impl Game<'_> { self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); + self.wear_armor(&events); for event in events { match event { MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet), @@ -8317,15 +8318,54 @@ impl Game<'_> { } self.apply_enchantment_attributes(); } - /// Inventory.hurtArmor: each piece loses max(1, damage / 4) durability. - fn hurt_armor(&mut self, damage: f32) { + /// The armor hits among a tick's motion events (`Player.hurtArmor` from + /// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion, + /// a cactus, lava, a campfire. + fn wear_armor(&mut self, events: &[MotionEvent]) { + for event in events { + if let MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { + source, + amount, + }) = event + { + self.hurt_armor(*source, *amount); + } + } + } + /// `Player.hurtArmor` -> `LivingEntity.doHurtEquipment`: each worn piece that + /// wears when its wearer is hurt loses max(1, damage / 4) durability, except + /// to a fire source when the piece resists fire (`ItemStack.canBeHurtBy`). + fn hurt_armor(&mut self, source: horizon_world::damage_source::DamageTypeId, damage: f32) { + if damage <= 0.0 { + return; + } let amount = ((damage / 4.0) as u32).max(1); + let name = self + .motion + .health + .damage + .definition(source) + .map(|d| d.name.clone()) + .unwrap_or_default(); + let fire = self + .motion + .health + .damage + .in_tag(source, "minecraft:is_fire"); let mut broke = false; let losses: Vec = (0..4) - .map(|i| { - self.armor[i] - .clone() - .map_or(amount, |s| self.durability_loss(&s, amount)) + .map(|i| match self.armor[i].clone() { + Some(s) + if self + .res + .items() + .get(&s.item) + .is_some_and(|info| info.equip_damage_on_hurt) + && !(fire && self.res.items().resists_fire(&s, &name)) => + { + self.durability_loss(&s, amount) + } + _ => 0, }) .collect(); for (slot, amount) in self.armor.iter_mut().zip(losses) { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3a8562ee1..150541a49 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1535,7 +1535,6 @@ impl Game<'_> { r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]); } } - self.hurt_armor(amount); self.thorns(from); } if raised { diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..03b65e795 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -314,9 +314,6 @@ impl Game<'_> { ); } } - if outcome.hurt && !creative { - self.hurt_armor(amount); - } if raised { self.after_block(outcome, amount, None, false); } diff --git a/crates/horizon-client/src/game/falling_damage.rs b/crates/horizon-client/src/game/falling_damage.rs index 8eec28d1d..759b4f646 100644 --- a/crates/horizon-client/src/game/falling_damage.rs +++ b/crates/horizon-client/src/game/falling_damage.rs @@ -108,16 +108,10 @@ impl Game<'_> { } else { amount }; - let hurt = if let Some(p) = self.player.as_mut() + if let Some(p) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(kind) { - p.hurt(&self.motion.health, self.world.health_rules, id, amount) - .unwrap_or(false) - } else { - false - }; - if hurt { - self.hurt_armor(amount); + let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount); } } } diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index e6b4eb136..825519676 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -352,17 +352,11 @@ impl Game<'_> { if self.creative || self.spectator() { return; } - let hurt = if let Some(p) = self.player.as_mut() + if let Some(p) = self.player.as_mut() && !p.health.dead_or_dying() && let Some(id) = self.motion.health.damage.id("minecraft:fireworks") { - p.hurt(&self.motion.health, self.world.health_rules, id, amount) - .unwrap_or(false) - } else { - false - }; - if hurt { - self.hurt_armor(amount); + let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount); } } /// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera, diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..f824d3965 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2233,6 +2233,7 @@ impl Game<'_> { let marked = std::mem::take(&mut player.health.sync_velocity); let velocity = marked.then_some(player.velocity); game.record_combat(&events); + game.wear_armor(&events); (events, velocity) }); let Some((events, marked)) = events else { diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 25c719bfb..f45fec1fc 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -111,6 +111,12 @@ pub enum HealthEvent { amount: f32, }, Phase(&'static str), + /// `Player.hurtArmor`: a hit that doesn't bypass armor wears the worn + /// pieces (`LivingEntity.getDamageAfterArmorAbsorb`); the game applies it. + ArmorHurt { + source: DamageTypeId, + amount: f32, + }, /// `ServerLevel.broadcastDamageEvent`: the client's /// `LivingEntity.handleDamageEvent` plays the source's hurt sound. DamageBroadcast { @@ -139,6 +145,7 @@ impl HealthEvent { format!("record-damage {:08x}", amount.to_bits()) } Self::Phase(name) => name.into(), + Self::ArmorHurt { amount, .. } => format!("hurt-armor {:08x}", amount.to_bits()), Self::DamageBroadcast { .. } => "broadcast-damage".into(), Self::Criterion { .. } => "hurt-criterion".into(), } @@ -517,9 +524,14 @@ impl ServerDamageEnvironment for Environment<'_> { fn bypasses(&mut self, kind: Bypass) -> io::Result { Ok(self.source.bypasses(kind)) } - fn hurt_armor(&mut self, _: &mut Attributes, _: f32) -> io::Result<()> { + fn hurt_armor(&mut self, _: &mut Attributes, damage: f32) -> io::Result<()> { + // The worn armor lives in the game, which wears it from this event. + self.event(HealthEvent::ArmorHurt { + source: self.source.type_id(), + amount: damage, + }); Ok(()) - } // empty equipment + } fn has_resistance(&mut self) -> io::Result { Ok(self.status.resistance.is_some()) } diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index 8ac450bcb..080c88394 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -111,6 +111,9 @@ pub struct ItemInfo { pub swappable: bool, /// `equippable.dispensable` (dispensers may put it on an entity). pub equip_dispensable: bool, + /// `equippable.damage_on_hurt`: the item wears when its wearer is hurt + /// (false for the elytra). + pub equip_damage_on_hurt: bool, /// `equippable.allowed_entities` resolved to entity type ids; None allows all. pub equip_allowed: Option>, pub equip_sound: Option, @@ -584,6 +587,9 @@ impl Items { equip_dispensable: c["minecraft:equippable"]["dispensable"] .as_bool() .unwrap_or(true), + equip_damage_on_hurt: c["minecraft:equippable"]["damage_on_hurt"] + .as_bool() + .unwrap_or(true), equip_allowed: match &c["minecraft:equippable"]["allowed_entities"] { Value::String(spec) => Some(entity_spec(&entity_tags, spec)), Value::Array(list) => Some( From d622030988cc744f6e9ec42ba3e4a42ff89ea70b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:53:34 +0100 Subject: [PATCH 041/475] Zombies call reinforcements when hurt on Hard A hurt zombie with a target now tries up to 50 spots 7 to 40 blocks away for a copy of itself, as Zombie.hurtServer does. The copy takes the target, and both zombies get the charge that lowers their chance. New zombies also roll their reinforcement chance and a leader's bonus (Zombie.handleAttributes); before this the chance stayed at 0. --- crates/horizon-client/src/game.rs | 4 + crates/horizon-client/src/game/creatures.rs | 4 + .../src/game/zombie_reinforcements.rs | 134 ++++++++++++++++++ crates/horizon-client/src/mobs.rs | 6 + crates/horizon-client/src/mobs/ai.rs | 5 + crates/horizon-client/src/mobs/finalize.rs | 117 ++++++++------- crates/horizon-client/src/mobs/spawning.rs | 12 ++ crates/horizon-client/src/mobs/undead.rs | 12 +- 8 files changed, 238 insertions(+), 56 deletions(-) create mode 100644 crates/horizon-client/src/game/zombie_reinforcements.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b397882cd..940a0c4aa 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -181,6 +181,7 @@ mod win; mod wind; mod wither; mod world_loading; +mod zombie_reinforcements; mod zombie_villagers; use horizon_client::{ Result, @@ -7648,6 +7649,9 @@ impl Game<'_> { if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event { self.audio .play(&format!("minecraft:{s}"), Some(at), volume, pitch); + } else { + // Left for the end of the tick. + self.mobs[index].outbox.push(event); } } self.weapon_hit(index); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 119fe2a1e..cbc4691cc 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -941,6 +941,9 @@ impl Game<'_> { volume, pitch, ); + } else { + // Left for the end of the tick. + t.outbox.push(event); } } } @@ -1192,6 +1195,7 @@ impl Game<'_> { self.creaking_tear_down(center, spread) } MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob), + MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target), MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other), MobEvent::VillagerBirth { parent, diff --git a/crates/horizon-client/src/game/zombie_reinforcements.rs b/crates/horizon-client/src/game/zombie_reinforcements.rs new file mode 100644 index 000000000..4c6100f43 --- /dev/null +++ b/crates/horizon-client/src/game/zombie_reinforcements.rs @@ -0,0 +1,134 @@ +//! `Zombie.hurtServer`'s reinforcements: a hurt zombie on Hard may call +//! another of its kind from 7 to 40 blocks away to fight its target. +use super::Game; +use horizon_client::mobs::{ + MobType, Species, Who, + finalize::{self, Attr, Op}, + spawn_rules::{self, Reason}, + spawn_selection::{self, NaturalLevel}, + spawner::SpawnerLevel, +}; +use horizon_core::random::{LegacyRandom, RandomSource}; +use horizon_world::food::Difficulty; + +/// `Zombie.REINFORCEMENT_ATTEMPTS`. +const ATTEMPTS: usize = 50; +/// `Zombie.REINFORCEMENT_RANGE_MIN` and `REINFORCEMENT_RANGE_MAX`. +const RANGE_MIN: i32 = 7; +const RANGE_MAX: i32 = 40; + +/// One axis of the offset: `Mth.nextInt(random, 7, 40) * Mth.nextInt(random, -1, 1)`. +fn offset(random: &mut LegacyRandom) -> i32 { + let distance = random + .next_int_between_inclusive(RANGE_MIN, RANGE_MAX) + .unwrap_or(RANGE_MIN); + distance * random.next_int_between_inclusive(-1, 1).unwrap_or(0) +} + +impl Game<'_> { + /// `Zombie.hurtServer` after the hit: on Hard, with the zombie's chance + /// and monsters spawning, a copy of it appears out of sight and takes + /// `target`; both then call less often. + pub(super) fn call_reinforcements(&mut self, caller: u32, target: Who) { + let Some(index) = self.mobs.iter().position(|m| m.id == caller) else { + return; + }; + if self.world.health_rules.difficulty != Difficulty::Hard { + return; + } + let zombie = &mut self.mobs[index]; + if f64::from(zombie.random.next_float()) >= zombie.attrs.spawn_reinforcements { + return; + } + // ServerLevel.isSpawningMonsters. + if !self.ui.rules.bool("spawn_mobs") || !self.ui.rules.bool("spawn_monsters") { + return; + } + let kind = zombie.kind.clone(); + let from = [ + zombie.position.x.floor() as i32, + zombie.position.y.floor() as i32, + zombie.position.z.floor() as i32, + ]; + let mut own = std::mem::replace(&mut zombie.random, LegacyRandom::new(0)); + let mut rules = std::mem::replace(&mut self.world.random, LegacyRandom::new(0)); + let spot = self.reinforcement_spot(&kind, from, &mut own, &mut rules); + self.mobs[index].random = own; + self.world.random = rules; + let Some(spot) = spot else { + return; + }; + let Some(id) = self.spawn_mob(&kind.id, spot.map(f64::from), false) else { + return; + }; + if let Some(reinforcement) = self.mobs.iter_mut().find(|m| m.id == id) { + reinforcement.ai.target = Some(target); + // ZOMBIE_REINFORCEMENT_CALLEE_CHARGE. + finalize::add_modifier( + reinforcement, + Attr::SpawnReinforcements, + "minecraft:reinforcement_callee_charge", + -f64::from(0.05f32), + Op::AddValue, + ); + } + // The caller's charge grows by 0.05 each time it calls. + let caller = &mut self.mobs[index]; + let charged = finalize::modifier_amount( + caller, + Attr::SpawnReinforcements, + "minecraft:reinforcement_caller_charge", + ) + .unwrap_or(0.0); + finalize::add_modifier( + caller, + Attr::SpawnReinforcements, + "minecraft:reinforcement_caller_charge", + charged - 0.05, + Op::AddValue, + ); + } + /// The block `Zombie.hurtServer` puts the reinforcement at: the first of + /// 50 random offsets with a valid spawn surface, no alive player within + /// 7 blocks, and room for the zombie standing on the block's corner. + fn reinforcement_spot( + &self, + kind: &MobType, + from: [i32; 3], + own: &mut LegacyRandom, + rules: &mut LegacyRandom, + ) -> Option<[i32; 3]> { + let level = self.natural_level(); + let (width, height) = (kind.width as f32, kind.height as f32); + for _ in 0..ATTEMPTS { + let [x, y, z] = from.map(|v| v + offset(own)); + if !spawn_rules::is_spawn_position_ok(kind.species, &level, x, y, z) + || !spawn_rules::check_spawn_rules( + kind.species, + &level, + Reason::Reinforcement, + rules, + x, + y, + z, + ) + { + continue; + } + // setPos(x, y, z): the box is centred on the block's corner. + let bounds = + spawn_selection::spawn_box(f64::from(x), f64::from(y), f64::from(z), width, height); + // Drowned.canSpawnInLiquids. + let wet_ok = kind.species == Species::Drowned + || !spawn_selection::contains_any_liquid(&level, bounds); + if !level.alive_player_near(f64::from(x), f64::from(y), f64::from(z), 7.0) + && level.unobstructed(bounds) + && level.no_collision(bounds) + && wet_ok + { + return Some([x, y, z]); + } + } + None + } +} diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..c92378eeb 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1151,6 +1151,12 @@ pub enum MobEvent { DigParticles { at: [f64; 3], }, + /// `Zombie.hurtServer`: a zombie hurt with a target in sight (its own, + /// else the attacker) may call a reinforcement against it on Hard. + Reinforcements { + mob: u32, + target: Who, + }, /// `Warden.applyDarknessAround`: darkness for survival players nearby. Darkness { at: [f64; 3], diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index c9bc15fda..c30146515 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -1306,6 +1306,11 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) { Species::Warden => super::warden::hurt(m, source), Species::Wolf => super::tame::after_hurt(m, source), Species::Nautilus | Species::ZombieNautilus => super::nautilus::after_hurt(m, source), + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::ZombifiedPiglin => super::undead::after_hurt(m, source), // NeutralMob: an iron golem hit by the player stays angry a while. // PiglinAi.wasHurtBy: drop what is being admired, no admiring for // a while after a player's hit, and anger. diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 4eeed885b..663b39f0a 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -590,6 +590,14 @@ pub fn has_modifier(m: &Mob, a: Attr, id: &str) -> bool { .as_ref() .is_some_and(|s| s.modifiers.iter().any(|x| x.attr == a && x.id == id)) } +/// The amount of a permanent modifier, if the mob has it. +pub fn modifier_amount(m: &Mob, a: Attr, id: &str) -> Option { + m.extra + .attributes + .as_ref() + .and_then(|s| s.modifiers.iter().find(|x| x.attr == a && x.id == id)) + .map(|x| x.amount) +} /// `addOrReplacePermanentModifier`. pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) { let s = m.extra.attributes.get_or_insert_with(Box::default); @@ -605,6 +613,59 @@ pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) } refresh(m, a); } +/// `Zombie.handleAttributes`, from the zombie's own random. +pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) { + let r = &mut m.random; + let reinforcements = if m.kind.species == Species::ZombifiedPiglin { + 0.0 + } else { + r.next_double() * f64::from(0.1f32) + }; + let knockback = r.next_double() * f64::from(0.05f32); + let follow = r.next_double() * 1.5 * f64::from(special); + let leader = r.next_float() < special * 0.05; + let leader_rolls = leader.then(|| (r.next_double() * 0.25 + 0.5, r.next_double() * 3.0 + 1.0)); + set_base(m, Attr::SpawnReinforcements, reinforcements); + add_modifier( + m, + Attr::KnockbackResistance, + "minecraft:random_spawn_bonus", + knockback, + Op::AddValue, + ); + if follow > 1.0 { + add_modifier( + m, + Attr::FollowRange, + "minecraft:zombie_random_spawn_bonus", + follow, + Op::AddMultipliedTotal, + ); + } + if let Some((reinforce, health)) = leader_rolls { + add_modifier( + m, + Attr::SpawnReinforcements, + "minecraft:leader_zombie_bonus", + reinforce, + Op::AddValue, + ); + add_modifier( + m, + Attr::MaxHealth, + "minecraft:leader_zombie_bonus", + health, + Op::AddMultipliedTotal, + ); + if !matches!( + reason, + Reason::Conversion | Reason::Load | Reason::DimensionTravel + ) { + m.health = max_health(m); + } + m.extra.can_break_doors = true; + } +} pub fn max_health(m: &Mob) -> f32 { value(m, Attr::MaxHealth) as f32 } @@ -1151,63 +1212,9 @@ impl Ctx<'_, L, R> { } } self.halloween(m); - self.zombie_attributes(m, special, reason); + zombie_attributes(m, special, reason); g } - /// `Zombie.handleAttributes`, from the zombie's own random. - fn zombie_attributes(&mut self, m: &mut Mob, special: f32, reason: Reason) { - let r = &mut m.random; - let reinforcements = if m.kind.species == Species::ZombifiedPiglin { - 0.0 - } else { - r.next_double() * f64::from(0.1f32) - }; - let knockback = r.next_double() * f64::from(0.05f32); - let follow = r.next_double() * 1.5 * f64::from(special); - let leader = r.next_float() < special * 0.05; - let leader_rolls = - leader.then(|| (r.next_double() * 0.25 + 0.5, r.next_double() * 3.0 + 1.0)); - set_base(m, Attr::SpawnReinforcements, reinforcements); - add_modifier( - m, - Attr::KnockbackResistance, - "minecraft:random_spawn_bonus", - knockback, - Op::AddValue, - ); - if follow > 1.0 { - add_modifier( - m, - Attr::FollowRange, - "minecraft:zombie_random_spawn_bonus", - follow, - Op::AddMultipliedTotal, - ); - } - if let Some((reinforce, health)) = leader_rolls { - add_modifier( - m, - Attr::SpawnReinforcements, - "minecraft:leader_zombie_bonus", - reinforce, - Op::AddValue, - ); - add_modifier( - m, - Attr::MaxHealth, - "minecraft:leader_zombie_bonus", - health, - Op::AddMultipliedTotal, - ); - if !matches!( - reason, - Reason::Conversion | Reason::Load | Reason::DimensionTravel - ) { - m.health = max_health(m); - } - m.extra.can_break_doors = true; - } - } fn husk(&mut self, m: &mut Mob, g: Option, reason: Reason) -> Option { let mut g = self.zombie(m, g, reason); if reason != Reason::Conversion { diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..b08b2b023 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -264,6 +264,18 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { S::Slime | S::MagmaCube => super::cube::finalize(m), _ => {} } + // Zombie.finalizeSpawn ends with handleAttributes: the reinforcement + // chance (and a leader's bonus) that `Zombie.hurtServer` reads. + if matches!( + m.species(), + S::Zombie | S::Husk | S::Drowned | S::ZombieVillager | S::ZombifiedPiglin + ) { + super::finalize::zombie_attributes( + m, + difficulty.special(), + super::finalize::Reason::Natural, + ); + } } /// VillagerType.byBiome pub fn villager_type(b: &BiomeFacts) -> u8 { diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index e77a37641..56f1dc29e 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -1,7 +1,7 @@ //! Zombies (zombie, husk, drowned, zombie villager) and skeletons (skeleton, //! stray, bogged, wither skeleton): goals, bow attacks, water conversion. use super::{ - Mob, MobEvent, Species, Who, + Damage, Mob, MobEvent, Species, Who, goals::{ Avoid, AvoidEntity, Cx, FleeSun, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, RandomLookAround, RestrictSun, Stroll, goal_clone, @@ -10,6 +10,16 @@ use super::{ use crate::projectiles::{Projectile, ProjectileKind}; use horizon_core::{geometry::Vec3, random::RandomSource}; +/// `Zombie.hurtServer`, once the hit landed: a zombie with a target (its own, +/// else a living attacker) asks the game to try calling a reinforcement; the +/// difficulty, chance and spawn tests are the game's (`call_reinforcements`). +pub fn after_hurt(m: &mut Mob, source: &Damage) { + if let Some(target) = m.ai.target.or(source.attacker) { + m.outbox + .push(MobEvent::Reinforcements { mob: m.id, target }); + } +} + pub fn register(m: &mut Mob) { use Species as S; match m.species() { From d057c891642f026589241fabcc82bb83a517be6d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:59:33 +0100 Subject: [PATCH 042/475] Mobs killed by a charged creeper drop their head A creeper's explosion hurt mobs with no attacker, and the creeper is already gone from the mob list when its blast is applied, so the charged_creeper loot never matched. The creeper now announces itself (ExplosionSource) before it explodes, its victims name it as the damage source, and it stays known until they have been looted. One head drops per charged explosion. --- crates/horizon-client/src/game/combat.rs | 4 ++ crates/horizon-client/src/game/creatures.rs | 48 +++++++++++++++------ crates/horizon-client/src/game/explosion.rs | 4 +- crates/horizon-client/src/mobs.rs | 8 ++++ crates/horizon-client/src/mobs/hostile.rs | 4 ++ 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 227aa0332..09e156aec 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -31,6 +31,10 @@ pub(super) struct CombatTracker { /// The mob exploding now (`ServerExplosion.source`): its team's friendly /// fire decides whom the blast may hurt. pub(super) explosion_source: Option, + /// Charged creepers whose blast just went off and that have not dropped + /// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list, + /// but the mobs they killed still ask for their loot. + pub(super) charged_exploders: Vec, /// The player's fall distance before this tick's movement (as of the /// end of the last one). fall_before: f64, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 119fe2a1e..4b839bd07 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -496,6 +496,8 @@ impl Game<'_> { self.died.insert(m.id); self.mob_died(&m); } + // Their victims have been looted: no charged creeper is waiting any more. + self.combat.charged_exploders.clear(); let uuid = self.level.player_uuid; let mut snapshot: Vec = self.mobs.iter().map(|m| seen(m, uuid)).collect(); let mut events = Vec::new(); @@ -778,7 +780,17 @@ impl Game<'_> { .mobs .iter() .find(|o| o.id == id) - .map(|o| drops::loot_entity(o, None)), + .map(|o| drops::loot_entity(o, None)) + // A charged creeper's blast kills after the creeper left the list. + .or_else(|| { + self.combat + .charged_exploders + .contains(&id) + .then(|| LootEntity { + powered: true, + ..LootEntity::new("minecraft:creeper") + }) + }), None => None, }; let direct = match d.kind { @@ -814,12 +826,12 @@ impl Game<'_> { world_seed: self.level.seed, }; let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences); - if skulls - && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) - && let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) - { + if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) { // Creeper.droppedSkulls: one head per charged explosion. - creeper.extra.charged = false; + self.combat.charged_exploders.retain(|c| *c != id); + if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) { + creeper.extra.charged = false; + } } let raider = horizon_client::mobs::raider::is_raider(m.species()); for d in items { @@ -901,16 +913,24 @@ impl Game<'_> { owner, } => self.add_fangs(at, yaw, delay, owner), MobEvent::Shoot(..) => {} + MobEvent::ExplosionSource { mob, charged } => { + self.combat.explosion_source = Some(mob); + if charged { + self.combat.charged_exploders.push(mob); + } + } MobEvent::Explode(at, radius) => { // The exploding mob causes the damage (Creeper.explodeCreeper). - let source_mob = self - .mobs - .iter() - .filter(|m| { - (m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0 - }) - .map(|m| m.id) - .next(); + let source_mob = self.combat.explosion_source.or_else(|| { + self.mobs + .iter() + .filter(|m| { + (m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) + < 1.0 + }) + .map(|m| m.id) + .next() + }); if let Some(id) = source_mob { self.note_attacker("minecraft:explosion", id); } diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..1da018238 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -391,9 +391,11 @@ impl Game<'_> { [feet[0] - w, feet[1], feet[2] - w], [feet[0] + w, feet[1] + h, feet[2] + w], ); + // The exploding mob is the damage source's entity, so victims + // it kills drop its charged-creeper loot (`Creeper.killedEntity`). let source = horizon_client::mobs::Damage { kind: horizon_client::mobs::DamageKind::Explosion, - attacker: None, + attacker: exploding.map(horizon_client::mobs::Who::Mob), from: Some(center), }; if hurtable && !m.damage(damage(dist, exposure), source) { diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..7e423512b 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -965,6 +965,14 @@ pub enum MobEvent { owner: u32, }, Shoot([f64; 3], [f64; 3]), + /// The mob the `Explode` right after this comes from + /// (`ServerExplosion.source`), and whether it is a charged creeper: the + /// exploding creeper is already out of the mob list when the game applies + /// the blast, so its victims' loot cannot look it up. + ExplosionSource { + mob: u32, + charged: bool, + }, Explode([f64; 3], f32), /// Melee or ranged damage to another mob. AttackMob { diff --git a/crates/horizon-client/src/mobs/hostile.rs b/crates/horizon-client/src/mobs/hostile.rs index 0d3feaa6c..f1c990c48 100644 --- a/crates/horizon-client/src/mobs/hostile.rs +++ b/crates/horizon-client/src/mobs/hostile.rs @@ -177,6 +177,10 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec) { if m.swell >= 30 { m.swell = 30; let radius = if m.extra.charged { 6.0 } else { 3.0 }; + events.push(MobEvent::ExplosionSource { + mob: m.id, + charged: m.extra.charged, + }); events.push(MobEvent::Explode( [m.position.x, m.position.y, m.position.z], radius, From d0509e2beeb38dfcb3b927e973e3d3ef6ccd5967 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:02:39 +0100 Subject: [PATCH 043/475] Witches stand still while drinking and skip potions a target already has A drinking witch now gets the -0.25 drinking speed modifier, so it does not walk for the 32 ticks. When it throws a potion, slowness, poison and weakness are only picked if the target (player or mob) does not already have that effect, as Witch.performRangedAttack does. --- .../examples/probe/mob_ai_probe.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/game/lan_mobs.rs | 1 + crates/horizon-client/src/mobs.rs | 25 ++++++++++++++++++- crates/horizon-client/src/mobs/witch.rs | 20 ++++++++++++--- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 49f56c250..7c501ee77 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1050,6 +1050,7 @@ pub fn main() -> horizon_client::Result<()> { velocity: Vec3::ZERO, yaw: 0.0, hero: false, + effects: Vec::new(), uuid: PLAYER_UUID, allies: Default::default(), remote: false, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 119fe2a1e..9eb37e9d8 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -193,6 +193,7 @@ impl Game<'_> { velocity: p.velocity, yaw: self.yaw, hero: self.potions.effects.get("hero_of_the_village").is_some(), + effects: self.potions.effects.list.iter().map(|e| e.id).collect(), uuid: self.level.player_uuid.unwrap_or_default(), allies: std::sync::Arc::new(self.player_allies()), remote: false, diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 4f53cec60..6527f1015 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -370,6 +370,7 @@ mod tests { velocity: Vec3::ZERO, yaw: 0.0, hero: false, + effects: Vec::new(), uuid: [0; 4], allies: Default::default(), remote: false, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..3ad48df3f 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1243,6 +1243,14 @@ pub struct Seen { /// `MAX_HEALTH`, `JUMP_STRENGTH` and `MOVEMENT_SPEED` base values (a /// horse's breeding stock). pub stats: [f64; 3], + /// The active effects, without namespace (`slowness`, `poison`...). + pub effects: Vec<&'static str>, +} +impl Seen { + /// `LivingEntity.hasEffect`, by id without namespace. + pub fn has_effect(&self, effect: &str) -> bool { + self.effects.contains(&effect) + } } impl Mob { pub fn burning_time(&self) -> f64 { @@ -1315,6 +1323,12 @@ impl Mob { m.extra.vector[0], m.attrs.speed, ], + effects: m + .extra + .effects + .iter() + .map(|e| e.0.trim_start_matches("minecraft:")) + .collect(), } } /// `EntityType.getCategory`'s name. @@ -1459,6 +1473,8 @@ pub struct PlayerView { pub yaw: f32, /// The Hero of the Village effect. pub hero: bool, + /// The active effects, without namespace (`slowness`, `poison`...). + pub effects: Vec<&'static str>, /// The player's UUID (villagers' gossip is keyed by it). pub uuid: [i32; 4], /// The mobs on the player's team (`isAlliedTo`): combat targeting @@ -1468,6 +1484,12 @@ pub struct PlayerView { /// (`EntitySelector.pushableBy` on the client). pub remote: bool, } +impl PlayerView { + /// `LivingEntity.hasEffect`, by id without namespace. + pub fn has_effect(&self, effect: &str) -> bool { + self.effects.contains(&effect) + } +} #[derive(Clone)] pub struct MobWorld<'a> { pub world: &'a World, @@ -1839,7 +1861,8 @@ impl Mob { /// Movement speed attribute bonus (baby zombies, sprinting modifiers). /// MOVEMENT_SPEED including effects (speed +20%/level, slowness -15%/level). pub fn movement_speed(&self) -> f64 { - let mut v = strider::movement_speed(self, self.attrs.speed) * self.speed_bonus(); + let mut v = strider::movement_speed(self, witch::speed(self, self.attrs.speed)) + * self.speed_bonus(); for (e, _, amp) in &self.extra.effects { match *e { "minecraft:speed" => v *= 1.0 + 0.2 * f64::from(amp + 1), diff --git a/crates/horizon-client/src/mobs/witch.rs b/crates/horizon-client/src/mobs/witch.rs index 0990ae01d..b0ec7866a 100644 --- a/crates/horizon-client/src/mobs/witch.rs +++ b/crates/horizon-client/src/mobs/witch.rs @@ -193,6 +193,11 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { } Who::Player => false, }; + // `target.hasEffect`: a target that already has the effect gets the next one. + let has = |effect: &str| match who { + Who::Player => cx.w.player.as_ref().is_some_and(|p| p.has_effect(effect)), + Who::Mob(id) => cx.w.seen(id).is_some_and(|s| s.has_effect(effect)), + }; let (effect, ticks, amplifier) = if heals { m.ai.target = None; if health <= 4.0 { @@ -200,11 +205,11 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { } else { ("minecraft:regeneration", 900, 0) } - } else if dist >= 8.0 { + } else if dist >= 8.0 && !has("slowness") { ("minecraft:slowness", 1800, 0) - } else if health >= 8.0 { + } else if health >= 8.0 && !has("poison") { ("minecraft:poison", 900, 0) - } else if dist <= 3.0 && m.random.next_float() < 0.25 { + } else if dist <= 3.0 && !has("weakness") && m.random.next_float() < 0.25 { ("minecraft:weakness", 1800, 0) } else { ("minecraft:instant_damage", 1, 0) @@ -236,6 +241,15 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { pitch, )); } +/// The movement speed of a witch with `SPEED_MODIFIER_DRINKING` (-0.25, +/// `ADD_VALUE`) while it drinks: it stands still for the 32 ticks. +pub fn speed(m: &Mob, base: f64) -> f64 { + if m.species() == Species::Witch && m.extra.flag { + (base - 0.25).max(0.0) + } else { + base + } +} /// Witch.aiStep before `super.aiStep()`: the healing cooldown, then drink /// water breathing, fire resistance, healing or swiftness. pub fn ai_step(m: &mut Mob, cx_events: &mut Vec, target_far: bool) { From 13e946a89c329411dab3ba3f1256cc34a384a14d Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Mon, 26 Oct 2026 16:20:17 +0300 Subject: [PATCH 044/475] HUD: the hotbar draws its stacks without cloning them `draw_item` and `draw_item_decorations` only read the game, so they take `&self`; the hotbar loop can then hand them `self.inventory[slot].as_ref()` and `self.offhand.as_ref()` instead of cloning the stack once per slot per frame. Co-Authored-By: Hermes Agent (Nous Research) --- crates/horizon-client/src/game.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index d148f8bd7..d34755b07 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4833,7 +4833,7 @@ impl Game<'_> { self.capture(true); } } - fn draw_item(&mut self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) { + fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) { // `GuiGraphics.renderItem`: the resolved item model (tints, foil and // special renderers included), then the decorations. self.draw_item_model(batch, stack, x, y); @@ -4923,7 +4923,7 @@ impl Game<'_> { /// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`) /// and damage bar. fn draw_item_decorations( - &mut self, + &self, batch: &mut HudBatch, stack: &ItemStack, x: f32, @@ -5050,12 +5050,12 @@ impl Game<'_> { for d in &draws { if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d { let stack = if slot == 40 { - self.offhand.clone() + self.offhand.as_ref() } else { - self.inventory[slot as usize].clone() + self.inventory[slot as usize].as_ref() }; if let Some(stack) = stack { - self.draw_item(&mut batch, &stack, x as f32, y as f32); + self.draw_item(&mut batch, stack, x as f32, y as f32); } } } From 3353f1b6e4cbe9e8e4ff69957252286c87e8a89a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:24:16 +0100 Subject: [PATCH 045/475] Elytra: hurt on wall hits, stop on climbables and levitation, Unbreaking wear, real gravity Gliding into a wall now hurts like LivingEntity.handleFallFlyingCollisions (fly_into_wall, big or small fall sound). A glide no longer starts on a climbable or under Levitation, ends there, and a climbable (not vines) ends a glide in progress. The glide's elytra wear goes through Unbreaking, and the glide uses the effective gravity instead of 0.08. --- crates/horizon-client/src/game.rs | 16 ++++--- crates/horizon-client/src/motion.rs | 70 ++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..37ea98ba5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4326,12 +4326,6 @@ impl Game<'_> { let events = std::mem::take(&mut player.events); let spin_hit = player.spin_hit.take(); let glider_wear = std::mem::take(&mut player.glider_wear); - if glider_wear > 0 - && !self.creative - && let Some(elytra) = self.armor[1].as_mut() - { - elytra.damage += glider_wear; - } let feet = [player.position.x, player.position.y, player.position.z]; let player_velocity = [player.velocity.x, player.velocity.y, player.velocity.z]; self.previous_eye = self.eye; @@ -4342,6 +4336,16 @@ impl Game<'_> { }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; + // `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear. + if glider_wear > 0 + && !self.creative + && let Some(stack) = self.armor[1].clone() + { + let loss = self.durability_loss(&stack, glider_wear); + if let Some(elytra) = self.armor[1].as_mut() { + elytra.damage += loss; + } + } self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 0bce8927c..29e9d3e06 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -50,6 +50,8 @@ pub struct MotionResources { pub attributes: Arc, pub blocks: BoundMovementProperties, pub climbable: BTreeSet, + /// `BlockTags.CAN_GLIDE_THROUGH`: climbable blocks a gliding player passes. + pub glide_through: BTreeSet, pub fall_reset: BTreeSet, pub landing: BlockLandingRegistry, pub health: crate::health::HealthResources, @@ -108,6 +110,7 @@ impl MotionResources { suppresses_bounce: tag("minecraft:suppresses_bounce")?, })?, climbable: tag("minecraft:climbable")?, + glide_through: tag("minecraft:can_glide_through")?, fall_reset: tag("minecraft:fall_damage_resetting")?, }) } @@ -765,6 +768,10 @@ impl MotionWorld<'_, '_, '_> { .state(self.block(p)) .ok_or("missing scene state")? .name; + // A gliding player passes through vines (`CAN_GLIDE_THROUGH`). + if self.player.fall_flying && self.resources.glide_through.contains(name) { + return Ok(false); + } let climbable = self.resources.climbable.contains(name); if climbable { self.player.last_climbable = Some(p); @@ -834,9 +841,17 @@ impl MotionWorld<'_, '_, '_> { let can_glide = self.player.glider && !self.player.ground.on_ground && !self.player.flying + && self.player.status.levitation.is_none() && !self.player.fluids.was_touching_water && !self.player.is_in_lava(); - if jumping && !toggled && !was_jumping && !self.player.fall_flying && can_glide { + // LocalPlayer.aiStep: `!onClimbable()` before tryToStartFallFlying. + if jumping + && !toggled + && !was_jumping + && !self.climbable()? + && !self.player.fall_flying + && can_glide + { self.player.fall_flying = true; } // LivingEntity.tick fallFlyTicks, then updateFallFlying. @@ -848,8 +863,11 @@ impl MotionWorld<'_, '_, '_> { self.player.fall_distance = 1.0; } self.player.fall_fly_ticks += 1; - // canGlide alone: water and lava don't end a glide. - let still = self.player.glider && !self.player.ground.on_ground && !self.player.flying; + // canGlide alone: water and lava don't end a glide, levitation does. + let still = self.player.glider + && !self.player.ground.on_ground + && !self.player.flying + && self.player.status.levitation.is_none(); if !still { self.player.glide_ending = true; } else if (self.player.fall_fly_ticks + 1) % 10 == 0 @@ -1021,7 +1039,14 @@ impl MotionWorld<'_, '_, '_> { { horizon_world::living_fluid::travel_in_fluid(self, input)?; } else if self.player.fall_flying { - self.travel_fall_flying()?; + // LivingEntity.travelFallFlying: on a climbable the glide ends and + // the player moves like in the air. + if self.climbable()? { + self.travel_in_air(input)?; + self.player.fall_flying = false; + } else { + self.travel_fall_flying()?; + } } else { self.travel_in_air(input)?; } @@ -1528,7 +1553,7 @@ impl MotionWorld<'_, '_, '_> { let lean = pitch; let look_h = (look.x * look.x + look.z * look.z).sqrt(); let move_h = (v.x * v.x + v.z * v.z).sqrt(); - let gravity = 0.08; + let gravity = living_motion::effective_gravity(self)?; let lift = f64::from(lean.cos()).powi(2); let mut m = Vec3::new(v.x, v.y + gravity * (-1.0 + lift * 0.75), v.z); if m.y < 0.0 && look_h > 0.0 { @@ -1556,7 +1581,40 @@ impl MotionWorld<'_, '_, '_> { } self.player.velocity = Vec3::new(m.x * 0.99, m.y * 0.98, m.z * 0.99); let movement = self.player.velocity; - self.move_self(movement) + self.move_self(movement)?; + // LivingEntity.handleFallFlyingCollisions: only the server hurts. + if !self.player.client_side && self.player.collision.horizontal { + let new_h = { + let v = self.player.velocity; + (v.x * v.x + v.z * v.z).sqrt() + }; + let dmg = ((move_h - new_h) * 10.0 - 3.0) as f32; + if dmg > 0.0 { + // getFallDamageSound((int) dmg) + let sound = if dmg as i32 > 4 { + crate::landing::SceneSound::BigFall + } else { + crate::landing::SceneSound::SmallFall + }; + self.player + .events + .push(MotionEvent::Fall(crate::landing::FallEvent::Sound { + sound, + position: None, + volume: 1.0, + pitch: 1.0, + })); + let id = self + .resources + .health + .damage + .id("minecraft:fly_into_wall") + .ok_or("missing fly_into_wall damage type")?; + let rules = self.scene.health_rules; + self.player.hurt(&self.resources.health, rules, id, dmg)?; + } + } + Ok(()) } /// `LocalPlayer.move`: the move, then `updateAutoJump` with the float /// horizontal displacement. From af6b375626c3eadacb66981a4ea1f346e506112d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:25:55 +0100 Subject: [PATCH 046/475] Guardians: damage scales with Hard and elders, squid and axolotls must be beyond 3 blocks --- crates/horizon-client/src/mobs/goals.rs | 11 +++++++++++ crates/horizon-client/src/mobs/guardian.rs | 9 ++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..d873a2820 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1210,6 +1210,9 @@ pub struct NearestTarget { pub interval: i32, /// Extra condition on the attacker, checked after the random interval. pub when: Option bool>, + /// Mobs closer than this squared distance are not picked + /// (`GuardianAttackSelector`: `distanceToSqr > 9`). + pub beyond_sqr: Option, /// A condition checked before anything else, by subclasses overriding /// `canUse` (SpiderTargetGoal: only in the dark). pub gate: Option bool>, @@ -1242,6 +1245,7 @@ impl NearestTarget { must_reach: false, interval: reduced(10), when: None, + beyond_sqr: None, gate: None, label: None, found: None, @@ -1274,6 +1278,10 @@ impl NearestTarget { self.when = Some(f); self } + pub fn beyond(mut self, distance_sqr: f64) -> Self { + self.beyond_sqr = Some(distance_sqr); + self + } pub fn gate(mut self, f: fn(&Mob, &MobWorld) -> bool) -> Self { self.gate = Some(f); self @@ -1361,6 +1369,9 @@ impl Goal for NearestTarget { if m.distance_sqr(cpos) > range * range { continue; } + if self.beyond_sqr.is_some_and(|d| m.distance_sqr(cpos) <= d) { + continue; + } if !m.sees(cx.w, Who::Mob(cid)) { continue; } diff --git a/crates/horizon-client/src/mobs/guardian.rs b/crates/horizon-client/src/mobs/guardian.rs index d6219794d..4cbce22d9 100644 --- a/crates/horizon-client/src/mobs/guardian.rs +++ b/crates/horizon-client/src/mobs/guardian.rs @@ -61,7 +61,8 @@ pub fn register(m: &mut Mob) { NearestTarget::species( &[Species::Squid, Species::GlowSquid, Species::Axolotl], true, - ), + ) + .beyond(9.0), ); } /// `GuardianAttackGoal`. @@ -120,9 +121,11 @@ impl Goal for GuardianAttack { 1.0, )); } else if self.attack_time >= attack_duration(m) { - // Indirect magic (1, +2 for the elder; NORMAL difficulty), then + // Indirect magic (1, +2 on Hard, +2 for the elder), then // doHurtTarget. - let magic = if is_elder(m) { 3.0 } else { 1.0 }; + let magic = 1.0 + + if cx.w.difficulty.hard { 2.0 } else { 0.0 } + + if is_elder(m) { 2.0 } else { 0.0 }; let from = [m.position.x, m.position.y, m.position.z]; match who { Who::Player => cx.events.push(MobEvent::DamagePlayer { From 05fd9c3795ab85b63cc1ab0a700e7d63cbf09f4e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:26:59 +0100 Subject: [PATCH 047/475] Chests with a block or a sitting cat on top no longer open --- crates/horizon-client/src/game.rs | 4 ++ crates/horizon-client/src/game/storage.rs | 49 +++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..b21a3ccec 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3005,6 +3005,10 @@ impl Game<'_> { .name .clone(); let key = (pos[0], pos[1], pos[2]); + if self.chest_menu_blocked(key, &name) { + // The use still succeeds, but no menu, stat or piglin anger. + return; + } if &*name == "minecraft:lectern" { self.open_lectern(pos); return; diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 1bcb6a524..e5cb0ab26 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -33,6 +33,55 @@ impl Game<'_> { self.container = Some(key); self.ender_open = Some(key); } + /// The block above `key` is a redstone conductor. + fn conductor_above(&self, key: (i32, i32, i32)) -> bool { + let above = self.world.block(key.0, key.1 + 1, key.2); + self.res.behaviours().facts(above).flags + & horizon_world::level::data::state_flags::CONDUCTOR + != 0 + } + /// `ChestBlock.isChestBlockedAt`: a redstone conductor above the chest or + /// a cat sitting on it. + fn chest_blocked_at(&self, key: (i32, i32, i32)) -> bool { + if self.conductor_above(key) { + return true; + } + // isCatSittingOnChest: a cat whose box meets the block above. + let area = horizon_core::geometry::Aabb::new( + horizon_core::geometry::Vec3::new( + f64::from(key.0), + f64::from(key.1 + 1), + f64::from(key.2), + ), + horizon_core::geometry::Vec3::new( + f64::from(key.0 + 1), + f64::from(key.1 + 2), + f64::from(key.2 + 1), + ), + ); + self.mobs.iter().any(|m| { + m.species() == horizon_client::mobs::Species::Cat + && m.alive() + && horizon_client::mobs::tame::cat_sitting(m) + && m.bounds().intersects(area) + }) + } + /// `ChestBlock.getMenuProvider` (`combine(.., false)`) and + /// `EnderChestBlock.useWithoutItem`: a blocked chest, or a double chest + /// with either half blocked, opens no menu. + pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool { + match name { + "minecraft:chest" | "minecraft:trapped_chest" => { + self.chest_blocked_at(key) + || self + .chest_partner(key) + .is_some_and(|(other, _)| self.chest_blocked_at(other)) + } + // The ender chest only looks at the block above (no cat check). + "minecraft:ender_chest" => self.conductor_above(key), + _ => false, + } + } /// Closing it copies the items back and leaves no block-entity contents. pub(super) fn close_ender_chest(&mut self) { let Some(key) = self.ender_open.take() else { From 0ba73d0a7e122b059ff862447722beb2460b07df Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:27:05 +0100 Subject: [PATCH 048/475] Match vanilla evoker, fangs and vex rules Count vexes and look for blue sheep within a 16 block radius, spare only illagers and their vexes with evoker fangs, and let vexes ignore raiders (including ravagers) instead of other vexes. --- crates/horizon-client/src/game/fangs.rs | 30 ++++++++++++++++------- crates/horizon-client/src/mobs/illager.rs | 10 +++++--- crates/horizon-client/src/mobs/vex.rs | 3 ++- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/game/fangs.rs b/crates/horizon-client/src/game/fangs.rs index cdf2d17a5..aed692649 100644 --- a/crates/horizon-client/src/game/fangs.rs +++ b/crates/horizon-client/src/game/fangs.rs @@ -81,16 +81,28 @@ impl Game<'_> { }; bite(self); self.each_guest_hit(bite); + // `AbstractIllager.considersEntityAsAlly`: with no teams, the illager + // tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose + // owner is allied (itself or another illager). Teams are not modelled. + let illager = |s: Species| { + matches!( + s, + Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator + ) + }; + let illagers: Vec = self + .mobs + .iter() + .filter(|m| illager(m.species())) + .map(|m| m.id) + .collect(); + // A missing owner hurts everyone (`currentOwner == null`). + let owner_known = illagers.contains(&f.owner); for m in &mut self.mobs { - let ally = matches!( - m.species(), - Species::Evoker - | Species::Illusioner - | Species::Vex - | Species::Pillager - | Species::Vindicator - | Species::Witch - ); + let ally = owner_known + && (illager(m.species()) + || m.species() == Species::Vex + && illagers.contains(&(m.extra.counter2 as u32))); if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) { m.damage( 6.0, diff --git a/crates/horizon-client/src/mobs/illager.rs b/crates/horizon-client/src/mobs/illager.rs index 45ef076d3..a037e7c5f 100644 --- a/crates/horizon-client/src/mobs/illager.rs +++ b/crates/horizon-client/src/mobs/illager.rs @@ -179,8 +179,8 @@ impl Goal for UseSpell { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { let age = i64::from(m.age); if self.spell == Spell::Wololo { - // EvokerWololoSpellGoal.canUse: a blue sheep within 16 (4 up or - // down), no target, griefing on. + // EvokerWololoSpellGoal.canUse: a blue sheep within the box + // (16 sideways, 4 up or down) and `range(16)`, no target, griefing on. if m.ai.target.is_some() || m.extra.counter > 0 || age < self.next || !cx.w.mob_griefing { return false; @@ -196,6 +196,7 @@ impl Goal for UseSpell { && (s.position.x - p.x).abs() <= 16.0 && (s.position.y - p.y).abs() <= 4.0 && (s.position.z - p.z).abs() <= 16.0 + && s.position.distance_squared(p) <= 16.0 * 16.0 }) .map(|s| s.id) .collect(); @@ -210,7 +211,8 @@ impl Goal for UseSpell { return false; } if self.spell == Spell::Vexes { - // EvokerSummonSpellGoal: fewer vexes nearby than a roll of 1..8. + // EvokerSummonSpellGoal: fewer vexes within `range(16)` than a + // roll of 1..8. let p = m.position; let vexes = cx.w.others @@ -218,7 +220,7 @@ impl Goal for UseSpell { .filter(|s| { s.species == Species::Vex && s.alive - && s.position.distance_squared(p) <= 16.0 * 16.0 * 3.0 + && s.position.distance_squared(p) <= 16.0 * 16.0 }) .count() as i32; return m.random.next_int_bound(8).unwrap_or(0) + 1 > vexes; diff --git a/crates/horizon-client/src/mobs/vex.rs b/crates/horizon-client/src/mobs/vex.rs index de38f6da8..1301d1922 100644 --- a/crates/horizon-client/src/mobs/vex.rs +++ b/crates/horizon-client/src/mobs/vex.rs @@ -29,6 +29,7 @@ pub fn register(m: &mut Mob) { g.add(8, RandomMove); g.add(9, LookAtPlayer::new(3.0).probability(1.0)); g.add(10, LookAtPlayer::mobs(8.0)); + // `HurtByTargetGoal(this, Raider.class)`: raiders are ignored, vexes are not. let mut hurt = HurtByTarget::new().alert(); hurt.ignore = vec![ Species::Pillager, @@ -36,7 +37,7 @@ pub fn register(m: &mut Mob) { Species::Evoker, Species::Illusioner, Species::Witch, - Species::Vex, + Species::Ravager, ]; m.ai.targets.add(1, hurt); m.ai.targets.add(2, CopyOwnerTarget); From 991406d0e0b8bfd8ac87e54372e1fd82b55b0e44 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:27:10 +0100 Subject: [PATCH 049/475] Turtle helmet: Water Breathing while the eyes are out of the water --- crates/horizon-client/src/game/music.rs | 2 +- crates/horizon-client/src/game/potions.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs index 6f8b5e7ae..fa5e71f7b 100644 --- a/crates/horizon-client/src/game/music.rs +++ b/crates/horizon-client/src/game/music.rs @@ -91,7 +91,7 @@ impl Game<'_> { }) } /// `isEyeInFluid(WATER)`: the eye inside the water of its block column. - fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool { + pub(super) fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool { let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32); let f = self.res.facts().get(self.world.block(bx, by, bz)); if f.fluid != Fluid::Water { diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b28c2fb4d..e0fc963c1 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -390,8 +390,28 @@ impl Game<'_> { for action in actions { self.apply_effect_action(action); } + self.turtle_helmet_tick(); self.apply_effect_status(); } + /// `Player.tick` -> `turtleHelmetTick`: with a turtle helmet on the head + /// and the eyes out of the water, Water Breathing for 200 ticks (no + /// particles, an icon). + fn turtle_helmet_tick(&mut self) { + let Some(p) = self.player.as_ref() else { + return; + }; + let eye_y = p.position.y + f64::from(p.dimensions.eye_height); + if !p.health.dead_or_dying() + && self.armor[0] + .as_ref() + .is_some_and(|s| &*s.item == "minecraft:turtle_helmet") + && !self.eye_in_water(p.position.x, eye_y, p.position.z) + { + let mut effect = EffectInstance::new("water_breathing", 200, 0); + effect.visible = false; + self.potions.effects.add(effect); + } + } /// A client's own effects (`LivingEntity.tickEffects` on the client, /// `MobEffectInstance.tickClient`): they count down, and the server /// removes them. Their modifiers and flags move the predicted player. From 4aa7838d1ba0434d8f5de4b0b0267b2698dd08e4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:27:48 +0100 Subject: [PATCH 050/475] Shulker bullets make open shulkers teleport and duplicate An open shulker hit by a shulker bullet teleports and may leave a new shulker of its colour behind, like Shulker.hitByShulkerBullet. Levitation from the bullet now needs the hit to have hurt the player. --- crates/horizon-client/src/game/creatures.rs | 10 +++-- crates/horizon-client/src/mobs/shulker.rs | 41 ++++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..18b5cd9fd 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2364,12 +2364,14 @@ impl Game<'_> { // ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s. (ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who { Who::Player => { - if !self.creative { - self.mob_hits_player( + // Only a hit that hurt (`wasHurt`) brings the levitation. + if !self.creative + && self.mob_hits_player( 4.0, [p.previous.x, p.previous.y, p.previous.z], "minecraft:mob_projectile", - ); + ) + { self.give_player_effect("minecraft:levitation", 200, 0); } } @@ -2378,6 +2380,8 @@ impl Game<'_> { && m.damage(4.0, source) { m.extra.levitation = 200; + // Shulker.hurtServer: a shulker hit by a bullet. + horizon_client::mobs::shulker::hit_by_bullet(m); } } }, diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index 11ebdadb4..ccb33fd24 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -3,7 +3,7 @@ //! their face is lost or when badly hurt, and shrugging off arrows while //! closed. use super::{ - Damage, DamageKind, Mob, MobEvent, MobWorld, Who, + Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, goals::{ Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, NearestTarget, RandomLookAround, @@ -164,12 +164,49 @@ pub fn after_hurt(m: &mut Mob) { m.extra.flag = true; } } -/// The deferred teleport of `after_hurt`. +/// `Shulker.hurtServer` after a shulker bullet's damage: unless the +/// low-health teleport was drawn instead (`else if`), `hitByShulkerBullet` +/// runs on the next tick. +pub fn hit_by_bullet(m: &mut Mob) { + if m.species() == Species::Shulker && !m.extra.flag { + m.extra.flag2 = true; + } +} +/// The deferred teleport of `after_hurt` and `hitByShulkerBullet`. pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { if m.extra.flag { m.extra.flag = false; teleport(m, w, events); } + if m.extra.flag2 { + m.extra.flag2 = false; + hit_by_shulker_bullet(m, w, events); + } +} +/// `Shulker.hitByShulkerBullet`: an open shulker teleports, and leaves a +/// new one of its colour behind unless shulkers crowd the place (one in +/// five fewer chances for every other shulker within 8 blocks). +fn hit_by_shulker_bullet(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + let old_position = m.position; + let old_box = m.bounds(); + if m.extra.counter == 0 || !teleport(m, w, events) { + return; + } + let area = old_box.inflate(Vec3::new(8.0, 8.0, 8.0)); + // The shulker itself stands within reach of its old box, so it counts. + let others = w + .others_near(area) + .filter(|s| { + s.alive && s.species == Species::Shulker && s.id != m.id && s.bounds.intersects(area) + }) + .count(); + let failure_chance = others as f32 / 5.0; + if m.random.next_float() < failure_chance { + return; + } + let mut baby = Mob::new(0, m.kind.clone(), old_position, 0.0); + baby.extra.color = m.extra.color; + events.push(MobEvent::Spawn(Box::new(baby))); } /// A ProjectileWorld over the mob world, for aiming a new bullet. struct Probe<'a, 'w> { From 54fe73854e38f8bd16cee43f840ddd9d34ce40f1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:28:06 +0100 Subject: [PATCH 051/475] Cactus destroys dropped items that touch it --- crates/horizon-client/src/entities.rs | 9 ++++++ crates/horizon-client/src/items.rs | 30 +++++++++++++++++++ .../horizon-client/src/motion/environment.rs | 23 ++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 6e8089e9d..a45b1af59 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -95,6 +95,15 @@ impl ItemEntity { self.health -= fire_damage as i32; } } + // CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no + // hurt cooldown, so it loses 1 health every tick it touches a cactus + // (5 health: gone in 5 ticks). Fire resistance does not help; + // `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack. + if crate::motion::environment::cactus_contact(world, registry, self.bounds()) + && !items.resists_cactus(&self.stack) + { + self.health -= 1; + } ItemEnvironmentTick { alive: self.health > 0, lava_hurt, diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index 8ac450bcb..c425e2026 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -392,6 +392,36 @@ impl Items { self.get(&stack.item).is_some_and(|i| i.fire_resistant) } + /// `ItemStack.canBeHurtBy` for the cactus damage type: a `damage_resistant` + /// component naming `minecraft:cactus`, or a damage type tag that holds it. + /// (`#minecraft:is_fire`, as on netherite gear, does not.) + pub fn resists_cactus(&self, stack: &ItemStack) -> bool { + const CACTUS_TAGS: [&str; 5] = [ + "#minecraft:bypasses_shield", + "#minecraft:no_knockback", + "#minecraft:panic_environmental_causes", + "#minecraft:panic_causes", + "#minecraft:sulfur_cube_with_block_immune_to", + ]; + let Some((_, v)) = stack.data().and_then(|data| { + data.other + .iter() + .find(|(key, _)| key == "minecraft:damage_resistant") + }) else { + return false; + }; + match v.get("types") { + Some(horizon_world::nbt::Tag::String(id)) => id + .to_string() + .is_ok_and(|id| id == "minecraft:cactus" || CACTUS_TAGS.contains(&id.as_str())), + Some(horizon_world::nbt::Tag::List(ids)) => ids.iter().any(|t| { + matches!(t, horizon_world::nbt::Tag::String(id) + if id.to_string().is_ok_and(|id| id == "minecraft:cactus")) + }), + _ => false, + } + } + pub fn load(root: &Path, registry: &BlockRegistry, facts: &BlockFacts) -> Result { let mut block_for_item: HashMap<&str, BlockStateId> = HashMap::new(); for (i, s) in facts.states.iter().enumerate() { diff --git a/crates/horizon-client/src/motion/environment.rs b/crates/horizon-client/src/motion/environment.rs index fc3c63c77..5e9bdfec8 100644 --- a/crates/horizon-client/src/motion/environment.rs +++ b/crates/horizon-client/src/motion/environment.rs @@ -161,6 +161,29 @@ pub(crate) fn fire_contact( } damage } +/// `CactusBlock.entityInside`: the box (deflated by `1.0E-5`, as in +/// `Entity.checkInsideBlocks`) touches a cactus; its inside shape is the +/// whole block. +pub(crate) fn cactus_contact( + world: &World, + registry: &horizon_world::registry::BlockRegistry, + bounds: Aabb, +) -> bool { + let b = bounds.deflate(Vec3::new(1e-5, 1e-5, 1e-5)); + for x in b.min_position().x.floor() as i32..=b.max_position().x.floor() as i32 { + for y in b.min_position().y.floor() as i32..=b.max_position().y.floor() as i32 { + for z in b.min_position().z.floor() as i32..=b.max_position().z.floor() as i32 { + if registry + .state(world.block(x, y, z)) + .is_some_and(|s| s.name == "minecraft:cactus") + { + return true; + } + } + } + } + false +} impl LivingFluidWorld for MotionWorld<'_, '_, '_> { fn in_water(&mut self) -> Result { Ok(self.player.fluids.was_touching_water) From bcdf769f8d0790007201277219708f0e8bacefd3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:28:20 +0100 Subject: [PATCH 052/475] Illagers do not attack baby villagers AbstractIllager.canAttack refuses a baby villager or wandering trader. Skip them when an illager picks a nearest target and drop them from an illager's current target. --- crates/horizon-client/src/mobs/goals.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..931569b7d 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1284,9 +1284,25 @@ impl NearestTarget { self } } +/// `AbstractIllager.canAttack`: a baby `AbstractVillager` is never attacked. +fn illager_spares(attacker: Species, target: &Seen) -> bool { + matches!( + attacker, + Species::Pillager | Species::Vindicator | Species::Evoker | Species::Illusioner + ) && target.baby + && matches!(target.species, Species::Villager | Species::WanderingTrader) +} /// TargetGoal.canContinueToUse pub fn target_continue(st: &mut TargetState, must_see: bool, m: &mut Mob, cx: &mut Cx) -> bool { let Some(who) = m.ai.target else { return false }; + if let Who::Mob(id) = who + && cx + .w + .seen(id) + .is_some_and(|s| illager_spares(m.species(), s)) + { + return false; + } let Some(t) = cx.w.target_info(who) else { return false; }; @@ -1345,10 +1361,12 @@ impl Goal for NearestTarget { prey => { let pos = Vec3::new(m.position.x, m.eye_y(), m.position.z); let id = m.id; + let attacker = m.species(); let candidates: Vec<(u32, Vec3)> = cx.w.nearby(pos, range, move |s| { s.id != id && !s.playing_dead + && !illager_spares(attacker, s) && match prey { Prey::Species(list) => list.contains(&s.species), Prey::Filter(f) => f(s), From 6875c44bbe4974ec6d807808536001301bc0043f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:28:40 +0100 Subject: [PATCH 053/475] Piglins accept only gold ingots by hand, keep other loved items, pick up more Right-clicking a piglin with any loved item used to consume it and the piglin threw it back. Now only piglin currency (gold ingots) is accepted from the hand, and anything else a piglin admires is worn if it can be or stored in its inventory, which saves, and drops when it dies. Piglins also pick up gold nuggets when they have room, food when they have not eaten lately, and gear that fits an empty slot. --- crates/horizon-client/src/entity_nbt.rs | 23 +++ crates/horizon-client/src/game/piglins.rs | 221 ++++++++++++++++++---- crates/horizon-client/src/mobs.rs | 2 + crates/horizon-client/src/mobs/drops.rs | 8 + crates/horizon-client/src/mobs/piglin.rs | 6 + 5 files changed, 227 insertions(+), 33 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..ab6e2a328 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -265,6 +265,19 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { Tag::Byte(i8::from(m.extra.flag2)), ); map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2)); + if id == "minecraft:piglin" { + // InventoryCarrier.writeInventoryToTag. + map.insert( + "Inventory".into(), + Tag::List( + m.extra + .inventory + .iter() + .map(crate::save::item_to_nbt) + .collect(), + ), + ); + } } "minecraft:horse" | "minecraft:donkey" @@ -706,6 +719,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - ) { m.extra.flag2 = flag(tag, "IsImmuneToZombification"); m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32; + if m.species() == crate::mobs::Species::Piglin + && let Some(Tag::List(items)) = tag.get("Inventory") + { + // InventoryCarrier.readInventoryFromTag (eight slots). + m.extra.inventory = items + .iter() + .filter_map(crate::save::item_from_nbt) + .take(8) + .collect(); + } } if matches!( m.species(), diff --git a/crates/horizon-client/src/game/piglins.rs b/crates/horizon-client/src/game/piglins.rs index bcf9e6ab5..337efe8a6 100644 --- a/crates/horizon-client/src/game/piglins.rs +++ b/crates/horizon-client/src/game/piglins.rs @@ -4,18 +4,168 @@ use super::Game; use horizon_client::{ items::ItemStack, - mobs::{Species, Who, piglin::ADMIRE}, + mobs::{ + Mob, Species, Who, + finalize::{SLOT_NAMES, set_drop_chance, set_item, stored_drop_chance}, + piglin::{ADMIRE, ATE_RECENTLY}, + }, }; use horizon_core::{geometry::Vec3, random::RandomSource}; impl Game<'_> { - fn piglin_loved(&self, item: &str) -> bool { + fn item_in_tag(&self, tag: &str, item: &str) -> bool { self.res .items() .item_tags - .get("minecraft:piglin_loved") + .get(tag) .is_some_and(|t| t.contains(item)) } + fn piglin_loved(&self, item: &str) -> bool { + self.item_in_tag("minecraft:piglin_loved", item) + } + /// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`). + fn piglin_wanted(&self, item: &str) -> bool { + self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item) + } + /// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes + /// to its slot, anything else to the main hand. + fn piglin_slot(&self, item: &str) -> usize { + let slot = match self + .res + .items() + .get(item) + .and_then(|i| i.equip_slot.as_deref()) + { + Some(s @ ("head" | "chest" | "legs" | "feet")) => s, + _ => "mainhand", + }; + SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0) + } + /// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted + /// item replaces an unwanted one and never the other way round. Vanilla's + /// armour and weapon comparison for two equal-interest items is left out + /// (the piglin keeps what it has). + fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool { + let Some(current) = horizon_client::mobs::finalize::item(m, slot) else { + return true; + }; + self.piglin_wanted(item) && !self.piglin_wanted(¤t) + } + /// `SimpleContainer.canAddItem` on the piglin's eight slots. + fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool { + m.extra.inventory.len() < 8 + || m.extra.inventory.iter().any(|s| { + s.same_item_same_components(stack) && s.count < self.piglin_max_stack(&s.item) + }) + } + fn piglin_max_stack(&self, item: &str) -> u32 { + self.res.items().get(item).map_or(64, |i| i.max_stack) + } + /// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without + /// the baby rule, which babies skip entirely here. + fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool { + let item = &*stack.item; + if self.item_in_tag("minecraft:piglin_repellents", item) { + return false; + } + let holds_loved = m + .extra + .off_hand + .as_deref() + .is_some_and(|o| self.piglin_loved(o)); + if item == "minecraft:gold_ingot" { + return !holds_loved; + } + let space = self.piglin_can_add(m, stack); + if item == "minecraft:gold_nugget" { + space + } else if self.item_in_tag("minecraft:piglin_food", item) { + m.extra.ints[0] <= 0 && space + } else if !self.piglin_loved(item) { + self.piglin_can_replace(m, item, self.piglin_slot(item)) + } else { + !holds_loved && space + } + } + /// `Mob.equipItemIfPossible` for a piglin: put it in its slot when that + /// slot takes it (an armour piece that cannot replace what is worn goes + /// to an empty main hand), dropping what it replaces by drop chance. + fn piglin_equip(&mut self, index: usize, item: &str) -> bool { + let mut slot = self.piglin_slot(item); + let m = &self.mobs[index]; + let mut replace = self.piglin_can_replace(m, item, slot); + if slot > 1 && !replace { + slot = 0; + replace = m.extra.main_hand.is_none(); + } + if !replace { + return false; + } + let m = &mut self.mobs[index]; + let old = horizon_client::mobs::finalize::item(m, slot); + let chance = stored_drop_chance(m, slot); + let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance; + set_item(m, slot, item); + // setItemSlotAndDropWhenKilled, then persistenceRequired. + set_drop_chance(m, slot, 2.0); + m.persistent = true; + let at = [m.position.x, m.position.y, m.position.z]; + if let (true, Some(old)) = (drop_old, old) { + self.spawn_item( + ItemStack::new(&old, 1), + at, + super::dispenser::new_item_motion(), + 10, + ); + } + true + } + /// `PiglinAi.putInInventory`: what does not fit is thrown at a random + /// spot nearby. + fn piglin_stash(&mut self, index: usize, mut stack: ItemStack) { + let max = self.piglin_max_stack(&stack.item); + let m = &mut self.mobs[index]; + for s in &mut m.extra.inventory { + if stack.count > 0 && s.same_item_same_components(&stack) && s.count < max { + let n = (max - s.count).min(stack.count); + s.count += n; + stack.count -= n; + } + } + while stack.count > 0 && m.extra.inventory.len() < 8 { + let n = stack.count.min(max); + m.extra.inventory.push(ItemStack { + count: n, + ..stack.clone() + }); + stack.count -= n; + } + if stack.count == 0 { + return; + } + let from = [m.position.x, m.position.y + 1.3, m.position.z]; + let r = &mut self.world.random; + let to = [ + from[0] + f64::from(r.next_float() * 8.0 - 4.0), + from[1], + from[2] + f64::from(r.next_float() * 8.0 - 4.0), + ]; + self.piglin_throw(from, to, stack); + } + /// `PiglinAi.stopHoldingOffHandItem` for something that is not + /// bartering currency: wear it if it can, else keep it in the inventory. + fn piglin_keep(&mut self, index: usize, stack: ItemStack) { + if !self.piglin_equip(index, &stack.item) { + self.piglin_stash(index, stack); + } + } + /// `BehaviorUtils.throwItem`: toss a stack from a point toward another. + fn piglin_throw(&mut self, from: [f64; 3], to: [f64; 3], stack: ItemStack) { + let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); + let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3]; + self.spawn_item(stack, from, v, 40); + } /// `PiglinAi.isWearingSafeArmor`. pub(super) fn wearing_gold(&self) -> bool { let tag = self @@ -28,15 +178,13 @@ impl Game<'_> { .flatten() .any(|s| tag.is_some_and(|t| t.contains(&*s.item))) } - /// Piglins notice gold on the ground within 9 blocks, walk to it and - /// take it (`GoToWantedItem`, `Piglin.pickUpItem`). + /// Piglins notice wanted items on the ground within 9 blocks (`PiglinAi.wantsToPickup`: + /// gold and other loved items, nuggets, food, gear), walk to them and + /// take them (`GoToWantedItem`, `Piglin.pickUpItem`). pub(super) fn piglin_pickups(&mut self) { if !self.ui.rules.bool("mob_griefing") { return; } - // Whether each item is piglin-loved, kept until an item is added or - // removed (added ones are pushed at the end under a new id). - let mut loved: (Option<(usize, Option)>, Vec) = (None, Vec::new()); for i in 0..self.mobs.len() { let m = &self.mobs[i]; if m.species() != Species::Piglin @@ -44,27 +192,17 @@ impl Game<'_> { || m.is_baby() || m.extra.using_item > 0 || m.extra.egg_time > 0 - || m.extra.off_hand.is_some() { continue; } let pos = m.position; - let key = (self.dropped.len(), self.dropped.last().map(|e| e.id)); - if loved.0 != Some(key) { - loved.0 = Some(key); - loved.1 = self - .dropped - .iter() - .map(|e| self.piglin_loved(&e.stack.item)) - .collect(); - } let best = self .dropped .iter() .enumerate() - .filter(|&(j, _)| loved.1[j]) .map(|(j, e)| (j, e.position.distance_squared(pos))) .filter(|(_, d)| *d <= 81.0) + .filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack)) .min_by(|a, b| a.1.total_cmp(&b.1)); let Some((j, _)) = best else { self.mobs[i].extra.point = None; @@ -79,24 +217,38 @@ impl Game<'_> { continue; } // Nuggets come whole, anything else one at a time. - let item = e.stack.item.clone(); let (taken_item, id) = (e.clone(), self.mobs[i].id); + let item = e.stack.item.clone(); self.add_pickup( super::pickups::Taken::Item(Box::new(taken_item)), super::pickups::Collector::Mob(id), ); - let taken = if &*item == "minecraft:gold_nugget" { - self.dropped.remove(j).stack.count + let stack = if &*item == "minecraft:gold_nugget" { + self.dropped.remove(j).stack } else { let e = &mut self.dropped[j]; e.stack.count -= 1; + let one = ItemStack { + count: 1, + ..e.stack.clone() + }; if e.stack.count == 0 { self.dropped.remove(j); } - 1 + one }; - let _ = taken; - self.start_admiring(i, item); + if self.piglin_loved(&item) { + self.start_admiring(i, item); + } else if self.item_in_tag("minecraft:piglin_food", &item) + && self.mobs[i].extra.ints[0] <= 0 + { + // PiglinAi.eat: the food is gone and it will not eat for a while. + self.mobs[i].extra.ints[0] = ATE_RECENTLY; + self.mobs[i].nav_stop(); + } else { + self.mobs[i].nav_stop(); + self.piglin_keep(i, stack); + } } } fn start_admiring(&mut self, index: usize, item: std::sync::Arc) { @@ -109,14 +261,15 @@ impl Game<'_> { self.audio .play("minecraft:entity.piglin.admiring_item", Some(at), 1.0, 1.0); } - /// `PiglinAi.mobInteract`: hand a piglin something it loves. + /// `PiglinAi.mobInteract`: hand a piglin a gold ingot (only piglin + /// currency is accepted from the hand; other loved items are passed over). pub(super) fn give_piglin(&mut self, index: usize, item: &str) -> bool { let m = &self.mobs[index]; if m.species() != Species::Piglin || m.is_baby() || m.extra.using_item > 0 || m.extra.egg_time > 0 - || !self.piglin_loved(item) + || item != "minecraft:gold_ingot" { return false; } @@ -124,7 +277,8 @@ impl Game<'_> { true } /// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a - /// roll of the bartering table, thrown toward the player. + /// roll of the bartering table, thrown toward the player; any other loved + /// item it picked up is kept (worn if it can be, else stored). pub(super) fn finish_admiring(&mut self, mob: u32) { let Some(i) = self.mobs.iter().position(|m| m.id == mob) else { return; @@ -132,9 +286,13 @@ impl Game<'_> { let Some(item) = self.mobs[i].extra.off_hand.take() else { return; }; + if &*item != "minecraft:gold_ingot" { + self.piglin_keep(i, ItemStack::new(&item, 1)); + return; + } let m = &self.mobs[i]; let from = [m.position.x, m.position.y + 1.3, m.position.z]; - let items = if &*item == "minecraft:gold_ingot" && !m.is_baby() { + let items = if !m.is_baby() { self.res.items().loot_table( "minecraft:gameplay/piglin_bartering", false, @@ -176,10 +334,7 @@ impl Game<'_> { from[2] + f64::from(r.next_float() * 8.0 - 4.0), ] }); - let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; - let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); - let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3]; - self.spawn_item(stack, from, v, 40); + self.piglin_throw(from, to, stack); } } /// `PiglinAi.angerNearbyPiglins`. diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..f9b4f226f 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -665,6 +665,8 @@ pub struct Extra { pub chested: bool, /// The chest contents of a donkey or mule (15 slots). pub items: Vec>, + /// `Piglin.inventory` (`SimpleContainer(8)`): what it picked up and kept. + pub inventory: Vec, pub tame: bool, pub sitting: bool, /// The owner's UUID (tamed animals). diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index e5ccfd283..0dbae7db9 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -455,6 +455,14 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec) { m.ai.target = None; } } + if m.species() == Species::Piglin && m.extra.ints[0] > 0 { + m.extra.ints[0] -= 1; + } // Admiring disabled after being hit by a player. if m.extra.egg_time > 0 { m.extra.egg_time -= 1; From 551a9ee9c53382ed998b38cb1d390e3b57cea959 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:30:29 +0100 Subject: [PATCH 054/475] Make lightning strike the closest lightning rod --- crates/horizon-client/src/game/lightning.rs | 64 ++++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 916bd2a0a..3ffe8daf5 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -74,14 +74,74 @@ impl Game<'_> { self.spawn_bolt(at, trap); } } - /// `findLightningTargetAround`: a living entity under open sky in the - /// column (3 blocks around), else the top of the column. + /// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the + /// lightning rod points of interest within 128 blocks of `center` whose + /// block is the top (`WORLD_SURFACE`) of its column; the strike lands one + /// above it. Horizon keeps no point-of-interest index, so this scans the + /// loaded chunks the way `/locate poi` does. + fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> { + const RANGE: i32 = 128; + let facts = self.res.facts(); + let mut is_rod: std::collections::HashMap = std::collections::HashMap::new(); + let mut best: Option<(i64, [i32; 3])> = None; + let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4); + let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4); + for cx in cx0..=cx1 { + for cz in cz0..=cz1 { + let Some(chunk) = self.world.chunks.get(&(cx, cz)) else { + continue; + }; + for (i, section) in chunk.sections.iter().enumerate() { + let mut rod = |state: u32| { + *is_rod.entry(state).or_insert_with(|| { + facts + .get(horizon_world::registry::BlockStateId(state)) + .name + .ends_with("lightning_rod") + }) + }; + if !section.blocks.maybe_has(&mut rod) { + continue; + } + let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32; + for j in 0..4096 { + if !rod(section.get(j)) { + continue; + } + let p = [ + cx * 16 + (j & 15) as i32, + base_y + (j >> 8) as i32, + cz * 16 + ((j >> 4) & 15) as i32, + ]; + let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]] + .map(|d| i64::from(d) * i64::from(d)) + .iter() + .sum::(); + if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) { + continue; + } + let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s)); + if p[1] == surface - 1 { + best = Some((d, p)); + } + } + } + } + } + best.map(|(_, p)| [p[0], p[1] + 1, p[2]]) + } + /// `findLightningTargetAround`: the closest lightning rod, else a living + /// entity under open sky in the column (3 blocks around), else the top of + /// the column. fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> { if !self.world.loaded(x, z) { return None; } let top = self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z))); + if let Some(rod) = self.find_lightning_rod([x, top.y, z]) { + return Some(rod); + } let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15; let mut candidates: Vec<[i32; 3]> = self .mobs From fcfd425b94a719f80417b210bb671ed70db96aeb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:30:32 +0100 Subject: [PATCH 055/475] Give the conduit its power and its attack A conduit with enough prismarine around it now gives conduit power to players in water or rain in range, hurts an enemy in water or rain when it has 42 frame blocks, and plays its activate, deactivate and ambient sounds, as ConduitBlockEntity.serverTick does. Its target also feeds the client's attack particles. --- .../horizon-client/src/block_entity_anim.rs | 61 ++++++ .../src/game/block_entity_anims.rs | 205 +++++++++++++++++- crates/horizon-client/src/game/levels.rs | 1 + 3 files changed, 265 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/block_entity_anim.rs b/crates/horizon-client/src/block_entity_anim.rs index d91748e73..53884e5ec 100644 --- a/crates/horizon-client/src/block_entity_anim.rs +++ b/crates/horizon-client/src/block_entity_anim.rs @@ -648,6 +648,61 @@ impl Conduit { } } +/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ConduitCheck { + /// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`. + pub toggled: Option, + /// The frame blocks counted, while the conduit is active: `applyEffects` + /// and `updateAndAttackTarget` follow (the attack needs 42 or more). + pub frames: Option, +} + +/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks. +#[derive(Clone, Debug, Default)] +pub struct ConduitServer { + shape: Conduit, + /// `destroyTarget`: the mob id of the enemy being struck. + pub target: Option, + next_ambient_sound: i64, +} + +impl ConduitServer { + /// The `gameTime % 40` part of `serverTick`: the shape is checked again. + pub fn check( + &mut self, + pos: [i32; 3], + game_time: i64, + is_water: impl Fn([i32; 3]) -> bool, + is_frame: impl Fn([i32; 3]) -> bool, + ) -> Option { + if game_time % 40 != 0 { + return None; + } + let active = self.shape.update_shape(pos, &is_water, &is_frame); + let toggled = (active != self.shape.is_active).then_some(active); + self.shape.is_active = active; + Some(ConduitCheck { + toggled, + frames: active.then_some(self.shape.effect_blocks.len()), + }) + } + /// The sounds of an active conduit at the end of `serverTick`: whether + /// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every + /// 60 to 99 ticks) play. + pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) { + if !self.shape.is_active { + return (false, false); + } + let short = game_time > self.next_ambient_sound; + if short { + self.next_ambient_sound = + game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0)); + } + (game_time % 80 == 0, short) + } +} + /// `SkullBlockEntity` animation (client ticker of dragon and piglin heads). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Skull { @@ -832,6 +887,8 @@ pub struct BlockEntityAnims { pub gateways: FxHashMap<[i32; 3], EndGateway>, /// The integrated server's copies of the gateways (`portalTick`). pub gateway_servers: FxHashMap<[i32; 3], EndGateway>, + /// The integrated server's copies of the conduits (`serverTick`). + pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>, /// The client level's random (conduit particles). pub random: LegacyRandom, /// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]). @@ -854,6 +911,7 @@ impl Default for BlockEntityAnims { pots: FxHashMap::default(), gateways: FxHashMap::default(), gateway_servers: FxHashMap::default(), + conduit_servers: FxHashMap::default(), random: LegacyRandom::new(0x6c6576656c), kinds: Vec::new(), present: Vec::new(), @@ -892,6 +950,8 @@ impl BlockEntityAnims { self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell)); self.conduits .retain(|p, _| is(p, |t| *t == Ticker::Conduit)); + self.conduit_servers + .retain(|p, _| is(p, |t| *t == Ticker::Conduit)); self.skulls .retain(|p, _| is(p, |t| matches!(t, Ticker::Skull { .. }))); self.pots.retain(|p, _| is(p, |t| *t == Ticker::Pot)); @@ -909,6 +969,7 @@ impl BlockEntityAnims { self.pots.retain(|p, _| keep(*p)); self.gateways.retain(|p, _| keep(*p)); self.gateway_servers.retain(|p, _| keep(*p)); + self.conduit_servers.retain(|p, _| keep(*p)); } /// `getOpenNess(partial)` of the chest or ender chest at `pos`. pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 { diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index 3d452596e..daec3523a 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -5,10 +5,14 @@ //! [`Game::block_anim`] and [`Game::chest_lid`]. use super::Game; use horizon_client::{ - block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker}, + block_entity_anim::{ + BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker, + }, block_entity_render::BlockAnim, collisions::{SceneCollisions, no_collision_empty}, containers::BlockEntity, + effects::EffectInstance, + mobs::{Damage, DamageKind}, particles::{ParticleOptions, ParticleType}, save::number, skins, @@ -16,6 +20,7 @@ use horizon_client::{ use horizon_core::{ geometry::{Aabb, Vec3}, position::BlockPos, + random::RandomSource, }; use horizon_world::nbt::Tag; @@ -337,6 +342,12 @@ impl Game<'_> { } } Ticker::Conduit => { + // A server runs the effects and the attack; a client of + // one only animates what it is sent. + if self.remote.is_none() { + self.tick_conduit_server(pos); + } + let target = self.conduit_target(pos); let world = &self.world; let facts = self.res.facts(); let registry = self.pack.assets.registry; @@ -356,7 +367,7 @@ impl Game<'_> { time, is_water, is_frame, - None, + target, self.particles.random(), ); let nautilus = ParticleOptions::simple(ParticleType::Nautilus); @@ -383,6 +394,196 @@ impl Game<'_> { } self.be_anims.present = positions; } + /// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk + /// that ticks blocks: the shape check every 40 ticks with its sounds, the + /// power for players in range, the hurt for an enemy, then the ambient + /// sounds. + fn tick_conduit_server(&mut self, pos: [i32; 3]) { + if !self.tickets.levels().block_ticking_at(pos[0], pos[2]) + || !self.border.contains(f64::from(pos[0]), f64::from(pos[2])) + { + return; + } + let time = self.world.game_time; + let mut conduit = self + .be_anims + .conduit_servers + .remove(&pos) + .unwrap_or_default(); + let check = { + let world = &self.world; + let facts = self.res.facts(); + let registry = self.pack.assets.registry; + let frames = self + .res + .items() + .block_tags + .get("minecraft:conduit_effect_block"); + // Level.isWaterAt: the fluid at the position is water. + let is_water = |p: [i32; 3]| { + facts.get(world.block(p[0], p[1], p[2])).fluid + == horizon_client::blocks::Fluid::Water + }; + let is_frame = |p: [i32; 3]| { + registry + .state(world.block(p[0], p[1], p[2])) + .is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name))) + }; + conduit.check(pos, time, is_water, is_frame) + }; + let centre = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5, + ]; + if let Some(check) = check { + if let Some(active) = check.toggled { + let sound = if active { + "minecraft:block.conduit.activate" + } else { + "minecraft:block.conduit.deactivate" + }; + self.audio.play(sound, Some(centre), 1.0, 1.0); + } + if let Some(frames) = check.frames { + self.conduit_apply_effects(pos, frames); + self.conduit_attack(&mut conduit, pos, frames >= 42); + } + } + let (ambient, short) = conduit.ambient(time, &mut self.world.random); + if ambient { + self.audio + .play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0); + } + if short { + self.audio.play( + "minecraft:block.conduit.ambient_short", + Some(centre), + 1.0, + 1.0, + ); + } + self.be_anims.conduit_servers.insert(pos, conduit); + } + /// `ConduitBlockEntity.applyEffects`: conduit power for every player + /// in water or rain within `frames / 7 * 16` blocks. + fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) { + let range = (frames / 7 * 16) as i64; + self.for_each_player(|g| { + let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else { + return; + }; + if g.spectator() { + return; + } + let at = p.position; + let in_water = p.fluids.was_touching_water; + let top = at.y + f64::from(p.dimensions.height); + // BlockPos.closerThan: the squared distance under range squared. + let d = [at.x, at.y, at.z] + .map(|v| v.floor() as i64) + .iter() + .zip(pos) + .map(|(a, b)| (a - i64::from(b)).pow(2)) + .sum::(); + if d >= range * range || !(in_water || g.in_rain(at, top)) { + return; + } + let power = EffectInstance { + ambient: true, + ..EffectInstance::new("conduit_power", 260, 0) + }; + g.potions.effects.add(power); + }); + } + /// `Entity.isInRain`: rain at the block position or at the top of the box. + fn in_rain(&mut self, at: Vec3, top: f64) -> bool { + let p = BlockPos::new( + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + ); + let up = BlockPos::new(p.x, top.floor() as i32, p.z); + self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up)) + } + /// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts + /// (42 frame blocks) its target stays until it dies or leaves the 8 + /// blocks, else a random enemy in water or rain is chosen; the target + /// takes 4 magic damage. + fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) { + conduit.target = match conduit.target { + _ if !hunting => None, + None => self.conduit_new_target(pos), + Some(id) => self + .mobs + .iter() + .find(|m| m.id == id && !m.removed) + .filter(|m| { + let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64); + m.alive() + && at + .iter() + .zip(pos) + .map(|(a, b)| (a - i64::from(b)).pow(2)) + .sum::() + < 64 + }) + .map(|m| m.id), + }; + let Some(m) = conduit + .target + .and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) + else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + m.damage(4.0, Damage::of(DamageKind::Magic)); + self.audio + .play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0); + } + /// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or + /// rain whose box meets the conduit's block inflated by 8. + fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option { + let [x, y, z] = pos.map(f64::from); + let area = Aabb::new( + Vec3::new(x - 8.0, y - 8.0, z - 8.0), + Vec3::new(x + 9.0, y + 9.0, z + 9.0), + ); + let near: Vec<(u32, bool, Vec3, f64)> = self + .mobs + .iter() + .filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area)) + .map(|m| { + let top = m.position.y + f64::from(m.height()); + (m.id, m.fluids.in_water, m.position, top) + }) + .collect(); + let candidates: Vec = near + .into_iter() + .filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top)) + .map(|(id, ..)| id) + .collect(); + if candidates.is_empty() { + return None; + } + let pick = self + .world + .random + .next_int_bound(candidates.len() as i32) + .unwrap_or(0); + Some(candidates[pick as usize]) + } + /// The conduit's `destroyTarget` as the client draws it. + fn conduit_target(&self, pos: [i32; 3]) -> Option { + let id = self.be_anims.conduit_servers.get(&pos)?.target?; + let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?; + Some(ConduitTarget { + position: [m.position.x, m.position.y, m.position.z], + eye_y: m.eye_y(), + width: m.width(), + height: m.height(), + }) + } /// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the /// gateways a server keeps a copy of, in block-ticking chunks /// (`Level.tickBlockEntities`): the cooldown an entry started runs out, diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index bfc739cee..fae1387b5 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -222,6 +222,7 @@ impl Game<'_> { swap(&mut a.pots, &mut b.pots); swap(&mut a.gateways, &mut b.gateways); swap(&mut a.gateway_servers, &mut b.gateway_servers); + swap(&mut a.conduit_servers, &mut b.conduit_servers); self.raiding.swap_level(&mut p.raiding); self.sculk.swap_level(&mut p.sculk); self.creaking.swap_level(&mut p.creaking); From 8bfa7a5b7ba893b6faa899d5d9a5b72f2ae7c060 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:03 +0100 Subject: [PATCH 056/475] Loot: suspicious stew keeps the effect set_stew_effect rolled The function drew an effect and a duration but dropped them. The stack now carries the effect and it is stored in suspicious_stew_effects. --- .../probe/entity_death_loot_fixture.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs/drops.rs | 1 + crates/horizon-client/src/structure_blocks.rs | 8 ++++++++ crates/horizon-world/src/loot.rs | 20 ++++++++++++++++++- 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs index 86ef275d9..3f47ba787 100644 --- a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs @@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), }; for e in parts { let (name, level) = e.rsplit_once(':').unwrap_or((e, "1")); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..6abd646fd 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3297,6 +3297,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } /// A generated loot stack as a Horizon item stack. diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index e5ccfd283..c7b1c77e1 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -304,6 +304,7 @@ fn loot_stack(item: &str, count: i32) -> LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } /// `Mob.dropChances.byEquipment(slot)`: the recorded chance, or the one the diff --git a/crates/horizon-client/src/structure_blocks.rs b/crates/horizon-client/src/structure_blocks.rs index 9503294e0..0ff675572 100644 --- a/crates/horizon-client/src/structure_blocks.rs +++ b/crates/horizon-client/src/structure_blocks.rs @@ -63,6 +63,12 @@ pub fn loot_item_with_components(s: &horizon_world::loot::LootStack) -> ItemStac if let Some(level) = s.ominous_amplifier.filter(|l| *l > 0) { item.data_mut().ominous_amplifier = Some(level.clamp(0, 4) as u8); } + // set_stew_effect's `suspicious_stew_effects`. + for (effect, duration) in &s.stew_effects { + item.data_mut() + .stew_effects + .push((Arc::from(effect.as_str()), *duration)); + } item } @@ -243,6 +249,7 @@ mod tests { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), }; // An End city chest's enchant_with_levels roll. let boots = loot_item_with_components(&stack( @@ -286,6 +293,7 @@ mod tests { "zoom": 1 })))), ominous_amplifier: None, + stew_effects: Vec::new(), }; let mut item = loot_item(&stack); assert!(has_pending_map(&item)); diff --git a/crates/horizon-world/src/loot.rs b/crates/horizon-world/src/loot.rs index 96ca5bdee..f98afa21f 100644 --- a/crates/horizon-world/src/loot.rs +++ b/crates/horizon-world/src/loot.rs @@ -31,6 +31,9 @@ pub struct LootStack { pub exploration: Option>, /// Set by set_ominous_bottle_amplifier. pub ominous_amplifier: Option, + /// Added by set_stew_effect (the `suspicious_stew_effects` component): + /// (effect id, duration in ticks). + pub stew_effects: Vec<(String, i32)>, } /// `ExplorationMapFunction`'s settings. #[derive(Clone, Debug, PartialEq, Eq)] @@ -74,6 +77,7 @@ impl LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } pub fn is_empty(&self) -> bool { @@ -678,6 +682,13 @@ impl LootData { } } +/// `MobEffect.isInstantaneous`. +fn is_instantaneous(effect: &str) -> bool { + matches!( + effect, + "minecraft:saturation" | "minecraft:instant_health" | "minecraft:instant_damage" + ) +} /// The level of one enchantment on a stack (its `enchantments` component). fn item_level(stack: &LootStack, enchantment: &str) -> i32 { stack @@ -1134,7 +1145,14 @@ impl Context<'_, R> { .random .next_int_bound(effects.len() as i32) .unwrap_or(0) as usize; - self.int(&effects[pick]["duration"]); + let effect = effects[pick]["type"].as_str().unwrap_or(""); + let mut duration = self.int(&effects[pick]["duration"]); + // Seconds, unless the effect is instantaneous. + if !is_instantaneous(effect) { + duration *= 20; + } + // SuspiciousStewEffects.withEffectAdded + stack.stew_effects.push((effect.to_owned(), duration)); } } "minecraft:set_ominous_bottle_amplifier" => { From 3e788469756ab71a5208394bbc0f4c888c290fb4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:28 +0100 Subject: [PATCH 057/475] Soul Speed: speed, no soul sand slowdown, boots wear, soul particles The enchantment's location_changed effects now run for the player: the movement_speed and movement_efficiency modifiers turn on over soul sand and soul soil and stay on while airborne, the boots wear with a 4% chance per block change, and walking on those blocks spawns soul particles and the soul escape sound. --- crates/horizon-client/src/game.rs | 1 + .../src/game/enchant_effects.rs | 157 +++++++++++++++++- crates/horizon-client/src/motion.rs | 37 +++++ 3 files changed, 192 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..515a69cc8 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4342,6 +4342,7 @@ impl Game<'_> { }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; + self.tick_soul_speed(); self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index aa9f75e06..6550ba9d5 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -3,10 +3,15 @@ //! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power //! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`), //! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and -//! the armor attribute effects (respiration, depth strider, ...). +//! the armor attribute effects (respiration, depth strider, ...) and Soul +//! Speed's `location_changed` and `tick` effects. use super::Game; -use horizon_client::{enchant::EffectContext, items::ItemStack}; -use horizon_core::random::RandomSource; +use horizon_client::{ + enchant::{EffectContext, Enchantments, level_value}, + items::ItemStack, + particles::{ParticleOptions, ParticleType}, +}; +use horizon_core::{position::BlockPos, random::RandomSource}; /// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`). #[derive(Clone, Copy)] @@ -460,6 +465,127 @@ impl Game<'_> { } ignite } + /// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of + /// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The + /// `location_changed` effects run when the player enters a new block, lands, + /// or its boots change: + /// - the speed and movement efficiency modifiers turn on over a soul speed + /// block (not flying, not riding) and stay on while airborne, then off + /// (`enchantment_active_check`); + /// - the boots lose 1 durability with a 4% chance when on such a block. + /// + /// Every fifth tick the player walking on one also gets soul particles and + /// sometimes a sound. A remote server applies and syncs all of this itself. + pub(super) fn tick_soul_speed(&mut self) { + const ID: &str = "minecraft:soul_speed"; + let Some(player) = self.player.as_mut() else { + return; + }; + if player.client_side { + return; + } + let level = self.armor[3] + .as_ref() + .map_or(0, |s| s.enchantment_level(ID)); + let position = player.position; + let (on_ground, flying) = (player.ground.on_ground, player.flying); + let moved = position.subtract(player.old_position); + let (tick, width) = (player.health.tick_count, player.dimensions.width); + let fx = &mut player.location_effects; + let on_soul = fx.on_soul_speed_block; + let block = BlockPos::containing(position); + let changed_block = fx.block.replace(block) != Some(block); + let landed = std::mem::take(&mut fx.landed); + // Changed boots: `stopLocationBasedEffects`, then the effects run again. + let equipped = std::mem::replace(&mut fx.boots_level, level) != level; + let was_active = fx.applied_level; + let mut active = was_active > 0 && !equipped; + let mut wear = false; + if level > 0 && (changed_block || landed || equipped) { + let riding = self.riding.is_some(); + let wanted = !riding && !flying && (on_soul || (active && !on_ground)); + active = wanted; + wear = on_ground && on_soul && self.world.random.next_float() < 0.04; + } + fx.applied_level = if active { level } else { 0 }; + // `Entity.getKnownMovement` horizontal speed of at least 1.0E-5. + let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10; + let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0; + // The attribute effects were applied to the player before: swap them. + if was_active > 0 && (!active || equipped) { + for (attribute, id, _, op) in + soul_speed_modifiers(&self.res.stations().enchantments, was_active) + { + let _ = player.set_modifier(&attribute, &id, 0.0, &op, false); + } + } + if active && (was_active == 0 || equipped) { + for (attribute, id, amount, op) in + soul_speed_modifiers(&self.res.stations().enchantments, level) + { + let _ = player.set_modifier(&attribute, &id, amount, &op, true); + } + } + if wear && !self.creative { + self.wear_boots(1); + } + if tick_effects { + // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown + // back against the movement (-0.2) and up (0.1). + let r = &mut self.world.random; + let x = position.x + (r.next_double() - 0.5) * f64::from(width); + let z = position.z + (r.next_double() - 0.5) * f64::from(width); + self.particles.send_particles( + &ParticleOptions::simple(ParticleType::Soul), + [x, position.y + 0.1, z], + [moved.x * -0.2, 0.1, moved.z * -0.2], + 1.0, + 0, + ); + // `play_sound`: 35% chance, volume 0.6, pitch 0.6 to 1.0. + if r.next_float() < 0.35 { + let pitch = r.next_float() * 0.4 + 0.6; + self.audio.play( + "minecraft:particle.soul_escape", + Some([position.x, position.y, position.z]), + 0.6, + pitch, + ); + } + } + } + /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): + /// Unbreaking applies, and they break when worn out. + fn wear_boots(&mut self, amount: u32) { + let Some(stack) = self.armor[3].clone() else { + return; + }; + let loss = self.durability_loss(&stack, amount); + let max = self + .res + .items() + .get(&stack.item) + .map_or(0, |info| info.max_damage); + if max == 0 { + return; + } + let Some(boots) = self.armor[3].as_mut() else { + return; + }; + boots.damage += loss; + if boots.damage >= max { + self.armor[3] = None; + if let Some(eye) = self.eye_position() { + self.audio.play( + "minecraft:entity.item.break", + Some(eye), + 0.8, + 0.8 + self.world.random.next_float() * 0.4, + ); + } + self.apply_equipment(); + } + } /// Enchantment attribute modifiers of worn armor (respiration, depth strider...). /// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`), /// so the same enchantment on two pieces stacks instead of replacing itself. @@ -483,3 +609,28 @@ impl Game<'_> { self.potions.enchant_modifiers = wanted; } } + +/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation), +/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`. +fn soul_speed_modifiers( + enchantments: &Enchantments, + level: u32, +) -> Vec<(String, String, f64, String)> { + let Some(e) = enchantments.get("minecraft:soul_speed") else { + return Vec::new(); + }; + e.effects["minecraft:location_changed"][0]["effect"]["effects"] + .as_array() + .into_iter() + .flatten() + .filter(|a| a["type"] == "minecraft:attribute") + .map(|a| { + ( + a["attribute"].as_str().unwrap_or("").to_owned(), + format!("{}/feet", a["id"].as_str().unwrap_or("")), + f64::from(level_value(&a["amount"], level)), + a["operation"].as_str().unwrap_or("add_value").to_owned(), + ) + }) + .collect() +} diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 0bce8927c..156fc2549 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -50,6 +50,8 @@ pub struct MotionResources { pub attributes: Arc, pub blocks: BoundMovementProperties, pub climbable: BTreeSet, + /// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for. + pub soul_speed_blocks: BTreeSet, pub fall_reset: BTreeSet, pub landing: BlockLandingRegistry, pub health: crate::health::HealthResources, @@ -108,11 +110,29 @@ impl MotionResources { suppresses_bounce: tag("minecraft:suppresses_bounce")?, })?, climbable: tag("minecraft:climbable")?, + soul_speed_blocks: tag("minecraft:soul_speed_blocks")?, fall_reset: tag("minecraft:fall_damage_resetting")?, }) } } +/// What the `location_changed` enchantment effects (Soul Speed) need to know +/// between ticks. +#[derive(Clone, Debug, Default)] +pub struct LocationEffects { + /// `MovementAffectedByPredicate`: the block under the player + /// (`getBlockPosBelowThatAffectsMyMovement`) is in `#minecraft:soul_speed_blocks`. + pub on_soul_speed_block: bool, + /// `LivingEntity.lastPos`: the block the player was in last tick. + pub block: Option, + /// A landing ran `onChangedBlock` (`LivingEntity.checkFallDamage`). + pub landed: bool, + /// The Soul Speed level of the boots seen last tick (0: none). + pub boots_level: u32, + /// The Soul Speed level whose attribute modifiers are on the player (0: none). + pub applied_level: u32, +} + /// Ordered requests and phase records for world/audio/network consumers. Landing /// hurt dispatch updates stored health synchronously before these are drained. #[derive(Clone, Copy, Debug)] @@ -255,6 +275,8 @@ pub struct PlayerMotion { pub glide_ending: bool, /// Glider durability to take this tick (updateFallFlying). pub glider_wear: u32, + /// Soul Speed's `location_changed` bookkeeping. + pub location_effects: LocationEffects, /// `LivingEntity.autoSpinAttackTicks` and `autoSpinAttackDmg`: Riptide's /// spin. The damage stays until the game has dealt the hit. pub auto_spin_attack_ticks: i32, @@ -443,6 +465,7 @@ impl PlayerMotion { fall_fly_ticks: 0, glide_ending: false, glider_wear: 0, + location_effects: LocationEffects::default(), auto_spin_attack_ticks: 0, auto_spin_attack_dmg: 0.0, spin_targets: Vec::new(), @@ -984,6 +1007,7 @@ impl MotionWorld<'_, '_, '_> { ) }); self.player.try_add_frost(on_air)?; + self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; if self.player.health.tick_count % 40 == 0 && self.player.is_fully_frozen() && self.player.can_freeze() @@ -996,6 +1020,15 @@ impl MotionWorld<'_, '_, '_> { } Ok(()) } + /// `MovementAffectedByPredicate` for `#minecraft:soul_speed_blocks`. + fn on_soul_speed_block(&mut self) -> Result { + let on = self.support(movement_support::MOVEMENT_SUPPORT_OFFSET)?; + Ok(self + .assets + .registry + .state(self.block(on)) + .is_some_and(|s| self.resources.soul_speed_blocks.contains(&s.name))) + } /// Player.travel: swim steering, then LivingEntity.travel (fluids first, /// then gliding, then air), keeping 0.6 of the vertical speed while flying. fn player_travel(&mut self, input: Vec3) -> Result<()> { @@ -1834,6 +1867,10 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { if self.player.status.slow_falling || self.player.status.levitation.is_some() { self.player.fall_distance = 0.0; } + // LivingEntity.checkFallDamage: a landing runs onChangedBlock. + if grounded && self.player.fall_distance > 0.0 { + self.player.location_effects.landed = true; + } if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0 { self.player.events.push(MotionEvent::SmashLanding { From fd0cb002962ed5d1ae9488b913a4329f850532e1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:58 +0100 Subject: [PATCH 058/475] Axolotls dry out on land, squid squirt ink when a living entity hurts them --- .../src/game/commands/world_cmds.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs.rs | 15 +++++- crates/horizon-client/src/mobs/ai.rs | 32 +++++++++++- crates/horizon-client/src/mobs/aquatic.rs | 52 +++++++++++++++++++ crates/horizon-client/src/mobs/axolotl.rs | 2 + 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 30f019997..eac6f125f 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -108,6 +108,7 @@ impl Game<'_> { "in_fire" | "campfire" | "hot_floor" => DamageKind::InFire, "lava" => DamageKind::Lava, "drown" => DamageKind::Drown, + "dry_out" => DamageKind::DryOut, "magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic, "sting" => DamageKind::Sting, "cactus" | "sweet_berry_bush" => DamageKind::Cactus, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..b784b4c98 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3272,6 +3272,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::InFire => "minecraft:in_fire", K::Lava => "minecraft:lava", K::Drown => "minecraft:drown", + K::DryOut => "minecraft:dry_out", K::Magic => "minecraft:magic", K::Sting => "minecraft:sting", K::Cactus => "minecraft:cactus", diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..de9c7dfac 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -515,6 +515,8 @@ pub enum DamageKind { InFire, Lava, Drown, + /// `dry_out`: an axolotl out of water for too long. + DryOut, Magic, Sting, Cactus, @@ -573,7 +575,11 @@ impl Damage { pub fn causes_panic(&self) -> bool { !matches!( self.kind, - DamageKind::Fall | DamageKind::Drown | DamageKind::Generic | DamageKind::InWall + DamageKind::Fall + | DamageKind::Drown + | DamageKind::DryOut + | DamageKind::Generic + | DamageKind::InWall ) } /// DamageTypeTags.BYPASSES_ARMOR @@ -606,6 +612,7 @@ impl Damage { | DamageKind::InFire | DamageKind::Lava | DamageKind::Drown + | DamageKind::DryOut | DamageKind::Magic | DamageKind::Cactus | DamageKind::Generic @@ -1789,6 +1796,12 @@ impl Mob { } } } + // Squid.hurtServer: a squid hurt after a living entity hit it squirts ink. + if matches!(self.species(), Species::Squid | Species::GlowSquid) + && self.ai.last_hurt_by.is_some() + { + aquatic::spawn_ink(self); + } true } /// LivingEntity.knockback(power, xd, zd) towards the negative direction. diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..403f63101 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -441,6 +441,34 @@ pub fn tick_swing(m: &mut Mob) { } m.anim.attack_anim = m.ai.swing_time as f32 / 6.0; } +/// `LivingEntity.baseTick` refills a breather's air by 4, then +/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills +/// it, anywhere else it runs down from the pre-tick value, and at -20 the +/// axolotl dries out. +fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { + let before = m.air; + m.air = (m.air + 4).min(super::axolotl::MAX_AIR); + if m.no_ai { + return; + } + // Entity.isInRain: rain at the block, or at the top of the box. + let (x, y, z) = ( + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ); + let top = (m.position.y + f64::from(m.height())).floor() as i32; + let in_rain = w.raining && (w.can_see_sky(x, y, z) || w.can_see_sky(x, top, z)); + if m.fluids.in_water || in_rain { + m.air = super::axolotl::MAX_AIR; + } else { + m.air = before - 1; + if m.air <= -20 { + m.air = 0; + m.damage(2.0, Damage::of(DamageKind::DryOut)); + } + } +} /// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick. fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.was_eye_in_water = m.fluids.eye_in_water; @@ -500,7 +528,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.damage(1.0, Damage::of(DamageKind::InWall)); } // Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs). - if m.kind.flags.needs_water { + if m.species() == Species::Axolotl { + axolotl_air_supply(m, w); + } else if m.kind.flags.needs_water { if !m.fluids.in_water { m.air -= 1; if m.air == -20 { diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index dc80c6ea4..9603d5e58 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -24,6 +24,57 @@ const Z_BODY_ROT: usize = 3; const TENTACLE_SPEED: usize = 4; const ROTATE_SPEED: usize = 5; +/// `Squid.spawnInk`: the squirt sound and 30 ink particles shot from under +/// the squid, turned by its body rotation. +pub fn spawn_ink(m: &mut Mob) { + let sound = if m.species() == Species::GlowSquid { + "entity.glow_squid.squirt" + } else { + "entity.squid.squirt" + }; + let kind = if m.species() == Species::GlowSquid { + "glow_squid_ink" + } else { + "squid_ink" + }; + if !m.silent { + let pitch = m.voice_pitch(); + let at = [m.position.x, m.position.y, m.position.z]; + m.outbox.push(MobEvent::Sound( + sound, + at, + super::ai::sound_volume(m), + pitch, + )); + } + // rotateVector: xRot(xBodyRotO), then yRot(-yBodyRotO). + let to_radians = (std::f64::consts::PI / 180.0) as f32; + let (x_rot, y_rot) = ( + m.anim.vo[X_BODY_ROT] * to_radians, + -m.previous_body_yaw * to_radians, + ); + let rotate = |v: Vec3| v.rotate_x(x_rot).rotate_y(y_rot); + let pos = rotate(Vec3::new(0.0, -1.0, 0.0)).add_vector(m.position); + let offset_scale: f32 = if m.is_baby() { 0.1 } else { 0.3 }; + for _ in 0..30 { + let x = f64::from(m.random.next_float()) * 0.6 - 0.3; + let z = f64::from(m.random.next_float()) * 0.6 - 0.3; + let dir = rotate(Vec3::new(x, -1.0, z)); + let d = dir.scale(f64::from(offset_scale + m.random.next_float() * 2.0)); + // ServerLevel.sendParticles with a count of 0 and speed 0.1: one + // particle moving at the float offset times the float speed. + let speed = f64::from(0.1f32); + m.outbox.push(MobEvent::ParticleAt { + kind, + at: [pos.x, pos.y + 0.5, pos.z], + velocity: [ + speed * f64::from(d.x as f32), + speed * f64::from(d.y as f32), + speed * f64::from(d.z as f32), + ], + }); + } +} pub fn register(m: &mut Mob) { use Species as S; match m.species() { @@ -36,6 +87,7 @@ pub fn register(m: &mut Mob) { m.anim.v[TENTACLE_SPEED] = speed; } S::Axolotl => { + m.air = super::axolotl::MAX_AIR; // Native controller scaling is essential: the base speed attribute is 1.0. m.ai.mov.kind = MoveKind::SmoothSwimming { max_turn_x: 85.0, diff --git a/crates/horizon-client/src/mobs/axolotl.rs b/crates/horizon-client/src/mobs/axolotl.rs index 55f15717c..2815418e3 100644 --- a/crates/horizon-client/src/mobs/axolotl.rs +++ b/crates/horizon-client/src/mobs/axolotl.rs @@ -3,6 +3,8 @@ use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::Anim use horizon_core::random::RandomSource; pub const PLAY_DEAD_TICKS: i32 = 200; pub const HUNTING_COOLDOWN: i64 = 2400; +/// `Axolotl.getMaxAirSupply`. +pub const MAX_AIR: i32 = 6000; /// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown. pub fn prey_eligible( From f24ca405a8a4c13a41a857264ad790ffe8afe9a7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:33:25 +0100 Subject: [PATCH 059/475] Piglins walk away from soul fire, soul torches, soul lanterns and lit soul campfires Piglins now sense the nearest block from the piglin repellents tag within 8 blocks sideways and 4 up or down (a soul campfire only while lit). When one is closer than 8 blocks and the piglin is idle with nowhere to go, it walks away from it. --- crates/horizon-client/src/mobs.rs | 3 + crates/horizon-client/src/mobs/piglin.rs | 71 +++++++++++++++++++++++- crates/horizon-client/src/mobs/world.rs | 29 ++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..bf42bc587 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -709,6 +709,9 @@ pub struct Extra { /// A copper golem's `openedChestPos`. pub opened_chest: Option<[i32; 3]>, pub point: Option<[f64; 3]>, + /// `NEAREST_REPELLENT`: the nearest repelling block a hoglin or piglin + /// sensed, looked up every 20 ticks. + pub repellent: Option<[i32; 3]>, pub vector: [f64; 3], /// Active status effects: (effect id, ticks left, amplifier). pub effects: Vec<(&'static str, i32, i32)>, diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 410364097..2c92ecd0a 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -9,7 +9,7 @@ use super::{ NearestTarget, Prey, RandomLookAround, Stroll, goal_clone, }, }; -use horizon_core::geometry::trig::Mth; +use horizon_core::geometry::{Vec3, trig::Mth}; use horizon_core::random::RandomSource; use std::f32::consts::PI; @@ -45,6 +45,7 @@ pub fn register_piglin(m: &mut Mob) { g.add(3, super::illager::crossbow_goal(1.0, 8.0)); } g.add(3, MeleeAttack::new(1.0, true)); + g.add(4, PiglinAvoidRepellent); g.add(4, GoToWantedItem); g.add(5, Stroll::avoiding_water(0.6)); g.add(6, LookAtPlayer::new(8.0)); @@ -129,6 +130,54 @@ impl Goal for BabyPanic { self.0.stop(m, cx) } } +/// `PiglinAi.avoidRepellent`: `SetWalkTargetAwayFrom.pos(NEAREST_REPELLENT, 1.0, 8, false)` +/// of the idle activity. Closer than 8 blocks to a repellent and not walking +/// anywhere, walk away from it. +#[derive(Clone, Debug)] +struct PiglinAvoidRepellent; +impl Goal for PiglinAvoidRepellent { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let Some(r) = m.extra.repellent else { + return false; + }; + // The idle activity: not fighting or admiring, and no walk target yet. + if m.ai.target.is_some() || m.extra.using_item > 0 || !m.ai.nav.is_done() { + return false; + } + // Vec3.atBottomCenterOf. + let avoid = [ + f64::from(r[0]) + 0.5, + f64::from(r[1]), + f64::from(r[2]) + 0.5, + ]; + if m.distance_sqr(Vec3::new(avoid[0], avoid[1], avoid[2])) >= 64.0 { + return false; + } + for _ in 0..10 { + if let Some(p) = m.land_random_pos_away(cx.w, 16, 7, avoid) { + let at = [ + p[0].floor() as i32, + p[1].floor() as i32, + p[2].floor() as i32, + ]; + m.ai.pending_path = m.create_path_to(cx.w, at, 0); + return m.ai.pending_path.is_some(); + } + } + false + } + fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { + !m.ai.nav.is_done() + } + fn start(&mut self, m: &mut Mob, _: &mut Cx) { + let path = m.ai.pending_path.take(); + m.nav_move_to_path(path, 1.0); + } +} /// Admiring a gold item held in the off hand: stand and look at it. #[derive(Clone, Debug)] struct Admire; @@ -219,6 +268,26 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } else { m.extra.counter2 = 0; } + // PiglinSpecificSensor (every 20 ticks): the nearest repelling block. + if m.species() == Species::Piglin && m.alive() && m.age.wrapping_add(m.id) % 20 == 0 { + m.extra.repellent = find_repellent(m, w); + } +} +/// `PiglinSpecificSensor.findNearestRepellent`: the nearest `#piglin_repellents` +/// block within 8 blocks sideways and 4 up or down; a soul campfire only +/// counts while lit. +fn find_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { + let tag = w.block_tags.get("minecraft:piglin_repellents")?; + let at = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + w.nearest_block(at, 8, 4, |p, name| { + tag.contains(name) + && (name != "minecraft:soul_campfire" + || w.prop(p[0], p[1], p[2], "lit") == Some("true")) + }) } /// `HoglinBase.hurtAndThrowTarget`. pub fn hoglin_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) { diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 7836a49ef..aa665decc 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -65,6 +65,35 @@ impl MobWorld<'_> { pub fn has_tag(&self, tag: &str, block: &str) -> bool { self.block_tags.get(tag).is_some_and(|s| s.contains(block)) } + /// `findBlocksInBoxByManhattanDistance(at, range_xz, range_y).filterState(..).findFirst()`: + /// the first block `matches` accepts (given its position and name), going + /// out from `at` by Manhattan distance as `BlockPos.withinBoxByManhattanDistance` does. + pub fn nearest_block( + &self, + at: [i32; 3], + range_xz: i32, + range_y: i32, + matches: impl Fn([i32; 3], &str) -> bool, + ) -> Option<[i32; 3]> { + // Visiting order: distance, then x, then y, then +z before -z. + let mut best: Option<((i32, i32, i32, bool), [i32; 3])> = None; + for dx in -range_xz..=range_xz { + for dy in -range_y..=range_y { + for dz in -range_xz..=range_xz { + let key = (dx.abs() + dy.abs() + dz.abs(), dx, dy, dz < 0); + if best.is_some_and(|(k, _)| k <= key) { + continue; + } + let p = [at[0] + dx, at[1] + dy, at[2] + dz]; + let state = self.world.block(p[0], p[1], p[2]); + if !self.facts.is_air(state) && matches(p, &self.facts.get(state).name) { + best = Some((key, p)); + } + } + } + } + best.map(|(_, p)| p) + } /// Level.clip with COLLIDER shapes (optionally stopping at any fluid): true on a miss. pub fn clear_between(&self, from: Vec3, to: Vec3, fluids_block: bool) -> bool { let mut scene = SceneCollisions { From ee19d2c00cb0eb34cb33e2803c4208edd3bd35c2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:33:48 +0100 Subject: [PATCH 060/475] Experience orbs: wider pickup area, merging, and lava bounce --- crates/horizon-client/src/entities.rs | 66 ++++++++++++++++++--- crates/horizon-client/src/entity_nbt.rs | 20 ++++--- crates/horizon-client/src/game.rs | 68 ++++++++++++++++++---- crates/horizon-client/src/game/commands.rs | 2 + crates/horizon-client/src/game/pickups.rs | 2 + crates/horizon-client/src/game/remote.rs | 2 + crates/horizon-client/src/game/spill.rs | 12 ++-- 7 files changed, 139 insertions(+), 33 deletions(-) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 6e8089e9d..7454fc863 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -385,8 +385,29 @@ pub struct ExperienceOrb { pub value: u32, pub age: u32, pub on_ground: bool, + /// `ExperienceOrb.count`: orbs merged into this one; each pickup gives + /// `value` once and takes one off. + pub count: u32, + /// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`. + pub ticks: u32, } impl ExperienceOrb { + pub fn bounds(&self) -> Aabb { + let h = 0.25; + Aabb::new( + Vec3::new(self.position.x - h, self.position.y, self.position.z - h), + Vec3::new( + self.position.x + h, + self.position.y + 0.5, + self.position.z + h, + ), + ) + } + /// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed: + /// the same value and ids a multiple of 40 apart. + pub fn can_merge(&self, id: u32, value: u32) -> bool { + self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0 + } /// ExperienceOrb.getExperienceValue: the largest orb size not above `value`. pub fn split(value: u32) -> u32 { [2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1] @@ -416,7 +437,9 @@ impl ExperienceOrb { facts: &BlockFacts, collision: &BlockCollisionRegistry<'_>, player: Option<[f64; 3]>, + next_float: &mut dyn FnMut() -> f32, ) -> bool { + self.ticks += 1; self.previous = self.position; let block = world.block( self.position.x.floor() as i32, @@ -432,6 +455,14 @@ impl ExperienceOrb { } else { self.velocity.y -= 0.03; } + if facts.get(block).fluid == Fluid::Lava { + // ExperienceOrb.tick: lava throws the orb back out. + self.velocity = Vec3::new( + f64::from((next_float() - next_float()) * 0.2), + f64::from(0.2f32), + f64::from((next_float() - next_float()) * 0.2), + ); + } if let Some(p) = player { let d = [ (p[0] - self.position.x) / 8.0, @@ -449,15 +480,7 @@ impl ExperienceOrb { ); } } - let h = 0.25; - let bounds = Aabb::new( - Vec3::new(self.position.x - h, self.position.y, self.position.z - h), - Vec3::new( - self.position.x + h, - self.position.y + 0.5, - self.position.z + h, - ), - ); + let bounds = self.bounds(); let mut scene = SceneCollisions { world, binding: collision, @@ -494,6 +517,31 @@ impl ExperienceOrb { self.age < 6000 } } +/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`: +/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box, +/// adding their counts and keeping the smaller age. +pub fn merge_orbs(orbs: &mut Vec) { + let mut removed = vec![false; orbs.len()]; + for i in 0..orbs.len() { + if removed[i] || orbs[i].ticks % 20 != 1 { + continue; + } + let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5)); + for j in 0..orbs.len() { + if j != i + && !removed[j] + && orbs[j].can_merge(orbs[i].id, orbs[i].value) + && orbs[j].bounds().intersects(area) + { + orbs[i].count += orbs[j].count; + orbs[i].age = orbs[i].age.min(orbs[j].age); + removed[j] = true; + } + } + } + let mut removed = removed.into_iter(); + orbs.retain(|_| !removed.next().unwrap_or(false)); +} /// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel). #[derive(Clone, Copy, Debug, Default)] pub struct Experience { diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..930ab568b 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1043,18 +1043,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag { ("Health", Tag::Short(5)), ("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)), ("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)), - ("Count", Tag::Int(1)), + ("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)), ] { put(&mut tag, key, value); } tag } -/// One Horizon orb per counted orb (vanilla merges orbs of equal value). +/// One orb with its merged `Count`. pub fn orbs_from_nbt(tag: &Tag) -> Vec { let Some(position) = position(tag) else { return Vec::new(); }; - let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize; + let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32; let orb = ExperienceOrb { id: 0, position, @@ -1063,8 +1063,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec { value: number(tag.get("Value")).unwrap_or(1).max(1) as u32, age: number(tag.get("Age")).unwrap_or(0).max(0) as u32, on_ground: flag(tag, "OnGround"), + count, + ticks: 0, }; - vec![orb; count] + vec![orb] } /// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow; @@ -1840,7 +1842,7 @@ mod tests { assert_eq!(number(tag.get("Health")), Some(5)); } #[test] - fn orbs_expand_their_count() { + fn orbs_keep_their_count() { let o = ExperienceOrb { id: 0, position: Vec3::new(1.0, 2.0, 3.0), @@ -1849,12 +1851,14 @@ mod tests { value: 7, age: 3, on_ground: false, + count: 3, + ticks: 0, }; - let mut tag = orb_to_nbt(&o, [0; 4]); - put(&mut tag, "Count", Tag::Int(3)); + let tag = orb_to_nbt(&o, [0; 4]); let back = orbs_from_nbt(&tag); - assert_eq!(back.len(), 3); + assert_eq!(back.len(), 1); assert_eq!(back[0].value, 7); + assert_eq!(back[0].count, 3); } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..715b3a5f1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8905,8 +8905,29 @@ impl Game<'_> { while value > 0 { let v = ExperienceOrb::split(value); value -= v; - let _merge_id = self.world.random.next_int_bound(40); - self.add_orb(at, v); + let merge_id = self.world.random.next_int_bound(40).unwrap_or(0); + self.award_orb(at, v, merge_id); + } + } + /// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a + /// 1 block box of `at`, whose id is a multiple of 40 from `merge_id`, takes + /// the new one as one more in its count (and restarts its age); otherwise + /// a fresh orb spawns. + fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) { + let centre = Vec3::new(at[0], at[1], at[2]); + let half = Vec3::new(0.5, 0.5, 0.5); + let area = Aabb::new(centre.subtract(half), centre.add_vector(half)); + // A server's orbs are its own; a LAN or server guest only shows them. + let target = self.remote.is_none().then(|| { + self.orbs + .iter_mut() + .find(|o| o.can_merge(merge_id as u32, value) && o.bounds().intersects(area)) + }); + if let Some(Some(orb)) = target { + orb.count += 1; + orb.age = 0; + } else { + self.add_orb(at, value); } } /// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from @@ -8928,6 +8949,8 @@ impl Game<'_> { value, age: 0, on_ground: false, + count: 1, + ticks: 0, }); } fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool { @@ -9004,18 +9027,31 @@ impl Game<'_> { }), ); } + // Player.aiStep: the pickup area is the box inflated by (1, 0.5, 1). let bounds = player.map(|p| { let w = f64::from(p.dimensions.width) / 2.0; ( - [p.position.x - w, p.position.y, p.position.z - w], [ - p.position.x + w, - p.position.y + f64::from(p.dimensions.height), - p.position.z + w, + p.position.x - w - 1.0, + p.position.y - 0.5, + p.position.z - w - 1.0, + ], + [ + p.position.x + w + 1.0, + p.position.y + f64::from(p.dimensions.height) + 0.5, + p.position.z + w + 1.0, ], ) }); let mut orbs = std::mem::take(&mut self.orbs); + // An orb has no random of its own here: lava draws from one made on + // first use, apart from the level stream. + let mut lava_random = None; + let mut next_float = || { + lava_random + .get_or_insert_with(dispenser::entity_random) + .next_float() + }; orbs.retain_mut(|o| { let distance = |feet: &[f64; 3]| -> f64 { (feet[0] - o.position.x).powi(2) @@ -9027,12 +9063,22 @@ impl Game<'_> { .filter(|(feet, _)| distance(feet) < 64.0) .min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0))) .map(|(_, target)| *target); - o.tick(&self.world, self.res.facts(), self.collision, target) + o.tick( + &self.world, + self.res.facts(), + self.collision, + target, + &mut next_float, + ) }); + // ExperienceOrb.scanForMerges runs on the server only. + if self.remote.is_none() { + horizon_client::entities::merge_orbs(&mut orbs); + } let mut gained = 0; let mut taken = Vec::new(); if let Some((min, max)) = bounds { - orbs.retain(|o| { + orbs.retain_mut(|o| { let touching = o.position.x + 0.25 > min[0] && o.position.x - 0.25 < max[0] && o.position.y + 0.5 > min[1] @@ -9040,11 +9086,13 @@ impl Game<'_> { && o.position.z + 0.25 > min[2] && o.position.z - 0.25 < max[2]; if touching && self.xp_delay == 0 { - // ExperienceOrb.playerTouch: one orb every 2 ticks. + // ExperienceOrb.playerTouch: one orb every 2 ticks; a + // merged orb gives its value once per count. self.xp_delay = 2; gained += o.value; taken.push(o.clone()); - return false; + o.count -= 1; + return o.count > 0; } true }); diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index f3afdcab6..57501b8fe 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1682,6 +1682,8 @@ impl Game<'_> { value: 0, age: 0, on_ground: false, + count: 1, + ticks: 0, }); name = Text::translate("entity.minecraft.experience_orb", &[]); } diff --git a/crates/horizon-client/src/game/pickups.rs b/crates/horizon-client/src/game/pickups.rs index 1e19bcfc3..040a6f01b 100644 --- a/crates/horizon-client/src/game/pickups.rs +++ b/crates/horizon-client/src/game/pickups.rs @@ -155,6 +155,8 @@ mod tests { value: 1, age: 0, on_ground: false, + count: 1, + ticks: 0, })) } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 38f4ebbad..3293b0690 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1269,6 +1269,8 @@ impl Game<'_> { value: 1, age: 0, on_ground: false, + count: 1, + ticks: 0, }); } if let Some(t) = mob_type { diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index b7283c57b..cc54d127c 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -171,8 +171,8 @@ pub(super) struct Spawn { pub(super) struct Spill { pub(super) spawns: Vec, /// Experience orbs (`ExperienceOrb.award`): where and how much, each - /// having drawn its merge id from the level random. - pub(super) orbs: Vec<([f64; 3], u32)>, + /// and the merge id it drew from the level random. + pub(super) orbs: Vec<([f64; 3], u32, i32)>, /// Block entities the behaviour removed, for the game's own follow-ups /// (open screens, playing songs). pub(super) removed: Vec<([i32; 3], BlockEntity)>, @@ -342,8 +342,8 @@ impl GameLevel<'_> { while amount > 0 { let value = removal::orb_value(amount); amount -= value; - let _merge_id = self.world.random.next_int_bound(40); - self.spill.orbs.push((at, value)); + let merge_id = self.world.random.next_int_bound(40).unwrap_or(0); + self.spill.orbs.push((at, value, merge_id)); } } /// `BlockEntity.preRemoveSideEffects` and `removeBlockEntity` for the block @@ -579,8 +579,8 @@ impl Game<'_> { let motion = s.motion.unwrap_or_else(new_item_motion); self.spawn_item(s.stack, s.pos, motion, s.delay); } - for (at, value) in spill.orbs { - self.add_orb(at, value); + for (at, value, merge_id) in spill.orbs { + self.award_orb(at, value, merge_id); } for (pos, entity) in spill.removed { let key = (pos[0], pos[1], pos[2]); From c7bacfb2933ace0c7394feceb8947d36f2e37e18 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:35:50 +0100 Subject: [PATCH 061/475] Water buckets pick up fish and tadpoles, keeping their data --- crates/horizon-client/src/entity_nbt.rs | 93 +++++++++++++++++++-- crates/horizon-client/src/game/creatures.rs | 37 ++++++-- 2 files changed, 117 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..bdf89195d 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1919,12 +1919,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) { } } const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"]; +/// `Salmon.Variant` names, by id. +const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"]; +/// `TropicalFish.Pattern` names in enum order: the six small patterns, then +/// the six large ones (the order of `extra.variant`). +const TROPICAL_PATTERNS: [&str; 12] = [ + "kob", + "sunstreak", + "snooper", + "dasher", + "brinely", + "spotty", + "flopper", + "stripey", + "glitter", + "blockfish", + "betty", + "clayfish", +]; +/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a +/// tadpole add (`saveToBucketTag`). pub fn bucket_data(m: &Mob) -> Tag { - let mut data = Tag::compound([ - ("Age", Tag::Int(m.extra.age)), - ("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))), - ("Health", Tag::Float(m.health.to_bits())), - ]); + let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]); + if matches!( + m.species(), + crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole + ) { + put(&mut data, "Age", Tag::Int(m.extra.age)); + put( + &mut data, + "AgeLocked", + Tag::Byte(i8::from(m.extra.age_locked)), + ); + } for (key, on) in [ ("NoAI", m.no_ai), ("Silent", m.silent), @@ -1954,6 +1981,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData { .unwrap_or(&"lucy"); data.set_other("minecraft:axolotl/variant", Some(string(variant))); } + // Salmon.saveToBucketTag: the size. + if m.species() == crate::mobs::Species::Salmon { + let size = SALMON_SIZES + .get(usize::from(m.extra.variant)) + .unwrap_or(&"medium"); + data.set_other("minecraft:salmon/size", Some(string(size))); + } + // TropicalFish.saveToBucketTag: the pattern and both colours. + if m.species() == crate::mobs::Species::TropicalFish { + let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15]; + let pattern = TROPICAL_PATTERNS + .get(usize::from(m.extra.variant)) + .unwrap_or(&"kob"); + data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern))); + data.set_other( + "minecraft:tropical_fish/base_color", + Some(string(dye(m.extra.variant2))), + ); + data.set_other( + "minecraft:tropical_fish/pattern_color", + Some(string(dye(m.extra.variant3))), + ); + } data } pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) { @@ -2004,6 +2054,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) { { m.extra.variant = id as u8; } + // Salmon.applyImplicitComponents: the size. + if m.species() == crate::mobs::Species::Salmon + && let Some(name) = text(components.other("minecraft:salmon/size")) + && let Some(id) = SALMON_SIZES + .iter() + .position(|&v| v == name.trim_start_matches("minecraft:")) + { + m.extra.variant = id as u8; + } + // TropicalFish.applyImplicitComponent: the pattern and both colours. + if m.species() == crate::mobs::Species::TropicalFish { + let name = |key| text(components.other(key)); + let dye = |key| { + name(key).and_then(|n| { + crate::mobs::render::DYE_NAMES + .iter() + .position(|&v| v == n.trim_start_matches("minecraft:")) + }) + }; + if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| { + TROPICAL_PATTERNS + .iter() + .position(|&v| v == n.trim_start_matches("minecraft:")) + }) { + m.extra.variant = id as u8; + } + if let Some(id) = dye("minecraft:tropical_fish/base_color") { + m.extra.variant2 = id as u8; + } + if let Some(id) = dye("minecraft:tropical_fish/pattern_color") { + m.extra.variant3 = id as u8; + } + } } #[cfg(test)] diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..05e83432d 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2459,21 +2459,22 @@ impl Game<'_> { .as_ref() .map(|h| h.item.to_string()) .unwrap_or_default(); + // Bucketable.bucketMobPickup: a water bucket scoops up a fish, a + // tadpole or an axolotl, with the data its bucket carries. if item == "minecraft:water_bucket" - && self.mobs[index].species() == Species::Axolotl && self.mobs[index].alive() + && let Some((bucket, sound)) = bucketable(self.mobs[index].species()) { - let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1); + let mut filled = ItemStack::new(bucket, 1); filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components( &self.mobs[index], ))); - self.audio.play( - "minecraft:item.bucket.fill_axolotl", - Some(self.mobs[index].center()), - 1.0, - 1.0, - ); + self.audio + .play(sound, Some(self.mobs[index].center()), 1.0, 1.0); self.trigger_item("minecraft:filled_bucket", &filled); + // Leashable.dropLeash, then discard. + let id = self.mobs[index].id; + self.break_leash(id, true, false); self.mobs[index].removed = true; if self.creative { if let Some(rest) = self.give(filled) { @@ -3253,6 +3254,26 @@ impl Game<'_> { } } } +/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water +/// bucket picks up. +fn bucketable(species: Species) -> Option<(&'static str, &'static str)> { + let fish = "minecraft:item.bucket.fill_fish"; + Some(match species { + Species::Cod => ("minecraft:cod_bucket", fish), + Species::Salmon => ("minecraft:salmon_bucket", fish), + Species::Pufferfish => ("minecraft:pufferfish_bucket", fish), + Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish), + Species::Tadpole => ( + "minecraft:tadpole_bucket", + "minecraft:item.bucket.fill_tadpole", + ), + Species::Axolotl => ( + "minecraft:axolotl_bucket", + "minecraft:item.bucket.fill_axolotl", + ), + _ => return None, + }) +} /// Mob.getBaseExperienceReward /// The damage type id of a Horizon damage kind. pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str { From 628ff01a44d74c43c2e0a2db4afbec532f4fab95 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:35:52 +0100 Subject: [PATCH 062/475] Only zombies that break doors and raiding vindicators get through doors Mobs no longer open or break doors on their own except villagers and copper golems. Zombies that rolled canBreakDoors break doors on hard, and vindicators break doors on normal and hard and open them during a raid, as BreakDoorGoal, VindicatorBreakDoorGoal and RaiderOpenDoorGoal do. A zombie's CanBreakDoors value is now saved and loaded. --- crates/horizon-client/src/entity_nbt.rs | 12 +- crates/horizon-client/src/game.rs | 10 +- crates/horizon-client/src/game/creatures.rs | 15 ++ crates/horizon-client/src/game/raids.rs | 2 + crates/horizon-client/src/mobs.rs | 19 ++ crates/horizon-client/src/mobs/ai.rs | 1 + crates/horizon-client/src/mobs/doors.rs | 252 ++++++++++++++++++++ crates/horizon-client/src/mobs/finalize.rs | 5 +- crates/horizon-client/src/mobs/illager.rs | 9 + crates/horizon-client/src/mobs/raider.rs | 3 + crates/horizon-client/src/mobs/spawning.rs | 5 + crates/horizon-client/src/mobs/undead.rs | 4 + 12 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 crates/horizon-client/src/mobs/doors.rs diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..ba0a62489 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -482,7 +482,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { "minecraft:zombie" => { map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby()))); for (key, value) in [ - ("CanBreakDoors", Tag::Byte(0)), ("DrownedConversionTime", Tag::Int(-1)), ("InWaterTime", Tag::Int(-1)), ] { @@ -507,6 +506,13 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } _ => {} } + // Zombie.addAdditionalSaveData: CanBreakDoors. + if crate::mobs::doors::is_zombie(m.species()) { + map.insert( + "CanBreakDoors".into(), + Tag::Byte(i8::from(m.extra.can_break_doors)), + ); + } crate::mobs::raider::write_nbt(m, map); crate::mobs::riders::write_nbt(m, map); // LivingEntity equipment: what the mob holds now. @@ -925,6 +931,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") { crate::mobs::finalize::set_baby(&mut m, true); } + // Zombie.readAdditionalSaveData: setCanBreakDoors. + if crate::mobs::doors::is_zombie(m.species()) { + crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors")); + } crate::mobs::raider::read_nbt(&mut m, tag); crate::mobs::riders::read_nbt(&mut m, tag); // A mob saved mid-death finishes dying; one saved dead is dropped. diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..a3400f252 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2817,6 +2817,9 @@ impl Game<'_> { 1501 => Some(("minecraft:block.lava.extinguish", 0.5)), 1009 => Some(("minecraft:block.fire.extinguish", 0.5)), 1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)), + // LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break). + 1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)), + 1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)), 1030 => Some(("minecraft:block.anvil.use", 1.0)), 1029 => Some(("minecraft:block.anvil.destroy", 1.0)), 1031 => Some(("minecraft:block.anvil.land", 0.3)), @@ -2836,9 +2839,14 @@ impl Game<'_> { 1029 | 1030 | 1042 | 1043 | 1044 => { self.world.random.next_float() * 0.1 + 0.9 } + 1019 | 1021 => { + (self.world.random.next_float() - self.world.random.next_float()) + * 0.2 + + 1.0 + } _ => 1.0, }; - if matches!(id, 1029 | 1030) { + if matches!(id, 1019 | 1021 | 1029 | 1030) { // Clients play the anvil sound from its level event; // capturing it also sends a duplicate sound packet. self.audio.play_unshared(name, c, volume, pitch); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..4613ad5f7 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1043,6 +1043,21 @@ impl Game<'_> { self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open)); self.process_level_events()?; } + MobEvent::BreakDoor { pos } => { + // BreakDoorGoal.tick: removeBlock, then level events 1021 + // and 2001 with the state that is there now. + let air = self.world.air; + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3)); + self.process_level_events()?; + self.level_event_at(1021, pos, 0); + let left = self.world.block(pos[0], pos[1], pos[2]); + self.level_event_at(2001, pos, left.0 as i32); + } + MobEvent::DestroyProgress { mob, pos, progress } => { + self.destroy_block_progress(mob as i32, pos, progress); + } + MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data), MobEvent::SetBlockName { pos, name, effect } => { if effect { let old = self.world.block(pos[0], pos[1], pos[2]); diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index bedca5708..743f02f44 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -796,6 +796,8 @@ impl Game<'_> { let s = m.extra.raider.get_or_insert_with(Box::default); s.rider = has_rider; s.in_village = in_village; + // Vindicator.customServerAiStep: `ServerLevel.isRaided`. + s.raided = species == Species::Vindicator && raids.nearby(block).is_some(); if s.raid_id.is_some_and(|id| raids.get(id).is_none()) { s.raid_id = None; s.ticks_outside_raid = 0; diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..1d7f337e1 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -25,6 +25,7 @@ pub mod creaking; pub mod creaking_heart; pub mod cube; pub mod data; +pub mod doors; pub mod drops; pub mod enderman; pub mod equine; @@ -1001,6 +1002,24 @@ pub enum MobEvent { pos: [i32; 3], open: bool, }, + /// `BreakDoorGoal.tick` at its end: the door is removed (`removeBlock`), + /// then level events 1021 and 2001 with the state that is left there. + BreakDoor { + pos: [i32; 3], + }, + /// `Level.destroyBlockProgress` for a mob's crack on a block (`-1` + /// clears it). + DestroyProgress { + mob: u32, + pos: [i32; 3], + progress: i32, + }, + /// `Level.levelEvent`: the game plays and sends the event. + LevelEvent { + id: i32, + pos: [i32; 3], + data: i32, + }, /// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock). BreakBlock { pos: [i32; 3], diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..d5eb76202 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -944,6 +944,7 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { | Species::Parched | Species::WitherSkeleton | Species::ZombifiedPiglin => undead::custom_server_ai_step(m, cx), + Species::Vindicator => super::illager::vindicator_server_ai_step(m), Species::Enderman => super::enderman::custom_server_ai_step(m, cx), Species::Blaze => super::nether::blaze_server_ai_step(m, cx), Species::ElderGuardian => super::guardian::elder_server_ai_step(m, cx), diff --git a/crates/horizon-client/src/mobs/doors.rs b/crates/horizon-client/src/mobs/doors.rs new file mode 100644 index 000000000..a55ab9863 --- /dev/null +++ b/crates/horizon-client/src/mobs/doors.rs @@ -0,0 +1,252 @@ +//! Doors a mob gets through on its own: `DoorInteractGoal` with its two +//! users, `BreakDoorGoal` (zombies that can break doors, vindicators in a +//! raid) and `OpenDoorGoal` (`AbstractIllager.RaiderOpenDoorGoal`). +use super::{ + Mob, MobEvent, MobWorld, Species, + goals::{Cx, Goal, MOVE, goal_clone, reduced}, + nav::NavKind, + raider, +}; +use horizon_core::{geometry::Vec3, random::RandomSource}; + +/// `DefaultDoorBreakTime`: `BreakDoorGoal.getDoorBreakTime` is +/// `max(240, doorBreakTime)`, and the vindicator's 6 is below it. +const DOOR_BREAK_TIME: i32 = 240; + +/// `GoalUtils.hasGroundPathNavigation`: ground, wall-climbing and +/// amphibious navigations walk. +pub fn can_navigate_ground(m: &Mob) -> bool { + matches!( + m.ai.nav.kind, + NavKind::Ground | NavKind::WallClimber | NavKind::Amphibious + ) +} +/// `Zombie.setCanBreakDoors`: only a mob that walks can break doors, and +/// then its navigation opens them. +pub fn set_can_break_doors(m: &mut Mob, can_break: bool) { + let can_break = can_break && can_navigate_ground(m); + m.extra.can_break_doors = can_break; + m.ai.nav.can_open_doors = can_break; +} +/// The species that extend `Zombie` (`CanBreakDoors` is theirs to save). +pub fn is_zombie(s: Species) -> bool { + matches!( + s, + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::ZombifiedPiglin + ) +} +/// `DoorBlock.isWoodenDoor`: a door a hand opens, which is any but iron. +fn wooden_door(w: &MobWorld, p: [i32; 3]) -> bool { + let name = w.block_name(p[0], p[1], p[2]); + name.ends_with("_door") && name != "minecraft:iron_door" +} +/// `DoorInteractGoal`'s door: where it is and whether one is there. +#[derive(Clone, Debug, Default)] +struct Interact { + door: [i32; 3], + has_door: bool, +} +impl Interact { + /// `DoorInteractGoal.canUse`: the mob bumped into something and a + /// wooden door is on its path within 1.5 blocks, or at its own head. + fn can_use(&mut self, m: &Mob, w: &MobWorld) -> bool { + if !can_navigate_ground(m) || !m.horizontal_collision { + return false; + } + let Some(path) = m.ai.nav.path.as_ref().filter(|p| !p.is_done()) else { + return false; + }; + for node in path + .nodes + .iter() + .take((path.next + 2).min(path.nodes.len())) + { + self.door = [node.x, node.y + 1, node.z]; + let dx = m.position.x - f64::from(node.x); + let dz = m.position.z - f64::from(node.z); + if dx * dx + dz * dz <= 2.25 { + self.has_door = wooden_door(w, self.door); + if self.has_door { + return true; + } + } + } + let at = m.position; + self.door = [ + at.x.floor() as i32, + at.y.floor() as i32 + 1, + at.z.floor() as i32, + ]; + self.has_door = wooden_door(w, self.door); + self.has_door + } + /// `DoorInteractGoal.isOpen`: the door is still there and open. + fn is_open(&mut self, w: &MobWorld) -> bool { + if !self.has_door { + return false; + } + let d = self.door; + if !w.block_name(d[0], d[1], d[2]).ends_with("_door") { + self.has_door = false; + return false; + } + w.prop(d[0], d[1], d[2], "open") == Some("true") + } + /// `DoorInteractGoal.setOpen`. + fn set_open(&self, cx: &mut Cx, open: bool) { + let d = self.door; + if self.has_door && cx.w.block_name(d[0], d[1], d[2]).ends_with("_door") { + cx.events.push(MobEvent::SetDoorOpen { pos: d, open }); + } + } +} +/// `BlockPos.closerToCenterThan`. +fn closer_to_center(p: [i32; 3], at: Vec3, distance: f64) -> bool { + Vec3::new( + f64::from(p[0]) + 0.5, + f64::from(p[1]) + 0.5, + f64::from(p[2]) + 0.5, + ) + .distance_squared(at) + < distance * distance +} + +/// `BreakDoorGoal`: crack the door in the way for 240 ticks, then remove it. +/// A zombie does it on hard only; `Vindicator.VindicatorBreakDoorGoal` on +/// normal and hard, in a raid, and holds the mob's movement. +#[derive(Clone, Debug)] +pub struct BreakDoor { + interact: Interact, + vindicator: bool, + break_time: i32, + last_break_progress: i32, +} +impl BreakDoor { + /// `Zombie.breakDoorGoal`, added when the zombie can break doors. + pub fn zombie() -> Self { + Self::new(false) + } + /// `Vindicator.VindicatorBreakDoorGoal`. + pub fn vindicator() -> Self { + Self::new(true) + } + fn new(vindicator: bool) -> Self { + Self { + interact: Interact::default(), + vindicator, + break_time: 0, + last_break_progress: -1, + } + } + /// `DOOR_BREAKING_PREDICATE`. + fn valid_difficulty(&self, w: &MobWorld) -> bool { + if self.vindicator { + w.difficulty.normal || w.difficulty.hard + } else { + w.difficulty.hard + } + } + /// `BreakDoorGoal.canUse` after `DoorInteractGoal.canUse`. + fn can_break(&mut self, m: &Mob, w: &MobWorld) -> bool { + self.interact.can_use(m, w) + && w.mob_griefing + && self.valid_difficulty(w) + && !self.interact.is_open(w) + } +} +impl Goal for BreakDoor { + goal_clone!(); + fn flags(&self) -> u8 { + if self.vindicator { MOVE } else { 0 } + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + if self.vindicator { + return raider::has_active_raid(m) + && m.random.next_int_bound(reduced(10)).unwrap_or(1) == 0 + && self.can_break(m, cx.w); + } + m.extra.can_break_doors && self.can_break(m, cx.w) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + (!self.vindicator || raider::has_active_raid(m)) + && self.break_time <= DOOR_BREAK_TIME + && !self.interact.is_open(cx.w) + && closer_to_center(self.interact.door, m.position, 2.0) + && self.valid_difficulty(cx.w) + } + fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { + self.break_time = 0; + if self.vindicator { + m.ai.no_action_time = 0; + } + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + cx.events.push(MobEvent::DestroyProgress { + mob: m.id, + pos: self.interact.door, + progress: -1, + }); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let door = self.interact.door; + if m.random.next_int_bound(20).unwrap_or(1) == 0 { + cx.events.push(MobEvent::LevelEvent { + id: 1019, + pos: door, + data: 0, + }); + if !m.ai.swinging { + m.swing(); + } + } + self.break_time += 1; + let progress = (self.break_time as f32 / DOOR_BREAK_TIME as f32 * 10.0) as i32; + if progress != self.last_break_progress { + cx.events.push(MobEvent::DestroyProgress { + mob: m.id, + pos: door, + progress, + }); + self.last_break_progress = progress; + } + if self.break_time == DOOR_BREAK_TIME && self.valid_difficulty(cx.w) { + cx.events.push(MobEvent::BreakDoor { pos: door }); + } + } +} + +/// `AbstractIllager.RaiderOpenDoorGoal` (`OpenDoorGoal` that does not wait +/// to close): open the door in the way during a raid. It never continues, so +/// it closes the door again as soon as it stops. +#[derive(Clone, Debug, Default)] +pub struct RaiderOpenDoor { + interact: Interact, +} +impl Goal for RaiderOpenDoor { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.interact.can_use(m, cx.w) && raider::has_active_raid(m) + } + fn can_continue(&mut self, _m: &mut Mob, _cx: &mut Cx) -> bool { + false + } + fn start(&mut self, _m: &mut Mob, cx: &mut Cx) { + self.interact.set_open(cx, true); + } + fn stop(&mut self, _m: &mut Mob, cx: &mut Cx) { + self.interact.set_open(cx, false); + } +} diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 4eeed885b..4e58e9475 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -1144,7 +1144,8 @@ impl Ctx<'_, L, R> { } } } - m.extra.can_break_doors = self.float() < special * 0.1; + let can_break_doors = self.float() < special * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); if reason != Reason::Conversion { self.populate(m); self.enchant(m); @@ -1205,7 +1206,7 @@ impl Ctx<'_, L, R> { ) { m.health = max_health(m); } - m.extra.can_break_doors = true; + super::doors::set_can_break_doors(m, true); } } fn husk(&mut self, m: &mut Mob, g: Option, reason: Reason) -> Option { diff --git a/crates/horizon-client/src/mobs/illager.rs b/crates/horizon-client/src/mobs/illager.rs index 45ef076d3..641a474f6 100644 --- a/crates/horizon-client/src/mobs/illager.rs +++ b/crates/horizon-client/src/mobs/illager.rs @@ -72,6 +72,8 @@ pub fn register_vindicator(m: &mut Mob) { let g = &mut m.ai.goals; g.add(0, Float); g.add(1, avoid_creaking(1.0, 1.2)); + g.add(2, super::doors::BreakDoor::vindicator()); + g.add(3, super::doors::RaiderOpenDoor::default()); g.add(4, super::raider::HoldGroundAttack::new(10.0)); g.add(5, MeleeAttack::new(1.0, false)); g.add(8, Stroll::new(0.6)); @@ -86,6 +88,13 @@ pub fn register_vindicator(m: &mut Mob) { .when(|m, _| m.custom_name.as_deref() == Some("Johnny")), ); } +/// `Vindicator.customServerAiStep`: its navigation opens doors while a raid +/// is at its position. +pub fn vindicator_server_ai_step(m: &mut Mob) { + if super::doors::can_navigate_ground(m) { + m.ai.nav.can_open_doors = super::raider::state(m).is_some_and(|s| s.raided); + } +} // Evoker state: `counter` spellCastingTickCount, `variant2` the spell // being cast (0 none, 1 vexes, 2 fangs, 3 wololo), `counter2` the wololo // sheep's id. diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 6c3ab9b64..d2ed62374 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -30,6 +30,9 @@ pub struct State { pub celebrating: bool, /// The raid is active (`hasActiveRaid`); set by the game every tick. pub active_raid: bool, + /// A raid is active within 96 blocks (`ServerLevel.isRaided`); set by + /// the game every tick for vindicators. + pub raided: bool, /// The raid is won or lost (`Raid.isOver`), lost (`isLoss`). pub raid_over: bool, pub raid_loss: bool, diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..f4e55e7d0 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -35,6 +35,8 @@ pub fn placement_ok(species: Species, w: &MobWorld, x: i32, y: i32, z: i32) -> b #[derive(Clone, Copy, Debug)] pub struct LocalDifficulty { pub effective: f32, + /// The world difficulty is normal. + pub normal: bool, /// The world difficulty is hard. pub hard: bool, } @@ -45,9 +47,11 @@ impl LocalDifficulty { /// the overworld clock, the chunk's inhabited time and the moon. pub fn new(base: u8, total: i64, inhabited: i64, moon: f32) -> Self { let hard = base == 3; + let normal = base == 2; if base == 0 { return Self { effective: 0.0, + normal, hard, }; } @@ -60,6 +64,7 @@ impl LocalDifficulty { } Self { effective: f32::from(base) * (0.75 + global + local), + normal, hard, } } diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index e77a37641..b96ad2091 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -2,6 +2,7 @@ //! stray, bogged, wither skeleton): goals, bow attacks, water conversion. use super::{ Mob, MobEvent, Species, Who, + doors::BreakDoor, goals::{ Avoid, AvoidEntity, Cx, FleeSun, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, RandomLookAround, RestrictSun, Stroll, goal_clone, @@ -15,6 +16,7 @@ pub fn register(m: &mut Mob) { match m.species() { S::Zombie | S::Husk | S::ZombieVillager => { let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(4, AttackTurtleEgg::default()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); @@ -42,6 +44,7 @@ pub fn register(m: &mut Mob) { S::ZombifiedPiglin => { // ZombifiedPiglin.addBehaviourGoals: neutral until provoked. let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); g.add(2, MeleeAttack::new(1.0, false).zombie()); @@ -58,6 +61,7 @@ pub fn register(m: &mut Mob) { S::Drowned => { // Zombie.registerGoals, then Drowned.addBehaviourGoals. let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(4, AttackTurtleEgg::default()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); From 4ae514d0ae464b255b3df073d7c9b7f97d452cc5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:36:10 +0100 Subject: [PATCH 063/475] Throw leftover items from the eyes, and creative players keep their stack on an armor stand --- crates/horizon-client/src/game.rs | 21 ++++++++++++------- .../horizon-client/src/game/armor_stands.rs | 5 +++++ crates/horizon-client/src/game/potions.rs | 3 ++- crates/horizon-client/src/game/stations.rs | 3 ++- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..a38525e22 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2222,11 +2222,13 @@ impl Game<'_> { // Player.drop: the drop statistics. self.stats.add("dropped", &thrown.item, thrown.count as i32); self.stats.custom("drop", 1); - self.throw_from_eyes(thrown, eye); + self.throw_from_eyes(thrown, eye, true); } /// `Player.createItemStackToDrop`: an item entity thrown from the eyes - /// along the view direction. - fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) { + /// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the + /// drop key) names the player as the thrower; `drop(stack, false)`, for + /// what no longer fits in the inventory, does not. + fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3], thrown_from_hand: bool) { let (yaw, pitch) = ( f64::from(self.yaw).to_radians(), f64::from(self.pitch).to_radians(), @@ -2240,7 +2242,7 @@ impl Game<'_> { yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength, ]; self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40); - if self.lan_acting.is_none() { + if thrown_from_hand && self.lan_acting.is_none() { let uuid = self.level.ensure_player_uuid(); self.dropped.last_mut().unwrap().thrower = Some(uuid); } @@ -4517,7 +4519,7 @@ impl Game<'_> { && let Some(stack) = self.carried.take() && let Some(eye) = self.eye_position() { - self.throw_from_eyes(stack, eye); + self.throw_from_eyes(stack, eye, false); } } self.tick_world(running) @@ -5606,7 +5608,8 @@ impl Game<'_> { if let Some(rest) = self.give(stack) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); + // AbstractContainerMenu.dropOrPlaceInInventory: `drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } self.craft_size = 2; @@ -8437,7 +8440,8 @@ impl Game<'_> { } fn drop_stack(&mut self, stack: ItemStack) { if let Some(eye) = self.0.eye_position() { - self.0.spawn_item(stack, eye, [0.0, 0.1, 0.0], 40); + // ItemUtils.createFilledResult: `player.drop(stack, false)`. + self.0.throw_from_eyes(stack, eye, false); } } } @@ -8755,7 +8759,8 @@ impl Game<'_> { } else if let Some(rest) = self.give(filled) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + // ItemUtils.createFilledResult: `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } return Ok(UseResult::Success { swing: true }); diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index f175af401..9a8c846c5 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -453,6 +453,11 @@ impl Game<'_> { } // ArmorStand.swapItem. match held { + // A player with infinite materials puts a copy of one on an empty + // slot and keeps the stack. + Some(h) if self.creative && current.is_none() => { + self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h }); + } Some(h) if h.count > 1 => { if current.is_some() { return true; diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b28c2fb4d..1631131ae 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -266,7 +266,8 @@ impl Game<'_> { } else if let Some(rest) = self.give(filled) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + // `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } /// Drinking a potion while right-click is held; true when handled. diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index f7f05e1c4..f35691237 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -713,7 +713,8 @@ impl Game<'_> { if let Some(rest) = self.give(stack) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); + // Inventory.placeItemBackInInventory: `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } /// `Player.giveExperienceLevels`. From bd59cdc25cc3da28287933f55aa0aaf0a5608cd0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:39:16 +0100 Subject: [PATCH 064/475] Let turtles lay eggs that hatch into baby turtles A bred turtle now carries an egg instead of the baby appearing at once. She walks home, digs into sand for a while and lays a turtle egg there. A hatching egg gives one baby turtle per egg with the egg as its home, and a baby that grows up drops a scute. --- crates/horizon-client/src/entity_nbt.rs | 14 ++ crates/horizon-client/src/game.rs | 5 + crates/horizon-client/src/game/creatures.rs | 63 +++++- crates/horizon-client/src/mobs.rs | 7 + crates/horizon-client/src/mobs/ai.rs | 11 ++ crates/horizon-client/src/mobs/body_anim.rs | 4 +- crates/horizon-client/src/mobs/goals.rs | 8 + crates/horizon-client/src/mobs/turtle.rs | 209 ++++++++++++++++++++ 8 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 crates/horizon-client/src/mobs/turtle.rs diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..f7c843264 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -357,6 +357,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { "minecraft:bat" => { map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag))); } + "minecraft:turtle" => { + map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag))); + if let Some(home) = m.extra.home { + map.insert("home_pos".into(), Tag::IntArray(home.to_vec())); + } + } "minecraft:creaking" => { // home_pos: the heart it is bound to. if let Some(home) = m.extra.home { @@ -847,6 +853,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - { crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]); } + if m.species() == crate::mobs::Species::Turtle { + // Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`. + m.extra.flag = flag(tag, "has_egg"); + m.extra.home = match tag.get("home_pos") { + Some(Tag::IntArray(home)) if home.len() == 3 => Some([home[0], home[1], home[2]]), + _ => None, + }; + } if m.species() == crate::mobs::Species::Wither { m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32; } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..f29a71f20 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2968,6 +2968,11 @@ impl Game<'_> { kind: "hatch_sniffer", .. } => self.hatch_sniffer([pos.x, pos.y, pos.z]), + LevelEvent::BlockEvent { + pos, + state, + kind: "hatch_turtles", + } => self.hatch_turtles([pos.x, pos.y, pos.z], state), LevelEvent::BlockEvent { pos, kind: "portal_spawn", diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..03911ffda 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1185,6 +1185,10 @@ impl Game<'_> { self.mob_vibration_particle(from, to, ticks) } MobEvent::DigParticles { at } => self.warden_dig_particles(at), + MobEvent::BlockBreakEffect { pos, block } => { + let state = self.world.block(block[0], block[1], block[2]); + self.destroy_particles(pos, state); + } MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius), MobEvent::CreakingHurt { home, @@ -1222,6 +1226,11 @@ impl Game<'_> { } } MobEvent::GiftLoot { table, at } => { + // Turtle.ageBoundaryReached: only with mob loot on. + if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops") + { + continue; + } let drops = self.res .items() @@ -1234,6 +1243,39 @@ impl Game<'_> { } Ok(()) } + /// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle + /// per egg, a day from grown, with the egg as its home (the sound, the + /// crumbs and the block's removal came from the level). + pub(super) fn hatch_turtles( + &mut self, + pos: [i32; 3], + state: horizon_world::registry::BlockStateId, + ) { + let eggs = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("eggs")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + for i in 0..eggs { + let at = [ + f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2, + f64::from(pos[1]), + f64::from(pos[2]) + 0.3, + ]; + if let Some(id) = self.spawn_mob("minecraft:turtle", at, true) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.extra.age = -24000; + m.extra.home = Some(pos); + m.yaw = 0.0; + m.body_yaw = 0.0; + m.head_yaw = 0.0; + } + } + } fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) { let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { return; @@ -1289,6 +1331,8 @@ impl Game<'_> { .unwrap_or_else(|| parent.clone()); let mut child = parent.clone(); child.baby = true; + // TurtleBreedGoal.breed triggers it without a child. + let turtle = p.species() == Species::Turtle; if matches!( ( p.species(), @@ -1301,7 +1345,7 @@ impl Game<'_> { ) { child.kind = "minecraft:mule".into(); } - self.trigger_bred(&parent, &partner_view, Some(&child)); + self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child)); } if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) { q.extra.age = 6000; @@ -1313,6 +1357,15 @@ impl Game<'_> { self.lay_sniffer_egg(&p); return; } + // TurtleBreedGoal.breed: the goal gave the mother her egg; only the + // experience is left, and only with mob loot on. + if p.species() == Species::Turtle { + if self.ui.rules.bool("mob_drops") { + let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; + self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + } + return; + } let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0; let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0; // A horse and a donkey have a mule. @@ -2542,6 +2595,7 @@ impl Game<'_> { let mut handled = true; let mut drops: Vec<(String, u32)> = Vec::new(); let is_food = m.kind.food.contains(&item); + let baby_turtle = species == Species::Turtle && m.is_baby(); match species { Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => { give = Some("minecraft:milk_bucket"); @@ -2820,6 +2874,13 @@ impl Game<'_> { if !handled { return Ok(false); } + // Turtle.ageBoundaryReached: food that finishes a baby's growth. + if baby_turtle && !self.mobs[index].is_baby() { + self.handle_mob_events(vec![MobEvent::GiftLoot { + table: "minecraft:gameplay/turtle_grow", + at: feet, + }])?; + } self.swing_arm(); if let Some(s) = sound { let pitch = diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..0a158333d 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -68,6 +68,7 @@ pub mod tame; pub mod trader; pub mod transport_items; pub mod trial_spawner; +pub mod turtle; pub mod undead; pub mod vex; pub mod villager; @@ -1151,6 +1152,12 @@ pub enum MobEvent { DigParticles { at: [f64; 3], }, + /// `LevelEvent.PARTICLES_DESTROY_BLOCK` (2001) at `pos` for the block at + /// `block`: a turtle's digging. + BlockBreakEffect { + pos: [i32; 3], + block: [i32; 3], + }, /// `Warden.applyDarknessAround`: darkness for survival players nearby. Darkness { at: [f64; 3], diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..f0d5db934 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -183,6 +183,9 @@ pub fn register(m: &mut Mob) { if m.species() == Species::Rabbit { super::rabbit::register(m); } + if m.species() == Species::Turtle { + super::turtle::register(m); + } } Family::Inert => {} Family::Witch => super::witch::register(m), @@ -712,6 +715,13 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & // setAge at the age boundary: refreshDimensions. if m.extra.age >= 0 { m.fudge_position_after_size_change(w, bw, bh); + // Turtle.ageBoundaryReached: a grown turtle drops a scute. + if m.species() == Species::Turtle { + events.push(MobEvent::GiftLoot { + table: "minecraft:gameplay/turtle_grow", + at: [m.position.x, m.position.y, m.position.z], + }); + } } } else if m.extra.age > 0 { m.extra.age -= 1; @@ -999,6 +1009,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events), Species::Chicken => farm::chicken_ai_step(m, w, events), + Species::Turtle => super::turtle::ai_step(m, w, events), Species::Sheep if m.extra.anim_ticks > 0 => { m.extra.anim_ticks -= 1; } diff --git a/crates/horizon-client/src/mobs/body_anim.rs b/crates/horizon-client/src/mobs/body_anim.rs index 4411d347b..bbb44c5ab 100644 --- a/crates/horizon-client/src/mobs/body_anim.rs +++ b/crates/horizon-client/src/mobs/body_anim.rs @@ -969,8 +969,8 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool { s, &Turtle { on_land: !m.fluids.in_water && m.on_ground, - laying_egg: false, - has_egg: false, + laying_egg: m.extra.flag2, + has_egg: m.extra.flag, }, ), Species::Strider => strider(p, s, m.rider.is_some()), diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..7cb3011c9 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1762,6 +1762,10 @@ impl Goal for Breed { if tamable && (!m.extra.tame || m.extra.sitting) { return false; } + // TurtleBreedGoal.canUse: not while carrying an egg. + if species == Species::Turtle && super::turtle::has_egg(m) { + return false; + } let id = m.id; // Horses and donkeys breed together (AbstractHorse.canParent: tame). // Sniffer.canMate: calm states only. @@ -1832,6 +1836,10 @@ impl Goal for Breed { }); m.extra.in_love = 0; m.extra.age = 6000; + // TurtleBreedGoal.breed: the mother carries an egg, not a baby. + if m.species() == Species::Turtle { + m.extra.flag = true; + } cx.events.push(MobEvent::Breed { parent: m.id, partner: p.id, diff --git a/crates/horizon-client/src/mobs/turtle.rs b/crates/horizon-client/src/mobs/turtle.rs new file mode 100644 index 000000000..0596adfdf --- /dev/null +++ b/crates/horizon-client/src/mobs/turtle.rs @@ -0,0 +1,209 @@ +//! Turtles (`Turtle`): the egg a bred mother carries (`TurtleBreedGoal`), her +//! walk home (`TurtleGoHomeGoal`) and the egg she digs into the sand +//! (`TurtleLayEggGoal`). `extra.flag` is `hasEgg`, `extra.flag2` `isLayingEgg`, +//! `extra.counter` `layEggCounter` and `extra.home` `homePos`. +use super::{ + Mob, MobEvent, MobWorld, + goals::{Breed, Cx, Goal, JUMP, MOVE, MoveToBlock, goal_clone, reduced}, +}; +use horizon_core::random::RandomSource; + +pub fn register(m: &mut Mob) { + let g = &mut m.ai.goals; + g.add(1, Breed::new(1.0)); + g.add(1, LayEgg(MoveToBlock::new(1.0, 16, 1))); + g.add(4, GoHome::default()); +} +/// `Turtle.hasEgg`. +pub fn has_egg(m: &Mob) -> bool { + m.extra.flag +} +/// `Turtle.setLayingEgg`. +fn set_laying_egg(m: &mut Mob, on: bool) { + m.extra.counter = i32::from(on); + m.extra.flag2 = on; +} +/// `homePos.closerToCenterThan(position, distance)`; a turtle that never +/// had a home is at home where it stands. +fn close_to_home(m: &Mob, distance: f64) -> bool { + let home = m.extra.home.unwrap_or([ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]); + let d = [ + f64::from(home[0]) + 0.5 - m.position.x, + f64::from(home[1]) + 0.5 - m.position.y, + f64::from(home[2]) + 0.5 - m.position.z, + ]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < distance * distance +} +fn block_of(m: &Mob) -> [i32; 3] { + [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ] +} +/// `TurtleEggBlock.isSand`. +fn is_sand(w: &MobWorld, p: [i32; 3]) -> bool { + w.has_tag("minecraft:sand", w.block_name(p[0], p[1], p[2])) +} +/// `Turtle.aiStep`: while digging, sand crumbs fly every fifth tick. +pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + if m.alive() && m.extra.flag2 && m.extra.counter >= 1 && m.extra.counter % 5 == 0 { + let at = block_of(m); + let below = [at[0], at[1] - 1, at[2]]; + if is_sand(w, below) { + events.push(MobEvent::BlockBreakEffect { + pos: at, + block: below, + }); + } + } +} + +/// `Turtle.TurtleLayEggGoal`: a mother with an egg within 9 blocks of home +/// walks to sand with air above, digs for 200 ticks and lays it. +#[derive(Clone, Debug)] +struct LayEgg(MoveToBlock); +impl LayEgg { + /// `isValidTarget`: sand with air above it. + fn valid(w: &MobWorld, p: [i32; 3]) -> bool { + w.facts.is_air(w.world.block(p[0], p[1] + 1, p[2])) && is_sand(w, p) + } +} +impl Goal for LayEgg { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE | JUMP + } + fn name(&self) -> &'static str { + "TurtleLayEggGoal" + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + if !(has_egg(m) && close_to_home(m, 9.0)) { + return false; + } + // MoveToBlockGoal.canUse. + if self.0.next_start > 0 { + self.0.next_start -= 1; + return false; + } + self.0.next_start = reduced(200 + m.random.next_int_bound(200).unwrap_or(0)); + self.0.find_nearest(m, cx.w, Self::valid) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_continue(cx.w, Self::valid) && has_egg(m) && close_to_home(m, 9.0) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx.w); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.tick(m, cx.w); + if m.fluids.in_water || !self.0.reached { + return; + } + if m.extra.counter < 1 { + set_laying_egg(m, true); + } else if m.extra.counter > 200 { + let at = [m.position.x, m.position.y, m.position.z]; + let pitch = 0.9 + m.random.next_float() * 0.2; + cx.events + .push(MobEvent::Sound("entity.turtle.lay_egg", at, 0.3, pitch)); + // Blocks.TURTLE_EGG with EGGS = nextInt(4) + 1 above the sand. + let eggs = m.random.next_int_bound(4).unwrap_or(0) + 1; + let registry = cx.w.registry; + let state = registry + .default_state("minecraft:turtle_egg") + .and_then(|d| registry.state(d).cloned()) + .and_then(|mut s| { + s.properties.insert("eggs".into(), eggs.to_string()); + registry.resolve(&s).ok() + }); + let b = self.0.block; + if let Some(state) = state { + cx.events.push(MobEvent::SetBlock { + pos: [b[0], b[1] + 1, b[2]], + state, + }); + } + m.extra.flag = false; + set_laying_egg(m, false); + m.extra.in_love = 600; + } + if m.extra.flag2 { + m.extra.counter += 1; + } + } +} + +/// `Turtle.TurtleGoHomeGoal`: a mother with an egg heads home; any adult +/// that strayed 64 blocks away does now and then. +#[derive(Clone, Debug, Default)] +struct GoHome { + stuck: bool, + close_to_home_try_ticks: i32, +} +impl Goal for GoHome { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn name(&self) -> &'static str { + "TurtleGoHomeGoal" + } + fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { + if m.is_baby() { + false + } else if has_egg(m) { + true + } else { + m.random.next_int_bound(reduced(700)).unwrap_or(1) == 0 && !close_to_home(m, 64.0) + } + } + fn start(&mut self, _m: &mut Mob, _cx: &mut Cx) { + self.stuck = false; + self.close_to_home_try_ticks = 0; + } + fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { + !close_to_home(m, 7.0) && !self.stuck && self.close_to_home_try_ticks <= reduced(600) + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let home = m.extra.home.unwrap_or(block_of(m)); + let close = close_to_home(m, 16.0); + if close { + self.close_to_home_try_ticks += 1; + } + if !m.ai.nav.is_done() { + return; + } + let to = [ + f64::from(home[0]) + 0.5, + f64::from(home[1]), + f64::from(home[2]) + 0.5, + ]; + let mut next = m + .default_random_pos_towards(cx.w, 16, 3, to, std::f64::consts::PI / 10.0) + .or_else(|| m.default_random_pos_towards(cx.w, 8, 7, to, std::f64::consts::FRAC_PI_2)); + if let Some(at) = next + && !close + && cx.w.block_name( + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ) != "minecraft:water" + { + next = m.default_random_pos_towards(cx.w, 16, 5, to, std::f64::consts::FRAC_PI_2); + } + match next { + Some(at) => { + m.nav_move_to(cx.w, at, 1.0); + } + None => self.stuck = true, + } + } +} From 4abf5c0ea2d907024318aaf2343139fd1273b7b2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:58:57 +0100 Subject: [PATCH 065/475] Play no place sound after using an empty hand or a non-block item on a block --- crates/horizon-client/src/game/remote.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 857f955da..3bc62510e 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3950,15 +3950,11 @@ impl Game<'_> { world_border: false, sequence: seq, })); - let target = [ - hit.pos[0] + hit.face[0], - hit.pos[1] + hit.face[1], - hit.pos[2] + hit.face[2], - ]; - let ticks = self.ticks; + // Only a predicted placement (predict_place) marks a cell whose + // resend plays a place sound: an empty hand or a non-block item + // places nothing (BlockItem.place is where the sound comes from). if let Some(r) = self.remote.as_mut() { r.use_target = Some(hit.pos); - r.placing = Some((target, ticks)); } if reacts { self.predict_use(&changes, seq); From 0c22d75a2e6a88fa8daef1c8c59c34d2f154c6f2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:02:41 +0100 Subject: [PATCH 066/475] Satisfy clippy --- crates/horizon-client/src/game/piglins.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/piglins.rs b/crates/horizon-client/src/game/piglins.rs index 337efe8a6..7e58620bf 100644 --- a/crates/horizon-client/src/game/piglins.rs +++ b/crates/horizon-client/src/game/piglins.rs @@ -30,15 +30,13 @@ impl Game<'_> { /// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes /// to its slot, anything else to the main hand. fn piglin_slot(&self, item: &str) -> usize { - let slot = match self + let slot = self .res .items() .get(item) .and_then(|i| i.equip_slot.as_deref()) - { - Some(s @ ("head" | "chest" | "legs" | "feet")) => s, - _ => "mainhand", - }; + .filter(|s| matches!(*s, "head" | "chest" | "legs" | "feet")) + .unwrap_or("mainhand"); SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0) } /// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted From 84b621096a193b3ff34fa2db9b93d4201c419c35 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:02:46 +0100 Subject: [PATCH 067/475] Satisfy clippy --- crates/horizon-client/src/mobs/piglin.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 2c92ecd0a..59c0a9344 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -269,7 +269,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.extra.counter2 = 0; } // PiglinSpecificSensor (every 20 ticks): the nearest repelling block. - if m.species() == Species::Piglin && m.alive() && m.age.wrapping_add(m.id) % 20 == 0 { + if m.species() == Species::Piglin && m.alive() && m.age.wrapping_add(m.id).is_multiple_of(20) { m.extra.repellent = find_repellent(m, w); } } From 7d5a05226183aedba5a95047d8ecf9f91a3442fa Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:47:55 +0100 Subject: [PATCH 068/475] Target blocks react to projectile hits A target block never changed its power when something hit it. Every projectile that hits a block now asks the block how to react, as vanilla does: a target block takes its power from where the projectile landed (1 to 15 by the distance from the face centre) and resets after 20 ticks for an arrow or 8 for any other projectile. A player's hit also counts the target_hit statistic. --- crates/horizon-client/src/game/creatures.rs | 24 +++++++++++++ crates/horizon-world/src/level/mod.rs | 16 +++++++++ crates/horizon-world/src/level/redstone.rs | 37 +++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..d7e42f8c6 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1993,6 +1993,23 @@ impl Game<'_> { self.creatures.projectiles.push(arrow); } } + /// `TargetBlock.onProjectileHit`: a target block takes its power from + /// where the projectile landed and resets after 20 ticks for an arrow, 8 + /// for any other projectile. A player's shot counts as a target hit. + fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) { + let block = BlockPos::new(pos[0], pos[1], pos[2]); + let arrow = p.arrow.is_some(); + let location = [at.x, at.y, at.z]; + let hit = self.with_level(|b, level, ticks| { + b.target_projectile_hit(level, ticks, block, location, face, arrow) + }); + if hit.is_some() { + let _ = self.process_level_events(); + if p.owner == Some(Who::Player) { + self.stats.custom("target_hit", 1); + } + } + } pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> { self.combat.projectile_owner = match p.owner { Some(Who::Mob(id)) => Some(id), @@ -2002,6 +2019,13 @@ impl Game<'_> { Hit::Entity { at, .. } | Hit::Block { at, .. } => at, Hit::None => return Ok(()), }; + // Projectile.onHitBlock: BlockState.onProjectileHit, for every + // projectile (a wind charge out of the world's top has no face). + if let Hit::Block { pos, face, .. } = hit + && face != [0; 3] + { + self.target_block_hit(p, pos, at, face); + } if let Hit::Entity { who: Who::Mob(id), at, diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index 3831efbd6..38b953278 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -779,6 +779,22 @@ impl Behaviours { ) -> i32 { self.sim(level, ticks).best_neighbor_signal(pos) } + /// `BlockState.onProjectileHit` of a target block (`TargetBlock`) hit by + /// a projectile at `location` on the face with normal `face`; `arrow` + /// for an `AbstractArrow`. Returns the output strength, or None when the + /// block at `pos` is not a target block. + pub fn target_projectile_hit( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + location: [f64; 3], + face: [i32; 3], + arrow: bool, + ) -> Option { + self.sim(level, ticks) + .target_projectile_hit(pos, location, face, arrow) + } } /// One mutable view used by the ported behaviour code. diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 92f9059e5..89bcfc5f2 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -1187,6 +1187,43 @@ impl Sim<'_, L> { self.set_block(pos, next, 2); } } + /// `TargetBlock.onProjectileHit` for a projectile that hit `face` of the + /// block at `pos` at `location`: the output power from how near the + /// face's centre it landed (1 to 15), reset after 20 ticks for an arrow + /// and 8 for anything else, unless a reset is already scheduled. None + /// when the block is not a target block, else the strength. + pub(crate) fn target_projectile_hit( + &mut self, + pos: BlockPos, + location: [f64; 3], + face: [i32; 3], + arrow: bool, + ) -> Option { + let b = self.b; + let state = self.state(pos); + if !b.data(state).is(Class::Target) { + return None; + } + // `getRedstoneStrength`: the distance from the centre along the + // face's two other axes (Mth.frac of the location, minus a half). + let off = |v: f64| (v - v.floor() - 0.5).abs(); + let [dx, dy, dz] = [off(location[0]), off(location[1]), off(location[2])]; + let distance = if face[1] != 0 { + dx.max(dz) + } else if face[2] != 0 { + dx.max(dy) + } else { + dy.max(dz) + }; + let strength = ((15.0 * ((0.5 - distance) / 0.5).clamp(0.0, 1.0)).ceil() as i32).max(1); + let block = b.block(state); + if !self.ticks.has_block_tick(pos, block) { + // `setOutputPower`. + self.set_block_and_update(pos, b.table.set_int(state, b.props.power, strength)); + self.schedule_tick(pos, block, if arrow { 20 } else { 8 }); + } + Some(strength) + } } /// Order `positions` as a `java.util.HashSet` of capacity 16 would From d136dda0928f3150fead27675fa812eef83d8d66 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:08:48 +0100 Subject: [PATCH 069/475] Bee stings only poison on Normal and Hard --- crates/horizon-client/src/mobs/bee.rs | 19 +++++++++++++------ crates/horizon-client/src/mobs/spawning.rs | 4 ++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 27d8e7c84..741a78df6 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -323,12 +323,19 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) { match who { Who::Player => { cx.events.push(MobEvent::AttackPlayer(amount, from)); - let seconds = if cx.w.difficulty.hard { 18 } else { 10 }; - cx.events.push(MobEvent::EffectPlayer { - effect: "minecraft:poison", - ticks: seconds * 20, - amplifier: 0, - }); + // Poison only on normal (10 s) and hard (18 s). + let seconds = match cx.w.difficulty.base { + 2 => 10, + 3 => 18, + _ => 0, + }; + if seconds > 0 { + cx.events.push(MobEvent::EffectPlayer { + effect: "minecraft:poison", + ticks: seconds * 20, + amplifier: 0, + }); + } } Who::Mob(id) => cx.events.push(MobEvent::AttackMob { target: id, diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..57ecb1692 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -37,6 +37,8 @@ pub struct LocalDifficulty { pub effective: f32, /// The world difficulty is hard. pub hard: bool, + /// The world difficulty id, 0 peaceful to 3 hard. + pub base: u8, } /// `DimensionType.MOON_BRIGHTNESS_PER_PHASE`. pub const MOON_BRIGHTNESS: [f32; 8] = [1.0, 0.75, 0.5, 0.25, 0.0, 0.25, 0.5, 0.75]; @@ -49,6 +51,7 @@ impl LocalDifficulty { return Self { effective: 0.0, hard, + base, }; } let global = ((total as f32 - 72000.0) / 1_440_000.0).clamp(0.0, 1.0) * 0.25; @@ -61,6 +64,7 @@ impl LocalDifficulty { Self { effective: f32::from(base) * (0.75 + global + local), hard, + base, } } /// `getSpecialMultiplier`. From 790b191ed18f1e0fd839df8719a7d6300d3aac69 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:09:20 +0100 Subject: [PATCH 070/475] Bees let out of a sedated hive stay out for 400 ticks --- crates/horizon-client/src/mobs/bee.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 27d8e7c84..c351439a5 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -421,10 +421,10 @@ pub fn drop_off_nectar(m: &mut Mob) { set_has_nectar(m, false); st(m).crops_grown = 0; } -/// `Bee.setStayOutOfHiveCountdown`, which in 26.3 only resets the time -/// without nectar. +/// `Bee.setStayOutOfHiveCountdown`: the bee will not enter a hive for 400 +/// ticks (the hive passes that when a sedated hive lets its bees out). pub fn set_stay_out_of_hive_countdown(m: &mut Mob) { - st(m).ticks_without_nectar = 0; + st(m).stay_out_of_hive = 400; } fn drop_hive(m: &mut Mob) { let b = st(m); From e553db9bb5505ce579134136f6349432f21d6f30 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:10:09 +0100 Subject: [PATCH 071/475] Let frogs take fall damage five points below the base amount --- crates/horizon-client/src/mobs/living.rs | 6 +++++- crates/horizon-client/src/mobs/species.rs | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 394821ace..f089b5355 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -629,7 +629,11 @@ impl Mob { return; } let power = self.fall_distance + 1.0e-6 - self.kind.safe_fall_distance; - let damage = (power * self.kind.fall_damage_multiplier).floor(); + let mut damage = (power * self.kind.fall_damage_multiplier).floor(); + // Frog.calculateFallDamage: five points less than the base damage. + if self.species() == super::Species::Frog { + damage -= 5.0; + } if damage > 0.0 { let at = [self.position.x, self.position.y, self.position.z]; let sound = if damage > 4.0 { diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index f48a21fe9..fb62365a7 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -1447,7 +1447,6 @@ impl Def { | S::IronGolem | S::Cat | S::Ocelot - | S::Frog | S::Slime | S::MagmaCube | S::Wither From 7cc8def23f1d74efbd6b5dfa096b40e540a10c4c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:10:57 +0100 Subject: [PATCH 072/475] Let a lit TNT minecart explode when it hits a wall --- crates/horizon-client/src/game/minecarts.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a19b6df73..ceff3f127 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -539,7 +539,6 @@ impl Game<'_> { // applyGravity c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 }; let pos = self.rail_block(c.position); - let fall_speed = c.velocity; if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) { self.move_along_track(c, pos, &rail, intent); if &*rail.name == "minecraft:activator_rail" { @@ -581,15 +580,15 @@ impl Game<'_> { } } CartKind::Tnt => { - let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z; + // MinecartTNT.tick: the fuse and the collision check are separate. if c.fuse > 0 { c.fuse -= 1; let at = [c.position.x, c.position.y + 0.5, c.position.z]; self.particles .add(&ParticleOptions::simple(ParticleType::Smoke), at, [0.0; 3]); - } else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) { + } else if c.fuse == 0 { let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z; - let speed = if c.fuse == 0 && c.velocity.y > 0.0 { + let speed = if c.velocity.y > 0.0 { c.velocity.y * c.velocity.y } else { speed @@ -597,6 +596,13 @@ impl Game<'_> { self.explode_cart(c, speed); return false; } + if c.horizontal_collision { + let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z; + if speed >= f64::from(0.01_f32) { + self.explode_cart(c, speed); + return false; + } + } } _ => {} } From 3615c65d18e24888cea2e6b3a7dda86c7ba5c7a6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:11:01 +0100 Subject: [PATCH 073/475] Make allays drop what they carry on death and use the default pickup delay when throwing --- crates/horizon-client/src/game/allays.rs | 3 ++- crates/horizon-client/src/mobs/drops.rs | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index bed835663..dba4edfad 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -131,7 +131,8 @@ impl Game<'_> { let m = &mut self.mobs[i]; m.extra.ints[1] -= 1; m.extra.ints[3] = 60; - self.spawn_item(ItemStack::new(&item, 1), from, v, 40); + // ItemEntity.setDefaultPickUpDelay. + self.spawn_item(ItemStack::new(&item, 1), from, v, 10); if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 { const PITCHES: [f32; 16] = [ 0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5, diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index e5ccfd283..cdad5b7af 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -399,6 +399,15 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec (Vec 0 { + out.push(Drop::Item(loot_stack(&item, carried))); + } + if let Some(stack) = slot_item(m, "mainhand") + && !d.loot.prevents_equipment_drop(&stack) + { + out.push(Drop::Item(stack)); + clear_slot(m, "mainhand"); + } + } // dropEquipment: AbstractHorse's inventory, then a chested horse's chest. if equine::is_equine(s) { for stack in m.extra.items.iter().flatten() { From 9c3450622b8b94f69540e87fa4e629ab8e1c654c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:11:49 +0100 Subject: [PATCH 074/475] Update comparators when a copper golem moves items in or out of a chest --- crates/horizon-client/src/game/copper_golems.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/copper_golems.rs b/crates/horizon-client/src/game/copper_golems.rs index 2f98e1c7e..db4e03b0b 100644 --- a/crates/horizon-client/src/game/copper_golems.rs +++ b/crates/horizon-client/src/game/copper_golems.rs @@ -44,13 +44,13 @@ impl Game<'_> { slot: usize, stack: Option, ) { - if let Some(e) = self - .world - .block_entities - .get_mut(&(chest[0], chest[1], chest[2])) + let key = (chest[0], chest[1], chest[2]); + if let Some(e) = self.world.block_entities.get_mut(&key) && let Some(s) = e.slots_mut().get_mut(slot) { *s = stack; + // TransportItemsBetweenContainers calls Container.setChanged. + self.container_changed(key); } } /// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems From b7c47ccbf9398b2131927f4c07d736f6228a2a4e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:11:52 +0100 Subject: [PATCH 075/475] Let a sneaking player open chest and hopper minecarts --- crates/horizon-client/src/game/minecarts.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a19b6df73..d7c57fca9 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -1063,10 +1063,8 @@ impl Game<'_> { } self.riding = Some(self.carts[i].id); } + // AbstractMinecartContainer.interact: no sneak check. CartKind::Chest | CartKind::Hopper => { - if sneaking { - return false; - } let c = self.carts[i].clone(); let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32); self.world From 0a5e80de7ba474d420e5c215cabb7ac134817a5b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:12:33 +0100 Subject: [PATCH 076/475] Step out of a minecart in vanilla's order --- crates/horizon-client/src/game/minecarts.rs | 30 +++++++++++++-------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a19b6df73..454b15245 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -942,7 +942,7 @@ impl Game<'_> { } true } - /// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top. + /// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top. pub(super) fn dismount_cart(&mut self, i: usize) { self.riding = None; let c = &self.carts[i]; @@ -952,17 +952,25 @@ impl Game<'_> { c.position.z.floor() as i32, ); let mut target = None; + // DismountHelper.offsetsForDirection of the cart's facing + // (Direction.fromYRot of its yaw): right, left, back right, back left, + // forward right, forward left, back, forward. + let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize]; + let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32; + let (fx, fz) = step(facing); + let (rx, rz) = (-fz, fx); + let offsets = [ + (rx, rz), + (-rx, -rz), + (rx - fx, rz - fz), + (-rx - fx, -rz - fz), + (fx + rx, fz + rz), + (fx - rx, fz - rz), + (-fx, -fz), + (fx, fz), + ]; 'search: for dy in [0, 1, -1] { - for (dx, dz) in [ - (1, 0), - (-1, 0), - (0, 1), - (0, -1), - (1, 1), - (-1, -1), - (1, -1), - (-1, 1), - ] { + for (dx, dz) in offsets { let (x, y, z) = (bx + dx, by + dy, bz + dz); let below = self.res.facts().get(self.world.block(x, y - 1, z)); let here = self.res.facts().get(self.world.block(x, y, z)); From cc39d57e070d73b84080e031157704f2295529ce Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:12:42 +0100 Subject: [PATCH 077/475] Explosions empty and anger the bees of destroyed hives --- crates/horizon-client/src/game/beehives.rs | 2 +- crates/horizon-client/src/game/combat.rs | 4 +++ crates/horizon-client/src/game/creatures.rs | 10 ++++++- crates/horizon-client/src/game/explosion.rs | 29 +++++++++++++++++++++ crates/horizon-client/src/game/minecarts.rs | 4 ++- crates/horizon-client/src/game/tnt.rs | 4 ++- crates/horizon-client/src/game/wither.rs | 2 +- 7 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/beehives.rs b/crates/horizon-client/src/game/beehives.rs index b7f7efb1a..258610f5e 100644 --- a/crates/horizon-client/src/game/beehives.rs +++ b/crates/horizon-client/src/game/beehives.rs @@ -196,7 +196,7 @@ impl Game<'_> { } /// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down) /// without a target go for a player in the same box. - fn anger_nearby_bees(&mut self, at: [i32; 3]) { + pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) { let min = Vec3::new( f64::from(at[0]) - 8.0, f64::from(at[1]) - 6.0, diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 106e0e1a1..91cf5c61b 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -31,6 +31,10 @@ pub(super) struct CombatTracker { /// The mob exploding now (`ServerExplosion.source`): its team's friendly /// fire decides whom the blast may hurt. pub(super) explosion_source: Option, + /// The blast now going off comes from a primed TNT, creeper, wither + /// skull, wither or TNT minecart, the sources that empty a hive they + /// destroy (`BeehiveBlock.getDrops`). + pub(super) hive_emptying_blast: bool, /// The player's fall distance before this tick's movement (as of the /// end of the last one). fall_before: f64, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..6e6610dcc 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -915,7 +915,15 @@ impl Game<'_> { self.note_attacker("minecraft:explosion", id); } self.combat.explosion_source = source_mob; - let result = self.explode(at, radius); + // Only a creeper or a wither empties the hives it blows up. + let empties_hives = source_mob + .and_then(|id| self.mobs.iter().find(|m| m.id == id)) + .is_some_and(|m| matches!(m.species(), Species::Creeper | Species::Wither)); + let result = if empties_hives { + self.with_hive_emptying_blast(|g| g.explode(at, radius)) + } else { + self.explode(at, radius) + }; self.combat.explosion_source = None; result? } diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..805e4ebff 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -489,6 +489,15 @@ impl Game<'_> { ); } } + /// Runs an explosion whose source empties the hives it destroys + /// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither, + /// TNT minecart). + pub(super) fn with_hive_emptying_blast(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R { + self.combat.hive_emptying_blast = true; + let result = blast(self); + self.combat.hive_emptying_blast = false; + result + } /// `Level.explode` on the server: positions, entities, blocks, fire; then /// the client's explosion sound. pub(super) fn server_explode( @@ -600,6 +609,22 @@ impl Game<'_> { // Block.onExplosionHit: the drops copy from the block entity, which // then spills its contents as the block turns to air. let key = (pos[0], pos[1], pos[2]); + let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest"); + // BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a + // creeper, a wither skull, a wither or a TNT minecart come out. + if is_hive && self.combat.hive_emptying_blast { + let facing = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("facing").cloned()); + self.empty_hive( + pos, + horizon_client::beehive::facing_step(facing.as_deref()), + horizon_client::beehive::Release::Emergency, + ); + } if let Some(entity) = self.world.block_entities.get(&key).cloned() { let custom = self.world.block_names.get(&key).cloned(); self.pending.record(pos, &entity, &name, custom); @@ -631,6 +656,10 @@ impl Game<'_> { } self.pending.clear(pos); self.level_set(pos, self.world.air)?; + // BeehiveBlock.onExplosionHit: the bees about turn on the player. + if is_hive { + self.anger_nearby_bees(pos); + } // TntBlock.wasExploded: new PrimedTnt (its push drawn first), then // getRandomShortFuse(80); no game event. if is_tnt && self.ui.rules.bool("tnt_explodes") { diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a19b6df73..d21c99a55 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -640,7 +640,9 @@ impl Game<'_> { // The cart is the explosion's source: it is gone before the blast // looks for entities, so it can't hit itself. self.carts.retain(|x| x.id != c.id); - let _ = self.server_explode(at, power, BlockInteraction::Destroy, false); + let _ = self.with_hive_emptying_blast(|g| { + g.server_explode(at, power, BlockInteraction::Destroy, false) + }); } /// `MinecartHopper.suckInItems`: items above, into the five slots. fn hopper_cart_pickup(&mut self, c: &mut Minecart) { diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index 6bd2ae956..14d80c90e 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -178,7 +178,9 @@ impl Game<'_> { ]; self.tnts.remove(i); count -= 1; - self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?; + self.with_hive_emptying_blast(|g| { + g.server_explode(center, 4.0, BlockInteraction::Destroy, false) + })?; } else { i += 1; } diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 8084425ca..789e5cf68 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -130,7 +130,7 @@ impl Game<'_> { if let Some(id) = owner { self.note_attacker("minecraft:explosion", id); } - self.explode(at, 1.0) + self.with_hive_emptying_blast(|g| g.explode(at, 1.0)) } } /// Wither skulls drawn as the skull model, blue when dangerous. From a5fa40d2eea6609a3c567601059cdcf29415c4a3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:12:48 +0100 Subject: [PATCH 078/475] Fire the villager_trade trigger so What a Deal! can be earned --- .../horizon-client/src/game/advancements.rs | 21 ++++++++++++++ crates/horizon-client/src/game/lan_trading.rs | 7 ++++- crates/horizon-client/src/game/trading.rs | 28 +++++++++++++++---- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..f7bafd767 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -708,6 +708,27 @@ impl Game<'_> { let it = item_view(stack); self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w)); } + /// `TradeTrigger`: the villager and the item the trade gave. + pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) { + let me = self.player_entity(); + let villager = villager.clone(); + let it = item_view(stack); + self.trigger("minecraft:villager_trade", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let vctx = adv::Entities { + this: Some(&villager), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::conditions(&c["villager"], &vctx, w) + && adv::item(&c["item"], &it, w) + }); + } /// `EnterBlockTrigger`: the block, its state properties and the player. pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) { let Some(definition) = self.pack.assets.registry.state(state).cloned() else { diff --git a/crates/horizon-client/src/game/lan_trading.rs b/crates/horizon-client/src/game/lan_trading.rs index 1bec9809d..9e8fa55b3 100644 --- a/crates/horizon-client/src/game/lan_trading.rs +++ b/crates/horizon-client/src/game/lan_trading.rs @@ -276,7 +276,12 @@ impl Game<'_> { g.carried = menu.carried.take(); } self.register_exploration_maps(made); - gm.merchant.session = self.merchant_apply(session, gm.merchant.mob); + let (session, traded) = self.merchant_apply(session, gm.merchant.mob); + gm.merchant.session = session; + if !traded.is_empty() { + // The guest's own statistic and advancements. + self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded)); + } self.drop_guest_items(host, gi, dropped); self.lan_send_offers(host, conn, &mut gm, false); self.lan_send_merchant_content(host, conn, &mut gm); diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 87e0c18c6..4eaf00ec9 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -398,18 +398,21 @@ impl Game<'_> { .as_ref() .and_then(|s| s.merchant.as_ref()) .and_then(|m| m.mob); - let session = self.merchant_apply(session, mob); + let (session, traded) = self.merchant_apply(session, mob); + self.merchant_traded(mob, &traded); if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) { m.session = session; } } - /// What a menu click's session did, applied to the world and its mob. + /// What a menu click's session did, applied to the world and its mob; + /// with the result of each trade made, for `merchant_traded`. pub(super) fn merchant_apply( &mut self, session: Box, mob: Option, - ) -> Box { + ) -> (Box, Vec) { let mut session = session; + let mut traded = Vec::new(); let events = std::mem::take(&mut session.events); let (villager, origin) = (session.villager, session.origin); if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) { @@ -438,11 +441,26 @@ impl Game<'_> { }; self.audio.play(sound, Some(origin), 1.0, pitch); } - merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1), + merchant::Event::Traded(index) => { + traded.extend(session.offers.get(index).map(|o| o.result.clone())); + } merchant::Event::TradeSound => {} } } - session + (session, traded) + } + /// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade` + /// for each trade made: the statistic and the `villager_trade` trigger. + pub(super) fn merchant_traded(&mut self, mob: Option, results: &[ItemStack]) { + for result in results { + self.stats.custom("traded_with_villager", 1); + let villager = mob + .and_then(|id| self.mobs.iter().find(|m| m.id == id)) + .map(|m| self.mob_entity(m)); + if let Some(villager) = villager { + self.trigger_trade(&villager, result); + } + } } /// `Villager.customServerAiStep`: the TRADE reputation event of the last /// trade, a tick later; the prices follow the new reputation. From 00e2cfec294cae389cb742de5e022f2e43f4ae10 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:12:57 +0100 Subject: [PATCH 079/475] Let tadpoles be fed slime balls to grow up faster --- crates/horizon-client/src/game/creatures.rs | 13 +++++++++++++ crates/horizon-client/src/game/remote_mob_use.rs | 7 +++++-- crates/horizon-client/src/mobs/species.rs | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..fb3cea6bb 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2797,6 +2797,19 @@ impl Game<'_> { self.trigger_entity("minecraft:tame_animal", "entity", &view); } } + // Tadpole.feed: frog food ages it by a tenth of the time left to + // grow into a frog (it grows on its next tick once past 24000). + Species::Tadpole if is_food && !m.extra.age_locked => { + consume = true; + let left = 24_000 - m.extra.age; + m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20; + // One happy villager particle at rest about the body. + self.particles_around_mob( + self.mobs[index].id, + "happy_villager", + horizon_client::particles::Burst::HORSE_EATING, + ); + } // HappyGhast.canFallInLove: never, so only a ghastling takes food. _ if is_food && m.kind.flags.animal diff --git a/crates/horizon-client/src/game/remote_mob_use.rs b/crates/horizon-client/src/game/remote_mob_use.rs index 9575cf925..bd38c7301 100644 --- a/crates/horizon-client/src/game/remote_mob_use.rs +++ b/crates/horizon-client/src/game/remote_mob_use.rs @@ -66,8 +66,11 @@ impl Game<'_> { | Species::Salmon | Species::TropicalFish | Species::Pufferfish - | Species::Axolotl - | Species::Tadpole => item == "minecraft:water_bucket", + | Species::Axolotl => item == "minecraft:water_bucket", + // Tadpole.mobInteract: frog food, else the bucket. + Species::Tadpole => { + item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item) + } Species::Armadillo => item == "minecraft:brush", // ZombieVillager.mobInteract: CONSUME for a golden apple. Species::ZombieVillager => item == "minecraft:golden_apple", diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index f48a21fe9..3f3ff4e31 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -1581,7 +1581,7 @@ impl Def { S::Bee => "bee_food", S::Turtle => "turtle_food", S::Armadillo => "armadillo_food", - S::Frog => "frog_food", + S::Frog | S::Tadpole => "frog_food", S::Axolotl => "axolotl_food", S::Hoglin => "hoglin_food", S::Strider => "strider_food", From a4d0f76edd735cc6bcb5a9452679759564ebb3b8 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:08 +0100 Subject: [PATCH 080/475] Count movement statistics as vanilla does Fall distance is counted once per landing from 2 blocks or more instead of on every downward tick. Riding counts only for the mounts vanilla lists (pig, horse family by full distance, strider, happy ghast, nautilus, boat, minecart), and airborne sideways moves over 25 cm count as flying. --- crates/horizon-client/src/game.rs | 4 +++ crates/horizon-client/src/game/lan_actions.rs | 7 ++++ crates/horizon-client/src/game/stats.rs | 32 +++++++++++++------ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..5ac53d7df 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4421,6 +4421,10 @@ impl Game<'_> { }; self.audio.play(name, Some(feet), volume, pitch); } + // Player.causeFallDamage: a fall of 2 blocks or more counts. + MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => { + self.stats.custom("fall_one_cm", cm) + } MotionEvent::Fall(event) => self.landing_block_updates(&event), MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity), MotionEvent::EnterBlock { state } => self.trigger_enter_block(state), diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 0ad14bbcc..644ec7785 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2252,6 +2252,13 @@ impl Game<'_> { // ServerEntity.sendChanges sends it as it is after this travel. let marked = std::mem::take(&mut player.health.sync_velocity); let velocity = marked.then_some(player.velocity); + // Player.causeFallDamage: a fall of 2 blocks or more counts. + for event in &events { + if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event + { + game.stats.custom("fall_one_cm", *cm); + } + } game.record_combat(&events); (events, velocity) }); diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 7f64c6bb2..68a574eb1 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -256,9 +256,8 @@ impl Game<'_> { .fluid == horizon_client::blocks::Fluid::Water; let on_ground = p.ground.on_ground; - let (sprinting, flying, gliding, swimming) = ( + let (sprinting, gliding, swimming) = ( p.sprinting, - p.flying, p.fall_flying, p.pose == horizon_client::pose::Pose::Swimming, ); @@ -272,15 +271,27 @@ impl Game<'_> { }); let s = &mut self.stats; match riding { - Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total), - Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total), - Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => { - s.custom("horse_one_cm", horizontal) + Some(Ok(sp)) => { + use horizon_client::mobs::Species; + // `checkRidingStatistics`: only these mounts count, the horse + // family by the full 3D distance; any other mount nothing. + match sp { + Species::Pig => s.custom("pig_one_cm", total), + Species::Strider => s.custom("strider_one_cm", total), + Species::HappyGhast => s.custom("happy_ghast_one_cm", total), + Species::Nautilus | Species::ZombieNautilus => { + s.custom("nautilus_one_cm", total) + } + sp if horizon_client::mobs::equine::is_equine(sp) => { + s.custom("horse_one_cm", total) + } + _ => {} + } } - Some(_) => { + Some(Err(())) => { if self.boats.iter().any(|b| Some(b.id) == self.riding) { s.custom("boat_one_cm", total) - } else { + } else if self.carts.iter().any(|c| Some(c.id) == self.riding) { s.custom("minecart_one_cm", total) } } @@ -300,8 +311,9 @@ impl Game<'_> { } } None if gliding => s.custom("aviate_one_cm", total), - None if flying => s.custom("fly_one_cm", horizontal), - None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32), + // Airborne and not gliding: more than 25 cm sideways per move + // (creative flight, or a long jump), not only flying. + None if horizontal > 25 => s.custom("fly_one_cm", horizontal), None => {} } } From 2a0824cf5f532d1671e71b878dd03673be6ec0a2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:15 +0100 Subject: [PATCH 081/475] Invisible players are harder for mobs to notice Mobs now scale their detection range by 0.7 times the worn armor fraction (at least 0.1) when the player has Invisibility, like vanilla's getVisibilityPercent. --- .../horizon-client/examples/probe/mob_ai_probe.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/game/lan_mobs.rs | 1 + crates/horizon-client/src/mobs.rs | 15 +++++++++++++++ crates/horizon-client/src/mobs/animal_brain.rs | 3 +-- crates/horizon-client/src/mobs/goals.rs | 5 +---- crates/horizon-client/src/mobs/happy_ghast.rs | 3 +-- 7 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..033ef6faa 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1050,6 +1050,7 @@ pub fn main() -> horizon_client::Result<()> { off_hand: None, head: None, armor_cover: 0.0, + invisible: false, spectator: false, gold_armor: false, hurt_by: None, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..ae31d23dd 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -186,6 +186,7 @@ impl Game<'_> { off_hand: None, head: self.armor[0].as_ref().map(|s| s.item.clone()), armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0, + invisible: self.potions.effects.get("invisibility").is_some(), spectator: self.spectator(), gold_armor: self.wearing_gold(), hurt_by: self.player_hurt_by(), diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 4f53cec60..88632c7aa 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -363,6 +363,7 @@ mod tests { off_hand: None, head: None, armor_cover: 0.0, + invisible: false, spectator: false, gold_armor: false, hurt_by: None, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..ba9d63080 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1448,6 +1448,8 @@ pub struct PlayerView { pub off_hand: Option>, pub head: Option>, pub armor_cover: f32, + /// The Invisibility effect (`isInvisible`). + pub invisible: bool, pub spectator: bool, /// `PiglinAi.isWearingSafeArmor`: some golden armour. pub gold_armor: bool, @@ -1468,6 +1470,19 @@ pub struct PlayerView { /// (`EntitySelector.pushableBy` on the client). pub remote: bool, } +impl PlayerView { + /// `LivingEntity.getVisibilityPercent` without the worn-skull scaling. + pub fn visibility_percent(&self) -> f64 { + let mut visibility = 1.0; + if self.sneaking { + visibility *= 0.8; + } + if self.invisible { + visibility *= 0.7 * f64::from(self.armor_cover).max(0.1); + } + visibility + } +} #[derive(Clone)] pub struct MobWorld<'a> { pub world: &'a World, diff --git a/crates/horizon-client/src/mobs/animal_brain.rs b/crates/horizon-client/src/mobs/animal_brain.rs index bd0eb9c90..d928c34ee 100644 --- a/crates/horizon-client/src/mobs/animal_brain.rs +++ b/crates/horizon-client/src/mobs/animal_brain.rs @@ -625,8 +625,7 @@ fn targetable(m: &mut Mob, w: &MobWorld, who: Who) -> bool { // TargetingConditions.forNonCombat(): in range (scaled by the // player's visibility, at least 2) and in sight. let Some(p) = &w.player else { return false }; - let visibility = if p.sneaking { 0.8 } else { 1.0 }; - let r = (range * visibility).max(2.0); + let r = (range * p.visibility_percent()).max(2.0); p.alive && !p.spectator && m.distance_sqr(p.position) <= r * r && m.sees(w, who) } Who::Mob(id) => { diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..c44614571 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -601,10 +601,7 @@ impl Mob { return false; } // LivingEntity.getVisibilityPercent - let mut visibility = 1.0; - if p.sneaking { - visibility *= 0.8; - } + let mut visibility = p.visibility_percent(); if let Some(head) = &p.head { let skull = match self.species() { Species::Zombie | Species::Husk | Species::Drowned | Species::ZombieVillager => { diff --git a/crates/horizon-client/src/mobs/happy_ghast.rs b/crates/horizon-client/src/mobs/happy_ghast.rs index bd4c5b1f3..efcbf5246 100644 --- a/crates/horizon-client/src/mobs/happy_ghast.rs +++ b/crates/horizon-client/src/mobs/happy_ghast.rs @@ -364,8 +364,7 @@ impl Goal for GhastTempt { let Some(p) = &cx.w.player else { return false }; // getNearestPlayer(TargetingConditions.forNonCombat() // .ignoreLineOfSight().range(TEMPT_RANGE 16)): creative players too. - let visibility = if p.sneaking { 0.8 } else { 1.0 }; - let range = (16.0_f64 * visibility).max(2.0); + let range = (16.0_f64 * p.visibility_percent()).max(2.0); p.alive && !p.spectator && (Self::wants(m, &p.main_hand) || Self::wants(m, &p.off_hand)) From e2a399aff0ee5463e9059a9a3dd49cb67e5167db Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:19 +0100 Subject: [PATCH 082/475] Reopen signs on right click and apply dyes, ink sacs and honeycomb Right-clicking a placed sign opened an empty inventory screen. It now opens the editor for the side the player faces, or plays the waxed-sign fail sound. Dyes, glow ink sacs, ink sacs and honeycomb change the colour, glow or wax of the side the player faces and are used up. --- crates/horizon-client/src/game.rs | 9 ++ crates/horizon-client/src/game/sign_edit.rs | 152 +++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..35371a766 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3014,6 +3014,11 @@ impl Game<'_> { self.open_command_block(key); return; } + // SignBlock.useWithoutItem: the text editor, not a container. + if horizon_client::signs::sign_kind(&name).is_some() { + self.use_sign_empty_hand(pos); + return; + } if &*name == "minecraft:crafting_table" { self.craft_size = 3; self.craft_table = Some(pos); @@ -3519,6 +3524,10 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.use_sign_applicator(hit.pos, held.as_ref()) { + self.swing_arm(); + return Ok(true); + } if self.use_jukebox(hit.pos, held.as_ref()) { self.swing_arm(); return Ok(true); diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 2f50ad9ab..ea0abad3e 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -5,7 +5,8 @@ use super::{Game, lan::LanHost}; use horizon_client::{ containers::BlockEntity, hud::{HudBatch, rgba}, - signs::wood_type, + items::ItemStack, + signs::{DYE_TEXT, wood_type}, }; use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S}; use std::time::Instant; @@ -22,6 +23,155 @@ pub(super) struct SignEditor { } impl Game<'_> { + /// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in + /// front of the sign's text (within 90 degrees of its facing). + fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool { + let Some(player) = self.player.as_ref() else { + return true; + }; + let state = self.world.block(pos.0, pos.1, pos.2); + let Some(state) = self.pack.assets.registry.state(state) else { + return true; + }; + let prop = |key: &str| state.properties.get(key).map(String::as_str); + // getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or + // ceiling sign's rotation segment. + let y_rot = if state.name.contains("_wall_") { + match prop("facing") { + Some("west") => 90.0, + Some("north") => 180.0, + Some("east") => 270.0, + _ => 0.0, + } + } else { + prop("rotation") + .and_then(|r| r.parse::().ok()) + .unwrap_or(0.0) + * 22.5 + }; + // getSignHitboxCenterPosition: only a wall sign's board is off-centre, + // against the wall it hangs on. + let centre = if state.name.ends_with("_wall_sign") { + match y_rot as i32 { + 90 => (0.9375, 0.5), + 180 => (0.5, 0.9375), + 270 => (0.0625, 0.5), + _ => (0.5, 0.0625), + } + } else { + (0.5, 0.5) + }; + let xd = player.position.x - (f64::from(pos.0) + centre.0); + let zd = player.position.z - (f64::from(pos.2) + centre.1); + let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32; + // Mth.degreesDifferenceAbs. + let diff = (player_y_rot - y_rot).rem_euclid(360.0); + let diff = if diff >= 180.0 { 360.0 - diff } else { diff }; + diff <= 90.0 + } + /// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed + /// sign plays the failed-interaction sound; otherwise a player who may + /// build edits the side they face. + pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) { + let key = (pos[0], pos[1], pos[2]); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else { + return; + }; + if sign.waxed { + self.audio.play( + "minecraft:block.sign.waxed_interact_fail", + Some(pos.map(|v| f64::from(v) + 0.5)), + 1.0, + 1.0, + ); + } else if self.may_build() { + let front = self.facing_sign_front(key); + self.open_sign_editor(key, front); + } + } + /// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs, + /// honeycomb): the item changes the side the player faces and is used up. + /// Whether it did; a sign it does not apply to goes on to the empty hand. + pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool { + let Some(stack) = held else { + return false; + }; + let key = (pos[0], pos[1], pos[2]); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else { + return false; + }; + if sign.waxed || !self.may_build() { + return false; + } + let item = &*stack.item; + let dye = if item.ends_with("_dye") { + self.res + .items() + .get(&stack.item) + .and_then(|i| i.dye.clone()) + } else { + None + }; + let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d)); + if !matches!( + item, + "minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac" + ) && colour.is_none() + { + return false; + } + let front = self.facing_sign_front(key); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else { + return false; + }; + let centre = pos.map(|v| f64::from(v) + 0.5); + let sound = if item == "minecraft:honeycomb" { + // HoneycombItem.tryApplyToSign: waxes any sign, empty or not. + sign.waxed = true; + "minecraft:item.honeycomb.wax_on" + } else { + let side = if front { + &mut sign.front + } else { + &mut sign.back + }; + // SignApplicator.canApplyToSign: the side must have text. + if side.is_empty() { + return false; + } + match colour { + Some(c) if side.color != c as u8 => { + side.color = c as u8; + "minecraft:item.dye.use" + } + None if item == "minecraft:glow_ink_sac" && !side.glowing => { + side.glowing = true; + "minecraft:item.glow_ink_sac.use" + } + None if item == "minecraft:ink_sac" && side.glowing => { + side.glowing = false; + "minecraft:item.ink_sac.use" + } + _ => return false, + } + }; + if item == "minecraft:honeycomb" { + self.level_event_at(3003, pos, 0); + } + self.audio.play(sound, Some(centre), 1.0, 1.0); + self.mark_block_entity_updated(key); + let state = self.world.block(pos[0], pos[1], pos[2]); + self.post_block_game_event( + "minecraft:block_change", + pos, + self.sculk_actor(), + Some(state), + ); + self.stats.add("used", &stack.item, 1); + self.consume_held(); + self.trigger_block("minecraft:item_used_on_block", pos, stack); + true + } /// `SignBlock.openTextEdit` for the front side after placement. pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) { self.sign_edit = Some(SignEditor { From 329ce1b724aa72ee67dc1cfeeaeb884acb541399 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:27 +0100 Subject: [PATCH 083/475] Make armadillos shed scutes on their own Armadillos never dropped scutes because nothing ran their shed timer. Follow Armadillo.customServerAiStep: count down 5 to 10 minutes, then drop a scute, play the sound and post the entity_place game event. --- crates/horizon-client/src/game/creatures.rs | 18 +++++++++++++++ crates/horizon-client/src/mobs.rs | 6 +++++ crates/horizon-client/src/mobs/ai.rs | 25 +++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..9a78bc2e5 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1221,6 +1221,24 @@ impl Game<'_> { m.ai.aggressive |= aggressive; } } + MobEvent::ShedScute { mob, at, pitch } => { + // shouldDropLoot: the `mob_drops` rule. + if self.ui.rules.bool("mob_drops") { + let table = "minecraft:gameplay/armadillo_shed"; + let drops = self.sequence_loot(table, table, None); + for d in drops { + self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d); + } + self.audio.play( + "minecraft:entity.armadillo.scute_drop", + Some(at), + 1.0, + pitch, + ); + let ctx = self.who_context(Some(Who::Mob(mob)), None); + self.post_game_event("minecraft:entity_place", at, ctx); + } + } MobEvent::GiftLoot { table, at } => { let drops = self.res diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..315bfdce2 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1131,6 +1131,12 @@ pub enum MobEvent { table: &'static str, at: [f64; 3], }, + /// `Armadillo.customServerAiStep`: the armadillo sheds a scute. + ShedScute { + mob: u32, + at: [f64; 3], + pitch: f32, + }, /// A lead snaps or loses its holder: the lead item drops when asked. LeashBreak { mob: u32, diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..8259ddccb 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -307,6 +307,10 @@ fn generic(m: &mut Mob) { g.add(5, Stroll::avoiding_water(if rabbit { 0.6 } else { 1.0 })); g.add(6, LookAtPlayer::new(6.0)); g.add(7, RandomLookAround::default()); + if m.kind.species == Species::Armadillo { + // Armadillo's constructor: pickNextScuteDropTime. + m.extra.egg_time = next_scute_time(m); + } } } /// Subclasses of `net.minecraft.world.entity.monster.Monster`. @@ -930,9 +934,30 @@ fn server_ai_step( } m.jump_control_tick(); } +/// `Armadillo.pickNextScuteDropTime`: 5 to 10 minutes. +fn next_scute_time(m: &mut Mob) -> i32 { + m.random.next_int_bound(20 * 60 * 5).unwrap_or(0) + 20 * 60 * 5 +} +/// `Armadillo.customServerAiStep`: the scute timer. A baby only resets it +/// (`shouldDropLoot`); the game checks the `mob_drops` rule. +fn armadillo_server_ai_step(m: &mut Mob, events: &mut Vec) { + m.extra.egg_time -= 1; + if m.alive() && m.extra.egg_time <= 0 { + if !m.is_baby() { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + events.push(MobEvent::ShedScute { + mob: m.id, + at: [m.position.x, m.position.y, m.position.z], + pitch, + }); + } + m.extra.egg_time = next_scute_time(m); + } +} fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { match m.species() { Species::Rabbit => super::rabbit::server_ai_step(m), + Species::Armadillo => armadillo_server_ai_step(m, cx.events), Species::Sheep => {} Species::Zombie | Species::Husk From 84aba7b3d494f6d1315214c41a27464e14e8be87 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:27 +0100 Subject: [PATCH 084/475] Count talking to villagers under vanilla's statistic name The statistic was written as talk_to_villager, but vanilla's key is talked_to_villager, so the statistics screen and scoreboard criteria always showed 0. Wandering traders already count through the same code. --- crates/horizon-client/src/game/trading.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 87e0c18c6..bd43e5af2 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -245,7 +245,7 @@ impl Game<'_> { self.handle_mob_events(events)?; return Ok(true); } - self.stats.custom("talk_to_villager", 1); + self.stats.custom("talked_to_villager", 1); self.ensure_offers(index); let m = &self.mobs[index]; let trade = m.extra.trade.as_deref().cloned().unwrap_or_default(); From 183382cd2beead9560f0d6303106b9886dd3307c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:34 +0100 Subject: [PATCH 085/475] Fire the fishing_rod_hooked trigger when a hook is reeled in --- .../horizon-client/src/game/advancements.rs | 29 +++++++++++++++++++ crates/horizon-client/src/game/fishing.rs | 24 +++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..082bf0d62 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -708,6 +708,35 @@ impl Game<'_> { let it = item_view(stack); self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w)); } + /// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook) + /// and the items it caught. + pub(super) fn trigger_fishing_rod_hooked( + &mut self, + rod: &ItemStack, + hooked: &EntityView, + items: &[ItemStack], + ) { + let me = self.player_entity(); + let rod = item_view(rod); + let hooked = hooked.clone(); + let items: Vec = items.iter().map(item_view).collect(); + self.trigger("minecraft:fishing_rod_hooked", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let ectx = adv::Entities { + this: Some(&hooked), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::item(&c["rod"], &rod, w) + && adv::conditions(&c["entity"], &ectx, w) + && (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w))) + }); + } /// `EnterBlockTrigger`: the block, its state properties and the player. pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) { let Some(definition) = self.pack.assets.registry.state(state).cloned() else { diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index d454df406..33d93fb82 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -2,6 +2,7 @@ //! bobbing, lure and bite timers, open water, hooking mobs, retrieving the //! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line). use super::{Game, view_vector}; +use horizon_client::advancements::EntityView; use horizon_client::lan::{entity::States, hub::Hub}; use horizon_client::{ blocks::Fluid, @@ -77,7 +78,7 @@ impl Game<'_> { let at = [p.position.x, p.position.y, p.position.z]; let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8); if self.hook.is_some() { - let damage = self.retrieve_hook(); + let damage = self.retrieve_hook(stack); self.damage_held(damage); self.audio.play( "minecraft:entity.fishing_bobber.retrieve", @@ -143,7 +144,7 @@ impl Game<'_> { true } /// `FishingHook.retrieve`: the rod damage it costs. - fn retrieve_hook(&mut self) -> u32 { + fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 { let Some(hook) = self.hook.take() else { return 0; }; @@ -166,6 +167,15 @@ impl Game<'_> { m.velocity.z + d.z * 0.1, ); } + // CriteriaTriggers.FISHING_ROD_HOOKED with no items. + let hooked = self + .mobs + .iter() + .find(|m| m.id == id) + .map(|m| self.mob_entity(m)); + if let Some(hooked) = hooked { + self.trigger_fishing_rod_hooked(rod, &hooked, &[]); + } damage = 5; } else if hook.nibble > 0 { let biome = self.biome_name_at(hook.position); @@ -184,6 +194,7 @@ impl Game<'_> { self.world.random = random; // FishingHook.retrieve: fish_caught for a catch. self.stats.custom("fish_caught", 1); + let mut caught = Vec::new(); for stack in items { let mut item = ItemStack { item: Arc::from(stack.item.as_str()), @@ -205,6 +216,7 @@ impl Game<'_> { if let Some(potion) = &stack.potion { item = item.with_potion(potion); } + caught.push(item.clone()); let (dx, dy, dz) = ( owner.x - hook.position.x, owner.y - hook.position.y, @@ -224,6 +236,14 @@ impl Game<'_> { let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1; self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp); } + // CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity. + let bobber = EntityView { + kind: "minecraft:fishing_bobber".into(), + position: [hook.position.x, hook.position.y, hook.position.z], + on_ground: hook.on_ground, + ..Default::default() + }; + self.trigger_fishing_rod_hooked(rod, &bobber, &caught); damage = 1; } if hook.on_ground { From 5fa423169f8fe5791f4cf31ee5b97e485806e46a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:51 +0100 Subject: [PATCH 086/475] Fire player_hurt_entity and entity_hurt_player advancement triggers --- crates/horizon-client/src/advancements.rs | 46 ++++++++++++ crates/horizon-client/src/game.rs | 9 +++ .../horizon-client/src/game/advancements.rs | 71 +++++++++++++++++++ crates/horizon-client/src/game/creatures.rs | 35 +++++++++ crates/horizon-client/src/game/trident.rs | 13 +++- 5 files changed, 173 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index 6706532b7..62e7f365f 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -703,6 +703,52 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo } true } +/// A damage event as `DamagePredicate` sees it. +#[derive(Clone, Debug, Default)] +pub struct DamageView { + /// The damage type id (`minecraft:arrow`). + pub kind: String, + /// `DamageSource.getDirectEntity` and `getEntity`. + pub direct: Option, + pub source: Option, + /// `DamageSource.isDirect`: the direct entity is the causing one. + pub is_direct: bool, + /// The damage before and after the shield took its share. + pub original: f32, + pub actual: f32, + pub blocked: bool, +} +/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`. +pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool { + if v.is_null() { + return true; + } + let by = |key: &str, e: &Option| { + v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin)) + }; + if !range_f(&v["dealt"], f64::from(d.original)) + || !range_f(&v["taken"], f64::from(d.actual)) + || !by("source_entity", &d.source) + || v["blocked"].as_bool().is_some_and(|b| b != d.blocked) + { + return false; + } + let ty = &v["type"]; + let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| { + let tag = t["id"].as_str().unwrap_or(""); + let tag = tag.strip_prefix('#').unwrap_or(tag); + w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true) + }); + let direct_ok = ty["direct_entity"].is_null() + || d.direct + .as_ref() + .is_some_and(|e| entity(&ty["direct_entity"], e, w, origin)); + let source_ok = ty["source_entity"].is_null() + || d.source + .as_ref() + .is_some_and(|e| entity(&ty["source_entity"], e, w, origin)); + tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct) +} /// The entities a loot-style condition list may refer to. pub struct Entities<'a> { pub this: Option<&'a EntityView>, diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..44ae588cb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7602,6 +7602,15 @@ impl Game<'_> { None => horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic), }; let landed = self.hurt_by_player(index, amount, source); + if landed { + let victim = self.mob_entity(&self.mobs[index]); + self.player_hit( + &victim, + creatures::damage_type(source.kind, true), + None, + amount, + ); + } if let Some(p) = self.player.as_mut() { if p.attack_log.len() >= 64 { p.attack_log.remove(0); diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..ab783cbfa 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -180,6 +180,14 @@ impl adv::World for View<'_, '_> { "block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)), "entity_type" => self.0.res.mob_data().entity_has(tag, id), "worldgen/biome" => self.0.res.mob_data().biome_has(tag, id), + "damage_type" => self + .0 + .res + .stations() + .enchantments + .damage_tags + .get(tag) + .is_some_and(|t| t.contains(id)), _ => false, } } @@ -681,6 +689,69 @@ impl Game<'_> { adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w) }); } + /// `PlayerHurtEntityTrigger`: the player, the damage and the victim. + pub(super) fn trigger_player_hurt_entity( + &mut self, + victim: &EntityView, + damage: &adv::DamageView, + ) { + let me = self.player_entity(); + let (victim, damage) = (victim.clone(), damage.clone()); + self.trigger("minecraft:player_hurt_entity", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let vctx = adv::Entities { + this: Some(&victim), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::damage(&c["damage"], &damage, w, me.position) + && adv::conditions(&c["entity"], &vctx, w) + }); + } + /// The player's hit on `victim` as `LivingEntity.hurtServer` reports it: + /// `kind` damage of `amount` from `direct` (the player's own hand when + /// `None`), nothing blocked. + pub(super) fn player_hit( + &mut self, + victim: &EntityView, + kind: &str, + direct: Option, + amount: f32, + ) { + let me = self.player_entity(); + self.trigger_player_hurt_entity( + victim, + &adv::DamageView { + kind: kind.to_owned(), + is_direct: direct.is_none(), + direct: direct.or_else(|| Some(me.clone())), + source: Some(me), + original: amount, + actual: amount, + blocked: false, + }, + ); + } + /// `EntityHurtPlayerTrigger`: the player and the damage it took. + pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) { + let me = self.player_entity(); + let damage = damage.clone(); + self.trigger("minecraft:entity_hurt_player", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) + && adv::damage(&c["damage"], &damage, w, me.position) + }); + } /// A player condition plus one item (`item` key). pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) { let me = self.player_entity(); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..e7395c3f0 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1544,6 +1544,23 @@ impl Game<'_> { self.hurt_armor(amount); self.thorns(from); } + // LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned + // early (invulnerable, or inside the hurt cooldown). + if outcome.hurt || outcome.sound { + let source = attacker + .or(owner) + .and_then(|id| self.mobs.iter().find(|m| m.id == id)) + .map(|m| self.mob_entity(m)); + self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView { + kind: damage_type.to_owned(), + direct: source.clone().filter(|_| !projectile), + is_direct: !projectile, + source, + original: amount, + actual: amount - outcome.blocked, + blocked: outcome.blocked > 0.0, + }); + } if raised { self.after_block(outcome, amount, attacker, projectile); } @@ -2111,6 +2128,24 @@ impl Game<'_> { .is_some_and(|m| m.damage(amount, source)); if p.owner == Some(Who::Player) { self.last_hurt_by_player.insert(id, self.lan_acting); + if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) { + // LivingEntity.hurtServer: PLAYER_HURT_ENTITY. + let victim = self.mob_entity(m); + let spectral = p.arrow.as_ref().is_some_and(|a| { + &*a.pickup_item.item == "minecraft:spectral_arrow" + }); + let direct = horizon_client::advancements::EntityView { + kind: if spectral { + "minecraft:spectral_arrow" + } else { + "minecraft:arrow" + } + .into(), + position: [p.position.x, p.position.y, p.position.z], + ..Default::default() + }; + self.player_hit(&victim, "minecraft:arrow", Some(direct), amount); + } } hurt } diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index c31094596..04a1be415 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -311,9 +311,10 @@ impl Game<'_> { }); let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 }; t.dealt_damage = true; + let mut hurt = false; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { let from = Some([t.position.x, t.position.y, t.position.z]); - m.damage( + hurt = m.damage( damage, Damage { kind: DamageKind::Arrow, @@ -322,6 +323,16 @@ impl Game<'_> { }, ); } + if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) { + // LivingEntity.hurtServer: PLAYER_HURT_ENTITY. + let victim = self.mob_entity(m); + let direct = horizon_client::advancements::EntityView { + kind: "minecraft:trident".into(), + position: [t.position.x, t.position.y, t.position.z], + ..Default::default() + }; + self.player_hit(&victim, "minecraft:trident", Some(direct), damage); + } self.audio.play( "minecraft:item.trident.hit", Some([t.position.x, t.position.y, t.position.z]), From 8d77913ea524f60ea52c7ac22ccba3092f5d7409 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:10 +0100 Subject: [PATCH 087/475] Note blocks play on a left click, and take mob heads on top Mining a note block now plays its note and counts the play_noteblock statistic. A mob head used on the top face is placed instead of tuning the block. --- crates/horizon-client/src/game.rs | 8 +++-- crates/horizon-world/src/level/interact.rs | 35 ++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..3548bf15a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2510,9 +2510,13 @@ impl Game<'_> { self.process_level_events() } fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> { - self.with_level(|b, level, ticks| { - b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2])); + let note = self.with_level(|b, level, ticks| { + b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2])) }); + // NoteBlock.attack awards the statistic. + if note { + self.stats.custom("play_noteblock", 1); + } self.process_level_events() } /// Track which chunks are ticking (Ticks needs chunk load/unload). diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 7b2195c34..ed7c03dfb 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -513,20 +513,26 @@ impl Behaviours { sim.game_event_state(pos, "minecraft:block_change", next); } - /// The state change of `RedStoneOreBlock.attack` when mining begins. + /// `BlockBehaviour.attack` when mining begins: `RedStoneOreBlock.attack` + /// lights the ore, `NoteBlock.attack` plays the note. True for a note + /// block, whose player also earns the `play_noteblock` statistic. pub fn attack_block( &self, level: &mut L, ticks: &mut Ticks, pos: BlockPos, - ) { + ) -> bool { let mut sim = self.sim(level, ticks); let state = sim.state(pos); - if self.data(state).chain.contains(&Class::RedStoneOre) - && !self.table.bool(state, self.props.lit) - { + let chain = &self.data(state).chain; + if chain.contains(&Class::Note) { + sim.note_play(pos, state); + return true; + } + if chain.contains(&Class::RedStoneOre) && !self.table.bool(state, self.props.lit) { sim.set_block_and_update(pos, self.table.set_bool(state, self.props.lit, true)); } + false } /// `BlockState.useWithoutItem` for a right-click on `pos` (hit face and @@ -595,6 +601,25 @@ impl Behaviours { if self.data(state).chain.contains(&Class::CopperGolemStatue) { return self.turn_statue(level, ticks, pos, state, item); } + // NoteBlock.useItemOn: a mob head on the top face is placed, not tuned + // (`#minecraft:noteblock_top_instruments`). + if face == Direction::Up + && self.data(state).chain.contains(&Class::Note) + && item.is_some_and(|i| { + matches!( + i, + "minecraft:zombie_head" + | "minecraft:skeleton_skull" + | "minecraft:creeper_head" + | "minecraft:dragon_head" + | "minecraft:wither_skeleton_skull" + | "minecraft:piglin_head" + | "minecraft:player_head" + ) + }) + { + return InteractionResult::Pass; + } self.use_without_item(level, ticks, pos, face, hit, player) } From 79bfb8fcb81860893bd4664d4f3e86effd015eef Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:26 +0100 Subject: [PATCH 088/475] Let a brush take a scute off an armadillo Right-clicking an adult armadillo with a brush did nothing. Follow Armadillo.mobInteract: drop a scute from the brush loot table, play the brush sound, post the entity_interact game event and wear the brush by 16. --- crates/horizon-client/src/game/creatures.rs | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..0d4e94601 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2507,6 +2507,14 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.interact_armadillo(index, &item) { + if let Some(stack) = &held { + let target = self.mob_entity(&self.mobs[index]); + self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target); + } + self.swing_arm(); + return Ok(true); + } if self.interact_copper_golem(index, &item) { self.swing_arm(); return Ok(true); @@ -2876,6 +2884,50 @@ impl Game<'_> { } Ok(true) } + /// `Armadillo.mobInteract`: a brush brushes a scute off an adult + /// (`brushOffScute`) and loses 16 durability. + fn interact_armadillo(&mut self, index: usize, item: &str) -> bool { + let m = &self.mobs[index]; + if item != "minecraft:brush" + || m.species() != Species::Armadillo + || !m.alive() + || m.is_baby() + { + return false; + } + let (id, at) = (m.id, m.position); + let feet = [at.x, at.y, at.z]; + let tool = self.held().cloned(); + let table = "minecraft:brush/armadillo"; + for d in self.sequence_loot(table, table, tool.as_ref()) { + self.spawn_at_location(at, 0.0, d); + } + self.audio + .play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0); + let ctx = self.who_context(Some(Who::Mob(id)), None); + self.post_game_event("minecraft:entity_interact", feet, ctx); + // ItemStack.hurtAndBreak(16). + if !self.creative + && let Some(stack) = tool + { + let max = self + .res + .items() + .get(&stack.item) + .map_or(0, |i| i.max_damage); + if max > 0 { + let loss = self.durability_loss(&stack, 16); + let slot = self.held_slot(); + if let Some(held) = self.stack_in_mut(slot).as_mut() { + held.damage += loss; + if held.damage >= max { + *self.stack_in_mut(slot) = None; + } + } + } + } + true + } pub(super) fn damage_held_tool(&mut self) { if self.creative { return; From 8691d9a8e88dcdd3f25783a42f24cd00af293eaa Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:34 +0100 Subject: [PATCH 089/475] Let players harvest a full beehive with shears or a glass bottle --- crates/horizon-client/src/game.rs | 4 + crates/horizon-client/src/game/beehives.rs | 107 +++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..01442b5c4 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3539,6 +3539,10 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.use_beehive(hit.pos, held.as_ref())? { + self.swing_arm(); + return Ok(true); + } match self.use_respawn_anchor(hit.pos, held.as_ref())? { respawn_anchor::AnchorUse::Charged => { self.swing_arm(); diff --git a/crates/horizon-client/src/game/beehives.rs b/crates/horizon-client/src/game/beehives.rs index b7f7efb1a..dfd4382a8 100644 --- a/crates/horizon-client/src/game/beehives.rs +++ b/crates/horizon-client/src/game/beehives.rs @@ -3,8 +3,10 @@ //! player breaks the hive or fire reaches it. use super::Game; use horizon_client::{ + Result, beehive::{self, Beehive, HiveHost, Release}, containers::BlockEntity, + items::ItemStack, mobs::{Mob, Who, bee}, }; use horizon_core::{ @@ -194,6 +196,111 @@ impl Game<'_> { } self.anger_nearby_bees(at); } + /// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take + /// the honeycombs and a glass bottle a honey bottle from a full hive + /// (honey level 5), then the honey level resets and, unless a campfire + /// smokes the hive, the bees come out angry. True when it took the click. + pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result { + let Some(held) = held else { return Ok(false) }; + let shears = &*held.item == "minecraft:shears"; + if !shears && &*held.item != "minecraft:glass_bottle" { + return Ok(false); + } + let state = self.world.block(pos[0], pos[1], pos[2]); + if !matches!( + &*self.res.facts().get(state).name, + "minecraft:beehive" | "minecraft:bee_nest" + ) { + return Ok(false); + } + let prop = |key: &str| { + self.pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get(key).cloned()) + }; + if prop("honey_level").and_then(|v| v.parse::().ok()) != Some(5) { + return Ok(false); + } + let facing = beehive::facing_step(prop("facing").as_deref()); + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + if shears { + // dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak. + for stack in self.sequence_loot( + "minecraft:harvest/beehive", + "minecraft:harvest/beehive", + Some(held), + ) { + self.pop_resource(pos, stack); + } + self.audio + .play("minecraft:block.beehive.shear", at, 1.0, 1.0); + self.damage_held(1); + self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None); + } else { + // shrink(1), then the honey bottle in the hand, the inventory or + // on the ground. + self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0); + let cell = self.held_mut(); + if let Some(s) = cell.as_mut() { + s.count -= 1; + if s.count == 0 { + *cell = None; + } + } + let bottle = ItemStack::new("minecraft:honey_bottle", 1); + if self.held().is_none() { + *self.held_mut() = Some(bottle); + } else if let Some(rest) = self.give(bottle) + && let Some(eye) = self.eye_position() + { + self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + } + self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None); + } + self.stats.add("used", &held.item, 1); + let smokey = beehive::smokey(&self.mob_world_base(&[]), pos); + let key = (pos[0], pos[1], pos[2]); + if !smokey + && matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty()) + { + self.anger_nearby_bees(pos); + } + // resetHoneyLevel. + if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) { + self.level_set(pos, reset)?; + } + if !smokey + && let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key) + { + // emptyAllLivingFromHive: the bees within 4 blocks of the player + // go for them. + let released = hive.release_all( + pos, + facing, + None, + Release::Emergency, + &mut GameHive { game: self }, + ); + self.world + .block_entities + .put(key, BlockEntity::Beehive(hive)); + if let Some(p) = self.player_view().map(|p| p.position) { + for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) { + if m.distance_sqr(p) <= 16.0 { + m.ai.target = Some(Who::Player); + } + } + } + } + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, held); + Ok(true) + } /// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down) /// without a target go for a player in the same box. fn anger_nearby_bees(&mut self, at: [i32; 3]) { From 4c72a3a44f41278aa0481f95acf0d184bf540421 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:42 +0100 Subject: [PATCH 090/475] Make a detector rail's comparator read the minecart on it --- crates/horizon-client/src/game.rs | 43 +++++++++++++++++++++ crates/horizon-client/src/game/minecarts.rs | 29 +++++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..cc8cf7f1d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -541,6 +541,8 @@ struct GameLevel<'w> { player_collision: Option, /// Minecart boxes (detector rails). cart_boxes: Vec<([f64; 3], [f64; 3])>, + /// Each minecart's kind and comparator signal, in `cart_boxes` order. + cart_signals: Vec<(minecarts::CartKind, i32)>, /// The other entities that keep blocks from being placed into them. blockers: Blockers<'w>, biomes: &'w Biomes, @@ -609,6 +611,37 @@ impl EntityObstructionWorld for BuildingObstruction { } } impl GameLevel<'_> { + /// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the + /// minecart on it, a command block cart first (its success count, which + /// is 0 here), else the first container cart's fullness. + fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 { + if !powered { + return 0; + } + let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)); + let area = Aabb::new( + Vec3::new(x + 0.2, y, z + 0.2), + Vec3::new(x + 0.8, y + 0.8, z + 0.8), + ); + let mut container = None; + for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) { + let cart = Aabb::new( + Vec3::new(lo[0], lo[1], lo[2]), + Vec3::new(hi[0], hi[1], hi[2]), + ); + if !area.intersects(cart) { + continue; + } + match kind { + minecarts::CartKind::CommandBlock => return 0, + minecarts::CartKind::Chest | minecarts::CartKind::Hopper => { + container = container.or(Some(signal)); + } + _ => {} + } + } + container.unwrap_or(0) + } /// Every entity box that blocks building: the players who aren't /// spectators, the minecarts and the `Blockers` (a removed mob or a marker /// armor stand, whose box is empty, doesn't). @@ -666,6 +699,14 @@ impl LevelAccess for GameLevel<'_> { state: BlockStateId, _direction: Direction, ) -> i32 { + if let Some(s) = self.registry.state(state) + && s.name == "minecraft:detector_rail" + { + return self.detector_rail_signal( + pos, + s.properties.get("powered").is_some_and(|v| v == "true"), + ); + } if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) { return signal; } @@ -2448,6 +2489,7 @@ impl Game<'_> { ) }) .collect(); + let cart_signals = self.cart_signals(); let guest_boxes = self.lan_block_trigger_boxes(); let dimension = match self.dim { Dim::Overworld => LevelDimension::Overworld, @@ -2465,6 +2507,7 @@ impl Game<'_> { }; let mut level = GameLevel { cart_boxes, + cart_signals, blockers, world: &mut self.world, facts: self.res.facts(), diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a19b6df73..08eb20e02 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -9,7 +9,7 @@ use horizon_client::{ blocks::Fluid, collisions::SceneCollisions, containers::BlockEntity, - items::ItemStack, + items::{ItemStack, Items}, mesher::Vertex, mobs::{Part, anim::Poses}, particles::{ParticleOptions, ParticleType}, @@ -194,6 +194,17 @@ impl Minecart { fn horizontal(&self) -> f64 { self.velocity.x.hypot(self.velocity.z) } + /// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`. + fn container_signal(slots: &[Option], items: &Items) -> i32 { + let mut total = 0.0f32; + for s in slots.iter().flatten() { + let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64); + total += s.count as f32 / max as f32; + } + total /= slots.len().max(1) as f32; + // Mth.lerpDiscrete(total, 0, 15) + (total * 14.0).floor() as i32 + i32::from(total > 0.0) + } } pub(super) struct CartModels { pub models: HashMap, @@ -1046,6 +1057,22 @@ impl Game<'_> { } best } + /// Each cart's kind and comparator signal, for the detector rail + /// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open + /// counts the items in that menu. + pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> { + self.carts + .iter() + .map(|c| { + let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32); + let slots = match self.world.block_entities.get(&key) { + Some(BlockEntity::Chest(open)) => open, + _ => &c.items, + }; + (c.kind, Minecart::container_signal(slots, self.res.items())) + }) + .collect() + } /// `interact`: ride, open, fuel or ignite the cart under the crosshair. pub(super) fn interact_cart(&mut self) -> bool { let Some((i, d)) = self.cart_hit() else { From 5b9a66089e9bcabcad2a9f864be534fd35f841a7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:43 +0100 Subject: [PATCH 091/475] Give a partial item pickup the random pitch of a full pickup --- crates/horizon-client/src/game.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..57f817a00 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2390,8 +2390,14 @@ impl Game<'_> { pickups::Taken::Item(Box::new(item.clone())), pickups::Collector::Player, ); + // handleTakeItemEntity: the random pitch of a full pickup. + let pitch = ((self.world.random.next_float() + - self.world.random.next_float()) + * 0.7 + + 1.0) + * 2.0; self.audio - .play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0); + .play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch); } item.stack = rest; } From c273a7271ed3dbc53c28ad50784f2cbb69d04e76 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:59 +0100 Subject: [PATCH 092/475] Make breeze shots follow the world difficulty and aim higher at riders --- .../examples/probe/mob_ai_probe.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/game/lan_mobs.rs | 1 + crates/horizon-client/src/mobs.rs | 5 +++++ crates/horizon-client/src/mobs/monster_brain.rs | 16 ++++++++++++---- crates/horizon-client/src/mobs/spawning.rs | 4 ++++ crates/horizon-client/src/mobs/world.rs | 4 ++++ 7 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..d3b091490 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1060,6 +1060,7 @@ pub fn main() -> horizon_client::Result<()> { uuid: PLAYER_UUID, allies: Default::default(), remote: false, + passenger: false, } }); let mut scratch = LegacyRandom::new(0); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..17d518c3a 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -196,6 +196,7 @@ impl Game<'_> { uuid: self.level.player_uuid.unwrap_or_default(), allies: std::sync::Arc::new(self.player_allies()), remote: false, + passenger: self.riding.is_some(), }) } /// Biome facts at a position for finalizeSpawn and variant rules. diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 4f53cec60..746a712a4 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -373,6 +373,7 @@ mod tests { uuid: [0; 4], allies: Default::default(), remote: false, + passenger: false, } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..240ab4218 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1223,6 +1223,8 @@ pub struct Seen { pub active_raid: bool, /// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`). pub opened_chest: Option<[i32; 3]>, + /// `isPassenger`. + pub passenger: bool, /// A villager's reputation of the player (`getPlayerReputation`). pub player_rep: i32, /// `isPushable` and pushed by others (`Entity.push(Entity)` moves it). @@ -1298,6 +1300,7 @@ impl Mob { hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)), active_raid: raider::has_active_raid(m), opened_chest: m.extra.opened_chest, + passenger: m.extra.mount.is_some(), player_rep: match (villager, player) { (true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)), _ => 0, @@ -1467,6 +1470,8 @@ pub struct PlayerView { /// A LAN guest: its own client moves it, and touching mobs push it there /// (`EntitySelector.pushableBy` on the client). pub remote: bool, + /// `isPassenger`. + pub passenger: bool, } #[derive(Clone)] pub struct MobWorld<'a> { diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index 9456c00a1..827360406 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -1137,6 +1137,7 @@ struct Quarry { position: Vec3, head_yaw: f32, height: f64, + passenger: bool, } fn quarry(w: &MobWorld, who: Who) -> Option { let t = w.target_info(who)?; @@ -1148,6 +1149,7 @@ fn quarry(w: &MobWorld, who: Who) -> Option { position: t.position, head_yaw, height: t.bounds.max_position().y - t.bounds.min_position().y, + passenger: t.passenger, }) } /// `BreezeUtil.randomPointBehindTarget`. @@ -1233,7 +1235,8 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) { let firing = super::breeze::firing_y(m); let (xd, yd, zd) = ( q.position.x - m.position.x, - q.position.y + q.height * 0.3 - firing, + // target.getY(target.isPassenger() ? 0.8 : 0.3). + q.position.y + q.height * if q.passenger { 0.8 } else { 0.3 } - firing, q.position.z - m.position.z, ); let start = Vec3::new(m.position.x, firing, m.position.z); @@ -1242,14 +1245,19 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) { start, Some(Who::Mob(m.id)), ); - // The new entity's own random spreads the shot (5 - difficulty * 4 at - // normal): not the breeze's. + // The new entity's own random spreads the shot (5 - difficulty id * 4): + // not the breeze's. let mut own = LegacyRandom::new( cx.now .wrapping_mul(0x5DEE_CE66) .wrapping_add(i64::from(m.id)), ); - p.shoot(&mut own, [xd, yd, zd], 0.7, -3.0); + p.shoot( + &mut own, + [xd, yd, zd], + 0.7, + 5.0 - f32::from(cx.w.difficulty.id) * 4.0, + ); cx.events.push(MobEvent::Projectile(p)); breeze_sound(m, cx, "entity.breeze.shoot", 1.5); } diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..bbb8e2e46 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -37,6 +37,8 @@ pub struct LocalDifficulty { pub effective: f32, /// The world difficulty is hard. pub hard: bool, + /// `Difficulty.getId`: 0 peaceful to 3 hard. + pub id: u8, } /// `DimensionType.MOON_BRIGHTNESS_PER_PHASE`. pub const MOON_BRIGHTNESS: [f32; 8] = [1.0, 0.75, 0.5, 0.25, 0.0, 0.25, 0.5, 0.75]; @@ -49,6 +51,7 @@ impl LocalDifficulty { return Self { effective: 0.0, hard, + id: base, }; } let global = ((total as f32 - 72000.0) / 1_440_000.0).clamp(0.0, 1.0) * 0.25; @@ -61,6 +64,7 @@ impl LocalDifficulty { Self { effective: f32::from(base) * (0.75 + global + local), hard, + id: base, } } /// `getSpecialMultiplier`. diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 7836a49ef..0fb80d9e9 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -25,6 +25,8 @@ pub struct TargetInfo { pub can_be_seen_as_enemy: bool, pub is_player: bool, pub creative: bool, + /// `isPassenger`. + pub passenger: bool, } impl MobWorld<'_> { /// The biome's base temperature at a position. @@ -201,6 +203,7 @@ impl MobWorld<'_> { can_be_seen_as_enemy: true, is_player: true, creative: p.creative, + passenger: p.passenger, }), Who::Mob(id) => self.seen(id).map(|s| TargetInfo { position: s.position, @@ -210,6 +213,7 @@ impl MobWorld<'_> { can_be_seen_as_enemy: !s.playing_dead, is_player: false, creative: false, + passenger: s.passenger, }), } } From c418034f10e15eb6dccba6e4d9a18365eb08a3f0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:15:07 +0100 Subject: [PATCH 093/475] Let goats breed, follow wheat and be milked Goats only strolled and panicked, so wheat did nothing after the love hearts and a bucket gave no milk. Give them the GoatAi core and idle behaviours that exist here (panic 2.0, breed, tempt 1.25, baby follow 1.25) and Goat.mobInteract's milking with the goat milk sounds. --- crates/horizon-client/src/game/creatures.rs | 10 ++++++++++ crates/horizon-client/src/mobs/ai.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..0fe0f4210 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2548,6 +2548,16 @@ impl Game<'_> { consume = true; sound = Some("entity.cow.milk"); } + // Goat.mobInteract: a bucket on an adult gives milk. + Species::Goat if item == "minecraft:bucket" && !m.is_baby() => { + give = Some("minecraft:milk_bucket"); + consume = true; + sound = Some(if m.extra.screaming { + "entity.goat.screaming.milk" + } else { + "entity.goat.milk" + }); + } Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => { give = Some("minecraft:mushroom_stew"); consume = true; diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..e194d0132 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -301,6 +301,20 @@ fn generic(m: &mut Mob) { m.ai.targets.add(1, HurtByTarget::new()); m.ai.targets.add(2, NearestTarget::player(true)); } else { + if m.kind.species == Species::Goat { + // GoatAi's idle and core behaviours (the ram and long jump + // activities are not here): panic 2.0, AnimalMakeLove, tempting + // with goat_food and baby follow at 1.25, then stroll. + g.add(0, Float); + g.add(1, Panic::new(2.0)); + g.add(2, Breed::new(1.0)); + g.add(3, Tempt::new(1.25, m.kind.food.clone(), false)); + g.add(4, FollowParent::new(1.25)); + g.add(5, Stroll::avoiding_water(1.0)); + g.add(6, LookAtPlayer::new(6.0)); + g.add(7, RandomLookAround::default()); + return; + } g.add(0, Float); let rabbit = m.kind.species == Species::Rabbit; g.add(1, Panic::new(if rabbit { 2.2 } else { 1.25 })); From 06fb03567e4f124315872936eced832c6d47afde Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:15:11 +0100 Subject: [PATCH 094/475] Count the block interaction statistics Eating a cake slice, using a cauldron (fill, use, clean armor, banner or shulker box), tuning a note block, ringing a bell, opening an ender chest and putting a record in a jukebox now add to their statistics, which stayed at 0 before. --- crates/horizon-client/src/cauldron.rs | 31 +++++++++++++++++------ crates/horizon-client/src/game.rs | 21 +++++++++++++++ crates/horizon-client/src/game/jukebox.rs | 1 + 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/cauldron.rs b/crates/horizon-client/src/cauldron.rs index 03fb0676d..25a709e26 100644 --- a/crates/horizon-client/src/cauldron.rs +++ b/crates/horizon-client/src/cauldron.rs @@ -40,6 +40,8 @@ pub struct Interaction { /// The cauldron's new state. pub block: Option, pub sound: Option<&'static str>, + /// The custom statistic the player earns (`use_cauldron`, ...). + pub stat: Option<&'static str>, } fn state(name: &str, level: Option) -> BlockState { @@ -57,6 +59,7 @@ fn pass() -> Interaction { outcome: Outcome::Pass, block: None, sound: None, + stat: None, } } @@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState { } } -fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction { +fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction { Interaction { outcome: Outcome::Success, block: Some(block), sound, + stat: Some(stat), } } @@ -179,18 +183,22 @@ pub fn interact( stack.data_mut().dyed_color = None; stack.tidy(); } - return changed(lowered(cauldron), None); + return changed(lowered(cauldron), None, "clean_armor"); } let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| { if !can_fill { return pass(); } filled(player, ItemStack::new(new, 1), true); - changed(state("minecraft:cauldron", None), Some(sound)) + changed( + state("minecraft:cauldron", None), + Some(sound), + "use_cauldron", + ) }; let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| { filled(player, ItemStack::new("minecraft:bucket", 1), true); - changed(block, Some(sound)) + changed(block, Some(sound), "fill_cauldron") }; match item { "minecraft:lava_bucket" => { @@ -199,6 +207,7 @@ pub fn interact( outcome: Outcome::Consume, block: None, sound: None, + stat: None, } } else { empty_bucket( @@ -221,6 +230,7 @@ pub fn interact( outcome: Outcome::Consume, block: None, sound: None, + stat: None, } } else { empty_bucket( @@ -241,6 +251,7 @@ pub fn interact( changed( state("minecraft:water_cauldron", Some(1)), Some("minecraft:item.bottle.empty"), + "use_cauldron", ) } ("water", "minecraft:bucket") => fill_bucket( @@ -255,7 +266,11 @@ pub fn interact( ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"), true, ); - changed(lowered(cauldron), Some("minecraft:item.bottle.fill")) + changed( + lowered(cauldron), + Some("minecraft:item.bottle.fill"), + "use_cauldron", + ) } ("water", "minecraft:potion") => { if level == 3 || !water_potion { @@ -266,7 +281,7 @@ pub fn interact( // BlockState.cycle on a 1..=3 property next.properties .insert("level".into(), (level % 3 + 1).to_string()); - changed(next, Some("minecraft:item.bottle.empty")) + changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron") } ("water", name) if name @@ -283,7 +298,7 @@ pub fn interact( cleaned.data_mut().banner_patterns.pop(); cleaned.tidy(); filled(player, cleaned, false); - changed(lowered(cauldron), None) + changed(lowered(cauldron), None, "clean_banner") } ("water", name) if name @@ -292,7 +307,7 @@ pub fn interact( .is_some_and(|c| COLORS.contains(&c)) => { filled(player, held.transmute("minecraft:shulker_box", 1), false); - changed(lowered(cauldron), None) + changed(lowered(cauldron), None, "clean_shulker_box") } ("lava", "minecraft:bucket") => fill_bucket( player, diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..175c10447 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2915,6 +2915,8 @@ impl Game<'_> { // Popped as the behaviour emits it (GameLevel::emit). PlayerEffect::DropItem(_) => {} PlayerEffect::EatCake => { + // CakeBlock.eat + self.stats.custom("eat_cake_slice", 1); if let Some(p) = self.player.as_mut() { let food = &mut p.health.food; food.food = (food.food + 2).min(20); @@ -3019,6 +3021,8 @@ impl Game<'_> { self.craft_table = Some(pos); } else if &*name == "minecraft:ender_chest" { self.open_ender_chest(key); + // EnderChestBlock.useWithoutItem + self.stats.custom("open_enderchest", 1); self.container_opener_event(key, true); let at = [ f64::from(key.0) + 0.5, @@ -3574,6 +3578,20 @@ impl Game<'_> { ) }); self.process_level_events()?; + // NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung). + if result == horizon_world::level::InteractionResult::Success { + let name = self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])) + .name + .clone(); + match &*name { + "minecraft:note_block" => self.stats.custom("tune_noteblock", 1), + "minecraft:bell" => self.stats.custom("bell_ring", 1), + _ => {} + } + } if result.consumes_action() { self.swing_arm(); return Ok(true); @@ -8477,6 +8495,9 @@ impl Game<'_> { if result.outcome == Outcome::Pass { return Ok(false); } + if let Some(stat) = result.stat { + self.stats.custom(stat, 1); + } if let Some(block) = result.block && let Ok(new) = registry.resolve(&block) { diff --git a/crates/horizon-client/src/game/jukebox.rs b/crates/horizon-client/src/game/jukebox.rs index c3ed75ebb..61caea0c8 100644 --- a/crates/horizon-client/src/game/jukebox.rs +++ b/crates/horizon-client/src/game/jukebox.rs @@ -79,6 +79,7 @@ impl Game<'_> { if !self.creative { self.consume_held(); } + self.stats.custom("play_record", 1); self.world.block_entities.insert( (pos[0], pos[1], pos[2]), BlockEntity::Jukebox(Jukebox { From fd0f09d6f6530e8c442906a953d80414dc8dcb64 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:15:20 +0100 Subject: [PATCH 095/475] End the Absorption effect once the absorption is used up The effect used to stay in the list until its timer ran out, even with no absorption hearts left. --- crates/horizon-client/src/effects.rs | 15 +++++++++------ crates/horizon-client/src/game/potions.rs | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index 17dce93ab..5f8649642 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -213,8 +213,9 @@ impl PlayerEffects { } /// `LivingEntity.tickEffects` (server part) with the omens' effect ticks: /// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect` - /// (on its last tick). Returns the actions to apply, in order, and - /// whether Raid Omen was newly added. + /// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s + /// answer: the effect ends once the absorption is used up. Returns the + /// actions to apply, in order, and whether Raid Omen was newly added. /// /// Bad Omen turning into a new Raid Omen adds an entry to the map being /// iterated: vanilla's iterator then throws a @@ -224,6 +225,7 @@ impl PlayerEffects { pub fn tick_with( &mut self, omen: &mut dyn FnMut(&EffectInstance) -> OmenTick, + absorption_empty: bool, ) -> (Vec, bool) { self.tick_count = self.tick_count.wrapping_add(1); let mut actions = Vec::new(); @@ -246,6 +248,7 @@ impl PlayerEffects { let answer = match e.id { "bad_omen" => omen(e), "raid_omen" if t == 1 => omen(e), + "absorption" if absorption_empty => OmenTick::Remove, _ => OmenTick::Continue, }; match answer { @@ -465,13 +468,13 @@ mod tests { }; // The first tick adds Raid Omen and stops there (the iterator's // ConcurrentModificationException): Bad Omen and Speed stay as they were. - let (_, added) = fx.tick_with(&mut convert); + let (_, added) = fx.tick_with(&mut convert, false); assert!(added); assert_eq!(fx.get("bad_omen").unwrap().duration, 100); assert_eq!(fx.get("speed").unwrap().duration, 50); assert_eq!(fx.get("raid_omen").unwrap().duration, 600); // The second refreshes Raid Omen and drops Bad Omen. - let (_, added) = fx.tick_with(&mut convert); + let (_, added) = fx.tick_with(&mut convert, false); assert!(!added); assert!(fx.get("bad_omen").is_none()); assert_eq!(fx.get("speed").unwrap().duration, 49); @@ -486,9 +489,9 @@ mod tests { seen.push(e.duration); OmenTick::Remove }; - fx.tick_with(&mut watch); + fx.tick_with(&mut watch, false); assert!(fx.get("raid_omen").is_some()); - fx.tick_with(&mut watch); + fx.tick_with(&mut watch, false); assert!(fx.get("raid_omen").is_none()); assert_eq!(seen, vec![1]); } diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b28c2fb4d..e023ca4b2 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -381,8 +381,10 @@ impl Game<'_> { } // The omens act from inside the effect tick (BadOmenMobEffect, // RaidOmenMobEffect). + let absorption_empty = player.health.absorption() <= 0.0; let mut effects = std::mem::take(&mut self.potions.effects); - let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e)); + let (actions, raid_omen_added) = + effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty); self.potions.effects = effects; if raid_omen_added { self.raid_omen_added(); From 85a6ce09214e684be1810ee6530e85c4476407b3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:15:30 +0100 Subject: [PATCH 096/475] Lightning scrapes oxidation off copper A struck copper block goes back to unoxidized, and 3 to 5 random walks take one step of oxidation off the weathering copper around it. Waxed blocks stay as they are but still start the walks. --- crates/horizon-client/src/game/lightning.rs | 11 +++ crates/horizon-world/src/level/tick.rs | 83 +++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 916bd2a0a..55e867f87 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -221,6 +221,7 @@ impl Game<'_> { if !b.visual_only { self.bolt_fire(b.at, 4); self.strike_rod(b.at); + self.bolt_clear_copper(b.at); } } b.life -= 1; @@ -278,6 +279,16 @@ impl Game<'_> { self.level_event_at(3002, p, axis); } } + /// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck. + fn bolt_clear_copper(&mut self, at: [f64; 3]) { + let p = BlockPos::new( + at[0].floor() as i32, + (at[1] - 1.0e-6).floor() as i32, + at[2].floor() as i32, + ); + self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p)); + let _ = self.process_level_events(); + } /// `thunderHit` for entities within 3 blocks (up to 9 above). fn thunder_hit(&mut self, b: &mut Bolt) { let at = b.at; diff --git a/crates/horizon-world/src/level/tick.rs b/crates/horizon-world/src/level/tick.rs index 29f53e053..3cb8c2d2e 100644 --- a/crates/horizon-world/src/level/tick.rs +++ b/crates/horizon-world/src/level/tick.rs @@ -1479,6 +1479,80 @@ impl Sim<'_, L> { let block = b.table.block_by_name(&format!("minecraft:{next}"))?; Some(b.table.with_properties_of(block, state)) } + /// `WeatheringCopper.getPrevious` (and `getFirst` by repeating it) by + /// registry naming: the block one oxidation step back. + fn copper_previous_name(path: &str) -> Option { + if let Some(rest) = path.strip_prefix("oxidized_") { + Some(format!("weathered_{rest}")) + } else if let Some(rest) = path.strip_prefix("weathered_") { + Some(format!("exposed_{rest}")) + } else { + path.strip_prefix("exposed_").map(|rest| { + if rest == "copper" { + "copper_block".to_owned() + } else { + rest.to_owned() + } + }) + } + } + fn copper_stepped(&self, state: BlockStateId, all_the_way: bool) -> Option { + let b = self.b; + let name = &*b.table.info(b.block(state)).name; + let mut path = Self::copper_previous_name(name.strip_prefix("minecraft:")?)?; + while all_the_way && let Some(earlier) = Self::copper_previous_name(&path) { + path = earlier; + } + let block = b.table.block_by_name(&format!("minecraft:{path}"))?; + Some(b.table.with_properties_of(block, state)) + } + /// `LightningBolt.clearCopperOnLightningStrike`: the struck copper goes + /// back to unoxidized, then 3 to 5 random walks of up to 8 steps each + /// scrape one step of oxidation off the weathering copper they land on. + /// A waxed block is left as it is but still starts the walks. + pub(crate) fn lightning_clear_copper(&mut self, struck: BlockPos) { + let b = self.b; + let state = self.state(struck); + let weathering = b + .data(state) + .implements(super::classes::Interface::WeatheringCopper); + let waxed = b.table.name(state).starts_with("minecraft:waxed_"); + if !(weathering || waxed) { + return; + } + if weathering && let Some(first) = self.copper_stepped(state, true) { + self.set_block_and_update(struck, first); + } + for _ in 0..self.rand_int(3) + 3 { + let mut at = struck; + for _ in 0..self.rand_int(8) + 1 { + // randomStepCleaningCopper: up to 10 random blocks in the + // 3x3x3 cube around the walk's position. + let mut next = None; + for _ in 0..10 { + let x = at.x - 1 + self.rand_int(3); + let y = at.y - 1 + self.rand_int(3); + let z = at.z - 1 + self.rand_int(3); + let candidate = BlockPos::new(x, y, z); + let state = self.state(candidate); + if b.data(state) + .implements(super::classes::Interface::WeatheringCopper) + { + if let Some(previous) = self.copper_stepped(state, false) { + self.set_block_and_update(candidate, previous); + } + self.level_event(3002, candidate, -1); + next = Some(candidate); + break; + } + } + match next { + Some(p) => at = p, + None => break, + } + } + } + } } /// `Biome.Precipitation` at a position. @@ -1662,6 +1736,15 @@ impl Behaviours { ) { self.sim(level, ticks).random_tick_at(pos); } + /// `LightningBolt.clearCopperOnLightningStrike` for the block at `struck`. + pub fn lightning_clear_copper( + &self, + level: &mut L, + ticks: &mut Ticks, + struck: BlockPos, + ) { + self.sim(level, ticks).lightning_clear_copper(struck); + } /// Run one scheduled block tick at `pos` immediately (no queue check). pub fn scheduled_block_tick( &self, From 53501ab94ea4ad6e91003b14e2ae37106498ce2a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:15:55 +0100 Subject: [PATCH 097/475] Merge a mob's repeated effects like vanilla A stronger effect now replaces the old one with its own duration, instead of taking the longer duration and the higher level separately. --- crates/horizon-client/src/mobs.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..6a220d63d 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1854,8 +1854,15 @@ impl Mob { } pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) { if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) { - e.1 = e.1.max(ticks); - e.2 = e.2.max(amplifier); + // MobEffectInstance.update: a stronger effect replaces with its + // own duration, an equal one only extends it; a weaker one is + // ignored (vanilla keeps it hidden behind the active one). + if amplifier > e.2 { + e.1 = ticks; + e.2 = amplifier; + } else if amplifier == e.2 && e.1 != -1 && (ticks == -1 || e.1 < ticks) { + e.1 = ticks; + } } else { self.extra.effects.push((effect, ticks, amplifier)); } From 728bf14aa7db38211cd50d751f38486194d28de3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:06 +0100 Subject: [PATCH 098/475] Use vanilla ambient sound intervals for guardians, hoglins, shulkers and allays A happy ghast with a passenger waits six times as long. --- crates/horizon-client/src/mobs.rs | 12 +++++++++++- crates/horizon-client/src/mobs/ai.rs | 2 +- crates/horizon-client/src/mobs/species.rs | 6 ++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..b2028f964 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1778,7 +1778,7 @@ impl Mob { let dead = !self.alive(); let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }]; if !dead { - self.ambient_timer = -self.kind.ambient_interval; + self.ambient_timer = -self.ambient_interval(); } if !sound.is_empty() { let pitch = self.voice_pitch(); @@ -1874,6 +1874,16 @@ impl Mob { let at = [self.position.x, self.eye_y(), self.position.z]; events.push(MobEvent::Sound(event, at, 1.0, 1.0)); } + /// `Mob.getAmbientSoundInterval`; `HappyGhast` waits six times as long + /// while it carries a passenger. + pub fn ambient_interval(&self) -> i32 { + let ridden = self.rider.is_some() || self.extra.rider.is_some(); + if self.species() == Species::HappyGhast && ridden { + self.kind.ambient_interval * 6 + } else { + self.kind.ambient_interval + } + } /// LivingEntity.getVoicePitch. pub fn voice_pitch(&mut self) -> f32 { let pitch = (self.random.next_float() - self.random.next_float()) * 0.2 diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..e6508b448 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -547,7 +547,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { let t = m.ambient_timer; m.ambient_timer += 1; if roll < t { - m.ambient_timer = -m.kind.ambient_interval; + m.ambient_timer = -m.ambient_interval(); play_ambient(m, w, events); } } diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index f48a21fe9..6630ea4c5 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -1658,6 +1658,12 @@ impl Def { | S::Camel | S::CamelHusk => 400, S::Ocelot => 900, + // Guardian.getAmbientSoundInterval (the elder too). + S::Guardian | S::ElderGuardian => 160, + // Hoglin extends Animal; Shulker extends AbstractGolem. + S::Hoglin | S::Shulker => 120, + // Allay is a PathfinderMob: the Mob default. + S::Allay => 80, S::Squid | S::GlowSquid | S::Cod From 273729aac8e7d6f6de6730688b900663efce2d8c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:34 +0100 Subject: [PATCH 099/475] Count the combat statistics damage_resisted, damage_absorbed and damage_blocked_by_shield now add up when Resistance, absorption hearts or a shield take damage, and a player who lands the killing blow on another player gets player_kills. --- crates/horizon-client/src/game/combat.rs | 10 ++++++++++ crates/horizon-client/src/game/lan_pvp.rs | 22 ++++++++++++++++++++++ crates/horizon-client/src/game/shield.rs | 7 +++++++ 3 files changed, 39 insertions(+) diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 106e0e1a1..197c77f17 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -73,6 +73,16 @@ impl Game<'_> { self.recheck_combat(); for e in events { let (name, amount) = match e { + // LivingEntity.getDamageAfterMagicAbsorb (Resistance) and + // Player.actuallyHurt (absorption hearts), in tenths. + MotionEvent::Health(HealthEvent::Statistic { name, amount }) => { + match *name { + "resisted" => self.stats.custom("damage_resisted", *amount), + "absorbed" => self.stats.custom("damage_absorbed", *amount), + _ => {} + } + continue; + } MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => { let Some(d) = self.motion.health.damage.definition(*source) else { continue; diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index c7c6b6525..a2ff13444 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -253,6 +253,28 @@ impl Game<'_> { Victim::Host => land(self), Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false), }; + // ServerPlayer.awardKillScore: the blow that kills a player. + if hurt { + let died = match victim { + Victim::Host => self + .player + .as_ref() + .is_some_and(|p| p.health.dead_or_dying()), + Victim::Guest(conn) => host + .hub + .guest(conn) + .and_then(|g| g.motion.as_deref()) + .is_some_and(|p| p.health.dead_or_dying()), + }; + if died { + match s.attacker { + Some(conn) => { + self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1)); + } + None => self.stats.custom("player_kills", 1), + } + } + } if hurt && s.crit { let target = match victim { Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)), diff --git a/crates/horizon-client/src/game/shield.rs b/crates/horizon-client/src/game/shield.rs index c4f7f98d1..f4642f272 100644 --- a/crates/horizon-client/src/game/shield.rs +++ b/crates/horizon-client/src/game/shield.rs @@ -121,6 +121,13 @@ impl Game<'_> { attacker: Option, projectile: bool, ) { + // LivingEntity.hurtServer: the damage the shield took, in tenths. + if outcome.blocked > 0.0 { + self.stats.custom( + "damage_blocked_by_shield", + (outcome.blocked * 10.0).round() as i32, + ); + } let Some((slot, _)) = self.using else { return }; let Some(stack) = self.stack_in(slot).cloned() else { return; From dd3697bb4d0f6acd161fc18aaa7eeab3bafbfea5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:43 +0100 Subject: [PATCH 100/475] Hopper minecarts take from the container above, one item per tick --- crates/horizon-client/src/game/machines.rs | 144 ++++++++++++++------ crates/horizon-client/src/game/minecarts.rs | 48 +------ 2 files changed, 106 insertions(+), 86 deletions(-) diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 8742962e9..55e47aa67 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -624,46 +624,8 @@ impl Game<'_> { /// `HopperBlockEntity.suckInItems`: from the container above, or item entities. fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool { let above = (pos.0, pos.1 + 1, pos.2); - let c = self.containers(); - if let Some(source) = c.view(above, false) { - if let Kind::Composter(level) = source.kind { - if level != 8 { - return false; - } - // tryTakeInItemFromSlot on the OutputContainer: removeItem calls - // its setChanged, which empties the composter, and so does the - // second setChanged after the move or, when the hopper has no - // room, the setItem putting the bone meal back into the detached - // container (whose bone meal is lost with it). - let bone_meal = c.bone_meal.clone(); - let at = BlockPos::new(above.0, above.1, above.2); - self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); - let moved = self.add_item(None, hopper, bone_meal, None).is_none(); - self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); - self.world.modified.insert((above.0 >> 4, above.2 >> 4)); - return moved; - } - let source_hopper = (source.kind == Kind::Hopper).then_some(above); - for slot in source.slots_for_face(Some(Direction::Down)) { - let c = self.containers(); - let Some(stack) = c.get(&source, slot) else { - continue; - }; - if !c.can_take(&source, slot, stack, Direction::Down) { - continue; - } - let Some(one) = self.take_one(&source, slot) else { - continue; - }; - match self.add_item(source_hopper, hopper, one, None) { - None => { - self.view_changed(&source); - return true; - } - Some(rest) => self.put_back(&source, slot, rest), - } - } - return false; + if let Some(took) = self.suck_from_container(above, hopper) { + return took; } let above_state = self.world.block(above.0, above.1, above.2); let blocked = self.res.facts().get(above_state).full_collision @@ -693,6 +655,56 @@ impl Game<'_> { { return false; } + self.suck_item_entities(hopper, min, max) + } + /// The container half of `HopperBlockEntity.suckInItems`: one item from the + /// container at `above`, through its bottom face. `None` when there is no + /// container there. + fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option { + let c = self.containers(); + let source = c.view(above, false)?; + if let Kind::Composter(level) = source.kind { + if level != 8 { + return Some(false); + } + // tryTakeInItemFromSlot on the OutputContainer: removeItem calls + // its setChanged, which empties the composter, and so does the + // second setChanged after the move or, when the hopper has no + // room, the setItem putting the bone meal back into the detached + // container (whose bone meal is lost with it). + let bone_meal = c.bone_meal.clone(); + let at = BlockPos::new(above.0, above.1, above.2); + self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); + let moved = self.add_item(None, hopper, bone_meal, None).is_none(); + self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); + self.world.modified.insert((above.0 >> 4, above.2 >> 4)); + return Some(moved); + } + let source_hopper = (source.kind == Kind::Hopper).then_some(above); + for slot in source.slots_for_face(Some(Direction::Down)) { + let c = self.containers(); + let Some(stack) = c.get(&source, slot) else { + continue; + }; + if !c.can_take(&source, slot, stack, Direction::Down) { + continue; + } + let Some(one) = self.take_one(&source, slot) else { + continue; + }; + match self.add_item(source_hopper, hopper, one, None) { + None => { + self.view_changed(&source); + return Some(true); + } + Some(rest) => self.put_back(&source, slot, rest), + } + } + Some(false) + } + /// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`): + /// adds the item entities that meet the box, until one is taken whole. + fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool { let mut i = 0; while i < self.dropped.len() { let b = self.dropped[i].bounds(); @@ -717,6 +729,58 @@ impl Game<'_> { } false } + /// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled + /// hopper minecart: one item from the container above, else the item + /// entities over it or touching it, each tick without a cooldown. + pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) { + // The cart's slots are edited as a container under the placeholder key + // its menu uses; when that menu is open the placeholder is the live copy. + let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32); + let open = self.world.block_entities.contains_key(&key); + if !open { + let items = BlockEntity::Chest(std::mem::take(&mut cart.items)); + self.world.block_entities.insert(key, items); + } + let size = self + .world + .block_entities + .get(&key) + .map_or(0, |e| e.slots().len()); + let view = View { + parts: vec![(key, size)], + kind: Kind::Generic, + }; + let p = cart.position; + // getLevelY is the cart's y + 0.5, so the container is the block at + // y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16 + // above the cart. + let above = ( + p.x.floor() as i32, + (p.y + 1.5).floor() as i32, + p.z.floor() as i32, + ); + let took = match self.suck_from_container(above, &view) { + Some(took) => took, + None => self.suck_item_entities( + &view, + [p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5], + [p.x + 0.5, p.y + 2.0, p.z + 0.5], + ), + }; + if !took { + // The cart's own box, inflated by 0.25 sideways. + let b = cart.bounds(); + let (lo, hi) = (b.min_position(), b.max_position()); + self.suck_item_entities( + &view, + [lo.x - 0.25, lo.y, lo.z - 0.25], + [hi.x + 0.25, hi.y, hi.z + 0.25], + ); + } + if !open && let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) { + cart.items = items; + } + } // ---- dispensers and droppers ---- diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a19b6df73..33e08a922 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -642,50 +642,6 @@ impl Game<'_> { self.carts.retain(|x| x.id != c.id); let _ = self.server_explode(at, power, BlockInteraction::Destroy, false); } - /// `MinecartHopper.suckInItems`: items above, into the five slots. - fn hopper_cart_pickup(&mut self, c: &mut Minecart) { - if !c.enabled { - return; - } - let p = c.position; - let zone = Aabb::new( - Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5), - Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5), - ); - let mut i = 0; - while i < self.dropped.len() { - let e = &self.dropped[i]; - if e.pickup_delay > 0 || !zone.contains(e.position) { - i += 1; - continue; - } - let mut stack = e.stack.clone(); - for slot in c.items.iter_mut() { - match slot { - None => { - *slot = Some(stack.clone()); - stack.count = 0; - } - Some(s) if s.same_item_same_components(&stack) => { - let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack); - let moved = stack.count.min(max.saturating_sub(s.count)); - s.count += moved; - stack.count -= moved; - } - _ => {} - } - if stack.count == 0 { - break; - } - } - if stack.count == 0 { - self.dropped.remove(i); - } else { - self.dropped[i].stack = stack; - i += 1; - } - } - } /// `AbstractMinecart.push` from each player walking into a cart /// (`LivingEntity.pushEntities`): the window's player and every server /// player (single player's own on its server thread, LAN guests). A @@ -823,8 +779,8 @@ impl Game<'_> { } let mut c = self.carts[i].clone(); let alive = self.tick_cart(&mut c, [0.0; 2]); - if c.kind == CartKind::Hopper { - self.hopper_cart_pickup(&mut c); + if c.kind == CartKind::Hopper && c.enabled { + self.hopper_cart_suck(&mut c); } let Some(i) = self.carts.iter().position(|c| c.id == id) else { continue; From 601e7918a844884574222a26ec75d05169499386 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:43 +0100 Subject: [PATCH 101/475] Lightning scrapes one oxidation step off a copper golem A copper golem hit by a bolt goes back one weathering step, and its weathering timer (or wax) is reset. --- crates/horizon-client/src/game/lightning.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 916bd2a0a..b4e2c587c 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -16,7 +16,7 @@ pub(super) struct Bolt { flashes: i32, seed: i64, visual_only: bool, - /// Mooshrooms switch colour once per bolt. + /// Mooshrooms and copper golems change once per bolt. converted: Vec, } impl Game<'_> { @@ -337,6 +337,17 @@ impl Game<'_> { continue; } Species::Creeper => m.extra.charged = true, + // CopperGolem.thunderHit (after Entity.thunderHit's fire and + // damage): once per bolt, an oxidized golem goes back one step + // and its weathering timer (or wax, -2) is reset to -1. + Species::CopperGolem if !b.converted.contains(&m.id) => { + b.converted.push(m.id); + if m.extra.variant > 0 { + m.extra.vector[0] = -1.0; + m.extra.variant = + horizon_client::mobs::copper_golem::weather_previous(m.extra.variant); + } + } _ => {} } // Entity.thunderHit: burning (8 seconds) and 5 lightning damage. From ef711f8b1f356c1f1b6745b366a00c38f19076b4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:44 +0100 Subject: [PATCH 102/475] Fire the brewed_potion trigger when a potion is taken from a brewing stand --- crates/horizon-client/src/game.rs | 4 +++ .../horizon-client/src/game/advancements.rs | 19 +++++++++++ .../horizon-client/src/game/lan_crafting.rs | 9 ++++++ crates/horizon-client/src/menu.rs | 32 +++++++++++++------ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..bb8d61f09 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6176,6 +6176,7 @@ impl Game<'_> { self.player_menu_sound(sound); } let crafted = std::mem::take(&mut menu.crafted); + let brewed = std::mem::take(&mut menu.brewed); let equipped = std::mem::take(&mut menu.equipped); for (i, stack) in after.into_iter().enumerate() { if let Some(g) = game_of[i] { @@ -6191,6 +6192,9 @@ impl Game<'_> { self.player_drop(stack); } self.award_crafted(&crafted); + for stack in &brewed { + self.trigger_brewed_potion(stack); + } if furnace_before > furnace_after && self.remote.is_none() && let (Some(pos), Some(p)) = (self.container, self.player.as_ref()) diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..53fa282d4 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -708,6 +708,25 @@ impl Game<'_> { let it = item_view(stack); self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w)); } + /// `BrewedPotionTrigger`: the potion contents of a stack taken from a + /// brewing stand. + pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) { + let me = self.player_entity(); + let potion = stack.potion().map(str::to_owned); + self.trigger("minecraft:brewed_potion", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) + && (c["potion"].is_null() + || potion + .as_deref() + .is_some_and(|p| adv::id_matches(&c["potion"], "potion", p, w))) + }); + } /// `EnterBlockTrigger`: the block, its state properties and the player. pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) { let Some(definition) = self.pack.assets.registry.state(state).cloned() else { diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index db6235c4d..f868bb957 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -127,9 +127,18 @@ impl Game<'_> { } let dropped = std::mem::take(&mut menu.dropped); let equipped = std::mem::take(&mut menu.equipped); + let brewed = std::mem::take(&mut menu.brewed); menu.drops.clear(); menu.crafted.clear(); store_player(menu, &mut host.hub.guests[gi]); + if !brewed.is_empty() { + let conn = host.hub.guests[gi].conn; + self.as_guest(host, conn, |g| { + for stack in &brewed { + g.trigger_brewed_potion(stack); + } + }); + } if !equipped.is_empty() { let conn = host.hub.guests[gi].conn; self.as_guest(host, conn, |g| { diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index 3b2b8fd31..50f90b280 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -288,6 +288,9 @@ pub struct Menu { pub player_sounds: Vec<&'static str>, /// Recipes whose result was taken (`awardUsedRecipes`), for the caller. pub crafted: Vec, + /// Potions taken from a brewing stand's bottle slots + /// (`PotionSlot.onTake`), for the caller. + pub brewed: Vec, /// `LivingEntity.onEquipItem` calls that change the item: the /// `EquipmentSlot` name and the new stack, in order, for the caller. pub equipped: Vec<(&'static str, Option)>, @@ -683,6 +686,7 @@ impl Menu { sounds: Vec::new(), player_sounds: Vec::new(), crafted: Vec::new(), + brewed: Vec::new(), equipped: Vec::new(), merchant: None, stone: Stonecutter::default(), @@ -1504,7 +1508,17 @@ impl Menu { } /// `ResultSlot.onTake` and the other `Slot.onTake` overrides. - fn on_take(&mut self, env: &Env, s: usize) { + fn on_take(&mut self, env: &Env, s: usize, taken: &ItemStack) { + if self.slots[s].role == Role::Potion { + // BrewingStandMenu.PotionSlot.onTake: a stack with potion contents. + if matches!( + &*taken.item, + "minecraft:potion" | "minecraft:splash_potion" | "minecraft:lingering_potion" + ) { + self.brewed.push(taken.clone()); + } + return; + } if self.slots[s].role == Role::MerchantResult { self.merchant_take(env); return; @@ -1671,7 +1685,7 @@ impl Menu { max_amount: u32, ) -> Option { let mut result = self.try_remove(env, s, amount, max_amount)?; - self.on_take(env, s); + self.on_take(env, s, &result); if self.slots[s].role == Role::CartoResult { Self::map_crafted(env, &mut result); } @@ -2346,7 +2360,7 @@ impl Menu { return None; } if after_take || matches!(self.kind, Kind::Inventory | Kind::Crafting) { - self.on_take(env, index); + self.on_take(env, index, &clicked); } // What the result slot's move left over is dropped. if (index == 0 && matches!(self.kind, Kind::Inventory | Kind::Crafting)) @@ -2594,7 +2608,7 @@ impl Menu { }; if let Some(mut taken) = self.try_remove(env, s, amount, u32::MAX) { self.carried = Some(taken.clone()); - self.on_take(env, s); + self.on_take(env, s, &taken); if self.slots[s].role == Role::CartoResult { // onTake's onCraftedBy edits the carried stack. Self::map_crafted(env, &mut taken); @@ -2628,7 +2642,7 @@ impl Menu { if let Some(c) = self.carried.as_mut() { c.count += taken.count; } - self.on_take(env, s); + self.on_take(env, s, &taken); if self.slots[s].role == Role::CartoResult { // onCraftedBy edits the taken stack, not the carried one. Self::map_crafted(env, &mut taken); @@ -2656,7 +2670,7 @@ impl Menu { if self.may_pickup(env, target) { self.containers[PLAYER][inv_slot] = Some(taken.clone()); self.set_by_player(env, target, None); - self.on_take(env, target); + self.on_take(env, target, &taken); if self.slots[target].role == Role::CartoResult && let Some(stack) = self.containers[PLAYER][inv_slot].as_mut() { @@ -2684,14 +2698,14 @@ impl Menu { let part = split(&mut source, max); self.containers[PLAYER][inv_slot] = Some(source); self.set_by_player(env, target, Some(part)); - self.on_take(env, target); + self.on_take(env, target, &taken); if !self.inventory_add(env, &mut taken) { self.drop_stack(&taken); } } else { - self.containers[PLAYER][inv_slot] = Some(taken); + self.containers[PLAYER][inv_slot] = Some(taken.clone()); self.set_by_player(env, target, Some(source)); - self.on_take(env, target); + self.on_take(env, target, &taken); } } } From 13144a82a15291da42e1449741ae15d63c33d4a9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:28 +0100 Subject: [PATCH 103/475] Pull the third-person camera back further when riding a happy ghast --- crates/horizon-client/src/game/perspective.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index caf49883b..2d5c6b05c 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -218,11 +218,20 @@ impl Game<'_> { _ => None, }) }); - // A living mount at the default scale and camera distance. + // Camera.setup: the living mount's scale and camera distance + // attributes (the happy ghast's is 8, the default 4). let mount = self .riding .and_then(|id| self.mobs.iter().find(|m| m.id == id)) - .map(|_| (1.0, 4.0)); + .map(|m| { + let attribute = |name: &str, default: f64| { + m.kind.attributes.get(name).copied().unwrap_or(default) + }; + ( + attribute("minecraft:scale", 1.0) as f32, + attribute("minecraft:camera_distance", 4.0), + ) + }); // AbstractBoat.positionRider turns a passenger by the boat's // deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot // interpolates a passenger's yaw: the boat's turn is spread over the From 9c427dfae01619b5f02a863fe067990947d267ec Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:54 +0100 Subject: [PATCH 104/475] Mobs killed by a wither leave a wither rose --- crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/game/wither.rs | 42 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..cd211ec48 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -717,6 +717,7 @@ impl Game<'_> { self.villager_murdered(m, by_player); let pos = [m.position.x, m.position.y + 0.5, m.position.z]; self.drop_death_loot(&mut dying, by_player, consumed, pos); + self.create_wither_rose(m); if m.species() == Species::CopperGolem { // CopperGolem.dropEquipment: the preserved equipment too. self.golem_drop_equipment(&mut dying); diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 8084425ca..101818f68 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -2,11 +2,53 @@ //! its boss bar, and its skulls hitting and exploding. use super::Game; use horizon_client::{ + items::ItemStack, mobs::{Damage, DamageKind, Species, Who}, projectiles::{Projectile, ProjectileKind}, }; +use horizon_core::position::BlockPos; impl Game<'_> { + /// `LivingEntity.createWitherRose`: a mob a wither killed leaves a wither + /// rose where it stood (with mobGriefing, on air that can hold it), else + /// drops one. + pub(super) fn create_wither_rose(&mut self, m: &horizon_client::mobs::Mob) { + // getKillCredit: the player who last hurt it, else the last mob (kept + // for 100 ticks). + let by_wither = m.ai.last_hurt_by_player <= 0 + && m.age as i32 - m.ai.last_hurt_by_time <= 100 + && matches!(m.ai.last_hurt_by, Some(Who::Mob(id)) + if self.mobs.iter().any(|o| o.id == id && o.alive() && o.species() == Species::Wither)); + if !by_wither { + return; + } + let pos = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + let registry = self.pack.assets.registry; + let rose = registry.default_state("minecraft:wither_rose"); + if self.ui.rules.bool("mob_griefing") + && let Some(rose) = rose + && self + .res + .facts() + .is_air(self.world.block(pos[0], pos[1], pos[2])) + && self.with_level(|b, level, ticks| { + b.can_survive(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]), rose) + }) + { + let _ = self.level_set(pos, rose); + return; + } + self.spawn_item( + ItemStack::new("minecraft:wither_rose", 1), + [m.position.x, m.position.y, m.position.z], + super::dispenser::new_item_motion(), + 0, + ); + } fn is_wither_skull(&self, p: [i32; 3]) -> bool { let name = &*self .res From 8c8f857313bd5fbe82cb47af8d339640460ac174 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:17:42 +0100 Subject: [PATCH 105/475] Fire item_used_on_block when an axe, honeycomb, disc or vault key is used on a block --- crates/horizon-client/src/game.rs | 4 ++++ crates/horizon-client/src/game/jukebox.rs | 2 ++ crates/horizon-client/src/game/vault.rs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..fa7f0c133 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8586,6 +8586,8 @@ impl Game<'_> { else { return Ok(false); }; + // BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap. + self.trigger_block("minecraft:item_used_on_block", pos, stack); self.level_set(pos, new)?; // BlockTransformer.TransformParticle.send. self.transform_particle(&particle, pos); @@ -8832,6 +8834,8 @@ impl Game<'_> { if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old)) && let Ok(state) = registry.resolve(&waxed) { + // HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap. + self.trigger_block("minecraft:item_used_on_block", hit.pos, stack); self.level_set(hit.pos, state)?; self.level_event_at(3003, hit.pos, 0); self.audio.play( diff --git a/crates/horizon-client/src/game/jukebox.rs b/crates/horizon-client/src/game/jukebox.rs index c3ed75ebb..bd130ce9b 100644 --- a/crates/horizon-client/src/game/jukebox.rs +++ b/crates/horizon-client/src/game/jukebox.rs @@ -90,6 +90,8 @@ impl Game<'_> { self.set_has_record(pos, true); self.start_song(pos, &song); self.jukebox_changed(pos); + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, stack); true } /// Level event 1010: the song from the jukebox, and the "Now Playing" overlay. diff --git a/crates/horizon-client/src/game/vault.rs b/crates/horizon-client/src/game/vault.rs index f3edfddb3..18430daf6 100644 --- a/crates/horizon-client/src/game/vault.rs +++ b/crates/horizon-client/src/game/vault.rs @@ -169,6 +169,8 @@ impl Game<'_> { self.consume_held(); } } + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, &stack); Ok(true) } fn vault_events(&mut self, key: Pos, events: Vec) -> Result<()> { From b913dab5e52e179e73c3bdebe1ee77266e92a34f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:17:53 +0100 Subject: [PATCH 106/475] Walk, follow and flee at the camel's real AI speeds --- crates/horizon-client/src/mobs/camel.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/mobs/camel.rs b/crates/horizon-client/src/mobs/camel.rs index 21ebe9e16..703c8fa8f 100644 --- a/crates/horizon-client/src/mobs/camel.rs +++ b/crates/horizon-client/src/mobs/camel.rs @@ -24,12 +24,13 @@ pub fn register(m: &mut Mob) { m.extra.tame = true; let tempt = m.kind.tempt.clone(); let g = &mut m.ai.goals; + // CamelAi speed modifiers (times the 0.09 movement speed attribute). g.add(0, Float); - g.add(1, CamelPanic(Panic::new(4.0 * 0.25))); + g.add(1, CamelPanic(Panic::new(4.0))); g.add(2, Breed::new(1.0)); - g.add(3, Tempt::new(2.5 * 0.25, tempt, false)); - g.add(4, FollowParent::new(2.5 * 0.25)); - g.add(5, Stroll::new(2.0 * 0.25)); + g.add(3, Tempt::new(2.5, tempt, false)); + g.add(4, FollowParent::new(2.5)); + g.add(5, Stroll::new(2.0)); g.add(5, RandomSitting); g.add(6, LookAtPlayer::new(6.0)); g.add(7, RandomLookAround::default()); From b3a499d85d720acf6d133f47f79806edfa485560 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:18:05 +0100 Subject: [PATCH 107/475] Play chest, barrel and shulker box sounds with a random pitch from the right spot --- crates/horizon-client/src/game.rs | 22 ++------------ crates/horizon-client/src/game/storage.rs | 35 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..147aefe36 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3077,16 +3077,7 @@ impl Game<'_> { None }; if let Some(sound) = sound { - self.audio.play( - sound, - Some([ - f64::from(key.0) + 0.5, - f64::from(key.1) + 0.5, - f64::from(key.2) + 0.5, - ]), - 0.5, - 1.0, - ); + self.container_sound(sound, key, &name); } } else if self.open_station(pos, &name) { // Workstation menus: game/stations.rs. @@ -5654,16 +5645,7 @@ impl Game<'_> { None }; if let Some(sound) = sound { - self.audio.play( - sound, - Some([ - f64::from(key.0) + 0.5, - f64::from(key.1) + 0.5, - f64::from(key.2) + 0.5, - ]), - 0.5, - 1.0, - ); + self.container_sound(sound, key, &name); } self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 1bcb6a524..3456c4ab0 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -3,6 +3,7 @@ //! player's `PlayerEnderChestContainer`, saved as `EnderItems`). use super::Game; use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text}; +use horizon_core::random::RandomSource; pub(super) fn is_shulker_box(name: &str) -> bool { name.ends_with("shulker_box") @@ -107,6 +108,40 @@ impl Game<'_> { ); paired.then_some((other, kind == "right")) } + /// The open or close sound of a container block (`ChestBlockEntity.playSound`, + /// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume + /// 0.5 and pitch 0.9 to 1.0. A double chest sounds once from the middle of + /// both halves, a barrel from the face it opens on. + pub(super) fn container_sound(&mut self, sound: &str, key: (i32, i32, i32), name: &str) { + let mut at = [ + f64::from(key.0) + 0.5, + f64::from(key.1) + 0.5, + f64::from(key.2) + 0.5, + ]; + if let Some((other, _)) = self.chest_partner(key) { + at[0] = f64::from(key.0 + other.0) / 2.0 + 0.5; + at[2] = f64::from(key.2 + other.2) / 2.0 + 0.5; + } else if name == "minecraft:barrel" + && let Some(state) = self + .pack + .assets + .registry + .state(self.world.block(key.0, key.1, key.2)) + && let Some(facing) = state.properties.get("facing") + { + let (dx, dy, dz) = match facing.as_str() { + "up" => (0.0, 0.5, 0.0), + "down" => (0.0, -0.5, 0.0), + f => { + let (x, z) = facing_step(f); + (f64::from(x) / 2.0, 0.0, f64::from(z) / 2.0) + } + }; + at = [at[0] + dx, at[1] + dy, at[2] + dz]; + } + let pitch = self.world.random.next_float() * 0.1 + 0.9; + self.audio.play(sound, Some(at), 0.5, pitch); + } /// The other half of a double chest at `key`, for neighbour updates. pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> { self.chest_partner(key).map(|p| p.0) From 147e5269c76430060007b13a1dc947a99062896c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:19:20 +0100 Subject: [PATCH 108/475] Play the rain sound the way vanilla picks it --- crates/horizon-client/src/game.rs | 35 +++---------------- crates/horizon-client/src/game/render_tick.rs | 23 +++++++++++- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..f491b3f8f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1382,6 +1382,8 @@ struct Game<'a> { /// `ClientAvatarState`: walked distance and view bob. avatar: horizon_client::camera::AvatarBob, flash: u32, + /// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound. + rain_sound_time: i32, /// Bolts that struck here and sky flashes they made, in total (`status`). lightning_seen: [u32; 2], /// The head of the bed while sleeping; the visual timer also survives waking. @@ -9497,37 +9499,7 @@ impl Game<'_> { if self.flash > 0 { self.flash -= 1; } - let (rain, _) = self.world.weather.levels(1.0); - let Some(eye) = self.eye_position() else { - return; - }; - // WeatherEffectRenderer.tickRainParticles: occasional rain sounds. - if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 { - let r = &mut self.world.random; - let (dx, dz) = ( - r.next_int_bound(21).unwrap_or(10) - 10, - r.next_int_bound(21).unwrap_or(10) - 10, - ); - let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz); - let top = self.rain_height(x, z); - if self.precipitation(x, top, z) == Some(false) - && (top - eye[1] as i32).abs() < 10 - && self.world.random.next_int_bound(5).unwrap_or(1) == 0 - { - let above = f64::from(top) > eye[1] + 1.0; - let (name, volume, pitch) = if above { - ("minecraft:weather.rain.above", 0.1, 0.5) - } else { - ("minecraft:weather.rain", 0.2, 1.0) - }; - self.audio.play( - name, - Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]), - volume, - pitch, - ); - } - } + // The rain sounds come with the rain splashes (`tick_particles`). } /// WeatherEffectRenderer: rain and snow columns within the weather radius. fn weather_geometry( @@ -12267,6 +12239,7 @@ impl<'a> Game<'a> { dragging: false, avatar: Default::default(), flash: 0, + rain_sound_time: 0, lightning_seen: [0; 2], sleeping: None, sleep_timer: 0, diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index e013c8d29..2356fbffe 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -288,11 +288,13 @@ impl Game<'_> { ]; animate_tick(self.res.animate(), &mut self.particles, &ctx, block); if rain > 0.0 { - // ClientLevel.tickWeatherEffects: rain splashes (weather radius 10). + // ClientLevel.tickWeatherEffects: rain splashes (weather radius 10) + // and the rain sound. let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231)); let radius = 10; let diameter = 2 * radius + 1; let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32; + let mut rain_position = None; for _ in 0..count { let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius; let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius; @@ -305,6 +307,7 @@ impl Game<'_> { continue; } let below = top - 1; + rain_position = Some([x, below, z]); let (bx, bz) = (random.next_double(), random.next_double()); let state = world.block(x, below, z); let f = facts.get(state); @@ -336,6 +339,24 @@ impl Game<'_> { self.particles .add(&ParticleOptions::simple(ty), p, [0.0; 3]); } + // The last rain column of the tick makes the sound, more often the + // longer it has been quiet; indoors it is the quieter one. + if let Some(at) = rain_position { + let waited = self.rain_sound_time; + self.rain_sound_time += 1; + if random.next_int_bound(3).unwrap_or(0) < waited { + self.rain_sound_time = 0; + let roof = + motion_blocking(world, facts, block[0], block[2], MIN_Y + HEIGHT); + let (name, volume, pitch) = if at[1] > block[1] + 1 && roof > block[1] { + ("minecraft:weather.rain.above", 0.1, 0.5) + } else { + ("minecraft:weather.rain", 0.2, 1.0) + }; + let p = at.map(|c| f64::from(c) + 0.5); + self.audio.play(name, Some(p), volume, pitch); + } + } } } if let Some(r) = &self.renderer { From 2e642857fc36d4b4d2e1148dc0fedf73aead5128 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:20:03 +0100 Subject: [PATCH 109/475] Fire the levitation and using_item advancement triggers --- crates/horizon-client/src/advancements.rs | 47 +++++----- crates/horizon-client/src/game.rs | 2 + .../horizon-client/src/game/advancements.rs | 85 +++++++++++++++++++ crates/horizon-client/src/game/lan_actions.rs | 7 ++ 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index 6706532b7..187f88382 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -437,6 +437,8 @@ pub struct EntityView { /// mainhand, offhand, head, chest, legs, feet, body. pub equipment: [Option; 7], pub vehicle: Option>, + /// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`). + pub looking_at: Option>, pub effects: Vec<(String, i32, i32)>, /// Player: game mode name. pub gamemode: Option, @@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool { _ => false, } } -fn range_i(v: &Value, x: i64) -> bool { +pub fn range_i(v: &Value, x: i64) -> bool { range_f(v, x as f64) } /// A registry id, `#tag`, or list matched against one id. @@ -614,6 +616,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool { }) }) } +/// `DistancePredicate.matches` between two points. +pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool { + let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + range_f(&v["x"], d[0].abs()) + && range_f(&v["y"], d[1].abs()) + && range_f(&v["z"], d[2].abs()) + && range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt()) + && range_f( + &v["absolute"], + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(), + ) +} /// `EntityPredicate` (26.x keyed sub-predicates). pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool { if v.is_null() { @@ -629,21 +643,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo "minecraft:stepping_on" => { location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w) } - "minecraft:distance" | "distance" => { - let d = [ - e.position[0] - origin[0], - e.position[1] - origin[1], - e.position[2] - origin[2], - ]; - range_f(&p["x"], d[0].abs()) - && range_f(&p["y"], d[1].abs()) - && range_f(&p["z"], d[2].abs()) - && range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt()) - && range_f( - &p["absolute"], - (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(), - ) - } + "minecraft:distance" | "distance" => distance(p, e.position, origin), "minecraft:flags" | "flags" => { let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have); f("is_on_fire", e.on_fire) @@ -678,11 +678,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo }) }) }), - "minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| { - e.gamemode - .as_deref() - .is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g))) - }), + "minecraft:type_specific/player" => { + p["gamemode"].as_array().is_none_or(|modes| { + e.gamemode + .as_deref() + .is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g))) + }) && (p["looking_at"].is_null() + || e.looking_at + .as_ref() + .is_some_and(|t| entity(&p["looking_at"], t, w, origin))) + } "minecraft:type_specific/lightning" => { range_i(&p["blocks_set_on_fire"], 0) && (p["entity_struck"].is_null() diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1d86fe990..ae29abe8f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3875,6 +3875,8 @@ impl Game<'_> { self.release_used_item(&stack, ticks, hand_changed); return; } + // ServerPlayer.updateUsingItem: USING_ITEM every tick of the use. + self.trigger_using_item(&stack); let Some(info) = self.res.items().get(&stack.item).cloned() else { self.using = None; return; diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..67615b628 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -82,6 +82,8 @@ pub(super) struct AdvState { pub(super) quiet: bool, /// The inventory last seen by `inventory_changed`. pub(super) last_inventory: u64, + /// `ServerPlayer.levitationStartPos` and `levitationStartTime`. + pub(super) levitation: Option<([f64; 3], u64)>, /// While the game acts for a network player (`as_guest`): the /// advancements whose progress changed (`progressChanged`), the recipes /// unlocked and the announcements, for their client and everyone's. @@ -647,6 +649,28 @@ impl Game<'_> { }; adv::conditions(&c["player"], &ctx, w) }); + // ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a + // levitation; tick fires LEVITATION while there is one. + let levitating = self + .potions + .effects + .list + .iter() + .any(|e| e.id == "levitation"); + match (levitating, self.adv.levitation) { + (true, None) => { + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + self.adv.levitation = at.map(|at| (at, self.ticks)); + } + (false, Some(_)) => self.adv.levitation = None, + _ => {} + } + if let Some((start, since)) = self.adv.levitation { + self.trigger_levitation(start, (self.ticks - since) as i64); + } if self.ticks.is_multiple_of(20) { let me = self.player_entity(); self.trigger("minecraft:location", |c, w| { @@ -681,6 +705,67 @@ impl Game<'_> { adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w) }); } + /// `LevitationTrigger`: how far the player rose from where it started + /// levitating, and for how long. + fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) { + let me = self.player_entity(); + self.trigger("minecraft:levitation", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) + && (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position)) + && adv::range_i(&c["duration"], duration) + }); + } + /// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with + /// what the player looks at (`PlayerPredicate.lookingAt`). + pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) { + if self.remote.is_some() || self.no_player_here() { + return; + } + let mut me = self.player_entity(); + me.looking_at = self.looking_at_entity().map(Box::new); + let it = item_view(stack); + self.trigger("minecraft:using_item", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w) + }); + } + /// The mob a 100 block ray from the eyes meets first, if no block is in + /// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`). + fn looking_at_entity(&self) -> Option { + let eye = self.eye_position()?; + let dir = super::view_vector(self.yaw, self.pitch); + let (m, _) = self + .mobs + .iter() + .filter(|m| m.alive()) + .filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?))) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + // Eye to the target's centre, two samples per block. + let to = m.center(); + let reach = (0..3).map(|i| (to[i] - eye[i]).powi(2)).sum::().sqrt(); + let steps = (reach * 2.0).ceil().max(1.0) as i32; + let clear = (1..steps).all(|i| { + let f = f64::from(i) / f64::from(steps); + let p = [0, 1, 2].map(|a| (eye[a] + (to[a] - eye[a]) * f).floor() as i32); + !self + .res + .facts() + .get(self.world.block(p[0], p[1], p[2])) + .full_collision + }); + clear.then(|| self.mob_entity(m)) + } /// A player condition plus one item (`item` key). pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) { let me = self.player_entity(); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 0ad14bbcc..d1696d19a 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -75,6 +75,8 @@ pub(super) struct GuestPlay { /// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw. pub(super) stats: super::stats::Stats, last_inventory: u64, + /// `ServerPlayer.levitationStartPos` and `levitationStartTime`. + levitation: Option<([f64; 3], u64)>, /// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs). pub(super) portal: horizon_client::nether_portal::PortalState, /// `Player.takeXpDelay`: ticks until the next orb can be taken. @@ -154,6 +156,7 @@ pub(super) struct GuestSwap { host_ender: Option<(i32, i32, i32)>, adv_loaded: bool, last_inventory: u64, + levitation: Option<([f64; 3], u64)>, death_cause: Option, /// The guest's own damage record, in place while they act. combat: super::combat::CombatTracker, @@ -378,6 +381,7 @@ impl Game<'_> { std::mem::swap(&mut self.hook, &mut play.hook); let adv_loaded = std::mem::replace(&mut self.adv.loaded, true); let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory); + let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation); let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take()); let acting = ( self.lan_acting.replace(conn), @@ -442,6 +446,7 @@ impl Game<'_> { host_ender, adv_loaded, last_inventory, + levitation, death_cause, acting, packets, @@ -500,6 +505,7 @@ impl Game<'_> { host_ender, adv_loaded, last_inventory, + levitation, death_cause, combat, acting, @@ -610,6 +616,7 @@ impl Game<'_> { std::mem::swap(&mut self.hook, &mut play.hook); self.adv.loaded = adv_loaded; play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory); + play.levitation = std::mem::replace(&mut self.adv.levitation, levitation); let stat_events = std::mem::take(&mut play.stats.events); let name = g.name.clone(); self.potions.swap_player(&mut g.effects, &mut play.potions); From aff673a08dbec0ababc2e70c51abe1706cd32c5e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:20:10 +0100 Subject: [PATCH 110/475] Give the Ender Dragon its ambient sound --- crates/horizon-client/src/game/dragon.rs | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index f28049fd5..96c8ef478 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -26,6 +26,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096; const CRYSTAL_IDS: u32 = SPECIAL + 16; const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat"; const MAX_HEALTH: f32 = 200.0; +/// `Mob.getAmbientSoundInterval`. +const AMBIENT_INTERVAL: i32 = 80; #[derive(Default)] pub(super) struct Fight { @@ -269,6 +271,8 @@ pub(super) struct Dragon { flame_count: i32, sitting_damage: f32, growl: i32, + /// `Mob.ambientSoundTime`. + ambient_time: i32, ticks: i32, /// DragonFlightHistory: (y, yRot) samples, newest at `head`. history: [(f64, f32); 64], @@ -396,6 +400,7 @@ impl Dragon { flame_count: 0, sitting_damage: 0.0, growl: 100, + ambient_time: 0, ticks: 0, history: [(position.y, yaw); 64], head: 0, @@ -986,6 +991,8 @@ impl Game<'_> { }; let hurt = d.hurt(part, damage, counts, arrow); if hurt == (Hurt::Landed { full: true }) { + // Mob.playHurtSound: the ambient sound clock starts again. + d.ambient_time = -AMBIENT_INTERVAL; let at = [d.position.x, d.position.y, d.position.z]; let pitch = 0.8 + self.world.random.next_float() * 0.3; self.audio @@ -1656,6 +1663,24 @@ impl Game<'_> { self.tick_dragon_death(); return; } + // Mob.baseTick: the ambient sound, rarer right after one. + let roll = d.random.next_int_bound(1000).unwrap_or(999); + let waited = d.ambient_time; + d.ambient_time += 1; + if roll < waited { + d.ambient_time = -AMBIENT_INTERVAL; + let at = [d.position.x, d.position.y, d.position.z]; + // LivingEntity.getVoicePitch. + let r = &mut self.world.random; + let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0; + self.audio.play( + "minecraft:entity.ender_dragon.ambient", + Some(at), + 5.0, + pitch, + ); + } + let d = self.dragon.as_mut().expect("dragon"); // Flapping, growls and the flap sound. let flap_was = (d.old_flap * std::f32::consts::TAU).cos(); let v = d.velocity; From 70868f32087fda64a976ff027af6658e6725f69c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:20:34 +0100 Subject: [PATCH 111/475] Give Oozing, Infested, Weaving and Wind Charged their effects These four 26.x effects only existed as potion entries. Oozing spawns two slimes, Weaving places cobwebs and Wind Charged makes a gust when the bearer dies. Infested sometimes releases silverfish when the bearer is hurt. --- crates/horizon-client/src/game/combat.rs | 4 + crates/horizon-client/src/game/creatures.rs | 26 ++++ crates/horizon-client/src/game/potions.rs | 156 +++++++++++++++++++- crates/horizon-client/src/mobs.rs | 7 + 4 files changed, 191 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 106e0e1a1..1af22fe48 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -109,6 +109,10 @@ impl Game<'_> { attacker, }); self.combat.last_damage = now; + // InfestedMobEffect.onMobHurt. + if self.potions.effects.get("infested").is_some() { + self.player_infested(); + } } self.combat.pending.clear(); self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..de1cd1bee 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -501,6 +501,7 @@ impl Game<'_> { let mut events = Vec::new(); let mut mobs = std::mem::take(&mut self.mobs); let mut dead = Vec::new(); + let mut corpses = Vec::new(); let mut died = std::mem::take(&mut self.died); let ticking = self.entity_ticking_area(); // With guests on, each mob sees its nearest player (game/lan_mobs.rs). @@ -593,6 +594,11 @@ impl Game<'_> { horizon_client::mobs::apply_mob_pushes(m, &mut pushes); let from = events.len(); let kept = m.tick(&w, &mut scratch, &mut events); + // A killed mob's body is removed: its effects that act on + // death run (`LivingEntity.remove` with KILLED). + if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() { + corpses.push(m.clone()); + } horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes); if let Some(conn) = seen_guest { // ShulkerBullet.finalTarget is the guest's entity: the @@ -661,6 +667,15 @@ impl Game<'_> { for m in dead { self.mob_died(&m); } + for m in corpses { + let names: Vec<&'static str> = m + .extra + .effects + .iter() + .map(|e| e.0.trim_start_matches("minecraft:")) + .collect(); + self.effects_on_death(&names, m.bounds(), false, Some(m.id)); + } self.handle_mob_events(events)?; self.lan_guest_mob_events(guest_events); self.tick_projectiles()?; @@ -990,6 +1005,17 @@ impl Game<'_> { } MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob), MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive), + MobEvent::Infested { mob } => { + if let Some(m) = self.mobs.iter().find(|m| m.id == mob) { + let at = [ + m.position.x, + m.position.y + f64::from(m.height()) / 2.0, + m.position.z, + ]; + let look = m.view_vector(); + self.infested_hurt(at, look); + } + } MobEvent::Gift { target, item } => { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) && m.saddle_item().is_none() diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b28c2fb4d..360ed0612 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -13,7 +13,10 @@ use horizon_client::{ items::ItemStack, mesher::Vertex, }; -use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_core::{ + geometry::{Aabb, Direction, Vec3}, + random::RandomSource, +}; /// A thrown splash or lingering potion in flight. #[derive(Clone, Debug)] @@ -45,6 +48,136 @@ fn mob_effect_id(id: &str) -> Option<&'static str> { .copied() } impl Game<'_> { + /// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason + /// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer + /// dies. `bearer` is the dying mob's id, `player` whether it is a player. + pub(super) fn effects_on_death( + &mut self, + effects: &[&str], + bounds: Aabb, + player: bool, + bearer: Option, + ) { + let feet = bounds.bottom_center(); + if effects.contains(&"oozing") { + // OozingMobEffect.numberOfSlimesToSpawn: two, fewer when the + // slimes close by already fill the cramming limit. + let limit = self.ui.rules.int("max_entity_cramming"); + let mut count = 2; + if limit >= 1 { + let near = bounds.inflate(Vec3::new(2.0, 2.0, 2.0)); + let nearby = self + .mobs + .iter() + .filter(|m| { + m.species() == horizon_client::mobs::Species::Slime + && Some(m.id) != bearer + && m.bounds().intersects(near) + }) + .count() + .min(limit as usize) as i32; + count = (limit - nearby).clamp(0, count); + } + for _ in 0..count { + // spawnSlimeOffspring: a size 2 slime half a block up. + if let Some(id) = self.spawn_mob_for( + "minecraft:slime", + [feet.x, feet.y + 0.5, feet.z], + false, + true, + ) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + horizon_client::mobs::cube::set_size(m, 2); + } + } + } + if effects.contains(&"weaving") && (player || self.ui.rules.bool("mob_griefing")) { + self.spawn_cobwebs_around([ + feet.x.floor() as i32, + feet.y.floor() as i32, + feet.z.floor() as i32, + ]); + } + if effects.contains(&"wind_charged") { + // A burst centred at half the height, radius 3 to 5. + let center = [feet.x, feet.y + bounds.size().y / 2.0, feet.z]; + let radius = 3.0 + self.world.random.next_float() * 2.0; + let _ = self.wind_burst(center, radius, 1.0, false, false); + } + } + /// `WeavingMobEffect.spawnCobwebsRandomlyAround`: two or three cobwebs in + /// free spaces with a solid floor, picked among 15 random blocks within one + /// block of `pos`. + fn spawn_cobwebs_around(&mut self, pos: [i32; 3]) { + let wanted = 2 + self.world.random.next_int_bound(2).unwrap_or(0) as usize; + let mut chosen: Vec<[i32; 3]> = Vec::new(); + for _ in 0..15 { + let r = &mut self.world.random; + let at = [ + pos[0] - 1 + r.next_int_bound(3).unwrap_or(0), + pos[1] - 1 + r.next_int_bound(3).unwrap_or(0), + pos[2] - 1 + r.next_int_bound(3).unwrap_or(0), + ]; + let facts = self.res.facts(); + let here = facts.get(self.world.block(at[0], at[1], at[2])); + let below = facts.get(self.world.block(at[0], at[1] - 1, at[2])); + if !chosen.contains(&at) + && here.replaceable + && below.sturdy_full & (1 << Direction::Up as u8) != 0 + { + chosen.push(at); + if chosen.len() >= wanted { + break; + } + } + } + for at in chosen { + if self.set_state_named(at, "minecraft:cobweb", &[]).is_ok() { + self.level_event_at(3018, at, 0); + } + } + } + /// `InfestedMobEffect.onMobHurt` for the player: from half its height up, + /// along where it looks. + pub(super) fn player_infested(&mut self) { + if let Some(p) = self.player.as_ref() { + let at = [ + p.position.x, + p.position.y + f64::from(p.dimensions.height) / 2.0, + p.position.z, + ]; + let look = super::view_vector(self.yaw, self.pitch); + self.infested_hurt(at, look); + } + } + /// `InfestedMobEffect.onMobHurt`: a 10% chance of one or two silverfish at + /// `at` (half the bearer's height up), thrown along its look direction. + pub(super) fn infested_hurt(&mut self, at: [f64; 3], look: [f64; 3]) { + if self.world.random.next_float() > 0.1 { + return; + } + let count = 1 + self.world.random.next_int_bound(2).unwrap_or(0); + for _ in 0..count { + // Mth.randomBetween(-PI / 2, PI / 2), then the look direction + // scaled by (0.3, 0.45, 0.3) and turned about the y axis. + let half = std::f32::consts::FRAC_PI_2; + let angle = self.world.random.next_float() * (2.0 * half) - half; + let (sin, cos) = angle.sin_cos(); + let (x, z) = (look[0] * 0.3, look[2] * 0.3); + let velocity = Vec3::new( + f64::from(x as f32 * cos + z as f32 * sin), + look[1] * 0.3 * 1.5, + f64::from(-x as f32 * sin + z as f32 * cos), + ); + if let Some(id) = self.spawn_mob_for("minecraft:silverfish", at, false, true) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.velocity = velocity; + } + self.audio + .play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0); + } + } /// A splash potion, lingering potion or experience bottle in flight (the /// player's throws and dispensers both end up here). pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) { @@ -376,8 +509,27 @@ impl Game<'_> { return; }; if player.health.dead_or_dying() { - // LivingEntity.die → removeAllEffects. + // LivingEntity.die → removeAllEffects. Vanilla runs the effects + // that act on death (`onMobRemoved`) when the dead player is + // removed, up to 20 ticks later; here it is as the effects go. + let w = f64::from(player.dimensions.width) / 2.0; + let bounds = Aabb::new( + Vec3::new( + player.position.x - w, + player.position.y, + player.position.z - w, + ), + Vec3::new( + player.position.x + w, + player.position.y + f64::from(player.dimensions.height), + player.position.z + w, + ), + ); + let ids: Vec<&'static str> = self.potions.effects.list.iter().map(|e| e.id).collect(); self.potions.effects.clear(); + if !ids.is_empty() { + self.effects_on_death(&ids, bounds, true, None); + } } // The omens act from inside the effect tick (BadOmenMobEffect, // RaidOmenMobEffect). diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..e4132e799 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -896,6 +896,10 @@ pub enum MobEvent { slot: usize, stack: Option, }, + /// `InfestedMobEffect.onMobHurt`: the mob was hurt while infested. + Infested { + mob: u32, + }, /// `OfferFlowerGoal.stop`: the item goes to the target's antenna (saddle) /// slot if it is empty, as a guaranteed drop. Gift { @@ -1789,6 +1793,9 @@ impl Mob { } } } + if self.has_effect("minecraft:infested") { + self.outbox.push(MobEvent::Infested { mob: self.id }); + } true } /// LivingEntity.knockback(power, xd, zd) towards the negative direction. From 7102727197458311255f1c8ccf9f903cdb611d50 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:24:26 +0100 Subject: [PATCH 112/475] Play the container close sound when a server-thread player closes it --- crates/horizon-client/src/game.rs | 21 +------------------ crates/horizon-client/src/game/lan_actions.rs | 5 ++++- crates/horizon-client/src/game/storage.rs | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 147aefe36..d0e060c13 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5627,26 +5627,7 @@ impl Game<'_> { Some(horizon_client::mobs::Who::Player), None, ); - let name = self - .res - .facts() - .get(self.world.block(key.0, key.1, key.2)) - .name - .clone(); - let sound = if storage::is_shulker_box(&name) { - Some("minecraft:block.shulker_box.close") - } else if &*name == "minecraft:ender_chest" { - Some("minecraft:block.ender_chest.close") - } else if name.contains("chest") { - Some("minecraft:block.chest.close") - } else if &*name == "minecraft:barrel" { - Some("minecraft:block.barrel.close") - } else { - None - }; - if let Some(sound) = sound { - self.container_sound(sound, key, &name); - } + self.container_close_sound(key); self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } self.close_ender_chest(); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 0ad14bbcc..41ada973c 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -854,13 +854,16 @@ impl Game<'_> { } /// The guest's open container is closing (`AbstractContainerMenu.removed`): - /// it no longer counts among the openers of the lid. + /// it no longer counts among the openers of the lid, and the lid's close + /// sound is played for everyone around (`ChestBlockEntity.onClose`, a + /// `playSound` with no player), the guest included. pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) { if let Some(g) = host.hub.guest(conn) && let Some(pos) = g.menu.as_ref().and_then(|m| m.pos) && !g.spectator() { self.container_openers_changed(pos, false); + self.container_close_sound(pos); } } diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 3456c4ab0..d733f1cca 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -142,6 +142,27 @@ impl Game<'_> { let pitch = self.world.random.next_float() * 0.1 + 0.9; self.audio.play(sound, Some(at), 0.5, pitch); } + /// The close sound of the container block at `key`, if it has one. + pub(super) fn container_close_sound(&mut self, key: (i32, i32, i32)) { + let name = self + .res + .facts() + .get(self.world.block(key.0, key.1, key.2)) + .name + .clone(); + let sound = if is_shulker_box(&name) { + "minecraft:block.shulker_box.close" + } else if &*name == "minecraft:ender_chest" { + "minecraft:block.ender_chest.close" + } else if name.contains("chest") { + "minecraft:block.chest.close" + } else if &*name == "minecraft:barrel" { + "minecraft:block.barrel.close" + } else { + return; + }; + self.container_sound(sound, key, &name); + } /// The other half of a double chest at `key`, for neighbour updates. pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> { self.chest_partner(key).map(|p| p.0) From 6bdf5fe3683dc20f1c0fe2e8ceb87bae17b58526 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:26:32 +0100 Subject: [PATCH 113/475] Sweep: hit mobs within 3 blocks in creative too Player.doSweepAttack keeps nearby entities with distanceToSqr < 9.0; it doesn't use the entity interaction range. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 4e795c3c5..72d087d1c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7467,7 +7467,8 @@ impl Game<'_> { } /// The damage and knockback part of `Player.doSweepAttack` after a sweep /// hit on the mob at `target`: every other living mob in its box grown by - /// (1, 0.25, 1) and within the player's reach, but the player's allies + /// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative + /// too), but the player's allies /// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the /// weapon's enchantments, scaled by the attack strength, and is pushed 0.4 /// along the player's yaw. @@ -7495,7 +7496,6 @@ impl Game<'_> { let hit_box = self.mobs[target] .bounds() .inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0)); - let reach = if self.creative { 5.0 } else { 3.0 }; let (uuid, allies) = ( self.level.player_uuid.or(Some([0, 0, 0, 1])), self.player_allies(), @@ -7511,7 +7511,7 @@ impl Game<'_> { && !allies.contains(&m.id) && !mace::ally_of_player(m, uuid) && m.bounds().intersects(hit_box) - && m.position.distance_squared(p.position) < reach * reach + && m.position.distance_squared(p.position) < 9.0 }) .map(|(i, _)| i) .collect(); From 7fbfbb5e2b16640613c14601caaeb367676c1216 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:27:12 +0100 Subject: [PATCH 114/475] Shulker bullet split: draw the failure chance from the level's random Shulker.hitByShulkerBullet tests level().getRandom().nextFloat() against the crowding chance, not the shulker's own random. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/ai.rs | 11 ++++++++--- crates/horizon-client/src/mobs/shulker.rs | 21 ++++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..5fce47b37 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -746,7 +746,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() { m.ignite_for_ticks(160); } - species_ai_step(m, w, events); + species_ai_step(m, w, level_random, events); } impl Mob { /// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`): @@ -955,7 +955,12 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { _ => {} } } -fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +fn species_ai_step( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { if family(m.species()) == Family::Aquatic { aquatic::ai_step(m, w, events); } @@ -988,7 +993,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } Species::Shulker => { super::shulker::tick(m, w, events); - super::shulker::ai_step(m, w, events); + super::shulker::ai_step(m, w, level_random, events); } Species::SnowGolem => super::golem::snow_ai_step(m, w, events), Species::CopperGolem => super::copper_golem::ai_step(m, w, events), diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index ccb33fd24..124d1b299 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -14,7 +14,7 @@ use crate::projectiles::{Projectile, ProjectileWorld, STEPS}; use horizon_core::geometry::trig::Mth; use horizon_core::{ geometry::{Aabb, Vec3}, - random::RandomSource, + random::{LegacyRandom, RandomSource}, }; use std::f32::consts::PI; @@ -173,20 +173,30 @@ pub fn hit_by_bullet(m: &mut Mob) { } } /// The deferred teleport of `after_hurt` and `hitByShulkerBullet`. -pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +pub fn ai_step( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { if m.extra.flag { m.extra.flag = false; teleport(m, w, events); } if m.extra.flag2 { m.extra.flag2 = false; - hit_by_shulker_bullet(m, w, events); + hit_by_shulker_bullet(m, w, level_random, events); } } /// `Shulker.hitByShulkerBullet`: an open shulker teleports, and leaves a /// new one of its colour behind unless shulkers crowd the place (one in /// five fewer chances for every other shulker within 8 blocks). -fn hit_by_shulker_bullet(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +fn hit_by_shulker_bullet( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { let old_position = m.position; let old_box = m.bounds(); if m.extra.counter == 0 || !teleport(m, w, events) { @@ -201,7 +211,8 @@ fn hit_by_shulker_bullet(m: &mut Mob, w: &MobWorld, events: &mut Vec) }) .count(); let failure_chance = others as f32 / 5.0; - if m.random.next_float() < failure_chance { + // The level's random, not the shulker's. + if level_random.next_float() < failure_chance { return; } let mut baby = Mob::new(0, m.kind.clone(), old_position, 0.0); From 26626e8c2759f6cd7fef2535dd7ae915168af805 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:45:51 +0100 Subject: [PATCH 115/475] Give frogs vanilla's smooth swimming move control --- crates/horizon-client/src/mobs/ai.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..50f1f335b 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -183,6 +183,18 @@ pub fn register(m: &mut Mob) { if m.species() == Species::Rabbit { super::rabbit::register(m); } + if m.species() == Species::Frog { + // `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true). + // The speed attribute is 1.0, so the controller's scaling is + // what keeps a frog at a walking pace on land. + m.ai.mov.kind = super::control::MoveKind::SmoothSwimming { + max_turn_x: 85.0, + max_turn_y: 10.0, + in_water: 0.02, + outside: 0.1, + gravity: true, + }; + } } Family::Inert => {} Family::Witch => super::witch::register(m), From 5eef2bea0c1ed8ea945ce84fff9d391f97d45329 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:45:59 +0000 Subject: [PATCH 116/475] Clean up evoker summons after fang integration check Remove the test arena's surviving vexes so later checks receive no stray attacks. Co-Authored-By: OpenCode GPT-6.1 Sol --- tools/lan_e2e.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 816fe9b82..1b2a7219b 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -6977,6 +6977,8 @@ def check_fangs(host, guest, out): finally: host('command', command='tick unfreeze') host('command', command=f'kill @e[tag={tag}]') + # The evoker can summon vexes before fangs; killing it leaves those summons alive. + host('command', command=f'kill @e[type=minecraft:vex,x={x},y={y},z={z},distance=..32]') guest('command', command='effect clear @s') face_each_other(host, guest) return {'fangs': len(paths['host']), 'warmup': warmup, 'drawn': drawn, 'removed': True} From 7d8b6f3b90d1dd7f44ea9e89b7929dc925cac6e0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:47:37 +0100 Subject: [PATCH 117/475] Composter: keep addLayer's doc on composter_add_layer The extracted composter_extract_produce took the doc comment of the function below it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-world/src/level/interact.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index a1f786184..6bed21440 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -423,8 +423,6 @@ impl Sim<'_, L> { InteractionResult::Success } - /// `ComposterBlock.addLayer` once its `compostable` provider gave - /// `layers`: the new state (the old one when no layer is added). /// `ComposterBlock.extractProduce`: the bone meal pops out, the composter /// empties and its sound plays. fn composter_extract_produce(&mut self, pos: BlockPos, state: BlockStateId) { @@ -444,6 +442,8 @@ impl Sim<'_, L> { }); } + /// `ComposterBlock.addLayer` once its `compostable` provider gave + /// `layers`: the new state (the old one when no layer is added). fn composter_add_layer(&mut self, pos: BlockPos, layers: i32) -> BlockStateId { let b = self.b; let state = self.state(pos); From 1d51f9d873b7ec024f9ceba69b57c9e2b3178977 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:30 +0100 Subject: [PATCH 118/475] Soul Speed: draw the random chances in vanilla's order Both random_chance terms come first in their all_of conditions, so the level random is drawn before the block and movement checks: the boots' 4% on every location change, and the sound's 35% on every tick the boots are worn. Co-Authored-By: Claude Opus 5.5 --- .../src/game/enchant_effects.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 6550ba9d5..8ae54dba7 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -505,7 +505,10 @@ impl Game<'_> { let riding = self.riding.is_some(); let wanted = !riding && !flying && (on_soul || (active && !on_ground)); active = wanted; - wear = on_ground && on_soul && self.world.random.next_float() < 0.04; + // `random_chance` is the first term of the wear's `all_of`: it + // draws from the level random before the block is looked at. + let roll = self.world.random.next_float() < 0.04; + wear = roll && on_ground && on_soul; } fx.applied_level = if active { level } else { 0 }; // `Entity.getKnownMovement` horizontal speed of at least 1.0E-5. @@ -529,10 +532,13 @@ impl Game<'_> { if wear && !self.creative { self.wear_boots(1); } + if level == 0 { + return; + } + let r = &mut self.world.random; if tick_effects { // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown // back against the movement (-0.2) and up (0.1). - let r = &mut self.world.random; let x = position.x + (r.next_double() - 0.5) * f64::from(width); let z = position.z + (r.next_double() - 0.5) * f64::from(width); self.particles.send_particles( @@ -542,16 +548,18 @@ impl Game<'_> { 1.0, 0, ); - // `play_sound`: 35% chance, volume 0.6, pitch 0.6 to 1.0. - if r.next_float() < 0.35 { - let pitch = r.next_float() * 0.4 + 0.6; - self.audio.play( - "minecraft:particle.soul_escape", - Some([position.x, position.y, position.z]), - 0.6, - pitch, - ); - } + } + // `play_sound`: its `random_chance` (35%) comes first in its `all_of`, + // so the level random is drawn every tick the boots are worn; volume + // 0.6, pitch 0.6 to 1.0. + if r.next_float() < 0.35 && tick_effects { + let pitch = r.next_float() * 0.4 + 0.6; + self.audio.play( + "minecraft:particle.soul_escape", + Some([position.x, position.y, position.z]), + 0.6, + pitch, + ); } } /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): From 75f7029d016b0ff1109f030d20c28471242ce5e1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:37 +0100 Subject: [PATCH 119/475] Squid ink: draw the squirt's voice pitch even when silent Mob.makeSound calls getVoicePitch before Entity.playSound checks isSilent, so the squid's random advances either way. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/aquatic.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index 9603d5e58..f09f7c3b3 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -37,8 +37,9 @@ pub fn spawn_ink(m: &mut Mob) { } else { "squid_ink" }; + // makeSound draws the voice pitch even for a silent squid. + let pitch = m.voice_pitch(); if !m.silent { - let pitch = m.voice_pitch(); let at = [m.position.x, m.position.y, m.position.z]; m.outbox.push(MobEvent::Sound( sound, From 608a6d9bee44680bb640777663d2c3258e5590b4 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:50:47 +0100 Subject: [PATCH 120/475] Turtles: a fixed home from spawn or load, one breeding orb Turtle.finalizeSpawn sets homePos to where it spawns, and loading falls back to the block it stands on; the home no longer moves with a turtle that had none, which let it lay eggs anywhere. TurtleBreedGoal.breed adds one ExperienceOrb of 1 to 7 drawn from the mother's random. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity_nbt.rs | 12 ++++++++---- crates/horizon-client/src/game/creatures.rs | 10 +++++++--- crates/horizon-client/src/mobs/spawning.rs | 8 ++++++++ crates/horizon-client/src/mobs/turtle.rs | 4 ++-- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index f7c843264..73bc580bc 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -856,10 +856,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - if m.species() == crate::mobs::Species::Turtle { // Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`. m.extra.flag = flag(tag, "has_egg"); - m.extra.home = match tag.get("home_pos") { - Some(Tag::IntArray(home)) if home.len() == 3 => Some([home[0], home[1], home[2]]), - _ => None, - }; + m.extra.home = Some(match tag.get("home_pos") { + Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]], + _ => [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ], + }); } if m.species() == crate::mobs::Species::Wither { m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 03911ffda..d9764288b 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1360,9 +1360,13 @@ impl Game<'_> { // TurtleBreedGoal.breed: the goal gave the mother her egg; only the // experience is left, and only with mob loot on. if p.species() == Species::Turtle { - if self.ui.rules.bool("mob_drops") { - let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + // One `new ExperienceOrb` of 1 to 7 from the mother's random (not + // split up as `ExperienceOrb.award` does). + if self.ui.rules.bool("mob_drops") + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent) + { + let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32; + self.add_orb([p.position.x, p.position.y, p.position.z], xp); } return; } diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..d58fa35c3 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -148,6 +148,14 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { S::Pig | S::Cow | S::Chicken => m.extra.variant = climate_index, S::Sheep => m.extra.color = super::farm::sheep_color(biome.climate, &mut m.random), S::Mooshroom => m.extra.variant = 0, + // Turtle.finalizeSpawn: home is where it spawned. + S::Turtle => { + m.extra.home = Some([ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]) + } S::Frog => m.extra.variant = biome.frog, S::Wolf => m.extra.variant = biome.wolf, S::Cat => m.extra.variant = super::tame::cat_variant(m, biome.full_moon), diff --git a/crates/horizon-client/src/mobs/turtle.rs b/crates/horizon-client/src/mobs/turtle.rs index 0596adfdf..98dd5b558 100644 --- a/crates/horizon-client/src/mobs/turtle.rs +++ b/crates/horizon-client/src/mobs/turtle.rs @@ -23,8 +23,8 @@ fn set_laying_egg(m: &mut Mob, on: bool) { m.extra.counter = i32::from(on); m.extra.flag2 = on; } -/// `homePos.closerToCenterThan(position, distance)`; a turtle that never -/// had a home is at home where it stands. +/// `homePos.closerToCenterThan(position, distance)`. Spawning, hatching and +/// loading all set a home; one made any other way is at home where it stands. fn close_to_home(m: &Mob, distance: f64) -> bool { let home = m.extra.home.unwrap_or([ m.position.x.floor() as i32, From 9e71837df99a26c8ec77922d2718e8daeb46b2bd Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:56:15 +0000 Subject: [PATCH 121/475] Keep server logging from panicking on a closed stdout pipe Co-Authored-By: OpenAI GPT-6.1 Sol --- crates/horizon-client/src/game/dedicated.rs | 112 +++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index fa5365195..4a3b5b4ca 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -24,6 +24,7 @@ use horizon_client::{ use horizon_protocol::text::translatable; use horizon_world::nbt::{JavaString, Tag}; use std::{ + io::Write, path::PathBuf, sync::{Arc, Mutex, OnceLock, mpsc}, time::{Duration, Instant}, @@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) { } }); // Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log. - println!( + // The launcher can close its stdout pipe while the server keeps running. + let _ = writeln!( + std::io::stdout().lock(), "[{}] [{thread}/{level}]: {message}", chrono::Local::now().format("%H:%M:%S") ); @@ -1308,3 +1311,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?); Some((w, h)) } + +#[cfg(test)] +mod tests { + use super::{capture_log, log, log_from}; + use std::{ + fs, + io::{self, BufRead, BufReader, Read, Write}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, + }; + + const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)"; + const WARNING: &str = "Authentication warning"; + const READY: &str = "horizon-console-log-ready\n"; + const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME"; + + fn assert_log_lines(text: &str) { + let expected = [ + format!("] [Server thread/INFO]: {LOGIN}"), + format!("] [User Authenticator #1/WARN]: {WARNING}"), + ]; + assert_eq!(text.lines().count(), expected.len()); + for (line, suffix) in text.lines().zip(expected) { + assert!(line.starts_with('[')); + assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok()); + assert_eq!(&line[9..], suffix); + } + } + + #[test] + fn console_logging_survives_closed_stdout() { + if let Some(home) = std::env::var_os(CHILD_HOME) { + horizon_client::logging::init(std::path::Path::new(&home), "Server thread"); + // Let the parent close the real stdout pipe only after libtest's startup output. + io::stdout().write_all(READY.as_bytes()).unwrap(); + io::stdout().flush().unwrap(); + io::stdin().read_exact(&mut [0]).unwrap(); + std::thread::Builder::new() + .name("Server thread".into()) + .spawn(|| { + let captured = capture_log(|| { + log("INFO", LOGIN); + log_from("User Authenticator #1", "WARN", WARNING); + }); + assert_eq!(captured, [LOGIN, WARNING]); + horizon_client::logging::flush(); + }) + .unwrap() + .join() + .unwrap(); + // Libtest's final status also writes to stdout; only the game logger is under test. + std::process::exit(0); + } + + for closed_stdout in [false, true] { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let home = std::env::temp_dir().join(format!( + "horizon-console-log-test-{}-{unique}", + std::process::id() + )); + fs::create_dir(&home).unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "game::dedicated::tests::console_logging_survives_closed_stdout", + "--nocapture", + ]) + .env(CHILD_HOME, &home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + loop { + let mut line = String::new(); + assert!( + stdout.read_line(&mut line).unwrap() > 0, + "child was not ready" + ); + if line.ends_with(READY) { + break; + } + } + if closed_stdout { + drop(stdout); // No reader remains: the next console write gets EPIPE. + } else { + child.stdout = Some(stdout.into_inner()); + } + child.stdin.take().unwrap().write_all(&[1]).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "closed_stdout={closed_stdout}: {}", + String::from_utf8_lossy(&output.stderr) + ); + if !closed_stdout { + assert_log_lines(std::str::from_utf8(&output.stdout).unwrap()); + } + assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap()); + fs::remove_dir_all(home).unwrap(); + } + } +} From e71d4281acce0741804c2120bbcc6ed57dbe66d2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:04:35 +0100 Subject: [PATCH 122/475] Spawn a baby when a spawn egg is used on its own mob kind Mob.checkAndHandleImportantInteractions: a spawn egg of the clicked mob's kind spawns a baby at the mob's position (SpawnEggItem.spawnOffspringFromSpawnEgg), inheriting the parent's variant, and uses one egg outside creative. Fixes #2269. --- crates/horizon-client/src/game/creatures.rs | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..3ebf9ef49 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2447,6 +2447,10 @@ impl Game<'_> { if self.name_mob(index) { return Ok(true); } + if self.spawn_egg_offspring(index) { + self.swing_arm(); + return Ok(true); + } { let item = self.held().map(|h| h.item.to_string()).unwrap_or_default(); if self.interact_leash(index, &item) { @@ -2953,6 +2957,42 @@ impl Game<'_> { } Ok(true) } + /// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob + /// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby + /// at the mob's feet, taking the parent's variant as `getBreedOffspring` + /// does, and one egg used. False (PASS) when the mob has no baby form. + fn spawn_egg_offspring(&mut self, index: usize) -> bool { + let Some(stack) = self.held().cloned() else { + return false; + }; + let parent = &self.mobs[index]; + if !parent.alive() + || stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str()) + || !horizon_client::mobs::drops::can_be_baby(parent.species()) + { + return false; + } + let (kind, at) = ( + parent.kind.id.clone(), + [parent.position.x, parent.position.y, parent.position.z], + ); + let (species, variant, color) = + (parent.species(), parent.extra.variant, parent.extra.color); + let target = self.mob_entity(&self.mobs[index]); + let Some(id) = self.spawn_mob(&kind, at, true) else { + return false; + }; + if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) { + horizon_client::mobs::finalize::set_baby(child, true); + child.extra.variant = variant; + if species == Species::Sheep { + child.extra.color = color; + } + } + self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target); + self.consume_held(); + true + } fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result { let Some(hit) = self.pick() else { return Ok(false); From af6faa88e51f6d7632191124b6e49f5a9ceaff8f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:06:30 +0100 Subject: [PATCH 123/475] Report the hook's open-water flag to the fishing_rod_hooked trigger --- crates/horizon-client/src/game/fishing.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index 33d93fb82..a2c3dcf1d 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -241,6 +241,7 @@ impl Game<'_> { kind: "minecraft:fishing_bobber".into(), position: [hook.position.x, hook.position.y, hook.position.z], on_ground: hook.on_ground, + fishing_open_water: Some(hook.open_water), ..Default::default() }; self.trigger_fishing_rod_hooked(rod, &bobber, &caught); From c75854a9683fc63045dc8b16fbd0f6d345c6262b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:07:11 +0100 Subject: [PATCH 124/475] Send mob door cracks to nearby players --- crates/horizon-client/src/game/creatures.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4613ad5f7..8e11f3103 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1056,6 +1056,25 @@ impl Game<'_> { } MobEvent::DestroyProgress { mob, pos, progress } => { self.destroy_block_progress(mob as i32, pos, progress); + // ServerLevel.destroyBlockProgress: every player nearby sees it. + if let Some(host) = self.lan.as_ref() { + host.hub.broadcast_near( + [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])], + 64.0, + &horizon_protocol::packets::play::Clientbound::BlockDestruction( + horizon_protocol::packets::play::BlockDestruction { + id: mob as i32, + pos, + progress: if (0..10).contains(&progress) { + progress as u8 + } else { + 255 + }, + }, + ), + None, + ); + } } MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data), MobEvent::SetBlockName { pos, name, effect } => { From af49fd7dc3b7248f47b7f2cf066df183abee353e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:07:14 +0100 Subject: [PATCH 125/475] Use a collision-shape raycast for the spyglass line-of-sight check --- .../horizon-client/src/game/advancements.rs | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 67615b628..5908926a1 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -9,6 +9,7 @@ use horizon_client::{ text::{self, Style, Text}, toasts::Slide, }; +use horizon_core::geometry::Vec3; use serde_json::Value; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -751,19 +752,12 @@ impl Game<'_> { .filter(|m| m.alive()) .filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?))) .min_by(|a, b| a.1.total_cmp(&b.1))?; - // Eye to the target's centre, two samples per block. - let to = m.center(); - let reach = (0..3).map(|i| (to[i] - eye[i]).powi(2)).sum::().sqrt(); - let steps = (reach * 2.0).ceil().max(1.0) as i32; - let clear = (1..steps).all(|i| { - let f = f64::from(i) / f64::from(steps); - let p = [0, 1, 2].map(|a| (eye[a] + (to[a] - eye[a]) * f).floor() as i32); - !self - .res - .facts() - .get(self.world.block(p[0], p[1], p[2])) - .full_collision - }); + // `LivingEntity.hasLineOfSight`: eye to the target's eye height, + // `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks. + let from = Vec3::new(eye[0], eye[1], eye[2]); + let to = Vec3::new(m.position.x, m.eye_y(), m.position.z); + let world = self.mob_world_base(&[]); + let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false); clear.then(|| self.mob_entity(m)) } /// A player condition plus one item (`item` key). From 92e59024f250df66bb7e0daf54beb33b38891b52 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:07:23 +0100 Subject: [PATCH 126/475] Give merged orbs their value once per count for LAN guests and dedicated players --- crates/horizon-client/src/game/lan_actions.rs | 8 +++++++- crates/horizon-client/src/game/remote.rs | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..72e364a46 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2591,7 +2591,13 @@ impl Game<'_> { .random .next_int_bound(touching.len() as i32) .unwrap_or(0) as usize; - let orb = self.orbs.remove(touching[pick]); + // A merged orb gives its value once per count and goes at zero. + let orb = self.orbs[touching[pick]].clone(); + if orb.count <= 1 { + self.orbs.remove(touching[pick]); + } else { + self.orbs[touching[pick]].count -= 1; + } play.xp_delay = 2; host.hub.take_item(orb.id as i32, id, 1, at); // ClientPacketListener.handleTakeItemEntity's sound, at the orb. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3293b0690..40ab50c88 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2018,7 +2018,10 @@ impl Game<'_> { { self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector); } - self.remove_entity(p.item); + // An orb stays: the server may still hold merged ones in it. + if !orb { + self.remove_entity(p.item); + } } // ClientPacketListener.handleSetEntityMotion: knockback on this player. C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => { From e157c5a0a5043b74ccb36e7320d5b3a4bfcb12e8 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:07:31 +0100 Subject: [PATCH 127/475] Tick conduits on the server without a renderer --- .../horizon-client/src/block_entity_anim.rs | 2 - .../src/game/block_entity_anims.rs | 64 +++++++++++++++++-- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/block_entity_anim.rs b/crates/horizon-client/src/block_entity_anim.rs index 53884e5ec..fd91051fa 100644 --- a/crates/horizon-client/src/block_entity_anim.rs +++ b/crates/horizon-client/src/block_entity_anim.rs @@ -950,8 +950,6 @@ impl BlockEntityAnims { self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell)); self.conduits .retain(|p, _| is(p, |t| *t == Ticker::Conduit)); - self.conduit_servers - .retain(|p, _| is(p, |t| *t == Ticker::Conduit)); self.skulls .retain(|p, _| is(p, |t| matches!(t, Ticker::Skull { .. }))); self.pots.retain(|p, _| is(p, |t| *t == Ticker::Pot)); diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index daec3523a..6f92d7f55 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -223,9 +223,12 @@ impl Game<'_> { /// The client tickers of the loaded animated block entities, once per /// client tick (`ClientLevel.tickBlockEntities`). pub(super) fn tick_block_entity_anims(&mut self) { + // A server has no window, but its gateways and conduits still run + // their server tickers; a client of one only animates what it is sent. + if self.remote.is_none() { + self.tick_conduit_servers(); + } let Some(r) = &self.renderer else { - // A server has no window, but its gateways still run their - // server ticker. if self.remote.is_none() { self.tick_gateway_servers(); } @@ -342,11 +345,6 @@ impl Game<'_> { } } Ticker::Conduit => { - // A server runs the effects and the attack; a client of - // one only animates what it is sent. - if self.remote.is_none() { - self.tick_conduit_server(pos); - } let target = self.conduit_target(pos); let world = &self.world; let facts = self.res.facts(); @@ -394,11 +392,63 @@ impl Game<'_> { } self.be_anims.present = positions; } + /// `ConduitBlockEntity.serverTick` of every conduit in the chunks that + /// tick blocks, with or without a renderer. The conduits are looked for + /// when the shape is checked, every 40 ticks (they have no block entity + /// of their own here). + fn tick_conduit_servers(&mut self) { + if self.world.game_time % 40 == 0 { + self.find_conduits(); + } + let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect(); + positions.sort_unstable(); + for pos in positions { + self.tick_conduit_server(pos); + } + } + /// Sets `be_anims.conduit_servers` to the conduits in the + /// chunks that tick blocks, keeping the state of those already known. + fn find_conduits(&mut self) { + let levels = self.tickets.levels(); + let kinds = &self.be_anims.kinds; + let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit))); + let mut found = Vec::new(); + for (&(cx, cz), chunk) in &self.world.chunks { + if !levels.block_ticking_at(cx * 16, cz * 16) { + continue; + } + for (si, section) in chunk.sections.iter().enumerate() { + if !section.blocks.maybe_has(is_conduit) { + continue; + } + for i in 0..4096 { + if is_conduit(section.get(i)) { + found.push([ + cx * 16 + (i & 15) as i32, + horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32, + cz * 16 + ((i >> 4) & 15) as i32, + ]); + } + } + } + } + let mut old = std::mem::take(&mut self.be_anims.conduit_servers); + for pos in found { + let known = old.remove(&pos).unwrap_or_default(); + self.be_anims.conduit_servers.insert(pos, known); + } + } /// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk /// that ticks blocks: the shape check every 40 ticks with its sounds, the /// power for players in range, the hurt for an enemy, then the ambient /// sounds. fn tick_conduit_server(&mut self, pos: [i32; 3]) { + let state = self.world.block(pos[0], pos[1], pos[2]).0; + if self.be_anims.ticker(state) != Some(Ticker::Conduit) { + // Broken since the chunks were searched. + self.be_anims.conduit_servers.remove(&pos); + return; + } if !self.tickets.levels().block_ticking_at(pos[0], pos[2]) || !self.border.contains(f64::from(pos[0]), f64::from(pos[2])) { From 409a8281283d7776dbb25e8c2f42863cc083971e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:11:29 +0100 Subject: [PATCH 128/475] mods: capability recipes (API level 4), the game's items and recipes for recipe viewers recipes.items() lists every item in registry order, with its name in the player's language and its tags. recipes.all() lists every recipe: crafting and furnace ones from the recipe book's own entries (vanilla's displays and display ids), then campfire, stonecutting, smithing and brewing (display only). Stacks point into the item table by index. recipes.known() gives the book ids the player's recipe book holds, from the client book in every mode, so on a server it follows the server's book. The game builds the tables once, the first time a running mod reads them. Experimental, new functions only. Smithing::Transform now keeps its recipe id. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/mod_recipes.rs | 252 ++++++++++++++++++ crates/horizon-client/src/game/mods.rs | 11 + crates/horizon-client/src/game/recipe_book.rs | 19 ++ crates/horizon-client/src/stations.rs | 3 + crates/horizon-mod-sdk/src/lib.rs | 2 +- crates/horizon-mod-sdk/wit/horizon.wit | 76 ++++++ crates/horizon-mods/src/api_level.rs | 7 +- crates/horizon-mods/src/manifest.rs | 11 +- crates/horizon-mods/src/runtime.rs | 54 ++++ crates/horizon-mods/tests/sandbox.rs | 90 ++++++- crates/horizon-mods/tests/ui_guest/src/lib.rs | 40 ++- docs/api/mods-reference.md | 72 ++++- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 15 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_recipes.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9bb6602fd..826ea73c1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -100,6 +100,8 @@ mod minecarts; mod mob_blocks; mod mob_gear; mod mod_input; +#[cfg(feature = "mods")] +mod mod_recipes; mod mod_render; mod mods; mod mount_menu; diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs new file mode 100644 index 000000000..0d57e1f94 --- /dev/null +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -0,0 +1,252 @@ +//! Capability `recipes` (docs/mods.md): every item and recipe the game +//! knows, made once for the mods that read them, and the recipe book's +//! known displays. The recipes are the window's own data: the world's in +//! single player, vanilla's on a server (a client is sent no others), while +//! the known displays follow the server's book as the recipe book does. +use super::Game; +use horizon_client::{ + crafting::Ingredient, + items::ItemStack, + recipe_book::{Category, Display}, + stations::Smithing, +}; +use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; +use std::{collections::HashMap, sync::Arc}; + +/// Item id -> its index in [`Recipes::items`]. +struct Index(HashMap, u32>); +impl Index { + fn stack(&self, item: &str, count: u32, components: String) -> Option { + Some(RecipeStack { + item: *self.0.get(item)?, + count, + components, + }) + } + fn of(&self, s: &ItemStack) -> Option { + self.stack(&s.item, s.count, components(s)) + } + fn any(&self, i: &Ingredient) -> Vec { + i.1.iter() + .filter_map(|id| self.stack(id, 1, String::new())) + .collect() + } +} + +/// A stack's components beyond its item's, as vanilla's item syntax writes +/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "". +fn components(s: &ItemStack) -> String { + use horizon_world::nbt::Tag; + let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else { + return String::new(); + }; + let parts: Vec = entries + .iter() + .map(|(k, v)| { + format!( + "{}={}", + k.to_string_lossy(), + horizon_client::snbt::to_snbt(v) + ) + }) + .collect(); + format!("[{}]", parts.join(",")) +} + +fn recipe(id: String, kind: RecipeKind) -> Recipe { + Recipe { + id, + kind, + category: String::new(), + group: String::new(), + width: 0, + height: 0, + slots: Vec::new(), + results: Vec::new(), + cooking_time: 0, + experience: 0.0, + book_id: -1, + } +} + +impl Game<'_> { + /// Capability `recipes`'s tables and known displays for this tick's + /// snapshot; nothing while no running mod reads them. + pub(super) fn mod_recipes(&mut self) -> (Option>, Arc<[i32]>) { + if !self.mods.mods.wants_recipes() { + return (None, Arc::from([])); + } + if self.mods.recipes.is_none() { + self.mods.recipes = Some(Arc::new(self.make_mod_recipes())); + } + let (version, known) = self.recipe_book_displays(); + if self.mods.known_recipes.0 != Some(version) { + self.mods.known_recipes = (Some(version), known.into()); + } + (self.mods.recipes.clone(), self.mods.known_recipes.1.clone()) + } + + fn make_mod_recipes(&mut self) -> Recipes { + let names = &self.pack.names; + let items = self.res.items(); + let mut tags: HashMap<&str, Vec> = HashMap::new(); + for (tag, members) in &items.item_tags { + for m in members { + tags.entry(m).or_default().push(tag.clone()); + } + } + let table: Vec = items + .registry_order + .iter() + .map(|id| RecipeItem { + id: id.to_string(), + name: names.display_item(id), + tags: tags.remove(&**id).map_or_else(Vec::new, |mut t| { + t.sort(); + t + }), + }) + .collect(); + let index = Index( + items + .registry_order + .iter() + .enumerate() + .map(|(i, id)| (id.clone(), i as u32)) + .collect(), + ); + let mut out = Vec::new(); + let cooking: HashMap = self + .res + .recipes() + .cooking + .iter() + .map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience))) + .collect(); + for e in self.recipe_book_entries() { + let furnace = |c: Category| match c.name().split('_').next() { + Some("blast") => RecipeKind::Blasting, + Some("smoker") => RecipeKind::Smoking, + _ => RecipeKind::Smelting, + }; + let kind = match &e.display { + Display::Shaped { .. } => RecipeKind::CraftingShaped, + Display::Shapeless(_) => RecipeKind::CraftingShapeless, + Display::Furnace(_) => furnace(e.category), + }; + let mut r = recipe(e.id.clone(), kind); + r.category = e.category.name().to_owned(); + r.group = e.group.clone().unwrap_or_default(); + r.book_id = e.display_id; + r.slots = match &e.display { + Display::Shaped { + width, + height, + cells, + } => { + (r.width, r.height) = (*width as u32, *height as u32); + cells + .iter() + .map(|c| c.as_ref().map_or_else(Vec::new, |i| index.any(i))) + .collect() + } + Display::Shapeless(list) => list.iter().map(|i| index.any(i)).collect(), + Display::Furnace(i) => vec![index.any(i)], + }; + // Requirements shown as other stacks (an arrow's potions): the + // requirement's index counts the filled slots only. + let filled: Vec = (0..r.slots.len()) + .filter( + |&k| !matches!(&e.display, Display::Shaped { cells, .. } if cells[k].is_none()), + ) + .collect(); + for (req, stacks) in &e.stacks { + if let Some(&k) = filled.get(*req) { + r.slots[k] = stacks.iter().filter_map(|s| index.of(s)).collect(); + } + } + r.results = e.results().iter().filter_map(|s| index.of(s)).collect(); + if let Some(&(time, xp)) = cooking.get(&e.id) { + (r.cooking_time, r.experience) = (time, xp); + } + out.push(r); + } + for c in &self.res.recipes().cooking { + if c.kind == "minecraft:campfire_cooking" { + let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire); + r.group = c.group.clone(); + r.slots = vec![index.any(&c.ingredient)]; + r.results = index.of(&c.result).into_iter().collect(); + (r.cooking_time, r.experience) = (c.time, c.experience); + out.push(r); + } + } + let stations = self.res.stations(); + for s in &stations.recipes.stonecutting { + let mut r = recipe(s.id.clone(), RecipeKind::Stonecutting); + r.slots = vec![index.any(&s.input)]; + r.results = index.of(&s.result).into_iter().collect(); + out.push(r); + } + for s in &stations.recipes.smithing { + let (id, template, base, addition, results) = match s { + Smithing::Transform { + id, + template, + base, + addition, + result, + } => ( + id, + template, + base, + addition, + index.stack(result, 1, String::new()).into_iter().collect(), + ), + Smithing::Trim { + id, + template, + base, + addition, + .. + } => (id, template, base, addition, index.any(base)), + }; + let mut r = recipe(id.clone(), RecipeKind::Smithing); + r.slots = vec![index.any(template), index.any(base), index.any(addition)]; + r.results = results; + out.push(r); + } + for b in &stations.brewing.recipes { + let potion = |p: &horizon_client::brewing::PotionIngredient| -> Vec { + let mut item_ids: Vec<&String> = p.items.iter().collect(); + item_ids.sort(); + let mut potions: Vec<&String> = p.potions.iter().flatten().collect(); + potions.sort(); + item_ids + .into_iter() + .flat_map(|item| { + let one = |c: String| index.stack(item, 1, c); + if potions.is_empty() { + one(String::new()).into_iter().collect::>() + } else { + potions + .iter() + .filter_map(|p| { + one(format!("[minecraft:potion_contents={{potion:\"{p}\"}}]")) + }) + .collect() + } + }) + .collect() + }; + let mut r = recipe(b.id.clone(), RecipeKind::Brewing); + r.slots = vec![potion(&b.input), potion(&b.reagent)]; + r.results = index.of(&b.output).into_iter().collect(); + out.push(r); + } + Recipes { + items: table, + recipes: out, + } + } +} diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 12deb61fc..156467e7a 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -70,6 +70,12 @@ pub(super) struct ModsState { /// The HUD font's advances for `render.text-width`, made once. #[cfg(feature = "mods")] font_widths: std::sync::Arc<[f32]>, + /// Capability `recipes`'s tables, made once a mod reads them. + #[cfg(feature = "mods")] + pub(super) recipes: Option>, + /// The recipe book's known displays, and the book's version they are of. + #[cfg(feature = "mods")] + pub(super) known_recipes: (Option, std::sync::Arc<[i32]>), /// Extras that are official mods now, being installed. #[cfg(feature = "mods")] pub(super) moved: super::moved_extras::MovedState, @@ -282,6 +288,8 @@ impl Game<'_> { self.mods.mods.set_bindings(|id| chosen.keys_of(id)); // Entries were replaced wholesale: a screen's owner index is stale. self.mods.screen = None; + // Content mods may have changed the items: remade on the next read. + self.mods.recipes = None; self.list_mods(); self.sync_mod_commands(); self.show_mod_output(); @@ -409,6 +417,7 @@ impl Game<'_> { .map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1)) .collect(); } + let (recipes, known_recipes) = self.mod_recipes(); Snapshot { player, clock, @@ -420,6 +429,8 @@ impl Game<'_> { font_widths: self.mods.font_widths.clone(), players: Vec::new(), view_hit: self.mod_view_hit(), + recipes, + known_recipes, } } /// The first block along the player's view within diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 96e12d79b..045abeb6f 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -338,6 +338,25 @@ impl Game<'_> { ui.client_known = Some(known.clone()); ui.client_version += 1; } + /// Every entry of the book, in its order (capability `recipes`). + #[cfg(feature = "mods")] + pub(super) fn recipe_book_entries(&mut self) -> Vec { + self.ensure_book(); + self.recipe_ui + .book + .as_ref() + .map_or_else(Vec::new, |b| b.entries.clone()) + } + /// The display ids the player's book holds, and a number that changes + /// whenever they do (capability `recipes`). + #[cfg(feature = "mods")] + pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec) { + self.sync_client_book(); + ( + self.recipe_ui.client_version, + self.recipe_ui.client.displays(), + ) + } fn ensure_book(&mut self) { if self.recipe_ui.book.is_none() { let mut book = Book::build(self.res.recipes(), self.res.items()); diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index 715c2663e..bd157843a 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -445,6 +445,7 @@ pub fn grindstone_experience( #[derive(Clone, Debug)] pub enum Smithing { Transform { + id: String, template: Ingredient, base: Ingredient, addition: Ingredient, @@ -524,6 +525,7 @@ impl StationRecipes { ); match v["type"].as_str().unwrap_or("") { "minecraft:smithing_transform" => smithing.push(Smithing::Transform { + id, template: ingredient(&v["template"], items), base: ingredient(&v["base"], items), addition: ingredient(&v["addition"], items), @@ -627,6 +629,7 @@ impl StationRecipes { base: b, addition: a, result, + .. } => { if t.test(template) && b.test(base) && a.test(addition) { // SmithingTransformRecipe: transmute the base keeping its components. diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index 679fdc7b8..b00d06ecf 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -39,7 +39,7 @@ wit_bindgen::generate!({ pub use exports::horizon::mods::events::{Guest as __Guest, Registration}; pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest; -pub use horizon::mods::{chat, command, game, hud, input, log, render, storage, ui}; +pub use horizon::mods::{chat, command, game, hud, input, log, recipes, render, storage, ui}; /// The game's calls into a mod. Only the hooks [`Registration`] asks for /// are called; the others may stay as their defaults. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index d9bb9f5c2..7d7adaa0d 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -439,6 +439,76 @@ interface events { chat: func(text: string); } +/// Capability `recipes` (experimental): the recipes and items the game +/// knows, for recipe viewers. In single player and on Horizon's servers +/// they are the world's; on any other server they are vanilla 26.3's (a +/// client is sent no others), while `known` follows that server's book. +interface recipes { + /// An item of `items`, by its index there, with a count and the + /// components it has beyond the item's own, in vanilla's item syntax + /// (`[potion_contents={potion:"minecraft:swiftness"}]`), or "". + record stack { + item: u32, + count: u32, + components: string, + } + /// The station a recipe is made at. + enum kind { + crafting-shaped, + crafting-shapeless, + smelting, + blasting, + smoking, + campfire, + stonecutting, + smithing, + brewing, + } + record recipe { + /// `minecraft:oak_planks`. + id: string, + kind: kind, + /// The recipe book's tab (`crafting_building_blocks`, + /// `furnace_food`), or "". + category: string, + /// Recipes the book shows on one button share a group, or "". + group: string, + /// A shaped recipe's grid: `slots` holds its cells row by row. + /// 0 for the other kinds. + width: u32, + height: u32, + /// Each slot's stacks, any one of which fits; an empty list is an + /// empty cell. Cooking and stonecutting: the input. Smithing: the + /// template, the base, the addition. Brewing: the bottle, the + /// reagent. + slots: list>, + /// What it makes; several when that varies (a trim on any armour). + results: list, + /// Cooking: game ticks and experience; 0 for the other kinds. + cooking-time: u32, + experience: f32, + /// The recipe book's id for it (one per way the book shows it), as + /// `known` lists them; -1 for the kinds the book doesn't show + /// (campfire, stonecutting, smithing, brewing). + book-id: s32, + } + record item { + /// `minecraft:diamond`, `:`. + id: string, + /// In the player's language. + name: string, + /// Its item tags, without the `#`. + tags: list, + } + /// Every item, in the registry's order: `stack.item` indexes this list. + items: func() -> list; + /// Every recipe: the book's in its order, then campfire, stonecutting, + /// smithing and brewing. They don't change while a world is open. + all: func() -> list; + /// The `book-id`s of the recipes the player's recipe book holds now. + known: func() -> list; +} + /// Capability `players`, server mods only (manifest `api = "0.3"`, /// experimental): the players on the server, and messages to them. interface players { @@ -545,6 +615,7 @@ world horizon-ui-mod { import ui; import render; import input; + import recipes; export ui-events; } @@ -559,3 +630,8 @@ world render-host { import render; import input; } + +/// The game's side of capability `recipes` (imports only). +world recipes-host { + import recipes; +} diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index bf87d0cfa..ff06a94ae 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -7,13 +7,13 @@ //! version's level so the launcher can tell before it installs. /// The newest level this Horizon serves. -pub const LEVEL: u32 = 3; +pub const LEVEL: u32 = 4; /// Every function the game offers mods, as `interface.function`, with its /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 34] = [ +pub const FUNCTIONS: [(&str, u32); 37] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -48,6 +48,9 @@ pub const FUNCTIONS: [(&str, u32); 34] = [ ("players.broadcast", 1), ("game.raycast", 2), ("command.run-quiet", 3), + ("recipes.items", 4), + ("recipes.all", 4), + ("recipes.known", 4), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 5ad7823aa..05cf96447 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -97,9 +97,12 @@ pub enum Capability { Input, /// The players on the server and messages to them (server mods). Players, + /// The recipes and items the game knows, for recipe viewers + /// (interface `recipes`). + Recipes, } impl Capability { - pub const ALL: [Capability; 10] = [ + pub const ALL: [Capability; 11] = [ Capability::GameRead, Capability::Chat, Capability::Hud, @@ -110,6 +113,7 @@ impl Capability { Capability::Render, Capability::Input, Capability::Players, + Capability::Recipes, ]; /// Whether a server mod may have it: the others act on a local player. pub fn on_server(self) -> bool { @@ -128,6 +132,7 @@ impl Capability { Capability::Render => "render", Capability::Input => "input", Capability::Players => "players", + Capability::Recipes => "recipes", } } /// May change in any release: a mod uses it only with @@ -135,7 +140,7 @@ impl Capability { pub fn is_experimental(self) -> bool { matches!( self, - Capability::Ui | Capability::Render | Capability::Input + Capability::Ui | Capability::Render | Capability::Input | Capability::Recipes ) } /// What the player reads before turning the mod on: "May ". @@ -153,6 +158,7 @@ impl Capability { "see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys" } Capability::Players => "see who is on the server and message them", + Capability::Recipes => "read the game's recipes and items, and which recipes you know", } } /// The capability a component import needs, `Ok(None)` for one every @@ -188,6 +194,7 @@ impl fmt::Display for Capability { Capability::Render => "render", Capability::Input => "input", Capability::Players => "players", + Capability::Recipes => "recipes", }; f.write_str(name) } diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index ce48f2317..ebce03944 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -78,6 +78,18 @@ pub use render_bindings::horizon::mods::render::{ Zoom as ModZoom, }; use render_bindings::{RenderHost, horizon::mods::render}; +/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items. +mod recipes_bindings { + wasmtime::component::bindgen!({ + path: "../horizon-mod-sdk/wit", + world: "recipes-host", + }); +} +/// What capability `recipes` reads (WIT `interface recipes`). +pub use recipes_bindings::horizon::mods::recipes::{ + Item as RecipeItem, Kind as RecipeKind, Recipe, Stack as RecipeStack, +}; +use recipes_bindings::{RecipesHost, horizon::mods::recipes}; /// A mod's linear memory cap. const MEMORY: usize = 64 << 20; @@ -133,6 +145,19 @@ pub struct Snapshot { /// its distance from the eye, for `game.raycast`; the game works it out /// only while a mod calls it ([`Mods::wants_raycast`]). pub view_hit: Option<(f32, Hit)>, + /// Every item and recipe, for capability `recipes`; the game makes them + /// once, and only while a mod reads them ([`Mods::wants_recipes`]). + pub recipes: Option>, + /// The `book-id`s of the recipes the player's book holds. + pub known_recipes: Arc<[i32]>, +} + +/// Capability `recipes`'s tables: `items` in the registry's order, which +/// each [`RecipeStack::item`] indexes. +#[derive(Clone, Debug, Default)] +pub struct Recipes { + pub items: Vec, + pub recipes: Vec, } /// Which mods a game starts: the window's game the client mods, the game @@ -241,6 +266,8 @@ struct Running { hooks: Hooks, /// It imports `game.raycast`. raycasts: bool, + /// It imports capability `recipes`. + reads_recipes: bool, } /// A running mod's exports and the hooks its `init` asked for. @@ -392,6 +419,8 @@ impl Mods { .map_err(|e| e.to_string())?; RenderHost::add_to_linker::>(&mut linker, |s| s) .map_err(|e| e.to_string())?; + RecipesHost::add_to_linker::>(&mut linker, |s| s) + .map_err(|e| e.to_string())?; self.engine = Some((engine, linker)); } Ok(self.engine.as_ref().expect("made above")) @@ -509,6 +538,7 @@ impl Mods { ui, hooks, raycasts: functions.iter().any(|f| f == "game.raycast"), + reads_recipes: functions.iter().any(|f| f.starts_with("recipes.")), })) } @@ -769,6 +799,13 @@ impl Mods { .iter() .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) } + /// Some running mod reads capability `recipes`: the snapshot needs + /// [`Snapshot::recipes`] and [`Snapshot::known_recipes`]. + pub fn wants_recipes(&self) -> bool { + self.entries + .iter() + .any(|e| e.run.as_ref().is_some_and(|r| r.reads_recipes)) + } /// Some running mod has a `frame` hook: the game refreshes the snapshot /// every frame for it. pub fn wants_frames(&self) -> bool { @@ -1125,6 +1162,23 @@ impl game::Host for State { (*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone()) } } +impl recipes::Host for State { + fn items(&mut self) -> Vec { + self.snapshot + .recipes + .as_ref() + .map_or_else(Vec::new, |r| r.items.clone()) + } + fn all(&mut self) -> Vec { + self.snapshot + .recipes + .as_ref() + .map_or_else(Vec::new, |r| r.recipes.clone()) + } + fn known(&mut self) -> Vec { + self.snapshot.known_recipes.to_vec() + } +} impl State { /// Queues a chat line unless the call already sent [`CHAT_PER_CALL`]. fn say(&mut self, line: impl FnOnce(String) -> Output) -> bool { diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index c6b835f10..06a8e6aa2 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -26,8 +26,8 @@ id = "test_ui_guest" name = "Test UI Guest" version = "0.1.0" api = "0.1" -capabilities = ["game-read", "chat", "command", "ui", "render", "input"] -experimental = ["ui", "render", "input"] +capabilities = ["game-read", "chat", "command", "ui", "render", "input", "recipes"] +experimental = ["ui", "render", "input", "recipes"] "#; /// Builds the mods once and writes their `.hmod` files; returns the folder. @@ -238,8 +238,8 @@ id = "too_new" name = "Too New" version = "0.1.0" api = "0.1" -capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input"] -experimental = ["ui", "render", "input"] +capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input", "recipes"] +experimental = ["ui", "render", "input", "recipes"] "#; std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap(); std::fs::write(dir.join("mod.wasm"), wasm).unwrap(); @@ -1016,3 +1016,85 @@ fn a_server_mod_runs_only_where_the_world_does() { "a server command isn't the window's" ); } + +/// Capability `recipes`: the guest reads tables as big as vanilla's (1,500 +/// items, 1,600 recipes of nine five-item slots) within one command's fuel. +#[test] +fn recipes_reads_the_tables() { + use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + let stack = |item| RecipeStack { + item, + count: 1, + components: String::new(), + }; + let items = (0..1500) + .map(|i| RecipeItem { + id: format!("minecraft:item_{i}"), + name: format!("Item {i}"), + tags: vec!["planks".into(), "logs".into()], + }) + .collect(); + let mut recipes: Vec = (0..1600) + .map(|i| Recipe { + id: format!("minecraft:recipe_{i}"), + kind: RecipeKind::CraftingShaped, + category: "crafting_misc".into(), + group: String::new(), + width: 3, + height: 3, + slots: (0..9) + .map(|k| (0..5).map(|j| stack((i + k + j) % 1500)).collect()) + .collect(), + results: vec![RecipeStack { + item: 7, + count: 4, + components: String::new(), + }], + cooking_time: 0, + experience: 0.0, + book_id: i as i32, + }) + .collect(); + recipes[1].kind = RecipeKind::Brewing; + recipes[1].results[0].components = + "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]".into(); + mods.set_snapshot(Snapshot { + recipes: Some(std::sync::Arc::new(Recipes { items, recipes })), + known_recipes: vec![1, 5, 9].into(), + ..Snapshot::default() + }); + let said = |mods: &mut Mods, command: &str| { + assert!(mods.command(command)); + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::Chat { text, .. } => Some(text), + _ => None, + }) + .collect::>() + }; + assert_eq!( + said(&mut mods, "recipes"), + ["1500 items, 1600 recipes, 3 known, 1 brewing"] + ); + assert_eq!( + said(&mut mods, "recipes minecraft:recipe_1"), + [ + "minecraft:recipe_1 Kind::Brewing 3x3 0t slots 9, the first 1 minecraft:item_1 -> \ + 4 minecraft:item_7[minecraft:potion_contents={potion:\"minecraft:swiftness\"}] known true" + ] + ); + assert!( + mods.entries() + .iter() + .all(|e| e.status == horizon_mods::Status::Running), + "it ran out of fuel" + ); + // Without the game's tables (no mod read them yet): empty, not a trap. + mods.set_snapshot(Snapshot::default()); + assert_eq!( + said(&mut mods, "recipes"), + ["0 items, 0 recipes, 0 known, 0 brewing"] + ); +} diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 74fb64350..2fd835d3c 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -6,10 +6,12 @@ //! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`; //! `/corner` and `/readings` the calls the official extras mods use; //! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs -//! lines with `command.run-quiet` (`/quiet_many N`: N of them). +//! lines with `command.run-quiet` (`/quiet_many N`: N of them). `/recipes` +//! counts what `recipes` reads; `/recipes ID` describes that recipe. use horizon_mod_sdk::{ Mod, Registration, chat, command, game, input::{self, Event}, + recipes, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget}, }; @@ -102,6 +104,7 @@ impl Mod for Guest { "raycast", "quiet", "quiet_many", + "recipes", "corner", "readings", ] @@ -241,6 +244,41 @@ impl Mod for Guest { None => "no hit".into(), }); } + "recipes" => { + let items = recipes::items(); + let all = recipes::all(); + let known = recipes::known(); + let name = |s: &recipes::Stack| { + let id = items.get(s.item as usize).map_or("?", |i| i.id.as_str()); + format!("{} {id}{}", s.count, s.components) + }; + chat::show(&match all.iter().find(|r| r.id == args) { + Some(r) => format!( + "{} {:?} {}x{} {}t slots {}, the first {} -> {} known {}", + r.id, + r.kind, + r.width, + r.height, + r.cooking_time, + r.slots.len(), + r.slots + .first() + .and_then(|s| s.first()) + .map_or("-".into(), name), + r.results.first().map_or("-".into(), name), + known.contains(&r.book_id) + ), + None => format!( + "{} items, {} recipes, {} known, {} brewing", + items.len(), + all.len(), + known.len(), + all.iter() + .filter(|r| r.kind == recipes::Kind::Brewing) + .count() + ), + }); + } // Clamped to (1, 10); the two after it shake nothing. "render_shake" => { render::shake(2.0, 99.0); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index c44f27576..1ee54e70f 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -17,6 +17,7 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`, | `render` | [`render`](#render) | draw on your screen, shake and zoom the camera | | `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys | | `players` | [`players`](#players) | see who is on the server and message them | +| `recipes` | [`recipes`](#recipes) | read the game's recipes and items, and which recipes you know | ## World `horizon-mod` @@ -36,7 +37,7 @@ A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs whe A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets. -- Imports (what the game offers): `ui`, `render`, `input`, `log`, `game`, `chat`, `hud`, `storage`, `command` +- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `log`, `game`, `chat`, `hud`, `storage`, `command` - Exports (what the mod provides): `ui-events`, `events` ## World `ui-host` @@ -53,6 +54,13 @@ The game's side of capability `render` (imports only). - Imports (what the game offers): `render`, `input` - Exports (what the mod provides): +## World `recipes-host` + +The game's side of capability `recipes` (imports only). + +- Imports (what the game offers): `recipes` +- Exports (what the mod provides): + ## `log` Access: always granted. @@ -461,6 +469,68 @@ What the game calls. Only the hooks `init` asks for are ever called. | `command` | `func(name: string, args: string) -> result, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. | | `chat` | `func(text: string)` | Each chat line the player receives, as plain text. | +## `recipes` + +Access: capability `recipes`. + +Capability `recipes` (experimental): the recipes and items the game knows, for recipe viewers. In single player and on Horizon's servers they are the world's; on any other server they are vanilla 26.3's (a client is sent no others), while `known` follows that server's book. + +### record `stack` + +An item of `items`, by its index there, with a count and the components it has beyond the item's own, in vanilla's item syntax (`[potion_contents={potion:"minecraft:swiftness"}]`), or "". + +| Field | Type | | +| --- | --- | --- | +| `item` | `u32` | | +| `count` | `u32` | | +| `components` | `string` | | + +### enum `kind` + +The station a recipe is made at. + +| Case | | +| --- | --- | +| `crafting-shaped` | | +| `crafting-shapeless` | | +| `smelting` | | +| `blasting` | | +| `smoking` | | +| `campfire` | | +| `stonecutting` | | +| `smithing` | | +| `brewing` | | + +### record `recipe` + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | `minecraft:oak_planks`. | +| `kind` | `kind` | | +| `category` | `string` | The recipe book's tab (`crafting_building_blocks`, `furnace_food`), or "". | +| `group` | `string` | Recipes the book shows on one button share a group, or "". | +| `width` | `u32` | A shaped recipe's grid: `slots` holds its cells row by row. 0 for the other kinds. | +| `height` | `u32` | | +| `slots` | `list>` | Each slot's stacks, any one of which fits; an empty list is an empty cell. Cooking and stonecutting: the input. Smithing: the template, the base, the addition. Brewing: the bottle, the reagent. | +| `results` | `list` | What it makes; several when that varies (a trim on any armour). | +| `cooking-time` | `u32` | Cooking: game ticks and experience; 0 for the other kinds. | +| `experience` | `f32` | | +| `book-id` | `s32` | The recipe book's id for it (one per way the book shows it), as `known` lists them; -1 for the kinds the book doesn't show (campfire, stonecutting, smithing, brewing). | + +### record `item` + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | `minecraft:diamond`, `:`. | +| `name` | `string` | In the player's language. | +| `tags` | `list` | Its item tags, without the `#`. | + +| Function | Signature | What it does | +| --- | --- | --- | +| `items` | `func() -> list` | Every item, in the registry's order: `stack.item` indexes this list. | +| `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | +| `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | + ## `players` Access: capability `players`. diff --git a/docs/api/stability.md b/docs/api/stability.md index df2c1aeb3..00b5541a9 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`. A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`). A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index 28415d28f..73ecfb9a5 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -91,6 +91,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | +| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | | Hook (export) | When | |---|---| @@ -116,7 +117,6 @@ loading. Ordered by what the first native ports need: | `keys` | keybinds | registered binds appear in Controls; press/release events | | `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips | | `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers | -| `recipes` | the recipe registry and lookups | JEI/EMI-style viewers | | `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics | | `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints | | `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) | From bf2c8679660a72f3377e809ece12eb6aeaac32b9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:49:55 +0100 Subject: [PATCH 129/475] Zombies: roll canBreakDoors when the game spawns them Zombie.finalizeSpawn calls setCanBreakDoors(nextFloat() < special * 0.1) after the baby roll; the game's spawn finalize never rolled it, so only the finalize probe's zombies could break doors. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/spawning.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index f4e55e7d0..f362139cd 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -163,6 +163,9 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { if m.random.next_float() < 0.05 { m.extra.age = i32::MIN / 2; } + // setCanBreakDoors(nextFloat() < special * 0.1), before the gear. + let can_break_doors = m.random.next_float() < difficulty.special() * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); if m.species() != S::Drowned { populate_armor(m, difficulty); } @@ -196,6 +199,9 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { // ZombifiedPiglin.populateDefaultEquipmentSlots: a golden sword, or // one time in 20 a golden spear. S::ZombifiedPiglin => { + // Zombie.finalizeSpawn's setCanBreakDoors. + let can_break_doors = m.random.next_float() < difficulty.special() * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); let spear = m.random.next_int_bound(20).unwrap_or(1) == 0; m.extra.main_hand = Some( if spear { From 6f4c2cfbbc9dad4ef51e695c08a1d086fd88cf0d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:16:19 +0100 Subject: [PATCH 130/475] Count the hurt tilt down while the player is dead or riding --- crates/horizon-client/src/game/remote.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 857f955da..54659e0f9 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3219,6 +3219,11 @@ impl Game<'_> { resources: self.motion, } .controlled_simulation_tick(keys)?; + } else { + // LivingEntity.baseTick counts hurtTime down for a dead or riding + // player too (controlled_simulation_tick does it otherwise): left + // at 10, the hurt tilt restarted every frame and shook the camera. + player.health.advance_clock(); } // LocalPlayer.onUpdateAbilities: flight turned on or off (a double // jump, a spectator, landing) is sent to the server. From 2f3dade42ffd73511b597f8993b3f94b564ca310 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:19:53 +0100 Subject: [PATCH 131/475] Show the firework trail on a boosting rocket and hide its sprite On a server-thread client an elytra boost rocket drew no spark trail and showed up as a static item sprite. The client now follows the entity the rocket is attached to, spawns the trail each tick like FireworkRocketEntity.tick, and does not draw an attached rocket. --- crates/horizon-client/src/game/fireworks.rs | 9 ++- .../src/game/remote_projectiles.rs | 61 +++++++++++++------ 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index e6b4eb136..4f61d728a 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -21,7 +21,7 @@ pub(super) struct Rocket { /// The entity id a server tracks it by (0 until it is tracked). pub id: u32, pub(super) position: Vec3, - previous: Vec3, + pub(super) previous: Vec3, pub(super) velocity: Vec3, life: i32, lifetime: i32, @@ -369,7 +369,12 @@ impl Game<'_> { /// lit at the rocket's light probe. pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec) { let orientation = glam::Mat4::from_quat(self.camera_orientation()); - for r in self.rockets.iter().filter(|r| !r.attached) { + // shouldRender: not while attached to an entity (`isAttachedToEntity`). + for r in self + .rockets + .iter() + .filter(|r| !r.attached && r.attached_to.is_none()) + { // FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks; // EntityRenderer.shouldRender: its 0.25 box in the view. if !self.standing_entity_in_view( diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index a117b1b7c..41631c4f1 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -3,8 +3,11 @@ //! and `handleSetEntityData`): the server simulates them, the client draws //! them where it says and interpolates their moves. use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector}; -use horizon_client::projectiles::{Projectile, ProjectileKind}; -use horizon_core::{geometry::Vec3, position::BlockPos}; +use horizon_client::{ + particles::{ParticleOptions, ParticleType}, + projectiles::{Projectile, ProjectileKind}, +}; +use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource}; use horizon_protocol::entity_data::Value; use horizon_world::registry::BlockStateId; @@ -273,8 +276,9 @@ impl Game<'_> { let id = t.id; t.remote_step(|at| step(id, at)); } - // FireworkRocketEntity.tick: a rocket attached to the gliding local - // player boosts it client-side too (attachedToEntity.isFallFlying). + // FireworkRocketEntity.tick: a rocket attached to an entity follows it + // and boosts it client-side too (attachedToEntity.isFallFlying), and + // every rocket leaves its spark trail on a client. let me = self .remote .as_ref() @@ -282,21 +286,42 @@ impl Game<'_> { for r in &mut self.rockets { let id = r.id; r.remote_step(|at| step(id, at)); - if me.is_some() - && r.attached_to == me - && let Some(p) = self.player.as_mut() - && p.fall_flying - { - let look = view_vector(self.yaw, self.pitch); - let m = p.velocity; - p.velocity = Vec3::new( - m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, - m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, - m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, - ); - r.position = p.position; - r.velocity = p.velocity; + if let Some(target) = r.attached_to { + if me == Some(target) { + if let Some(p) = self.player.as_mut() { + if p.fall_flying { + let look = view_vector(self.yaw, self.pitch); + let m = p.velocity; + p.velocity = Vec3::new( + m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, + m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, + m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, + ); + } + r.position = p.position; + r.velocity = p.velocity; + } + } else if let Some(m) = self.mobs.iter().find(|m| m.id == target) { + // Another player's motion is not known here: its step. + r.velocity = Vec3::new( + m.position.x - r.previous.x, + m.position.y - r.previous.y, + m.position.z - r.previous.z, + ); + r.position = m.position; + } } + let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05; + let v = [ + g(&mut self.world.random), + -r.velocity.y * 0.5, + g(&mut self.world.random), + ]; + self.particles.add( + &ParticleOptions::simple(ParticleType::Firework), + [r.position.x, r.position.y, r.position.z], + v, + ); } for c in &mut self.crystals { c.time += 1; From 37825b750a8908d8682dc2d63b751c3d712dd608 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:23:06 +0100 Subject: [PATCH 132/475] Piglin AI: name the piglin repellent finder for its mob #2191 and #2192 each add a `find_repellent` to piglin.rs, one for hoglins and one for piglins; merged together they would collide. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/piglin.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 59c0a9344..51cef4c6c 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -270,13 +270,13 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } // PiglinSpecificSensor (every 20 ticks): the nearest repelling block. if m.species() == Species::Piglin && m.alive() && m.age.wrapping_add(m.id).is_multiple_of(20) { - m.extra.repellent = find_repellent(m, w); + m.extra.repellent = find_piglin_repellent(m, w); } } /// `PiglinSpecificSensor.findNearestRepellent`: the nearest `#piglin_repellents` /// block within 8 blocks sideways and 4 up or down; a soul campfire only /// counts while lit. -fn find_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { +fn find_piglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { let tag = w.block_tags.get("minecraft:piglin_repellents")?; let at = [ m.position.x.floor() as i32, From 47654b62a3b3f64ff163590ec1c29d16c341d50d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:24:26 +0100 Subject: [PATCH 133/475] Save the armadillo scute timer and reset it only when a scute can drop --- .../examples/probe/mob_ai_probe.rs | 1 + crates/horizon-client/src/entity_nbt.rs | 10 +++++++ crates/horizon-client/src/game/creatures.rs | 29 +++++++++---------- crates/horizon-client/src/mobs.rs | 2 ++ crates/horizon-client/src/mobs/ai.rs | 27 ++++++++--------- 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..81c739464 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1097,6 +1097,7 @@ pub fn main() -> horizon_client::Result<()> { block_tags: &items.block_tags, biomes: &biomes, mob_griefing: true, + mob_drops: true, difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0), end: false, world_seed: 0, diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 00576ea02..91ede9110 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -445,6 +445,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new())); } } + // Armadillo.addAdditionalSaveData: the time left to the next scute. + "minecraft:armadillo" => { + map.insert("scute_time".into(), Tag::Int(m.extra.egg_time)); + } "minecraft:ravager" => { map.insert("AttackTick".into(), Tag::Int(m.extra.counter)); map.insert("StunTick".into(), Tag::Int(m.extra.counter2)); @@ -876,6 +880,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - st.vibration = crate::sculk::Data::from_nbt(listener); } } + // Armadillo.readAdditionalSaveData: keeps the constructor's time when absent. + if m.species() == crate::mobs::Species::Armadillo + && let Some(t) = number(tag.get("scute_time")) + { + m.extra.egg_time = t as i32; + } if m.species() == crate::mobs::Species::Ravager { m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32; m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 9a78bc2e5..56215fe9c 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -416,6 +416,7 @@ impl Game<'_> { block_tags: &self.res.items().block_tags, biomes: self.res.biomes(), mob_griefing: self.ui.rules.bool("mob_griefing"), + mob_drops: self.ui.rules.bool("mob_drops"), end: self.dim == Dim::End, world_seed: self.level.seed, sea_level: if self.dim == Dim::Nether { 32 } else { 63 }, @@ -1222,22 +1223,20 @@ impl Game<'_> { } } MobEvent::ShedScute { mob, at, pitch } => { - // shouldDropLoot: the `mob_drops` rule. - if self.ui.rules.bool("mob_drops") { - let table = "minecraft:gameplay/armadillo_shed"; - let drops = self.sequence_loot(table, table, None); - for d in drops { - self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d); - } - self.audio.play( - "minecraft:entity.armadillo.scute_drop", - Some(at), - 1.0, - pitch, - ); - let ctx = self.who_context(Some(Who::Mob(mob)), None); - self.post_game_event("minecraft:entity_place", at, ctx); + // The mob already checked `shouldDropLoot` (a baby, `mob_drops`). + let table = "minecraft:gameplay/armadillo_shed"; + let drops = self.sequence_loot(table, table, None); + for d in drops { + self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d); } + self.audio.play( + "minecraft:entity.armadillo.scute_drop", + Some(at), + 1.0, + pitch, + ); + let ctx = self.who_context(Some(Who::Mob(mob)), None); + self.post_game_event("minecraft:entity_place", at, ctx); } MobEvent::GiftLoot { table, at } => { let drops = diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 315bfdce2..c276c1e66 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1501,6 +1501,8 @@ pub struct MobWorld<'a> { pub biomes: &'a crate::biome::Biomes, /// The `mob_griefing` game rule. pub mob_griefing: bool, + /// The `mob_drops` game rule (`shouldDropLoot`). + pub mob_drops: bool, /// `getCurrentDifficultyAt` near the player. pub difficulty: spawning::LocalDifficulty, /// The End (its dimension type's monster spawn light rules). diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8259ddccb..90337e416 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -938,26 +938,27 @@ fn server_ai_step( fn next_scute_time(m: &mut Mob) -> i32 { m.random.next_int_bound(20 * 60 * 5).unwrap_or(0) + 20 * 60 * 5 } -/// `Armadillo.customServerAiStep`: the scute timer. A baby only resets it -/// (`shouldDropLoot`); the game checks the `mob_drops` rule. -fn armadillo_server_ai_step(m: &mut Mob, events: &mut Vec) { +/// `Armadillo.customServerAiStep`: the scute timer keeps counting down and is +/// only reset once `shouldDropLoot` (not a baby, `mob_drops` on) lets it shed. +fn armadillo_server_ai_step(m: &mut Mob, mob_drops: bool, events: &mut Vec) { + if !m.alive() { + return; + } m.extra.egg_time -= 1; - if m.alive() && m.extra.egg_time <= 0 { - if !m.is_baby() { - let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; - events.push(MobEvent::ShedScute { - mob: m.id, - at: [m.position.x, m.position.y, m.position.z], - pitch, - }); - } + if m.extra.egg_time <= 0 && !m.is_baby() && mob_drops { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + events.push(MobEvent::ShedScute { + mob: m.id, + at: [m.position.x, m.position.y, m.position.z], + pitch, + }); m.extra.egg_time = next_scute_time(m); } } fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { match m.species() { Species::Rabbit => super::rabbit::server_ai_step(m), - Species::Armadillo => armadillo_server_ai_step(m, cx.events), + Species::Armadillo => armadillo_server_ai_step(m, cx.w.mob_drops, cx.events), Species::Sheep => {} Species::Zombie | Species::Husk From 18f4dd92aea541b501a154f341b36135210ac4d1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:24:42 +0100 Subject: [PATCH 134/475] Play container open and close sounds only when the opener count crosses zero --- crates/horizon-client/src/game.rs | 33 ++++++------------ .../src/game/block_entity_anims.rs | 34 +++++++++++-------- crates/horizon-client/src/game/lan_actions.rs | 7 ++-- crates/horizon-client/src/game/storage.rs | 30 ++++++++++++---- 4 files changed, 58 insertions(+), 46 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index d0e060c13..73dcfc455 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3019,15 +3019,9 @@ impl Game<'_> { self.craft_table = Some(pos); } else if &*name == "minecraft:ender_chest" { self.open_ender_chest(key); - self.container_opener_event(key, true); - let at = [ - f64::from(key.0) + 0.5, - f64::from(key.1) + 0.5, - f64::from(key.2) + 0.5, - ]; - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio - .play("minecraft:block.ender_chest.open", Some(at), 0.5, pitch); + if self.container_opener_event(key, true) { + self.container_edge_sound(key, true); + } } else if &*name == "minecraft:beacon" && self.open_station(pos, &name) { self.world.block_entities.get_or_insert_with(key, || { BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new())) @@ -3043,7 +3037,7 @@ impl Game<'_> { ]; self.resolve_container_maps(key, centre); self.container = Some(key); - self.container_opener_event(key, true); + let first = self.container_opener_event(key, true); self.post_block_game_event( "minecraft:container_open", [key.0, key.1, key.2], @@ -3067,17 +3061,8 @@ impl Game<'_> { if self.guarded_by_piglins(&name) { self.anger_piglins(true); } - let sound = if storage::is_shulker_box(&name) { - Some("minecraft:block.shulker_box.open") - } else if name.contains("chest") { - Some("minecraft:block.chest.open") - } else if &*name == "minecraft:barrel" { - Some("minecraft:block.barrel.open") - } else { - None - }; - if let Some(sound) = sound { - self.container_sound(sound, key, &name); + if first { + self.container_edge_sound(key, true); } } else if self.open_station(pos, &name) { // Workstation menus: game/stations.rs. @@ -5620,14 +5605,16 @@ impl Game<'_> { if let Some(key) = self.container.take() { // A cart's or chest boat's items go back to it. self.close_cart_container(key); - self.container_opener_event(key, false); + let last = self.container_opener_event(key, false); self.post_block_game_event( "minecraft:container_close", [key.0, key.1, key.2], Some(horizon_client::mobs::Who::Player), None, ); - self.container_close_sound(key); + if last { + self.container_edge_sound(key, false); + } self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } self.close_ender_chest(); diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index 3d452596e..4d48b0874 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -71,23 +71,24 @@ impl Game<'_> { /// A player opened (`open`) or closed the container at `key`: the opener /// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity` /// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes, - /// on both halves of a double chest (`CompoundContainer`). - pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) { + /// on both halves of a double chest (`CompoundContainer`). True when the + /// count went from 0 to 1 or back to 0: the only moments the container + /// plays its open or close sound. + pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool { // Spectators never count as openers. - if !self.spectator() { - self.container_openers_changed(key, open); - } + !self.spectator() && self.container_openers_changed(key, open) } /// `container_opener_event` for a player who is not a spectator (a guest). - pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) { + pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool { let pos = [key.0, key.1, key.2]; let state = self.world.block(pos[0], pos[1], pos[2]).0; match self.be_anims.ticker(state) { Some(Ticker::Lid) => { - self.lid_openers_changed(key, open); + let edge = self.lid_openers_changed(key, open); if let Some(other) = self.chest_other_half(key) { self.lid_openers_changed(other, open); } + edge } _ if self .res @@ -97,26 +98,27 @@ impl Game<'_> { .as_ref() == "minecraft:barrel" => { - self.barrel_openers_changed(key, open); + self.barrel_openers_changed(key, open) } Some(Ticker::Shulker { .. }) => { let shulker = self.be_anims.shulkers.entry(pos).or_default(); - let (count, _) = if open { + let (count, edge) = if open { shulker.start_open() } else { shulker.stop_open() }; self.block_entity_event(pos, 1, count); self.lan_block_event(pos, 1, count); + edge } - _ => {} + _ => false, } } /// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the /// chest at `key` (the host and the guests are counted), then /// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here - /// and for the guests. - fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) { + /// and for the guests. True when the count crossed zero. + fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool { let (before, count) = self.count_opener(key, open); let pos = [key.0, key.1, key.2]; self.block_entity_event(pos, 1, count); @@ -134,12 +136,15 @@ impl Game<'_> { b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block); }); } + (before == 0) != (count == 0) } /// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel /// (`open=true`), the last one to leave closes it (`updateBlockState`). - fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) { + /// True when the count crossed zero. + fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool { let (before, count) = self.count_opener(key, open); - if (before == 0) != (count == 0) { + let edge = (before == 0) != (count == 0); + if edge { self.set_bool_prop( key, "open", @@ -147,6 +152,7 @@ impl Game<'_> { horizon_world::level::flags::UPDATE_ALL, ); } + edge } /// One opener more (`open`) or less at `key`: the count before and after /// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`). diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 41ada973c..796e5f89c 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -856,14 +856,15 @@ impl Game<'_> { /// The guest's open container is closing (`AbstractContainerMenu.removed`): /// it no longer counts among the openers of the lid, and the lid's close /// sound is played for everyone around (`ChestBlockEntity.onClose`, a - /// `playSound` with no player), the guest included. + /// `playSound` with no player), the guest included, once the last opener + /// has left. pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) { if let Some(g) = host.hub.guest(conn) && let Some(pos) = g.menu.as_ref().and_then(|m| m.pos) && !g.spectator() + && self.container_openers_changed(pos, false) { - self.container_openers_changed(pos, false); - self.container_close_sound(pos); + self.container_edge_sound(pos, false); } } diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index d733f1cca..7db0ce82c 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -142,8 +142,10 @@ impl Game<'_> { let pitch = self.world.random.next_float() * 0.1 + 0.9; self.audio.play(sound, Some(at), 0.5, pitch); } - /// The close sound of the container block at `key`, if it has one. - pub(super) fn container_close_sound(&mut self, key: (i32, i32, i32)) { + /// The open or close sound of the container block at `key`, if it has one. + /// Callers play it only when the opener count crosses zero + /// (`ContainerOpenersCounter.onOpen` / `onClose`). + pub(super) fn container_edge_sound(&mut self, key: (i32, i32, i32), open: bool) { let name = self .res .facts() @@ -151,13 +153,29 @@ impl Game<'_> { .name .clone(); let sound = if is_shulker_box(&name) { - "minecraft:block.shulker_box.close" + if open { + "minecraft:block.shulker_box.open" + } else { + "minecraft:block.shulker_box.close" + } } else if &*name == "minecraft:ender_chest" { - "minecraft:block.ender_chest.close" + if open { + "minecraft:block.ender_chest.open" + } else { + "minecraft:block.ender_chest.close" + } } else if name.contains("chest") { - "minecraft:block.chest.close" + if open { + "minecraft:block.chest.open" + } else { + "minecraft:block.chest.close" + } } else if &*name == "minecraft:barrel" { - "minecraft:block.barrel.close" + if open { + "minecraft:block.barrel.open" + } else { + "minecraft:block.barrel.close" + } } else { return; }; From f364c4f9c6fdb28e114ef5b7a84d1f62e5a988d5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:28:28 +0100 Subject: [PATCH 135/475] Leave wandering traders out of the spawn egg baby check --- crates/horizon-client/src/game/creatures.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3ebf9ef49..420509900 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2969,6 +2969,9 @@ impl Game<'_> { if !parent.alive() || stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str()) || !horizon_client::mobs::drops::can_be_baby(parent.species()) + // WanderingTrader.getBreedOffspring returns null: no offspring, the + // click carries on to trading. + || parent.species() == Species::WanderingTrader { return false; } From c50557ea0ba2abb0c1476aa65faf12ee090478c7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:29:05 +0100 Subject: [PATCH 136/475] Credit the projectile owner for explosion damage to mobs --- crates/horizon-client/src/game/combat.rs | 3 +++ crates/horizon-client/src/game/creatures.rs | 5 ++++- crates/horizon-client/src/game/explosion.rs | 6 +++++- crates/horizon-client/src/game/wither.rs | 5 ++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 106e0e1a1..3d0c34ff2 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -31,6 +31,9 @@ pub(super) struct CombatTracker { /// The mob exploding now (`ServerExplosion.source`): its team's friendly /// fire decides whom the blast may hurt. pub(super) explosion_source: Option, + /// The mob that owns the projectile exploding now (`getIndirectSourceEntity`): + /// the mobs the blast hurts record it as their attacker. + pub(super) explosion_owner: Option, /// The player's fall distance before this tick's movement (as of the /// end of the last one). fall_before: f64, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index cd211ec48..48e029381 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2283,7 +2283,10 @@ impl Game<'_> { if let Some(owner) = self.combat.projectile_owner { self.note_attacker("minecraft:explosion", owner); } - self.explode(atp, *power as f32)?; + self.combat.explosion_owner = self.combat.projectile_owner; + let result = self.explode(atp, *power as f32); + self.combat.explosion_owner = None; + result?; } ( ProjectileKind::Potion { diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..675203517 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -393,7 +393,11 @@ impl Game<'_> { ); let source = horizon_client::mobs::Damage { kind: horizon_client::mobs::DamageKind::Explosion, - attacker: None, + attacker: self + .combat + .explosion_owner + .filter(|&o| o != m.id) + .map(horizon_client::mobs::Who::Mob), from: Some(center), }; if hurtable && !m.damage(damage(dist, exposure), source) { diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 101818f68..996f55d1c 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -172,7 +172,10 @@ impl Game<'_> { if let Some(id) = owner { self.note_attacker("minecraft:explosion", id); } - self.explode(at, 1.0) + self.combat.explosion_owner = owner; + let result = self.explode(at, 1.0); + self.combat.explosion_owner = None; + result } } /// Wither skulls drawn as the skull model, blue when dangerous. From b28108128d23ac61783b160a922f00b320133d98 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:33:08 +0100 Subject: [PATCH 137/475] Keep a hopper minecart's items in sync with a leftover menu copy --- crates/horizon-client/src/game/machines.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 55e47aa67..0562af8cd 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -777,7 +777,15 @@ impl Game<'_> { [hi.x + 0.25, hi.y, hi.z + 0.25], ); } - if !open && let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) { + // The cart's own items stay the one source of truth: a menu copy that + // is still around (open, or left by a guest's close) gets the pulled + // items back into `cart.items`, so reopening never restores a stale copy. + let slots = if open { + self.world.block_entities.get(&key).cloned() + } else { + self.world.block_entities.remove(&key) + }; + if let Some(BlockEntity::Chest(items)) = slots { cart.items = items; } } From 3842d851d8f088a138e5a09bd42cb054ac88ffa9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:38:07 +0100 Subject: [PATCH 138/475] Match the brewed_potion trigger's potion condition as a PotionsPredicate --- crates/horizon-client/src/advancements.rs | 45 +++++++++++++++++++ .../horizon-client/src/game/advancements.rs | 5 +-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index 6706532b7..531116ab6 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -473,6 +473,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool { _ => false, } } +/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate` +/// over the potion's effects (`contains`, `count`, `size`). +pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool { + if v.is_null() { + return true; + } + if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w)) + { + return false; + } + let c = &v["effects"]; + if c.is_null() { + return true; + } + let effects: Vec<(String, i32, i32)> = potion + .and_then(crate::brewing::potion) + .map(|d| { + d.effects + .iter() + .map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur)) + .collect() + }) + .unwrap_or_default(); + // `MobEffectsPredicate.test`: the one listed effect, amplifier and duration. + let test = |pred: &Value, e: &(String, i32, i32)| { + pred.as_object().is_some_and(|m| { + m.get(&e.0).is_some_and(|want| { + range_i(&want["amplifier"], i64::from(e.1)) + && range_i(&want["duration"], i64::from(e.2)) + && want["ambient"].as_bool().is_none_or(|b| !b) + && want["visible"].as_bool().is_none_or(|b| b) + }) + }) + }; + c["contains"] + .as_array() + .is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e)))) + && c["count"].as_array().is_none_or(|l| { + l.iter().all(|entry| { + let n = effects.iter().filter(|e| test(&entry["test"], e)).count(); + range_i(&entry["count"], n as i64) + }) + }) + && range_i(&c["size"], effects.len() as i64) +} /// `ItemPredicate`. pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool { if v.is_null() { diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 53fa282d4..a58066751 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -721,10 +721,7 @@ impl Game<'_> { block_pos: None, }; adv::conditions(&c["player"], &pctx, w) - && (c["potion"].is_null() - || potion - .as_deref() - .is_some_and(|p| adv::id_matches(&c["potion"], "potion", p, w))) + && adv::potions(&c["potion"], potion.as_deref(), w) }); } /// `EnterBlockTrigger`: the block, its state properties and the player. From 4471e59132dd8af6248d8685d066aa86ae6c15a6 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:41:23 +0100 Subject: [PATCH 139/475] Load the loot tables before a world's first chunks are read A loaded container with a loot table is rolled as its chunk is decoded, but the loot tables were loaded lazily by the first level tick. The spawn chunks are decoded while the world opens, so a loot chest in them came out empty and lost its table on the next save. WorldRequest::prepare, which every world opens through (in-frame, server thread, dedicated server), now loads them first. spawn_loot: vanilla writes a flat world with a seeded loot chest by the spawn and rolls it; Horizon opens it in a fresh process, in-frame and on the server thread, and must hold the same stacks in the same slots. Without the fix both modes hold nothing. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/world_loading.rs | 4 + tools/test_spawn_loot_parity.py | 117 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tools/test_spawn_loot_parity.py diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index 0cea31fd0..44c903737 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -46,6 +46,10 @@ impl WorldRequest { options: &Options, settings: &Settings, ) -> Result { + // Before any chunk is read: a loaded container with a loot table is rolled as its chunk + // is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world + // opens. Every world opens here: in-frame, on the server thread, on a dedicated server. + horizon_client::structure_blocks::loot_data(&options.resources); let Self { save_root, folder, diff --git a/tools/test_spawn_loot_parity.py b/tools/test_spawn_loot_parity.py new file mode 100644 index 000000000..896cfab0e --- /dev/null +++ b/tools/test_spawn_loot_parity.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""A vanilla loot chest at spawn has its loot when Horizon opens the world. + +Vanilla rolls a container's loot table when a player first opens it +(`RandomizableContainer.unpackLootTable`). Horizon rolls a loaded container with a loot table +and no items as its chunk is read from disk (`save::roll_loaded_loot`), so a container nobody +opens still saves as vanilla wrote it. The loot tables have to be loaded before the first chunks +are read: the spawn chunks are read while the world opens, and a chest read before the tables +came out empty, its loot table lost on the next save. + +The vanilla 26.3 server writes a flat world with a seeded loot chest beside the world spawn, then +rolls the chest itself in a copy of that world (`execute if items` unpacks it). Horizon opens the +untouched world in a new process, so nothing was loaded before the world, and must hold the same +items in the same slots. Both single-player paths: the default server thread and in-frame. + + test_spawn_loot_parity.py [--bin-dir DIR] [--keep] +""" +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_save_roundtrip_parity import Vanilla # noqa: E402 +from test_world_reopen_parity import game_env # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +RESOURCES = ROOT / '.local' / 'horizon-client' / '26.3' +RUN = ROOT / '.local' / 'spawn-loot' +CHEST = (2, -60, 1) +LOOT = '{LootTable:"minecraft:chests/simple_dungeon",LootTableSeed:42L}' +MODES = {'server thread': [], 'in-frame': ['--in-frame']} + + +def vanilla_world(): + """The world with the unopened chest, and what vanilla rolls from it: [(slot, id, count)].""" + server = Vanilla(RUN / 'v0') + try: + server.run('setworldspawn 0 -60 0') + server.run('forceload add 0 0') + time.sleep(2) + server.run('setblock {} {} {} minecraft:chest{}'.format(*CHEST, LOOT)) + server.run('save-all flush') + time.sleep(2) + finally: + server.stop() + shutil.copytree(RUN / 'v0', RUN / 'vv') + server = Vanilla(RUN / 'vv') + try: + server.run('forceload add 0 0') + time.sleep(2) + server.run('execute if items block {} {} {} container.* *'.format(*CHEST)) + text = ' '.join(server.run('data get block {} {} {} Items'.format(*CHEST))) + finally: + server.stop() + rolled = [] + for item in re.finditer(r'\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}', text.split('data:', 1)[-1]): + found = item.group(0) + count = re.search(r'count: (\d+)', found) + rolled.append((int(re.search(r'Slot: (\d+)b', found).group(1)), re.search(r'id: "([^"]+)"', found).group(1), + int(count.group(1)) if count else 1)) + return sorted(rolled) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build them with the fast profile)') + parser.add_argument('--keep', action='store_true', help='keep the run folder') + args = parser.parse_args() + bin_dir = Path(args.bin_dir).resolve() if args.bin_dir else None + if bin_dir is None: + subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'], cwd=ROOT, check=True) + bin_dir = ROOT / os.environ.get('CARGO_TARGET_DIR', 'target') / 'fast' + shutil.rmtree(RUN, ignore_errors=True) + RUN.mkdir(parents=True) + expected = vanilla_world() + print('vanilla rolls:', expected) + if not expected: + sys.exit('vanilla rolled nothing from the chest') + env, extra = game_env() + failures = 0 + for index, (mode, flags) in enumerate(MODES.items()): + folder = RUN / f'h{index}' + shutil.copytree(RUN / 'v0' / 'world', folder / 'saves' / 'world') + socket = Path(tempfile.mkdtemp(prefix='hzsl')) / 'control.sock' # sockets need short paths + + def ctl(*words, check=True): + result = subprocess.run([str(bin_dir / 'horizon-ctl'), '--socket', str(socket), '--timeout', '600', + *map(str, words)], cwd=folder, env=env, capture_output=True, text=True) + if check and result.returncode: + sys.exit(f'horizon-ctl {" ".join(map(str, words))} failed: {result.stdout}{result.stderr}') + return json.loads(result.stdout) if result.stdout.strip().startswith('{') else result.stdout + + ctl('launch', '--resources', RESOURCES, '--world', 'world', '--render-distance', '4', *flags, *extra) + try: + ctl('wait-ready', '300') + slots = ctl('container', *CHEST)['slots'] + finally: + ctl('quit', check=False) + got = sorted((slot, stack['item'], stack['count']) for slot, stack in enumerate(slots) if stack) + ok = got == expected + failures += not ok + print(f'{mode}: {len(got)} stacks', 'ok' if ok else f'FAIL: {got}') + if not args.keep and not failures: + shutil.rmtree(RUN, ignore_errors=True) + print('spawn loot:', 'PASS' if not failures else f'{failures} FAILED') + sys.exit(1 if failures else 0) + + +if __name__ == '__main__': + main() From cfd2dc83922ce606c518b085443d634f3931b23e Mon Sep 17 00:00:00 2001 From: Senjin Date: Mon, 26 Oct 2026 16:45:57 +0100 Subject: [PATCH 140/475] HDR: present HDR10 (PQ) where the surface offers it, scRGB otherwise HDR10 is what games usually present and what the display receives: a compositor composes in it or converts to it anyway. The NVIDIA driver offers it on Wayland next to scRGB. A 10-bit swapchain also halves the last pass's bandwidth; the frame before it is 8-bit, so nothing is lost. hdr.wgsl converts the same linear light to BT.2020 (BT.2087) and encodes it with the ST 2084 curve. HORIZON_HDR_OUTPUT=pq or scrgb allows only that one, for comparing them. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 34 ++++----- crates/horizon-client/src/gfx/hdr.rs | 99 +++++++++++++++++++++++---- crates/horizon-client/src/graphics.rs | 2 +- crates/horizon-client/src/hdr.wgsl | 25 ++++++- docs/graphics.md | 2 +- 5 files changed, 127 insertions(+), 35 deletions(-) diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 904370c04..86df94c0c 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -505,10 +505,10 @@ pub struct Renderer { occlusion: occlusion::Occlusion, /// Resolution scale and FXAA (Horizon settings). world_post: world_post::Post, - /// HDR output (Horizon setting): `Some` while the swapchain is scRGB. + /// HDR output (Horizon setting): `Some` while the swapchain is HDR. hdr: Option, - /// Whether the window's surface can present scRGB. - hdr_capable: bool, + /// The HDR output the window's surface can present. + hdr_output: Option, /// The shader pack's post-processing, and the dimension (legacy id) it was loaded for. pack_post: Option<(i32, Option)>, /// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s. @@ -1057,7 +1057,7 @@ impl Renderer { let info = adapter.get_info(); crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info)); let size = window.inner_size(); - let (config, present_modes, hdr_capable) = if let Some(surface) = &surface { + let (config, present_modes, hdr_output) = if let Some(surface) = &surface { let mut config = surface .get_default_config(&adapter, size.width.max(1), size.height.max(1)) .ok_or("surface has no supported configuration")?; @@ -1085,7 +1085,7 @@ impl Renderer { )); } surface.configure(&device, &config); - (config, caps.present_modes.clone(), hdr::supported(&caps)) + (config, caps.present_modes.clone(), hdr::output(&caps)) } else { let format = wgpu::TextureFormat::Bgra8Unorm; let config = wgpu::SurfaceConfiguration { @@ -1099,7 +1099,7 @@ impl Renderer { view_formats: vec![format], color_space: wgpu::SurfaceColorSpace::default(), }; - (config, Vec::new(), false) + (config, Vec::new(), None) }; let offscreen = surface .is_none() @@ -2441,7 +2441,7 @@ impl Renderer { occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16), world_post, hdr: None, - hdr_capable, + hdr_output, pack_post: None, native: None, pass_timing: pack_timing::PassTiming::logging(), @@ -2961,24 +2961,24 @@ impl Renderer { } } /// The swapchain's configuration: `config` (what every pipeline draws in), or - /// scRGB while HDR output is on. + /// HDR10 or scRGB while HDR output is on. fn surface_config(&self) -> wgpu::SurfaceConfiguration { let mut config = self.config.clone(); - if self.hdr.is_some() { - config.format = hdr::FORMAT; - config.view_formats = vec![hdr::FORMAT]; - config.color_space = hdr::COLOR_SPACE; + if let Some(hdr) = &self.hdr { + config.format = hdr.output.format(); + config.view_formats = vec![hdr.output.format()]; + config.color_space = hdr.output.color_space(); config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT; } config } - /// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's + /// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's /// highlights expanded up to `peak` nits. Off where the surface can't present it. pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) { - let on = on && self.hdr_capable && self.surface.is_some(); - if on != self.hdr.is_some() { - self.hdr = - on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix())); + let output = self.hdr_output.filter(|_| on && self.surface.is_some()); + if output.is_some() != self.hdr.is_some() { + let format = self.config.format.remove_srgb_suffix(); + self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output)); self.configure_surface(); } if let Some(hdr) = &mut self.hdr diff --git a/crates/horizon-client/src/gfx/hdr.rs b/crates/horizon-client/src/gfx/hdr.rs index 11deed546..8b9df7af0 100644 --- a/crates/horizon-client/src/gfx/hdr.rs +++ b/crates/horizon-client/src/gfx/hdr.rs @@ -1,17 +1,50 @@ //! HDR output (Horizon setting, off by default): the frame renders exactly as //! without it, into an 8-bit target in vanilla's gamma space, and one pass -//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white, -//! expanding the world's highlights towards their peak brightness. The world -//! is copied aside before the hand and HUD so only it is expanded. +//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper +//! white, expanding the world's highlights towards their peak brightness. The +//! world is copied aside before the hand and HUD so only it is expanded. -/// The swapchain format and colour space HDR output presents in. -pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; -pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear; +/// The swapchain HDR output presents to. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Output { + /// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal. + Pq, + /// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits. + ScRgb, +} -/// Whether `caps` can present scRGB. -pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool { - caps.color_spaces(FORMAT) - .contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR) +impl Output { + pub fn format(self) -> wgpu::TextureFormat { + match self { + Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm, + Self::ScRgb => wgpu::TextureFormat::Rgba16Float, + } + } + pub fn color_space(self) -> wgpu::SurfaceColorSpace { + match self { + Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq, + Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear, + } + } + fn space(self) -> wgpu::SurfaceColorSpaces { + match self { + Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ, + Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR, + } + } +} + +/// The HDR output `caps` can present: HDR10 where offered, else scRGB. +/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one. +pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option { + pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref()) +} +fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option { + [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")] + .into_iter() + .filter(|(_, name)| only.is_none_or(|o| o == *name)) + .map(|(output, _)| output) + .find(|o| caps.color_spaces(o.format()).contains(o.space())) } struct Targets { @@ -25,6 +58,7 @@ pub struct Hdr { layout: wgpu::BindGroupLayout, pipeline: wgpu::RenderPipeline, params: wgpu::Buffer, + pub output: Output, /// The frame's format: what every other pipeline draws in. format: wgpu::TextureFormat, targets: Option, @@ -36,7 +70,7 @@ pub struct Hdr { } impl Hdr { - pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Horizon HDR"), source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()), @@ -90,7 +124,7 @@ impl Hdr { entry_point: Some("fs_hdr"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { - format: FORMAT, + format: output.format(), blend: None, write_mask: wgpu::ColorWrites::ALL, })], @@ -100,7 +134,7 @@ impl Hdr { }); let params = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Horizon HDR parameters"), - size: 16, + size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -108,6 +142,7 @@ impl Hdr { layout, pipeline, params, + output, format, targets: None, debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"), @@ -198,6 +233,10 @@ impl Hdr { (self.peak / paper).max(1.0), f32::from(u8::from(world)), f32::from(u8::from(self.debug)), + f32::from(u8::from(self.output == Output::Pq)), + 0.0, + 0.0, + 0.0, ]; queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms)); let view = target.create_view(&Default::default()); @@ -220,3 +259,37 @@ impl Hdr { pass.draw(0..3, 0..1); } } + +#[cfg(test)] +mod tests { + use super::{Output, pick}; + use wgpu::{SurfaceColorSpaces as S, TextureFormat as F}; + + fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities { + wgpu::SurfaceCapabilities { + format_capabilities: offered + .iter() + .map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities { + format, + color_spaces, + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn hdr10_first_then_scrgb() { + let both = caps(&[ + (F::Bgra8UnormSrgb, S::SRGB), + (F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG), + (F::Rgba16Float, S::EXTENDED_SRGB_LINEAR), + ]); + assert_eq!(pick(&both, None), Some(Output::Pq)); + assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb)); + let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]); + assert_eq!(pick(&scrgb, None), Some(Output::ScRgb)); + assert_eq!(pick(&scrgb, Some("pq")), None); + assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None); + } +} diff --git a/crates/horizon-client/src/graphics.rs b/crates/horizon-client/src/graphics.rs index 4f6f04bc0..7a2f5e225 100644 --- a/crates/horizon-client/src/graphics.rs +++ b/crates/horizon-client/src/graphics.rs @@ -255,7 +255,7 @@ pub struct Graphics { pub resolution_scale: u32, /// FXAA over the world. pub fxaa: bool, - /// HDR output (scRGB), where the display path offers it. + /// HDR output (HDR10, or scRGB), where the display path offers it. pub hdr: bool, /// HDR paper white (where SDR white sits), in nits. pub hdr_paper_white: u32, diff --git a/crates/horizon-client/src/hdr.wgsl b/crates/horizon-client/src/hdr.wgsl index bf6c27cca..8f6c25d12 100644 --- a/crates/horizon-client/src/hdr.wgsl +++ b/crates/horizon-client/src/hdr.wgsl @@ -1,6 +1,6 @@ // Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma -// space, decoded to linear light at the paper white and written to the scRGB -// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the +// space, decoded to linear light at the paper white and written to the swapchain: +// HDR10 (BT.2020, PQ) or scRGB (1.0 = 80 nits). Bright pixels of light sources that still show the // world as it was before the hand and HUD are expanded towards the peak. @group(0) @binding(0) var frame: texture_2d; @group(0) @binding(1) var world: texture_2d; @@ -14,6 +14,8 @@ struct Params { world: f32, // 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1). debug: f32, + // 1 for an HDR10 swapchain, 0 for scRGB. + pq: f32, }; @group(0) @binding(2) var params: Params; @@ -40,6 +42,19 @@ fn shows_world(c: vec3, w: vec3) -> bool { return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03)); } +// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first. +const BT709_TO_BT2020 = mat3x3( + vec3(0.6274, 0.0691, 0.0164), + vec3(0.3293, 0.9195, 0.0880), + vec3(0.0433, 0.0114, 0.8956), +); + +// SMPTE ST 2084: nits (up to 10000) to the PQ signal. +fn pq(nits: vec3) -> vec3 { + let y = pow(clamp(nits / 10000.0, vec3(0.0), vec3(1.0)), vec3(0.1593017578125)); + return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375)); +} + @vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { let p = vec2(f32((i << 1u) & 2u), f32(i & 2u)); return vec4(p * 2.0 - 1.0, 0.0, 1.0); @@ -65,5 +80,9 @@ fn shows_world(c: vec3, w: vec3) -> bool { } } } - return vec4(light * params.paper, 1.0); + let scrgb = light * params.paper; + if params.pq > 0.5 { + return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0); + } + return vec4(scrgb, 1.0); } diff --git a/docs/graphics.md b/docs/graphics.md index dcba29773..79c969d90 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -29,7 +29,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi **Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are. -**HDR** (Display, off by default) presents to an scRGB swapchain (`Rgba16Float`, extended linear sRGB) on displays and compositors that offer it. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain. +**HDR** (Display, off by default) presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain. Which settings each screen shows, their descriptions and costs are in `crates/horizon-client/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them. From 49ad61035b285c0d24bca0cde1027fd3bf60b41e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:47:11 +0100 Subject: [PATCH 141/475] Count shield damage only for accepted hits and skip kills saved by a totem --- crates/horizon-client/src/game/combat.rs | 5 ++++- crates/horizon-client/src/game/lan_pvp.rs | 4 +++- crates/horizon-client/src/game/shield.rs | 7 ------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 197c77f17..fee887f97 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -74,11 +74,14 @@ impl Game<'_> { for e in events { let (name, amount) = match e { // LivingEntity.getDamageAfterMagicAbsorb (Resistance) and - // Player.actuallyHurt (absorption hearts), in tenths. + // Player.actuallyHurt (absorption hearts) and + // LivingEntity.hurtServer (the shield, only for an accepted + // hit), in tenths. MotionEvent::Health(HealthEvent::Statistic { name, amount }) => { match *name { "resisted" => self.stats.custom("damage_resisted", *amount), "absorbed" => self.stats.custom("damage_absorbed", *amount), + "blocked" => self.stats.custom("damage_blocked_by_shield", *amount), _ => {} } continue; diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index a2ff13444..276a33c83 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -256,10 +256,12 @@ impl Game<'_> { // ServerPlayer.awardKillScore: the blow that kills a player. if hurt { let died = match victim { + // A totem that fired is applied on the next tick_totem, so the + // host is still at zero health but is not dying. Victim::Host => self .player .as_ref() - .is_some_and(|p| p.health.dead_or_dying()), + .is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used), Victim::Guest(conn) => host .hub .guest(conn) diff --git a/crates/horizon-client/src/game/shield.rs b/crates/horizon-client/src/game/shield.rs index f4642f272..c4f7f98d1 100644 --- a/crates/horizon-client/src/game/shield.rs +++ b/crates/horizon-client/src/game/shield.rs @@ -121,13 +121,6 @@ impl Game<'_> { attacker: Option, projectile: bool, ) { - // LivingEntity.hurtServer: the damage the shield took, in tenths. - if outcome.blocked > 0.0 { - self.stats.custom( - "damage_blocked_by_shield", - (outcome.blocked * 10.0).round() as i32, - ); - } let Some((slot, _)) = self.using else { return }; let Some(stack) = self.stack_in(slot).cloned() else { return; From 36ef05b0b9470051b8a5e9e01ce0ba4811a30e0a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 16:51:22 +0100 Subject: [PATCH 142/475] Keep infinite mob effects through the effect tick --- crates/horizon-client/src/mobs/ai.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..68d5caee5 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -1455,25 +1455,31 @@ fn tick_effects(m: &mut Mob) { let mut effects = std::mem::take(&mut m.extra.effects); for (effect, ticks, amp) in &mut effects { let interval = |base: i32| (base >> (*amp).max(0)).max(1); + // MobEffectInstance.tickServer: an infinite (-1) effect times its + // ticks by the mob's tickCount and never counts down. + let infinite = *ticks == -1; + let count = if infinite { m.age as i32 } else { *ticks }; match *effect { "minecraft:poison" - if *ticks % interval(25) == 0 && !m.kind.flags.undead && m.health > 1.0 => + if count % interval(25) == 0 && !m.kind.flags.undead && m.health > 1.0 => { m.damage(1.0, Damage::of(DamageKind::Magic)); } - "minecraft:wither" if *ticks % interval(40) == 0 => { + "minecraft:wither" if count % interval(40) == 0 => { m.damage(1.0, Damage::of(DamageKind::Magic)); } "minecraft:regeneration" - if *ticks % interval(50) == 0 && !m.kind.flags.undead && m.alive() => + if count % interval(50) == 0 && !m.kind.flags.undead && m.alive() => { m.health = (m.health + 1.0).min(m.attrs.max_health); } _ => {} } - *ticks -= 1; + if !infinite { + *ticks -= 1; + } } - effects.retain(|e| e.1 > 0); + effects.retain(|e| e.1 > 0 || e.1 == -1); m.extra.effects = effects; } From 5d3e9d218dc9a74b712a91d0fe504b0ca532852d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 17:05:35 +0100 Subject: [PATCH 143/475] Clean copper for a skeleton trap's visual lightning too --- crates/horizon-client/src/game/lightning.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 55e867f87..7daab2ef8 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -221,6 +221,12 @@ impl Game<'_> { if !b.visual_only { self.bolt_fire(b.at, 4); self.strike_rod(b.at); + } + // LightningBolt.tick runs clearCopperOnLightningStrike for + // every server bolt, a skeleton trap's visual one included; + // a client's copy of a server bolt (spawn_remote_bolt) only + // flashes. + if self.remote.is_none() { self.bolt_clear_copper(b.at); } } From 96984300d380c7dffba31cdc991bde8e332502f3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:07:20 +0100 Subject: [PATCH 144/475] mods: the UI guest's level is 3 now that world is level 4 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mods/tests/sandbox.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 1d4297ba3..a5e95ddfa 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -255,8 +255,8 @@ experimental = ["ui", "render", "input"] needed(imports(&wasm).unwrap().iter().map(String::as_str)).0 }; assert_eq!(level("coords_hud.hmod"), 0); - // The UI guest shows a minimap (`render.set-minimap`). - assert_eq!(level("horizon_mods_test_ui_guest.hmod"), LEVEL); + // The UI guest runs quiet commands (`command.run-quiet`, level 3). + assert_eq!(level("horizon_mods_test_ui_guest.hmod"), 3); } #[test] From ba3b567f54ef63c6c3d00d93b9a81c04e80a411e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 17:15:29 +0100 Subject: [PATCH 145/475] Predict a full beehive reacting to shears and glass bottles on the client --- crates/horizon-client/src/game/remote.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 857f955da..b0a466d9d 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4084,6 +4084,16 @@ impl Game<'_> { if self.keys.contains(&KeyCode::ShiftLeft) && hands_full { return (false, DryUse::default()); } + // BeehiveBlock.useItemOn: shears or a glass bottle on a full hive + // (honey level 5) is SUCCESS, so the click ends after UseItemOn and + // no UseItem follows (the server does the harvest, see `use_beehive`). + if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle")) + && self + .hive_state(hit.pos) + .is_some_and(|(honey, _)| honey == Some(5)) + { + return (true, DryUse::default()); + } use horizon_core::geometry::Direction; let face = match hit.face { [0, 1, 0] => Direction::Up, From b62095dfcb58d5bd3b77bd40b1cdea70e4766de4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 17:25:37 +0100 Subject: [PATCH 146/475] Run the trident player_hurt_entity trigger as the thrower --- crates/horizon-client/src/game/trident.rs | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index 04a1be415..74525e69a 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -30,6 +30,8 @@ pub(super) struct ThrownTrident { pub(super) no_physics: bool, returning: u32, creative_only: bool, + /// The guest that threw it (`Projectile.getOwner`); `None` for the host. + guest: Option, } impl ThrownTrident { /// A trident a server sent (`handleAddEntity`): placed and moved by it. @@ -49,6 +51,7 @@ impl ThrownTrident { no_physics: false, returning: 0, creative_only: false, + guest: None, } } /// One tick of a server's client: the previous position, then `step`. @@ -240,11 +243,33 @@ impl Game<'_> { no_physics: false, returning: 0, creative_only: self.creative, + guest: self.lan_acting, }); self.player_projectile_shot(); self.audio .play("minecraft:item.trident.throw", at, 1.0, 1.0); } + /// The thrower's `PLAYER_HURT_ENTITY` for a trident hit: run as the + /// guest that threw it, and not at all once they have left. + fn thrower_hit( + &mut self, + guest: Option, + victim: &horizon_client::advancements::EntityView, + direct: horizon_client::advancements::EntityView, + damage: f32, + ) { + let Some(conn) = guest.filter(|c| self.lan_acting != Some(*c)) else { + self.player_hit(victim, "minecraft:trident", Some(direct), damage); + return; + }; + let Some(mut host) = self.lan.take() else { + return; + }; + self.as_guest(&mut host, conn, |g| { + g.player_hit(victim, "minecraft:trident", Some(direct), damage) + }); + self.lan = Some(host); + } pub(super) fn tick_tridents(&mut self) { let Some(eye) = self.eye_position() else { return; @@ -331,7 +356,7 @@ impl Game<'_> { position: [t.position.x, t.position.y, t.position.z], ..Default::default() }; - self.player_hit(&victim, "minecraft:trident", Some(direct), damage); + self.thrower_hit(t.guest, &victim, direct, damage); } self.audio.play( "minecraft:item.trident.hit", From e50eb7c71f5e5e7e7b27e2d8047f136e90f0ddc7 Mon Sep 17 00:00:00 2001 From: Carlos Nunez Date: Mon, 26 Oct 2026 12:36:46 -0400 Subject: [PATCH 147/475] Count leave_game for every player who leaves, owner included Vanilla's PlayerList.remove awards Stats.LEAVE_GAME to a player before saving them, and PlayerList.removeAll does it for everyone when the server stops. Horizon only counted it in the in-frame path, so on the default server thread the owner never got it, and guests who left or were removed at shutdown did not either. LanHost::award_leave_game counts it once per guest (a guest is saved more than once on the way out) and runs where a guest leaves, when the integrated server stops, and when LAN closes. Part of #2095 (item 4 of the report). The signal handling and the remote-world disconnect are #2116's. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/integrated.rs | 2 ++ crates/horizon-client/src/game/lan.rs | 31 +++++++++++++++++++ crates/horizon-client/src/game/lan_actions.rs | 3 ++ 3 files changed, 36 insertions(+) diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index dd1c04e23..6758c7c89 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -408,6 +408,8 @@ impl Game<'_> { s.flags.published.store(0, Ordering::Relaxed); } if let Some(mut host) = self.lan.take() { + // PlayerList.removeAll: the owner and every guest leave the game. + host.award_leave_game_to_all(); for g in &host.hub.guests { self.save_guest(&host, g); } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 9f468db9f..9226240bd 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -78,6 +78,33 @@ pub(super) struct LanHost { states: lan::entity::States, } +impl LanHost { + /// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the + /// player is saved. Once per guest, however often they are saved. + pub(super) fn award_leave_game(&mut self, conn: u32) { + if let Some(play) = self.play.get_mut(&conn) + && !play.left_game + { + play.left_game = true; + play.stats.custom("leave_game", 1); + } + } + + /// `PlayerList.removeAll`: every player who joined leaves with the world. + pub(super) fn award_leave_game_to_all(&mut self) { + let conns: Vec = self + .hub + .guests + .iter() + .filter(|g| g.placed) + .map(|g| g.conn) + .collect(); + for conn in conns { + self.award_leave_game(conn); + } + } +} + /// The player who owns an integrated server's world: a guest whose state is /// the world's own player (`players/data/.dat`), as in /// single player. @@ -634,6 +661,7 @@ impl Game<'_> { let Some(mut host) = self.lan.take() else { return; }; + host.award_leave_game_to_all(); for g in &host.hub.guests { self.save_guest(&host, g); } @@ -1016,6 +1044,9 @@ impl Game<'_> { } } self.lan_crafting_left(&mut host, &mut guest); + if guest.placed { + host.award_leave_game(guest.conn); + } self.save_guest(&host, &guest); host.models.remove(&guest.conn); host.play.remove(&guest.conn); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index e1a13881c..dffb0177e 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -74,6 +74,9 @@ pub(super) struct GuestPlay { potions: super::potions::BodyPotions, /// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw. pub(super) stats: super::stats::Stats, + /// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the + /// way out, the statistic is awarded once (`LanHost::award_leave_game`). + pub(super) left_game: bool, last_inventory: u64, /// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs). pub(super) portal: horizon_client::nether_portal::PortalState, From 2a98f584dfaf78561ffde4643b4e20fc8fe1d9a7 Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Mon, 26 Oct 2026 22:40:12 +0600 Subject: [PATCH 148/475] Send cushion breaking particles to nearby multiplayer clients Co-Authored-By: Codex --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/cushions.rs | 24 +++--- crates/horizon-client/src/game/lan.rs | 29 ++++++++ tests/test_cushion_particles.py | 87 ++++++++++++++++++++++ tools/lan_e2e.py | 6 ++ 5 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 tests/test_cushion_particles.py diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 755a00f6f..f05be393a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1584,6 +1584,7 @@ struct Game<'a> { guest_use: Option, /// Level events of this tick for the guests, and the guest acting now. lan_events: Vec<(i32, [i32; 3], i32, Option)>, + lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>, /// Block events (position, b0, b1, state) and entity events (id, event, /// position) for the guests. lan_block_events: Vec<([i32; 3], i32, i32, u32)>, @@ -12477,6 +12478,7 @@ impl<'a> Game<'a> { forced_hit: None, guest_use: None, lan_events: Vec::new(), + lan_cushion_breaks: Vec::new(), lan_block_events: Vec::new(), lan_entity_events: Vec::new(), lan_critical_hits: Vec::new(), diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs index 0847321d6..30af72104 100644 --- a/crates/horizon-client/src/game/cushions.rs +++ b/crates/horizon-client/src/game/cushions.rs @@ -348,11 +348,15 @@ impl Game<'_> { let Some(state) = self.pack.assets.registry.default_state(&name) else { return; }; - let at = [ - c.position.x.floor() as i32, - c.position.y.floor() as i32, - c.position.z.floor() as i32, - ]; + let p = c.position; + // Entity.getY(2/3) is relative to the cushion's quarter-block height. + let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z]; + // Guest actions temporarily take self.lan; flush once the hub is back. + // Queue before resolving a sprite: a dedicated server has no renderer. + if self.audio.capture.is_some() { + self.lan_cushion_breaks.push((state, particle_at)); + } + let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]; let Some((sprite, tint)) = self.particle_sprite(state, at) else { return; }; @@ -361,14 +365,8 @@ impl Game<'_> { sprite, tint, ); - let p = c.position; - self.particles.send_particles( - &options, - [p.x, p.y + 0.666_666_7, p.z], - [0.25, 0.0625, 0.25], - 0.05, - 10, - ); + self.particles + .send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10); } /// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100. diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 63aea7e9f..75500ba79 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3; use horizon_protocol::{ crypto::offline_uuid, entity_data::{Value, serializer}, + packets::play::{self, Clientbound as C}, }; use std::{ collections::HashMap, @@ -665,6 +666,7 @@ impl Game<'_> { self.audio.capture = None; self.lan_events.clear(); self.lan_critical_hits.clear(); + self.lan_cushion_breaks.clear(); self.streaming.extra.clear(); if announce { self.system_message(Text::translate( @@ -1229,6 +1231,33 @@ impl Game<'_> { /// Send the tick's level events, sounds and mob animations. fn lan_flush_effects(&mut self, host: &mut LanHost) { let critical_hits = std::mem::take(&mut self.lan_critical_hits); + for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) { + if let Some(id) = host + .hub + .registry_id("minecraft:particle_type", "minecraft:block") + { + host.hub.broadcast_near( + at, + 32.0, + &C::LevelParticles(play::LevelParticles { + particle: horizon_protocol::entity_data::Particle { + id, + data: Some(horizon_protocol::entity_data::ParticleData::State( + state.0 as i32, + )), + }, + override_limiter: false, + always_show: false, + pos: at, + dist: [0.25, 0.0625, 0.25], + max_speed: [0.05; 3], + count: 10, + randomization: 0, + }), + None, + ); + } + } for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) { if kind == 2001 { host.broken diff --git a/tests/test_cushion_particles.py b/tests/test_cushion_particles.py new file mode 100644 index 000000000..adfbf2e00 --- /dev/null +++ b/tests/test_cushion_particles.py @@ -0,0 +1,87 @@ +"""Cushion particles must reach clients even when the server has no sprite atlas.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(shutil.which("rustc"), "requires Rust") +class CushionParticlesTests(unittest.TestCase): + def test_headless_server_records_break_effect_before_sprite_lookup(self): + source = (ROOT / "crates/horizon-client/src/game/cushions.rs").read_text() + start = source.index(" fn cushion_break_particles(") + end = source.index(" /// `BlockAttachedEntity.tick`", start) + fixture = r''' +#![allow(dead_code)] +#[derive(Clone, Copy)] +struct Vec3 { x: f64, y: f64, z: f64 } +struct Cushion { position: Vec3 } +impl Cushion { fn color_name(&self) -> &str { "blue" } } +struct Registry; +impl Registry { + fn default_state(&self, name: &str) -> Option { + assert_eq!(name, "minecraft:blue_wool"); Some(123) + } +} +struct Assets { registry: Registry } +struct Pack { assets: Assets } +struct Audio { capture: Option<()> } +mod horizon_client { pub mod particles { + pub enum ParticleType { Block } + pub struct ParticleOptions; + impl ParticleOptions { + pub fn block(_: ParticleType, _: String, _: u32) -> Self { Self } + } +} } +#[derive(Default)] +struct Particles { count: u32 } +impl Particles { + fn send_particles(&mut self, _: &horizon_client::particles::ParticleOptions, + at: [f64; 3], spread: [f64; 3], speed: f64, count: u32) { + assert!((at[1] - (10.0 + 1.0/6.0)).abs() < 1e-12); + assert_eq!(spread, [0.25, 0.0625, 0.25]); + assert_eq!(speed, 0.05); assert_eq!(count, 10); self.count += count; + } +} +struct Game { + pack: Pack, audio: Audio, particles: Particles, headless: bool, + lan_cushion_breaks: Vec<(u32, [f64; 3])>, +} +impl Game { + fn particle_sprite(&self, _: u32, _: [i32; 3]) -> Option<(String, u32)> { + (!self.headless).then(|| ("block/blue_wool".into(), 0xffffff)) + } +METHOD +} +fn main() { + let cushion = Cushion { position: Vec3 { x: 1.5, y: 10.0, z: 2.5 } }; + for headless in [true, false] { + for serving in [true, false] { + let mut game = Game { + pack: Pack { assets: Assets { registry: Registry } }, + audio: Audio { capture: serving.then_some(()) }, + particles: Particles::default(), headless, lan_cushion_breaks: vec![], + }; + game.cushion_break_particles(&cushion); + assert_eq!(game.lan_cushion_breaks.len(), usize::from(serving)); + if serving { + let (state, at) = game.lan_cushion_breaks[0]; + assert_eq!(state, 123); + assert_eq!([at[0], at[2]], [1.5, 2.5]); + assert!((at[1] - (10.0 + 1.0/6.0)).abs() < 1e-12); + } + assert_eq!(game.particles.count, if headless { 0 } else { 10 }); + } + } +} +'''.replace("METHOD", source[start:end]) + with tempfile.TemporaryDirectory(prefix="cushion-particles-") as directory: + path = Path(directory) + (path / "main.rs").write_text(fixture) + subprocess.run(["rustc", "--edition=2024", str(path / "main.rs"), "-o", str(path / "probe")], + check=True, capture_output=True, text=True) + result = subprocess.run([str(path / "probe")], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index c508a67d6..6847f8322 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -7235,9 +7235,15 @@ def check_cushions(host, guest, out): assert player('status').get('riding') is None, seen player('command', command='gamemode creative') player.raw('look-at', at[0], at[1] + .125, at[2]) + before = {g.name: g('status').get('particles', {}).get('block', 0) for g in (host, guest)} player.raw('mouse', 'left', 'click') player.wait(10) assert not cushions(host) and not cushions(player), seen + seen[label]['break_particles'] = { + g.name: g('status').get('particles', {}).get('block', 0) - before[g.name] + for g in (host, guest) + } + assert all(n == 10 for n in seen[label]['break_particles'].values()), seen return seen From aa839fa99a28e2649d31d807bc032942a4ec739e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:39:02 +0100 Subject: [PATCH 149/475] mods: browser, a recipe viewer's item panel (API level 5) A new interface under capability ui. browser.set-items lists the items of a panel the game shows right of the inventory and container screens (an empty list hides it); browser.poll returns what the player did since the last poll: a click on an item, the search text, or one of the mod's own keys pressed over an item (a slot's or the panel's), with that stack. The slot-clicked, action, page and back events are there now for the recipe pages to come, since adding variant cases later would break mods. At most 8192 items and 256 waiting events. The game side is the next PR. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/src/lib.rs | 4 +- crates/horizon-mod-sdk/wit/horizon.wit | 56 ++++++++++++ crates/horizon-mods/src/api_level.rs | 6 +- crates/horizon-mods/src/manifest.rs | 4 + crates/horizon-mods/src/runtime.rs | 87 ++++++++++++++++++- crates/horizon-mods/tests/sandbox.rs | 65 ++++++++++++++ crates/horizon-mods/tests/ui_guest/src/lib.rs | 58 ++++++++++++- docs/api/mods-reference.md | 66 +++++++++++++- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 10 files changed, 340 insertions(+), 10 deletions(-) diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index b00d06ecf..c87c2dbc4 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -39,7 +39,9 @@ wit_bindgen::generate!({ pub use exports::horizon::mods::events::{Guest as __Guest, Registration}; pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest; -pub use horizon::mods::{chat, command, game, hud, input, log, recipes, render, storage, ui}; +pub use horizon::mods::{ + browser, chat, command, game, hud, input, log, recipes, render, storage, ui, +}; /// The game's calls into a mod. Only the hooks [`Registration`] asks for /// are called; the others may stay as their defaults. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 7d7adaa0d..ab20648d8 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -509,6 +509,60 @@ interface recipes { known: func() -> list; } +/// Capability `ui` (experimental): a recipe viewer's panel beside the +/// game's inventory and container screens (not the creative inventory), on +/// the right of the screen. The game draws it with its own widgets and +/// slots, pages it and keeps its search box; the mod says what it holds. +/// The first loaded mod with a panel shows. +interface browser { + /// An item to show: its id, a count (1 draws none) and its components + /// beyond the item's own, in vanilla's item syntax, or "". + record stack { + item: string, + count: u32, + components: string, + } + /// A click on an item of the panel, by its index in `set-items`'s list. + record click { + index: u32, + right: bool, + } + /// A key the mod registered (`input.register-key`) went down while the + /// pointer was on an item: a slot of the screen's, or the panel's. + record item-key { + key: string, + stack: stack, + } + /// A click on a slot of the recipe the panel shows (`set-recipes`). + record slot-click { + /// The recipe's index on the page and the slot's in it; the + /// results come after the slots. + recipe: u32, + slot: u32, + /// The stack the slot showed at that moment. + stack: stack, + right: bool, + } + variant event { + clicked(click), + /// The search box's text, each time the player changes it. + search(string), + key(item-key), + slot-clicked(slot-click), + /// The action button of the recipe at this index on the page. + action(u32), + /// The page arrows of `set-recipes`: -1 or 1. + page(s32), + /// The back button of `set-recipes`. + back, + } + /// The panel's items, at most 8192 (an empty list hides the panel). + set-items: func(items: list); + /// What the player did on the panel since the last poll, at most 256 + /// kept. + poll: func() -> list; +} + /// Capability `players`, server mods only (manifest `api = "0.3"`, /// experimental): the players on the server, and messages to them. interface players { @@ -616,6 +670,7 @@ world horizon-ui-mod { import render; import input; import recipes; + import browser; export ui-events; } @@ -634,4 +689,5 @@ world render-host { /// The game's side of capability `recipes` (imports only). world recipes-host { import recipes; + import browser; } diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index ff06a94ae..2474dfddd 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -7,13 +7,13 @@ //! version's level so the launcher can tell before it installs. /// The newest level this Horizon serves. -pub const LEVEL: u32 = 4; +pub const LEVEL: u32 = 5; /// Every function the game offers mods, as `interface.function`, with its /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 37] = [ +pub const FUNCTIONS: [(&str, u32); 39] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -51,6 +51,8 @@ pub const FUNCTIONS: [(&str, u32); 37] = [ ("recipes.items", 4), ("recipes.all", 4), ("recipes.known", 4), + ("browser.set-items", 5), + ("browser.poll", 5), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 05cf96447..4c12fd805 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -173,6 +173,10 @@ impl Capability { if interface == "log" { return Ok(None); } + // A recipe viewer's panel is part of capability `ui`. + if interface == "browser" { + return Ok(Some(Capability::Ui)); + } Capability::ALL .into_iter() .filter(|c| *c != Capability::Registries) diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index ebce03944..0f2de0246 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -85,11 +85,16 @@ mod recipes_bindings { world: "recipes-host", }); } +/// Capability `ui`'s recipe viewer panel (WIT `interface browser`). +pub use recipes_bindings::horizon::mods::browser::{ + Click as BrowserClick, Event as BrowserEvent, ItemKey as BrowserItemKey, + SlotClick as BrowserSlotClick, Stack as BrowserStack, +}; /// What capability `recipes` reads (WIT `interface recipes`). pub use recipes_bindings::horizon::mods::recipes::{ Item as RecipeItem, Kind as RecipeKind, Recipe, Stack as RecipeStack, }; -use recipes_bindings::{RecipesHost, horizon::mods::recipes}; +use recipes_bindings::{RecipesHost, horizon::mods::browser, horizon::mods::recipes}; /// A mod's linear memory cap. const MEMORY: usize = 64 << 20; @@ -309,6 +314,28 @@ struct UiState { close: bool, } +/// Capability `ui`'s panel beside container screens (`browser`). +#[derive(Default)] +struct BrowserState { + items: Vec, + /// Which `set-items` call the items are from, unique across mods and + /// reloads, so the game converts them once. + version: u64, + events: std::collections::VecDeque, +} +static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// Items a panel may hold, and events kept for `browser.poll`. +const BROWSER_ITEMS: usize = 8192; +const BROWSER_EVENTS: usize = 256; +impl BrowserState { + fn push(&mut self, event: BrowserEvent) { + if self.events.len() == BROWSER_EVENTS { + self.events.pop_front(); + } + self.events.push_back(event); + } +} + struct State { name: String, id: String, @@ -325,6 +352,7 @@ struct State { /// Capability `render`'s minimap, when the mod shows one. minimap: Option, input: InputState, + browser: BrowserState, /// Capability `storage`'s folder; made on first `set`. data_dir: PathBuf, /// Bytes `storage::set` may still write before this hook call ends @@ -490,6 +518,7 @@ impl Mods { zoom: None, minimap: None, input: InputState::default(), + browser: BrowserState::default(), data_dir, storage_budget: STORAGE_WRITE_BUDGET, limits: StoreLimitsBuilder::new() @@ -799,6 +828,40 @@ impl Mods { .iter() .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) } + /// The panel beside container screens (`browser.set-items`): the first + /// running mod with items, its index, the items' version and the items. + pub fn browser_items(&self) -> Option<(usize, u64, &[BrowserStack])> { + self.entries.iter().enumerate().find_map(|(i, e)| { + let b = &e.run.as_ref()?.store.data().browser; + (!b.items.is_empty()).then_some((i, b.version, b.items.as_slice())) + }) + } + /// The player did `event` on mod `owner`'s panel. + pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) { + if let Some(run) = self.entries.get_mut(owner).and_then(|e| e.run.as_mut()) { + run.store.data_mut().browser.push(event); + } + } + /// The key `key` went down with the pointer on `stack` in a container + /// screen: each mod with a key on it hears its own name for it. + pub fn browser_key(&mut self, key: &str, stack: &BrowserStack) { + for run in self.entries.iter_mut().filter_map(|e| e.run.as_mut()) { + let data = run.store.data_mut(); + let names: Vec = data + .input + .keys + .iter() + .filter(|k| k.key == key) + .map(|k| k.name.clone()) + .collect(); + for key in names { + data.browser.push(BrowserEvent::Key(BrowserItemKey { + key, + stack: stack.clone(), + })); + } + } + } /// Some running mod reads capability `recipes`: the snapshot needs /// [`Snapshot::recipes`] and [`Snapshot::known_recipes`]. pub fn wants_recipes(&self) -> bool { @@ -1101,7 +1164,10 @@ fn reason(e: &wasmtime::Error) -> String { } fn clip(text: String) -> String { - match text.char_indices().nth(LINE_CHARS) { + clip_to(text, LINE_CHARS) +} +fn clip_to(text: String, chars: usize) -> String { + match text.char_indices().nth(chars) { Some((at, _)) => text[..at].to_string(), None => text, } @@ -1162,6 +1228,23 @@ impl game::Host for State { (*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone()) } } +impl browser::Host for State { + fn set_items(&mut self, items: Vec) { + self.browser.items = items + .into_iter() + .take(BROWSER_ITEMS) + .map(|s| BrowserStack { + item: clip(s.item), + components: clip_to(s.components, 4096), + ..s + }) + .collect(); + self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + fn poll(&mut self) -> Vec { + self.browser.events.drain(..).collect() + } +} impl recipes::Host for State { fn items(&mut self) -> Vec { self.snapshot diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 06a8e6aa2..576dc5942 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1098,3 +1098,68 @@ fn recipes_reads_the_tables() { ["0 items, 0 recipes, 0 known, 0 brewing"] ); } + +#[test] +fn browser_lists_items_and_hears_the_player() { + use horizon_mods::{BrowserClick, BrowserEvent, BrowserStack}; + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + let said = |mods: &mut Mods, command: &str| { + assert!(mods.command(command)); + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::Chat { text, .. } => Some(text), + _ => None, + }) + .collect::>() + }; + assert!(mods.browser_items().is_none()); + let potion = "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]"; + assert_eq!( + said( + &mut mods, + &format!("browser minecraft:stone minecraft:potion{potion}") + ), + ["panel of 2"] + ); + let (owner, version, items) = mods.browser_items().unwrap(); + assert_eq!( + (items[0].item.as_str(), items[1].components.as_str()), + ("minecraft:stone", potion) + ); + mods.browser_event( + owner, + BrowserEvent::Clicked(BrowserClick { + index: 1, + right: true, + }), + ); + mods.browser_event(owner, BrowserEvent::Search("oak".into())); + let log = BrowserStack { + item: "minecraft:oak_log".into(), + count: 3, + components: String::new(), + }; + // R is the guest's `recipe`; X is none of its keys. + mods.browser_key("key.keyboard.r", &log); + mods.browser_key("key.keyboard.x", &log); + assert_eq!( + said(&mut mods, "browser_poll"), + [ + "3 events", + "clicked 1 right", + "search \"oak\"", + "key recipe on minecraft:oak_log x3" + ] + ); + // Each `set-items` is a new version; an empty list hides the panel. + said(&mut mods, "browser minecraft:dirt"); + assert_ne!(mods.browser_items().unwrap().1, version); + assert_eq!(said(&mut mods, "browser"), ["panel of 0"]); + assert!(mods.browser_items().is_none()); + // At most 256 events wait for a poll: the oldest go. + for _ in 0..300 { + mods.browser_event(owner, BrowserEvent::Page(1)); + } + assert_eq!(said(&mut mods, "browser_poll")[0], "256 events"); +} diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 2fd835d3c..4fc2ee2c4 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -8,8 +8,11 @@ //! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs //! lines with `command.run-quiet` (`/quiet_many N`: N of them). `/recipes` //! counts what `recipes` reads; `/recipes ID` describes that recipe. +//! `/browser ID...` fills `browser`'s panel (`/browser N`: the first N +//! items, `/browser` alone empties it) and registers the keys `recipe` (R) +//! and `uses` (U); `/browser_poll` says what the player did on it. use horizon_mod_sdk::{ - Mod, Registration, chat, command, game, + Mod, Registration, browser, chat, command, game, input::{self, Event}, recipes, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, @@ -105,6 +108,8 @@ impl Mod for Guest { "quiet", "quiet_many", "recipes", + "browser", + "browser_poll", "corner", "readings", ] @@ -279,6 +284,57 @@ impl Mod for Guest { ), }); } + "browser" => { + let stack = |item: &str| { + let (item, components) = match item.find('[') { + Some(at) => (&item[..at], &item[at..]), + None => (item, ""), + }; + browser::Stack { + item: item.into(), + count: 1, + components: components.into(), + } + }; + let items: Vec = match args.parse::() { + Ok(n) => recipes::items() + .iter() + .skip(1) + .take(n) + .map(|i| stack(&i.id)) + .collect(), + Err(_) => args.split_whitespace().map(stack).collect(), + }; + browser::set_items(&items); + input::register_key("recipe", "Show recipes", "key.keyboard.r")?; + input::register_key("uses", "Show uses", "key.keyboard.u")?; + chat::show(&format!("panel of {}", items.len())); + } + "browser_poll" => { + let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components); + // A hook shows at most 8 chat lines: the count, then the first 7. + let events = browser::poll(); + chat::show(&format!("{} events", events.len())); + for event in events.into_iter().take(7) { + chat::show(&match event { + browser::Event::Clicked(c) => { + format!( + "clicked {} {}", + c.index, + if c.right { "right" } else { "left" } + ) + } + browser::Event::Search(text) => format!("search {text:?}"), + browser::Event::Key(k) => format!("key {} on {}", k.key, stack(&k.stack)), + browser::Event::SlotClicked(c) => { + format!("slot {} {} {}", c.recipe, c.slot, stack(&c.stack)) + } + browser::Event::Action(i) => format!("action {i}"), + browser::Event::Page(step) => format!("page {step}"), + browser::Event::Back => "back".into(), + }); + } + } // Clamped to (1, 10); the two after it shake nothing. "render_shake" => { render::shake(2.0, 99.0); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 1ee54e70f..111fefd5c 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -37,7 +37,7 @@ A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs whe A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets. -- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `log`, `game`, `chat`, `hud`, `storage`, `command` +- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `browser`, `log`, `game`, `chat`, `hud`, `storage`, `command` - Exports (what the mod provides): `ui-events`, `events` ## World `ui-host` @@ -58,7 +58,7 @@ The game's side of capability `render` (imports only). The game's side of capability `recipes` (imports only). -- Imports (what the game offers): `recipes` +- Imports (what the game offers): `recipes`, `browser` - Exports (what the mod provides): ## `log` @@ -531,6 +531,68 @@ The station a recipe is made at. | `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | | `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | +## `browser` + +Access: not granted by any capability. + +Capability `ui` (experimental): a recipe viewer's panel beside the game's inventory and container screens (not the creative inventory), on the right of the screen. The game draws it with its own widgets and slots, pages it and keeps its search box; the mod says what it holds. The first loaded mod with a panel shows. + +### record `stack` + +An item to show: its id, a count (1 draws none) and its components beyond the item's own, in vanilla's item syntax, or "". + +| Field | Type | | +| --- | --- | --- | +| `item` | `string` | | +| `count` | `u32` | | +| `components` | `string` | | + +### record `click` + +A click on an item of the panel, by its index in `set-items`'s list. + +| Field | Type | | +| --- | --- | --- | +| `index` | `u32` | | +| `right` | `bool` | | + +### record `item-key` + +A key the mod registered (`input.register-key`) went down while the pointer was on an item: a slot of the screen's, or the panel's. + +| Field | Type | | +| --- | --- | --- | +| `key` | `string` | | +| `stack` | `stack` | | + +### record `slot-click` + +A click on a slot of the recipe the panel shows (`set-recipes`). + +| Field | Type | | +| --- | --- | --- | +| `recipe` | `u32` | The recipe's index on the page and the slot's in it; the results come after the slots. | +| `slot` | `u32` | | +| `stack` | `stack` | The stack the slot showed at that moment. | +| `right` | `bool` | | + +### variant `event` + +| Case | Payload | | +| --- | --- | --- | +| `clicked` | `click` | | +| `search` | `string` | The search box's text, each time the player changes it. | +| `key` | `item-key` | | +| `slot-clicked` | `slot-click` | | +| `action` | `u32` | The action button of the recipe at this index on the page. | +| `page` | `s32` | The page arrows of `set-recipes`: -1 or 1. | +| `back` | | The back button of `set-recipes`. | + +| Function | Signature | What it does | +| --- | --- | --- | +| `set-items` | `func(items: list)` | The panel's items, at most 8192 (an empty list hides the panel). | +| `poll` | `func() -> list` | What the player did on the panel since the last poll, at most 256 kept. | + ## `players` Access: capability `players`. diff --git a/docs/api/stability.md b/docs/api/stability.md index 00b5541a9..bb0ddf501 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`). A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`). A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index 73ecfb9a5..9aed4897b 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -88,7 +88,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | | `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | | `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | From 487a461a8a7454871aaf92e11466cadc9504699b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:39:12 +0100 Subject: [PATCH 150/475] game: the item panel of a mod's browser beside container screens The panel sits right of the inventory or container window, from the top of the screen to a search box at its foot, in up to 12 columns of the game's own item slots, with the recipe book's page arrows. It shows on the survival inventory and every container screen (not the creative one) while a running mod has items in it, and hides when there is no room for two columns. Clicks on it never throw the carried stack; the carried stack and tooltips are drawn again above it. The mod hears clicks on items, the search text as it changes, and its own keys pressed with the pointer on an item, the panel's or a slot's. The search box takes every key but Escape while focused, as the recipe book's. Each set-items is turned into stacks once. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 56 ++- crates/horizon-client/src/game/control.rs | 3 + crates/horizon-client/src/game/mod_browser.rs | 440 ++++++++++++++++++ crates/horizon-client/src/game/mod_input.rs | 12 + crates/horizon-client/src/game/mod_recipes.rs | 2 +- crates/horizon-client/src/game/mods.rs | 3 + 6 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_browser.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 826ea73c1..6779d367a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -99,6 +99,7 @@ mod maps; mod minecarts; mod mob_blocks; mod mob_gear; +mod mod_browser; mod mod_input; #[cfg(feature = "mods")] mod mod_recipes; @@ -5217,6 +5218,7 @@ impl Game<'_> { Screen::CommandBlock => self.draw_command_block(&mut batch), Screen::Inventory => { self.draw_inventory(&mut batch); + self.draw_mod_browser(&mut batch); } Screen::Creative => { self.draw_creative(&mut batch); @@ -5595,6 +5597,7 @@ impl Game<'_> { } } pub(super) fn close_container(&mut self) { + self.mod_browser_closed(); self.close_station(); self.mount_closed(); self.recipe_ui.reset_screen(); @@ -6319,6 +6322,9 @@ impl Game<'_> { fn inventory_press(&mut self, right: bool) { use horizon_client::container_screen::Target; use horizon_client::menu::Input; + if self.mod_browser_press(right) { + return; + } if !right && self.sort_press() { return; } @@ -6495,24 +6501,8 @@ impl Game<'_> { } /// `hasClickedOutside` of the open screen at the cursor. fn clicked_outside(&self, batch: &HudBatch) -> bool { - use horizon_client::container_screen::{Book, Image, clicked_outside}; - let (ox, oy) = self.inventory_origin(batch); - let (shift, width, height) = if self.screen == Screen::Creative { - (0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32) - } else { - let (shift, width) = match self.station.as_ref().map(|s| s.kind) { - Some(stations::Kind::Merchant) => (-50.0, 276), - Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), - _ => (0.0, 176), - }; - (shift, width, self.screen_height() as i32) - }; - let image = Image { - left: (ox + shift) as i32, - top: oy as i32, - width, - height, - }; + use horizon_client::container_screen::{Book, clicked_outside}; + let image = self.container_image(batch); let book = self .recipe_book_area(batch) .map(|(visible, tab_hovered)| Book { @@ -6526,6 +6516,26 @@ impl Game<'_> { book, ) } + /// The open container screen's window: its place and size. + fn container_image(&self, batch: &HudBatch) -> horizon_client::container_screen::Image { + let (ox, oy) = self.inventory_origin(batch); + let (shift, width, height) = if self.screen == Screen::Creative { + (0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32) + } else { + let (shift, width) = match self.station.as_ref().map(|s| s.kind) { + Some(stations::Kind::Merchant) => (-50.0, 276), + Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), + _ => (0.0, 176), + }; + (shift, width, self.screen_height() as i32) + }; + horizon_client::container_screen::Image { + left: (ox + shift) as i32, + top: oy as i32, + width, + height, + } + } /// A click outside the window (slot id -999): `THROW` does nothing; a /// `PICKUP` drops the carried stack (left) or one of it (right), thrown /// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`). @@ -6686,7 +6696,7 @@ impl Game<'_> { } } fn click_inventory(&mut self, right: bool) { - if self.mount_click(right) { + if self.mod_browser_press(right) || self.mount_click(right) { return; } if self.recipe_book_click(right) { @@ -11645,6 +11655,14 @@ impl ApplicationHandler for Game<'_> { // The recipe book's search box takes every key but Escape. self.recipe_key(&event); } + WindowEvent::KeyboardInput { event, .. } + if self.mod_browser_typing() + && event.state == ElementState::Pressed + && !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) => + { + // A mod's item panel's search box, as the recipe book's. + self.mod_browser_key(&event); + } WindowEvent::KeyboardInput { event, .. } if self.station_typing() && event.state == ElementState::Pressed diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 3202b23f4..191fa8359 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -628,6 +628,9 @@ impl Game<'_> { // The port, or the game rules' search and number boxes. Screen::WorldOptions if text == "\\b" => self.world_options_text(None), Screen::WorldOptions => self.world_options_text(Some(text)), + Screen::Inventory if self.mod_browser_typing() => { + self.mod_browser_text((text != "\\b").then_some(text), text == "\\b") + } Screen::Inventory if self.recipe_search_focused() => { self.recipe_text(Some(text), false) } diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs new file mode 100644 index 000000000..2f317792f --- /dev/null +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -0,0 +1,440 @@ +//! Capability `ui`'s panel beside container screens (WIT `interface +//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as +//! the game's own item slots in pages right of the screen's window, with a +//! search box under them. The mod says what the panel holds; the game pages +//! it and tells the mod what the player clicked, typed, and which of the +//! mod's keys went down over an item. With no mod showing a panel, each +//! hook here returns after one check. +use super::Game; +#[cfg(feature = "mods")] +use { + super::Screen, + horizon_client::{ + hud::{HudBatch, rgba}, + items::ItemStack, + ui::EditBox, + }, + horizon_mods::{BrowserClick, BrowserEvent, BrowserStack}, +}; + +#[cfg(not(feature = "mods"))] +impl Game<'_> { + pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {} + pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool { + false + } + pub(super) fn mod_browser_typing(&self) -> bool { + false + } + pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {} + pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {} + pub(super) fn mod_browser_closed(&mut self) {} +} + +/// One slot of the panel, in GUI pixels. +#[cfg(feature = "mods")] +const SLOT: f32 = 18.0; +/// The most columns the panel takes, and the fewest it shows with. +#[cfg(feature = "mods")] +const COLUMNS: (usize, usize) = (12, 2); +/// The gap between the window and the panel, and the screen's edges. +#[cfg(feature = "mods")] +const MARGIN: f32 = 8.0; + +/// The panel's state in the game. +#[cfg(feature = "mods")] +#[derive(Default)] +pub(super) struct Browser { + /// The items' owner and version they were made from. + of: Option<(usize, u64)>, + /// The mod's items as stacks; `None` for an item the game doesn't have. + stacks: Vec>, + page: usize, + search: Option, + /// The search text the mod last heard. + sent: String, +} + +/// Where the panel's parts are this frame. +#[cfg(feature = "mods")] +struct Layout { + /// The panel's whole area, for clicks that hit none of its parts. + area: [f32; 4], + /// The grid's top left corner, its columns and rows. + grid: (f32, f32), + columns: usize, + rows: usize, + /// The page arrows' row. + arrows_y: f32, + search: [f32; 4], +} +#[cfg(feature = "mods")] +impl Layout { + fn per_page(&self) -> usize { + self.columns * self.rows + } + fn width(&self) -> f32 { + self.columns as f32 * SLOT + } + fn slot(&self, n: usize) -> (f32, f32) { + ( + self.grid.0 + (n % self.columns) as f32 * SLOT, + self.grid.1 + (n / self.columns) as f32 * SLOT, + ) + } + /// The page arrows: backward, then forward. + fn arrows(&self) -> [(f32, f32); 2] { + [ + (self.grid.0, self.arrows_y), + (self.grid.0 + self.width() - 12.0, self.arrows_y), + ] + } +} + +#[cfg(feature = "mods")] +fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { + p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h +} + +#[cfg(feature = "mods")] +impl Game<'_> { + fn browser_batch(&self) -> Option { + let r = self.renderer.as_ref()?; + Some(HudBatch::new([r.config.width, r.config.height])) + } + /// The mod showing a panel, with its items made into stacks once per + /// `set-items`; none while no mod shows one or no container screen is up. + fn browser_sync(&mut self) -> Option { + if self.screen != Screen::Inventory || self.title.is_some() { + return None; + } + let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else { + self.mods.browser.of = None; + self.mods.browser.stacks.clear(); + return None; + }; + if self.mods.browser.of != Some((owner, version)) { + let items = self.res.items(); + let stacks = self + .mods + .mods + .browser_items() + .map_or_else(Vec::new, |(_, _, list)| { + list.iter().map(|s| browser_stack(items, s)).collect() + }); + self.mods.browser.stacks = stacks; + self.mods.browser.of = Some((owner, version)); + } + Some(owner) + } + /// The panel's parts: right of the window, from the top of the screen to + /// the search box at its foot. None when the screen has no room for it. + fn browser_layout(&self, batch: &HudBatch) -> Option { + let image = self.container_image(batch); + let left = (image.left + image.width) as f32 + MARGIN; + let right = batch.width - MARGIN; + let columns = (((right - left) / SLOT).floor().max(0.0) as usize).min(COLUMNS.0); + if columns < COLUMNS.1 { + return None; + } + let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor(); + let arrows_y = MARGIN; + let top = arrows_y + 17.0 + 4.0; + let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0]; + let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize; + (rows > 0).then_some(Layout { + area: [left, 0.0, right - left, batch.height], + grid: (x, top), + columns, + rows, + arrows_y, + search, + }) + } + fn browser_pages(&self, layout: &Layout) -> usize { + self.mods + .browser + .stacks + .len() + .div_ceil(layout.per_page()) + .max(1) + } + /// The panel's item under the pointer: its index and stack. + fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> { + let first = self.mods.browser.page * layout.per_page(); + (0..layout.per_page()).find_map(|n| { + let (x, y) = layout.slot(n); + if !inside(self.cursor, [x, y, SLOT, SLOT]) { + return None; + } + let stack = self.mods.browser.stacks.get(first + n)?.clone()?; + Some((first + n, stack)) + }) + } + fn browser_search(&mut self, layout: &Layout) -> &mut EditBox { + let search = self.mods.browser.search.get_or_insert_with(|| { + let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0); + edit.max_length = 50; + edit.focused = false; + edit + }); + [search.x, search.y, search.width, search.height] = layout.search; + search + } + + /// Draws the panel over the open container screen, then the carried + /// stack or the tooltip again so they stay on top of it. + pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { + if self.browser_sync().is_none() { + return; + } + let Some(layout) = self.browser_layout(batch) else { + return; + }; + let pages = self.browser_pages(&layout); + self.mods.browser.page = self.mods.browser.page.min(pages - 1); + let page = self.mods.browser.page; + let cursor = self.cursor; + if pages > 1 { + let labels = [ + self.translate("gui.recipebook.previous_page", "Previous Page"), + self.translate("gui.recipebook.next_page", "Next Page"), + ]; + let shown = [page > 0, page + 1 < pages]; + let atlas = &self.gui.atlas; + for (((x, y), name), (label, shown)) in layout + .arrows() + .into_iter() + .zip(["page_backward", "page_forward"]) + .zip(labels.iter().zip(shown)) + { + if !shown { + continue; + } + let hover = inside(cursor, [x, y, 12.0, 17.0]); + let suffix = if hover { "_highlighted" } else { "" }; + batch.sprite( + atlas, + &format!("gui/sprites/recipe_book/{name}{suffix}"), + x, + y, + ); + horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true); + } + let text = format!("{}/{pages}", page + 1); + let w = HudBatch::text_width(atlas, &text); + let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor(); + batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true); + } + let first = page * layout.per_page(); + for n in 0..layout.per_page() { + let Some(stack) = self.mods.browser.stacks.get(first + n) else { + break; + }; + let Some(stack) = stack.clone() else { + continue; + }; + let (x, y) = layout.slot(n); + self.draw_item(batch, &stack, x + 1.0, y + 1.0); + let rect = [x + 1.0, y + 1.0, 16.0, 16.0]; + if inside(cursor, [x, y, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &name, + rect, + true, + horizon_client::widgets::Kind::Button, + ); + } + let hint = self.translate("gui.recipebook.search_hint", "Search..."); + let search = self.browser_search(&layout); + // The hint shows in an empty box without focus, as the recipe book's. + search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint); + let mut search = search.clone(); + search.draw(batch, &self.gui.atlas, None); + self.mods.browser.search = Some(search); + if self.carried.is_some() { + self.draw_carried(batch); + } else if let Some((_, stack)) = self.browser_hovered(&layout) { + let lines = self.stack_tooltip(&stack); + self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1); + } else { + self.draw_slot_tooltip(batch); + } + } + + /// A mouse press on a container screen: true when the panel took it + /// (its search box, page arrows, items or empty space; a click there + /// never throws the carried stack). + pub(super) fn mod_browser_press(&mut self, right: bool) -> bool { + let Some(owner) = self.browser_sync() else { + return false; + }; + let Some(batch) = self.browser_batch() else { + return false; + }; + let Some(layout) = self.browser_layout(&batch) else { + return false; + }; + let cursor = self.cursor; + let shift = self.has_shift_down(); + let atlas = &self.gui.atlas; + let search = self.mods.browser.search.get_or_insert_with(|| { + let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0); + edit.max_length = 50; + edit + }); + [search.x, search.y, search.width, search.height] = layout.search; + if inside(cursor, layout.search) { + search.focused = true; + search.click(atlas, cursor.0, shift, false); + search.reset_blink(); + return true; + } + search.focused = false; + let pages = self.browser_pages(&layout); + let page = self.mods.browser.page; + let [back, forward] = layout.arrows(); + let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) { + Some(page - 1) + } else if pages > 1 + && page + 1 < pages + && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) + { + Some(page + 1) + } else { + None + }; + if let Some(to) = step { + self.mods.browser.page = to; + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + return true; + } + if let Some((index, _)) = self.browser_hovered(&layout) { + let event = BrowserEvent::Clicked(BrowserClick { + index: index as u32, + right, + }); + self.mods.mods.browser_event(owner, event); + return true; + } + inside(cursor, layout.area) + } + + /// Whether keys go to the panel's search box. + pub(super) fn mod_browser_typing(&self) -> bool { + self.screen == Screen::Inventory + && self.mods.browser.of.is_some() + && self.mods.browser.search.as_ref().is_some_and(|s| s.focused) + } + /// A key press while the search box has focus. + pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) { + use winit::keyboard::PhysicalKey; + let shortcut = self.edit_shortcut(event); + let ctrl = self.has_control_down(); + let shift = self.has_shift_down(); + let Some(search) = self.mods.browser.search.as_mut() else { + return; + }; + if let Some(shortcut) = shortcut { + search.shortcut(shortcut, &mut self.clipboard); + } else { + let handled = match event.physical_key { + PhysicalKey::Code(code) => search.key(code, ctrl, shift), + _ => false, + }; + if !handled + && !ctrl + && let Some(text) = &event.text + { + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + search.insert_text(&text); + } + } + search.reset_blink(); + self.browser_searched(); + } + /// Text typed (or one backspace) into the search box (`horizon-ctl type`). + pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) { + let Some(search) = self.mods.browser.search.as_mut() else { + return; + }; + if backspace { + search.delete_chars(-1); + } else if let Some(text) = text { + search.insert_text(text); + } + self.browser_searched(); + } + /// The mod hears the search text when it changed. + fn browser_searched(&mut self) { + let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search) + else { + return; + }; + if search.value != self.mods.browser.sent { + let value = search.value.clone(); + self.mods.browser.sent.clone_from(&value); + self.mods.browser.page = 0; + self.mods + .mods + .browser_event(owner, BrowserEvent::Search(value)); + } + } + /// The container screen closed: the search box lets go of the keys. + pub(super) fn mod_browser_closed(&mut self) { + if let Some(search) = self.mods.browser.search.as_mut() { + search.focused = false; + } + } + /// The item under the pointer on a container screen, for the mods' keys + /// (`browser`'s `key` event): the panel's, else a slot's. + pub(super) fn mod_browser_pointed(&mut self) -> Option { + let batch = self.browser_batch()?; + let stack = match self + .browser_sync() + .and_then(|_| self.browser_layout(&batch)) + { + Some(layout) if inside(self.cursor, layout.area) => self.browser_hovered(&layout)?.1, + _ => { + let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?; + self.slot_view(slot)? + } + }; + Some(BrowserStack { + item: stack.item.to_string(), + count: stack.count, + components: super::mod_recipes::components(&stack), + }) + } +} + +/// A mod's item as a stack: an item the game has, its components read as +/// `/give` reads them (ones it can't read are left out). +#[cfg(feature = "mods")] +fn browser_stack(items: &horizon_client::items::Items, s: &BrowserStack) -> Option { + items.get(&s.item)?; + let (damage, extra) = if s.components.is_empty() { + (0, None) + } else { + horizon_client::item_data::command_components(&s.components).map_or((0, None), |tag| { + horizon_client::item_data::decode_components(Some(&tag)) + }) + }; + Some(ItemStack { + item: s.item.as_str().into(), + count: s.count.clamp(1, 99), + damage, + extra, + }) +} diff --git a/crates/horizon-client/src/game/mod_input.rs b/crates/horizon-client/src/game/mod_input.rs index 10faf6a81..3df62c3d4 100644 --- a/crates/horizon-client/src/game/mod_input.rs +++ b/crates/horizon-client/src/game/mod_input.rs @@ -111,6 +111,18 @@ impl Game<'_> { if repeat || !self.mods.mods.has_keys() { return; } + // Over an item on a container screen, the mods' keys are `browser` + // events (a recipe viewer's R and U). + if pressed + && self.screen == Screen::Inventory + && self.title.is_none() + && !self.mod_browser_typing() + && let Some(name) = horizon_client::keybinds::key_name(key) + && let Some(stack) = self.mod_browser_pointed() + { + self.mods.mods.browser_key(name, &stack); + return; + } if pressed && (self.screen != Screen::Playing || self.title.is_some()) { return; } diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index 0d57e1f94..f02eb6424 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -35,7 +35,7 @@ impl Index { /// A stack's components beyond its item's, as vanilla's item syntax writes /// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "". -fn components(s: &ItemStack) -> String { +pub(super) fn components(s: &ItemStack) -> String { use horizon_world::nbt::Tag; let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else { return String::new(); diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 156467e7a..40298a922 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -76,6 +76,9 @@ pub(super) struct ModsState { /// The recipe book's known displays, and the book's version they are of. #[cfg(feature = "mods")] pub(super) known_recipes: (Option, std::sync::Arc<[i32]>), + /// Capability `ui`'s item panel beside container screens. + #[cfg(feature = "mods")] + pub(super) browser: super::mod_browser::Browser, /// Extras that are official mods now, being installed. #[cfg(feature = "mods")] pub(super) moved: super::moved_extras::MovedState, From 823941756f2981a76611538c1e66351b43da8b02 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:46:53 +0100 Subject: [PATCH 151/475] block_removal: print a merged orb once per count on both sides Whether two orbs merge (ExperienceOrb.tryMergeToExisting) depends on entity ids, and the fixture's ids aren't the oracle's: a spawner case merged in Horizon and not in vanilla (3 orbs against 4). Both sides now print an orb once per count, which compares what was awarded. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/dispense_fixture.rs | 16 ++++++++++------ .../parity/VanillaDispenserOracle.java | 14 +++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index 267f323c3..a3ac8dcad 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -1126,14 +1126,18 @@ impl Game<'_> { let (p, v) = (b.position, b.velocity); out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], "")); } + // A merged orb prints once per count, as the oracle does: whether orbs + // merge depends on entity ids, which differ from vanilla's. for o in &self.orbs { let (p, v) = (o.position, o.velocity); - out.push(line( - "minecraft:experience_orb", - [p.x, p.y, p.z], - [v.x, v.y, v.z], - "", - )); + for _ in 0..o.count.max(1) { + out.push(line( + "minecraft:experience_orb", + [p.x, p.y, p.z], + [v.x, v.y, v.z], + "", + )); + } } for c in &self.carts { let (p, v) = (c.position, c.velocity); diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java index a529ae6ac..b83634f94 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaDispenserOracle.java @@ -461,7 +461,9 @@ public final class VanillaDispenserOracle { line.append(' ').append(num(v.x)).append(' ').append(num(v.y)).append(' ').append(num(v.z)); if (e instanceof ItemEntity item) line.append(' ').append(ParityData.canon(item.getItem())); if (e instanceof PrimedTnt tnt) line.append(" fuse=").append(tnt.getFuse()); - result.add(line.toString()); + // An orb that took others in (ExperienceOrb.tryMergeToExisting) prints once per + // count: whether orbs merge depends on entity ids, which Horizon doesn't share. + for (int n = e instanceof ExperienceOrb orb ? orbCount(orb) : 1; n > 0; n--) result.add(line.toString()); } for (int i = 0; i < targets.size(); i++) result.add(describe(i, targets.get(i))); for (String s : w.log.toString().split("\n")) { @@ -821,6 +823,16 @@ public final class VanillaDispenserOracle { } /** A target's state after dispensing: removal, equipment and readiness for shearing. */ + static int orbCount(ExperienceOrb orb) { + try { + var f = ExperienceOrb.class.getDeclaredField("count"); + f.setAccessible(true); + return f.getInt(orb); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + static String describe(int index, Entity e) { StringBuilder line = new StringBuilder("T " + index + " " + BuiltInRegistries.ENTITY_TYPE.getKey(e.getType())); if (e.isRemoved()) line.append(" removed"); From b8da45f5ef2d4b1b1584b0255b36ef389ac18a10 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:51:32 +0100 Subject: [PATCH 152/475] mods: browser's page event names the first recipe shown, set-recipes' indexes are its list's Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/wit/horizon.wit | 14 ++++++++------ crates/horizon-mods/tests/ui_guest/src/lib.rs | 2 +- docs/api/mods-reference.md | 8 ++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index ab20648d8..38a0e5879 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -533,10 +533,10 @@ interface browser { key: string, stack: stack, } - /// A click on a slot of the recipe the panel shows (`set-recipes`). + /// A click on a slot of a recipe the panel shows (`set-recipes`). record slot-click { - /// The recipe's index on the page and the slot's in it; the - /// results come after the slots. + /// The recipe's index in `set-recipes`'s list and the slot's in + /// it; the results come after the slots. recipe: u32, slot: u32, /// The stack the slot showed at that moment. @@ -549,10 +549,12 @@ interface browser { search(string), key(item-key), slot-clicked(slot-click), - /// The action button of the recipe at this index on the page. + /// The action button of the recipe at this index in + /// `set-recipes`'s list. action(u32), - /// The page arrows of `set-recipes`: -1 or 1. - page(s32), + /// The player turned the recipes' page (`set-recipes`): the index + /// of the first recipe now shown. + page(u32), /// The back button of `set-recipes`. back, } diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 4fc2ee2c4..43aa207e0 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -330,7 +330,7 @@ impl Mod for Guest { format!("slot {} {} {}", c.recipe, c.slot, stack(&c.stack)) } browser::Event::Action(i) => format!("action {i}"), - browser::Event::Page(step) => format!("page {step}"), + browser::Event::Page(first) => format!("page {first}"), browser::Event::Back => "back".into(), }); } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 111fefd5c..298db26ba 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -567,11 +567,11 @@ A key the mod registered (`input.register-key`) went down while the pointer was ### record `slot-click` -A click on a slot of the recipe the panel shows (`set-recipes`). +A click on a slot of a recipe the panel shows (`set-recipes`). | Field | Type | | | --- | --- | --- | -| `recipe` | `u32` | The recipe's index on the page and the slot's in it; the results come after the slots. | +| `recipe` | `u32` | The recipe's index in `set-recipes`'s list and the slot's in it; the results come after the slots. | | `slot` | `u32` | | | `stack` | `stack` | The stack the slot showed at that moment. | | `right` | `bool` | | @@ -584,8 +584,8 @@ A click on a slot of the recipe the panel shows (`set-recipes`). | `search` | `string` | The search box's text, each time the player changes it. | | `key` | `item-key` | | | `slot-clicked` | `slot-click` | | -| `action` | `u32` | The action button of the recipe at this index on the page. | -| `page` | `s32` | The page arrows of `set-recipes`: -1 or 1. | +| `action` | `u32` | The action button of the recipe at this index in `set-recipes`'s list. | +| `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. | | `back` | | The back button of `set-recipes`. | | Function | Signature | What it does | From b37528f5d6e1f87ff59fdf40763c9ae3ce9f9cc1 Mon Sep 17 00:00:00 2001 From: socksprox Date: Mon, 26 Oct 2026 17:51:54 +0100 Subject: [PATCH 153/475] Pause menu: server links ClientboundServerLinksPacket (configuration and play) decoded and kept, the pause menu gains its "Server Links..." button when a server sends links, opening the server_links dialog screen: scrollable link buttons, ConfirmLinkScreen on click (chatLinks/chatLinksPrompt honoured), and the custom-screens warning with its disconnect. A dedicated server sends its `bug-report-link` property as the BUG_REPORT link, like vanilla. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game.rs | 32 ++ crates/horizon-client/src/game/agent.rs | 3 + crates/horizon-client/src/game/control.rs | 3 + crates/horizon-client/src/game/dedicated.rs | 16 + crates/horizon-client/src/game/packs.rs | 4 + crates/horizon-client/src/game/remote.rs | 9 + crates/horizon-client/src/game/screens.rs | 91 +++- .../horizon-client/src/game/server_links.rs | 487 ++++++++++++++++++ crates/horizon-client/src/lan/conn.rs | 10 + crates/horizon-client/src/lan/mod.rs | 5 + crates/horizon-client/src/net.rs | 9 + crates/horizon-client/src/options_ui.rs | 5 + crates/horizon-client/src/settings.rs | 4 + .../src/packets/configuration.rs | 61 +++ crates/horizon-protocol/src/packets/play.rs | 5 +- 15 files changed, 726 insertions(+), 18 deletions(-) create mode 100644 crates/horizon-client/src/game/server_links.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 755a00f6f..ca9e7110a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -150,6 +150,7 @@ mod scoreboard; mod screens; mod sculk; mod sculk_fixture; +mod server_links; mod server_mods; mod server_packs; mod server_travel; @@ -490,6 +491,12 @@ enum Screen { Friends, /// CommandBlockEditScreen (game/command_blocks.rs). CommandBlock, + /// ServerLinksDialogScreen: a server's links (game/server_links.rs). + ServerLinks, + /// ConfirmLinkScreen for a server link (game/server_links.rs). + ConfirmLink, + /// DialogScreen.WarningScreen: the note about custom screens. + DialogWarning, /// WinScreen: the end poem and the credits (game/win.rs). Win, /// A `ui` mod's own screen, or its settings page from Options > Mods @@ -1641,6 +1648,8 @@ struct Game<'a> { restore_player_state: bool, stats_ui: stats_screen::StatsUi, adv_ui: advancements_screen::AdvUi, + /// The Server Links screens' state (game/server_links.rs). + links_ui: server_links::LinksUi, camera_type: perspective::CameraType, fangs: fangs::FangState, /// The local player as a model (third person, the inventory). @@ -4765,6 +4774,9 @@ impl Game<'_> { | Screen::Advancements | Screen::WorldOptions | Screen::Friends + | Screen::ServerLinks + | Screen::ConfirmLink + | Screen::DialogWarning | Screen::CommandBlock ) || self.book.is_some() || self.win_screen_pauses()) @@ -5248,6 +5260,9 @@ impl Game<'_> { Screen::Advancements => self.draw_advancements(&mut batch), Screen::WorldOptions => self.draw_world_options(&mut batch), Screen::Friends => self.draw_friends_screen(&mut batch), + Screen::ServerLinks => self.draw_server_links(&mut batch), + Screen::ConfirmLink => self.draw_confirm_link(&mut batch), + Screen::DialogWarning => self.draw_dialog_warning(&mut batch), Screen::CommandBlock => self.draw_command_block(&mut batch), Screen::Inventory => { self.draw_inventory(&mut batch); @@ -10786,6 +10801,10 @@ impl Game<'_> { Screen::GameModeSwitcher => self.close_gamemode_switcher(false), Screen::Dead => {} Screen::Stats | Screen::Friends => self.screen = Screen::Paused, + Screen::ServerLinks => self.screen = Screen::Paused, + // ConfirmScreen.keyPressed: Escape answers no; both return to + // the Server Links screen they came from. + Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks, Screen::Advancements => self.close_advancements(), Screen::WorldOptions => self.world_options_escape(), Screen::CommandBlock => self.close_command_block(), @@ -11465,6 +11484,9 @@ impl ApplicationHandler for Game<'_> { if self.screen == Screen::WorldOptions && d != 0.0 { self.world_options_scroll(d); } + if self.screen == Screen::ServerLinks && d != 0.0 { + self.server_links_scroll(d); + } if self.screen == Screen::Advancements { self.advancements_scroll(scaled(raw_x) as f32, d); } @@ -11655,6 +11677,15 @@ impl ApplicationHandler for Game<'_> { Screen::WorldOptions if pressed && button == MouseButton::Left => { self.world_options_click() } + Screen::ServerLinks if pressed && button == MouseButton::Left => { + self.server_links_click()? + } + Screen::ConfirmLink if pressed && button == MouseButton::Left => { + self.confirm_link_click() + } + Screen::DialogWarning if pressed && button == MouseButton::Left => { + self.dialog_warning_click()? + } Screen::WorldOptions if button == MouseButton::Left => { self.world_options_release() } @@ -12511,6 +12542,7 @@ impl<'a> Game<'a> { restore_player_state: true, stats_ui: Default::default(), adv_ui: Default::default(), + links_ui: Default::default(), camera_type: Default::default(), fangs: Default::default(), player_model: None, diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index fa9dbf3c6..0b82ac0b8 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -419,6 +419,9 @@ impl Game<'_> { Screen::Advancements => "advancements", Screen::WorldOptions => "world-options", Screen::Friends => "friends", + Screen::ServerLinks => "server-links", + Screen::ConfirmLink => "confirm-link", + Screen::DialogWarning => "dialog-warning", Screen::CommandBlock => "command-block", Screen::Win => "win", Screen::Mod => "mod", diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 3202b23f4..6c910ab8a 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -875,6 +875,9 @@ impl Game<'_> { Screen::Advancements => "advancements", Screen::WorldOptions => "world-options", Screen::Friends => "friends", + Screen::ServerLinks => "server-links", + Screen::ConfirmLink => "confirm-link", + Screen::DialogWarning => "dialog-warning", Screen::CommandBlock => "command-block", Screen::Win => "win", Screen::Mod => "mod", diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index fa5365195..10e10cd31 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -618,6 +618,22 @@ impl Game<'_> { log_ips: props.log_ips, }), resource_pack: props.resource_pack.clone(), + // DedicatedServer.serverLinks: `bug-report-link` as the only link + // (ServerLinks.KnownLinkType.BUG_REPORT is id 0). + server_links: if props.bug_report_link.is_empty() { + Vec::new() + } else if super::server_links::valid_untrusted_uri(&props.bug_report_link) { + vec![horizon_protocol::packets::configuration::ServerLink { + kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0), + link: props.bug_report_link.clone(), + }] + } else { + log( + "WARN", + &format!("Failed to parse bug link {}", props.bug_report_link), + ); + Vec::new() + }, }; let config = HubConfig { default_game_mode: game_mode, diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index ee57e9f2b..e5026cd17 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -34,6 +34,10 @@ pub fn open_folder(dir: &Path) { log::warn!("Horizon: could not open {}: {e}", dir.display()); } } +/// `Util.getPlatform().openUri`: the system browser on the link. +pub fn open_uri(uri: &str) { + open_folder(Path::new(uri)); +} /// The resource root to load from, given the vanilla root, the selection and /// a server's packs, which go on top (`Pack.Position.TOP`, fixed). pub fn startup_root( diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3ef87c201..6e770aaeb 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -212,6 +212,9 @@ pub(super) struct Remote { /// `ClientboundTransferPacket`'s target: when the connection ends the /// game connects here, the stored cookies carried over (`TransferState`). transfer: Option, + /// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server + /// Links..." in the pause menu lists (only `http`/`https` URIs kept). + pub(super) server_links: Vec, } /// The time a frame spends on chunk packets before it leaves the rest of @@ -486,6 +489,7 @@ impl Game<'_> { chunk_center: (0, 0), backlog: VecDeque::new(), backlog_since: None, + server_links: Vec::new(), transfer: None, }); // The level's TickRateManager follows the server's ticking packets. @@ -862,6 +866,11 @@ impl Game<'_> { } Event::PackPush(p) => self.handle_pack_push(p), Event::PackPop(id) => self.handle_pack_pop(id), + Event::ServerLinks(links) => { + if let Some(r) = self.remote.as_mut() { + r.server_links = super::server_links::trusted(links); + } + } Event::ShowDialog(dialog) => self.show_dialog(&dialog), Event::ClearDialog => self.clear_dialog(), Event::DecodeError(e) => { diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index f8885f83a..750895adb 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -19,6 +19,9 @@ impl Screen { | Self::Advancements | Self::WorldOptions | Self::Friends + | Self::ServerLinks + | Self::ConfirmLink + | Self::DialogWarning | Self::Mod => true, Self::Playing | Self::GameModeSwitcher @@ -35,20 +38,35 @@ impl Screen { } /// PauseScreen buttons: (x, y, width, label key, action id, active). +/// `links`: the `minecraft:server_links` dialog's button, which the +/// `pause_screen_additions` tag (empty in vanilla) falls back to when the +/// server sent links — a full-width row that pushes the last two rows down. /// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen -fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> { +fn pause_buttons( + w: f32, + h: f32, + remote: bool, + links: bool, +) -> Vec<(f32, f32, f32, &'static str, u8, bool)> { // GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25). - let (grid_w, grid_h) = (212.0, 166.0); + let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 }); let x0 = ((w - grid_w) * 0.5).floor(); let y0 = ((h - grid_h) * 0.25).floor(); - vec![ + let down = if links { 24.0 } else { 0.0 }; + let mut v = vec![ (x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true), (x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true), (x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true), - (x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true), + ]; + if links { + // CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS. + v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true)); + } + v.extend([ + (x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true), ( x0 + 110.0, - y0 + 122.0, + y0 + 122.0 + down, 98.0, "options.worldOptions.button", 9, @@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s ), ( x0 + 4.0, - y0 + 146.0, + y0 + 146.0 + down, 204.0, if remote { "menu.disconnect" @@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s 2, true, ), - ] + ]); + v } /// The icon row: report bugs, feedback, friends, player reporting. /// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's /// Play with Friends; the others stay inactive. const FRIENDS_ICON: usize = 2; -fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] { +fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] { let x0 = ((w - 212.0) * 0.5).floor() + 60.0; - let y = ((h - 166.0) * 0.25).floor() + 98.0; + let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0; [ (x0, y, "gui/sprites/pause_menu/bug"), (x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"), @@ -120,7 +139,9 @@ impl Game<'_> { u32::MAX, true, ); - for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) { + let links = !self.server_links().is_empty(); + let mut tooltip = None; + for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) { let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0); ui::button( batch, @@ -133,9 +154,12 @@ impl Game<'_> { active, hover, ); + // PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP. + if id == 11 && hover { + tooltip = Some(Text::translate("menu.custom_options.tooltip", &[])); + } } - let mut tooltip = None; - for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() { + for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() { let active = i == FRIENDS_ICON && friends; let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0); // The icon has no label: name it for controller focus and the @@ -168,7 +192,13 @@ impl Game<'_> { } // SpriteIconButton.withTootip: the label as a tooltip. if let Some(tip) = tooltip { - ui::tooltip(batch, atlas, &[tip], self.cursor); + let lines: Vec = atlas + .font + .split_lines(&tip, batch.width) + .into_iter() + .map(|(line, _)| line) + .collect(); + ui::tooltip(batch, atlas, &lines, self.cursor); } } pub(super) fn pause_click(&mut self) -> Result<()> { @@ -180,8 +210,9 @@ impl Game<'_> { let b = HudBatch::new(size); (b.width, b.height) }; - let friends = pause_icons(w, h)[FRIENDS_ICON]; - let hit = pause_buttons(w, h, self.external_server()) + let links = !self.server_links().is_empty(); + let friends = pause_icons(w, h, links)[FRIENDS_ICON]; + let hit = pause_buttons(w, h, self.external_server(), links) .into_iter() .find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0)) .map(|b| b.4) @@ -200,6 +231,11 @@ impl Game<'_> { Some(4) => self.open_advancements(true), Some(9) => self.open_world_options(), Some(10) => self.screen = Screen::Friends, + // DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS). + Some(11) => { + self.links_ui.scroll = 0.0; + self.screen = Screen::ServerLinks; + } Some(2) => self.close_world()?, _ => {} } @@ -357,7 +393,7 @@ impl Game<'_> { #[cfg(test)] mod tests { - use super::{Screen, pause_buttons}; + use super::{Screen, pause_buttons, pause_icons}; #[test] fn menu_blur_matches_vanilla_screen_backgrounds() { @@ -390,11 +426,32 @@ mod tests { #[test] fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() { for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] { - let buttons = pause_buttons(427.0, 240.0, remote); + let buttons = pause_buttons(427.0, 240.0, remote, false); let quit = buttons.iter().find(|b| b.4 == 2).unwrap(); assert_eq!(quit.3, label); assert!(quit.5); assert!(buttons.iter().any(|b| b.4 == 9 && b.5)); } } + + #[test] + fn server_links_adds_a_full_width_row_above_options() { + let with = pause_buttons(427.0, 240.0, true, true); + let without = pause_buttons(427.0, 240.0, true, false); + let links = with.iter().find(|b| b.4 == 11).unwrap(); + assert_eq!(links.2, 204.0); + assert_eq!(links.3, "menu.server_links"); + assert!(links.5); + // The icon row sits above it; Options and Disconnect move down 24. + let icons = pause_icons(427.0, 240.0, true); + let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0; + assert_eq!(icons[0].1, y0_icons); + assert!(links.1 > icons[0].1); + for b in &with { + if b.4 == 1 || b.4 == 9 || b.4 == 2 { + let same = without.iter().find(|o| o.4 == b.4).unwrap(); + assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3); + } + } + } } diff --git a/crates/horizon-client/src/game/server_links.rs b/crates/horizon-client/src/game/server_links.rs new file mode 100644 index 000000000..85fda71d6 --- /dev/null +++ b/crates/horizon-client/src/game/server_links.rs @@ -0,0 +1,487 @@ +//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens +//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's +//! fallback when a server sent ClientboundServerLinksPacket): a scrollable +//! list of the server's links, each opened through `ConfirmLinkScreen` +//! (Screen.clickUrlAction), and the `dialog/warning_button`'s +//! DialogScreen.WarningScreen ("Note about custom screens"). +//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen +use super::{Game, Screen, packs::open_uri}; +use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui}; +use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind}; + +/// HeaderAndFooterLayout's header and footer heights; the content wants to +/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`). +const HEADER: f32 = 33.0; +const FOOTER: f32 = 33.0; +const CONTENT_TOP: f32 = HEADER + 30.0; +/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200. +const LINK_WIDTH: f32 = 310.0; +/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons. +const ROW: f32 = 22.0; +/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6). +const SCROLL_RESERVE: f32 = 10.0; +/// DialogScreen's `dialog/warning_button` ImageButton. +const WARNING_BUTTON: f32 = 20.0; + +/// The ServerLinks screen's state: ScrollableLayout's scroll and the link +/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`). +#[derive(Default)] +pub(super) struct LinksUi { + pub scroll: f32, + pub confirm: Option, +} + +/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI +/// (java.net.URI's scheme then the allowlist; whitespace-free). +pub(super) fn valid_untrusted_uri(uri: &str) -> bool { + let Some(colon) = uri.find(':') else { + return false; + }; + let scheme = &uri[..colon]; + let mut chars = scheme.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return false; + } + if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") { + return false; + } + !uri.chars().any(|c| c.is_control() || c == ' ') +} +/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links +/// that validate, warning about the dropped ones. +pub(super) fn trusted(links: Vec) -> Vec { + links + .into_iter() + .filter(|l| { + let ok = valid_untrusted_uri(&l.link); + if !ok { + log::warn!("Horizon: server sent an invalid link: {}", l.link); + } + ok + }) + .collect() +} +/// `ServerLinks.Entry.displayName`: `known_server_link.` for a known +/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else. +fn link_label(link: &ServerLink) -> Text { + const KNOWN: [&str; 10] = [ + "report_bug", + "community_guidelines", + "support", + "status", + "feedback", + "community", + "website", + "forums", + "news", + "announcements", + ]; + match &link.kind { + ServerLinkKind::Known(id) => Text::translate( + &format!( + "known_server_link.{}", + KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0]) + ), + &[], + ), + ServerLinkKind::Custom(tag) => { + horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag)) + } + } +} + +/// The scrolled column of link buttons: content height and, for a list that +/// fits, the `HeaderAndFooterLayout` contents frame's top. +fn list_height(n: usize) -> f32 { + n as f32 * ROW - 2.0 +} +fn list_top(h: f32, n: usize) -> f32 { + CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER)) +} +fn scroll_limit(h: f32, n: usize) -> f32 { + (list_height(n) - (h - HEADER - FOOTER)).max(0.0) +} + +/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the +/// footer `Back` button's corner. `top` includes the scroll. +fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> { + let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor(); + (0..n).map(|i| (x, top + i as f32 * ROW)).collect() +} +/// The warning button beside the title (DialogScreen's title row: 10 pixels +/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds). +fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) { + let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0; + let y = ((HEADER - WARNING_BUTTON) / 2.0).floor(); + if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON { + (0.0f32.max(w - 40.0), 5.0f32.min(h)) + } else { + (x, y) + } +} +fn back_button(w: f32, h: f32) -> (f32, f32) { + ( + ((w - 200.0) / 2.0).floor(), + h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(), + ) +} + +impl Game<'_> { + /// `connection.serverLinks()`: the links the open server sent (empty for + /// a world without a connection or one that sent none). + pub(super) fn server_links(&self) -> &[ServerLink] { + self.remote + .as_ref() + .map_or(&[], |r| r.server_links.as_slice()) + } + /// DialogScreen over the pause menu: `menu.server_links.title`, the + /// scrollable link buttons and `gui.back` in the footer. + pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) { + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let links = self.server_links().to_vec(); + self.links_ui.scroll = self + .links_ui + .scroll + .clamp(0.0, scroll_limit(h, links.len())); + let top = list_top(h, links.len()) - self.links_ui.scroll.trunc(); + // The title row: the title, then 10 pixels on the warning button. + let title = Text::translate("menu.server_links.title", &[]); + let tw = atlas.font.width_text(&title); + let tx = ((w - tw - 30.0) / 2.0).floor(); + batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true); + let (wx, wy) = warning_button(w, h, tw); + let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON); + horizon_client::widgets::record( + "menu.custom_screen_info.tooltip", + [wx, wy, WARNING_BUTTON, WARNING_BUTTON], + true, + ); + batch.sprite_scaled( + atlas, + if wh { + "gui/sprites/dialog/warning_button_highlighted" + } else { + "gui/sprites/dialog/warning_button" + }, + wx, + wy, + WARNING_BUTTON, + WARNING_BUTTON, + u32::MAX, + ); + let first = batch.vertices.len(); + let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER; + for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) { + let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0); + ui::button( + batch, + atlas, + x, + y, + LINK_WIDTH, + 20.0, + &link_label(link), + true, + hover, + ); + } + batch.clip_since(first, 0.0, HEADER, w, h - FOOTER); + ui::scrollbar( + batch, + atlas, + (w / 2.0).floor() + 155.0 + 4.0, + HEADER, + h - FOOTER, + list_height(links.len()), + self.links_ui.scroll.trunc(), + ); + let (bx, by) = back_button(w, h); + let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0); + ui::button( + batch, + atlas, + bx, + by, + 200.0, + 20.0, + &Text::translate("gui.back", &[]), + true, + hover, + ); + if wh { + ui::tooltip( + batch, + atlas, + &[Text::translate("menu.custom_screen_info.tooltip", &[])], + self.cursor, + ); + } + } + /// A click on `Screen::ServerLinks`: a link, the warning button or Back. + pub(super) fn server_links_click(&mut self) -> Result<()> { + let (w, h) = self.gui_size(); + let links = self.server_links().to_vec(); + let top = list_top(h, links.len()) - self.links_ui.scroll.trunc(); + let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER; + if inside + && let Some(i) = link_buttons(w, top, links.len()) + .iter() + .position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0)) + { + self.audio.play_button_click(); + self.click_link(&links[i].link); + return Ok(()); + } + let title = Text::translate("menu.server_links.title", &[]); + let tw = self.gui.atlas.font.width_text(&title); + let (wx, wy) = warning_button(w, h, tw); + if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) { + self.audio.play_button_click(); + self.screen = Screen::DialogWarning; + return Ok(()); + } + let (bx, by) = back_button(w, h); + if ui::hovered(self.cursor, bx, by, 200.0, 20.0) { + self.audio.play_button_click(); + self.screen = Screen::Paused; + } + Ok(()) + } + /// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on + /// Links" asks with ConfirmLinkScreen, else the browser opens directly. + fn click_link(&mut self, url: &str) { + if !self.settings.chat_links { + return; + } + if self.settings.chat_links_prompt { + self.links_ui.confirm = Some(url.to_owned()); + self.screen = Screen::ConfirmLink; + } else { + open_uri(url); + } + } + /// `ScrollableLayout`'s mouse wheel over the links. + pub(super) fn server_links_scroll(&mut self, delta: f32) { + let (_, h) = self.gui_size(); + self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0) + .clamp(0.0, scroll_limit(h, self.server_links().len())); + } + /// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the + /// following website?", the link, the red warning, and Yes / Copy Link to + /// Clipboard / No. + pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) { + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let url = self.links_ui.confirm.clone().unwrap_or_default(); + let layout = confirm_link_layout(atlas, (w, h), &url); + for (line, x, y) in &layout.lines { + batch.text_styled(atlas, line, *x, *y, u32::MAX, true); + } + let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"]; + for ((x, y), key) in layout.buttons.iter().zip(labels) { + let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0); + ui::button( + batch, + atlas, + *x, + *y, + 100.0, + 20.0, + &Text::translate(key, &[]), + true, + hover, + ); + } + } + /// ConfirmLinkScreen's answer: yes opens the browser, the middle button + /// copies the link, no closes; all return to Server Links. + pub(super) fn confirm_link_click(&mut self) { + let (w, h) = self.gui_size(); + let url = self.links_ui.confirm.clone().unwrap_or_default(); + let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url); + for (i, (x, y)) in layout.buttons.iter().enumerate() { + if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) { + continue; + } + self.audio.play_button_click(); + match i { + 0 => open_uri(&url), + 1 => { + self.clipboard.set(&url); + } + _ => {} + } + self.screen = Screen::ServerLinks; + return; + } + } + /// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect + /// button ("Custom screen rejected" is the shown reason) and Back. + pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) { + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let layout = dialog_warning_layout(atlas, (w, h)); + for (line, x, y) in &layout.lines { + batch.text_styled(atlas, line, *x, *y, u32::MAX, true); + } + let quit = if self.external_server() { + "menu.disconnect" + } else { + "menu.returnToMenu" + }; + for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) { + let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0); + ui::button( + batch, + atlas, + x, + y, + 150.0, + 20.0, + &Text::translate(key, &[]), + true, + hover, + ); + } + } + /// WarningScreen's answer: disconnect, or back to Server Links. + pub(super) fn dialog_warning_click(&mut self) -> Result<()> { + let (w, h) = self.gui_size(); + let layout = dialog_warning_layout(&self.gui.atlas, (w, h)); + for (i, (x, y)) in layout.buttons.iter().enumerate() { + if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) { + continue; + } + self.audio.play_button_click(); + if i == 0 { + // DialogConnectionAccess.disconnect: leave with the custom + // screens' reason. + let reason = self.translate( + "menu.custom_screen_info.disconnect", + "Custom screen rejected", + ); + self.leave_server(Some(reason))?; + } else { + self.screen = Screen::ServerLinks; + } + return Ok(()); + } + Ok(()) + } +} + +/// A confirm screen's laid-out text (title, message, extra warning lines) and +/// button corners; the whole column centred (ConfirmScreen.init). +struct ConfirmLayout { + lines: Vec<(Text, f32, f32)>, + buttons: Vec<(f32, f32)>, +} +/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at +/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16. +fn confirm_layout( + font: &horizon_client::font::Font, + (w, h): (f32, f32), + title: &Text, + message: &Text, + extra: &[Text], + button_widths: &[f32], +) -> ConfirmLayout { + let message: Vec = font + .split_lines(message, w - 50.0) + .into_iter() + .take(15) + .map(|(line, _)| line) + .collect(); + // LinearLayout vertical spacing 8; the button row's children pad 16 on top. + let extra_h = 17.0 * extra.len() as f32; + let mh = 9.0 * message.len() as f32; + let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0; + let mut y = ((h - total) / 2.0).trunc(); + let mut lines = Vec::new(); + let mut push = |line: &Text, at: f32| { + let lw = font.width_text(line); + lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at)); + }; + push(title, y); + y += 17.0; + for line in &message { + push(line, y); + y += 9.0; + } + for line in extra { + y += 8.0; + push(line, y); + y += 9.0; + } + let row_w: f32 = button_widths.iter().sum::() + 4.0 * (button_widths.len() - 1) as f32; + let by = y + 8.0 + 16.0; + let mut bx = ((w - row_w) / 2.0).trunc(); + let buttons = button_widths + .iter() + .map(|bw| { + let p = (bx, by); + bx += bw + 4.0; + p + }) + .collect(); + ConfirmLayout { lines, buttons } +} +fn confirm_link_layout( + atlas: &horizon_client::hud::HudAtlas, + size: (f32, f32), + url: &str, +) -> ConfirmLayout { + // ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the + // message, `chat.link.warning` in its red (0xFFCCCC). + confirm_layout( + &atlas.font, + size, + &Text::translate("chat.link.confirm", &[]), + &Text::literal(url.to_owned()), + &[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)], + &[100.0; 3], + ) +} +fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout { + confirm_layout( + &atlas.font, + size, + &Text::translate("menu.custom_screen_info.title", &[]), + &Text::translate("menu.custom_screen_info.contents", &[]), + &[], + &[150.0; 2], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn untrusted_uris_are_http_or_https_with_a_scheme() { + for ok in [ + "https://example.net/x", + "http://example.net", + "HTTPS://EXAMPLE.NET", + ] { + assert!(valid_untrusted_uri(ok), "{ok}"); + } + for bad in [ + "example.net/no-scheme", + "ftp://example.net", + "file:///etc/passwd", + "javascript:alert(1)", + "https://exa mple.net", + "://example.net", + "", + ] { + assert!(!valid_untrusted_uri(bad), "{bad}"); + } + } +} diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index f2dcf701d..99b70e065 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -791,6 +791,16 @@ fn login_and_configure( }, }), )?; + // ServerConfigurationPacketListenerImpl.startConfiguration: links, only + // when there are any (MinecraftServer.serverLinks is empty elsewhere). + if !shared.config.server_links.is_empty() { + send( + c, + cfg::Clientbound::ServerLinks(cfg::ServerLinks { + links: shared.config.server_links.clone(), + }), + )?; + } send( c, cfg::Clientbound::UpdateEnabledFeatures(cfg::UpdateEnabledFeatures { diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 3e9b93cac..34c465ffa 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -142,6 +142,10 @@ pub struct NetConfig { /// server.properties `resource-pack`: offered to every player during /// configuration (a dedicated server's; MinecraftServer.getServerResourcePack). pub resource_pack: Option, + /// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`): + /// sent in configuration when non-empty + /// (ServerConfigurationPacketListenerImpl.startConfiguration). + pub server_links: Vec, } impl NetConfig { /// MinecraftServer.enforceSecureProfile. @@ -168,6 +172,7 @@ impl NetConfig { online: None, log: None, resource_pack: None, + server_links: Vec::new(), } } } diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index 34daaf85c..93364ed91 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -68,6 +68,9 @@ pub enum Event { host: String, port: i32, }, + /// `ClientboundServerLinksPacket`: the links the pause menu's "Server + /// Links..." lists (ClientCommonPacketListenerImpl.handleServerLinks). + ServerLinks(Vec), /// The connection ended. `from_server`: the server gave the reason (or /// the connection was refused for good), rather than the connection /// failing (auto-reconnect retries only failures and shutdowns). @@ -565,6 +568,9 @@ fn receive_play( play::Clientbound::StoreCookie(p) => { cookies.lock().unwrap().insert(p.key, p.payload); } + play::Clientbound::ServerLinks(p) => { + let _ = send.send(Event::ServerLinks(p.links)); + } // ClientCommonPacketListenerImpl.handleTransfer: the connection // ends and the game connects to the other address. play::Clientbound::Transfer(p) => { @@ -644,6 +650,9 @@ fn configure( cfg::Clientbound::StoreCookie(p) => { cookies.lock().unwrap().insert(p.key, p.payload); } + cfg::Clientbound::ServerLinks(p) => { + let _ = send.send(Event::ServerLinks(p.links)); + } cfg::Clientbound::Transfer(p) => { let _ = send.send(Event::Transfer { host: p.host, diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 61fda9878..63c6165eb 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -56,6 +56,7 @@ pub enum Opt { ChatVisibility, ChatColors, ChatLinks, + ChatLinksPrompt, ChatOpacity, TextBackgroundOpacity, ChatScale, @@ -237,6 +238,7 @@ impl Opt { Opt::ChatVisibility => "options.chat.visibility", Opt::ChatColors => "options.chat.color", Opt::ChatLinks => "options.chat.links", + Opt::ChatLinksPrompt => "options.chat.links.prompt", Opt::ChatOpacity => "options.chat.opacity", Opt::TextBackgroundOpacity => "options.accessibility.text_background_opacity", Opt::ChatScale => "options.chat.scale", @@ -574,6 +576,7 @@ impl Opt { ][usize::from(s.chat_visibility.min(2))]), Opt::ChatColors => on_off(s.chat_colors), Opt::ChatLinks => on_off(s.chat_links), + Opt::ChatLinksPrompt => on_off(s.chat_links_prompt), Opt::AutoSuggestions => on_off(s.auto_suggestions), Opt::ReducedDebugInfo => on_off(s.reduced_debug_info), Opt::HighContrast => on_off(s.high_contrast), @@ -743,6 +746,7 @@ impl Opt { Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3), Opt::ChatColors => s.chat_colors = !s.chat_colors, Opt::ChatLinks => s.chat_links = !s.chat_links, + Opt::ChatLinksPrompt => s.chat_links_prompt = !s.chat_links_prompt, Opt::AutoSuggestions => s.auto_suggestions = !s.auto_suggestions, Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info, Opt::HighContrast => s.high_contrast = !s.high_contrast, @@ -1487,6 +1491,7 @@ impl OptionsUi { Opt::ChatVisibility, Opt::ChatColors, Opt::ChatLinks, + Opt::ChatLinksPrompt, Opt::ChatOpacity, Opt::TextBackgroundOpacity, Opt::ChatScale, diff --git a/crates/horizon-client/src/settings.rs b/crates/horizon-client/src/settings.rs index 74c428923..c64cfcb33 100644 --- a/crates/horizon-client/src/settings.rs +++ b/crates/horizon-client/src/settings.rs @@ -149,6 +149,7 @@ pub struct Settings { pub chat_visibility: u8, pub chat_colors: bool, pub chat_links: bool, + pub chat_links_prompt: bool, pub chat_opacity: f32, pub text_background_opacity: f32, pub chat_scale: f32, @@ -234,6 +235,7 @@ impl Default for Settings { chat_visibility: 0, chat_colors: true, chat_links: true, + chat_links_prompt: true, chat_opacity: 1.0, text_background_opacity: 0.5, chat_scale: 1.0, @@ -427,6 +429,7 @@ impl Settings { chat_visibility: f.int("chatVisibility") as u8, chat_colors: f.bool("chatColors"), chat_links: f.bool("chatLinks"), + chat_links_prompt: f.bool("chatLinksPrompt"), chat_opacity: f.double("chatOpacity") as f32, text_background_opacity: f.double("textBackgroundOpacity") as f32, chat_scale: f.double("chatScale") as f32, @@ -510,6 +513,7 @@ impl Settings { put_i(&mut f, "chatVisibility", i32::from(self.chat_visibility)); put_b(&mut f, "chatColors", self.chat_colors); put_b(&mut f, "chatLinks", self.chat_links); + put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt); put_f(&mut f, "chatOpacity", self.chat_opacity); put_f( &mut f, diff --git a/crates/horizon-protocol/src/packets/configuration.rs b/crates/horizon-protocol/src/packets/configuration.rs index ced56dacf..22a8a1456 100644 --- a/crates/horizon-protocol/src/packets/configuration.rs +++ b/crates/horizon-protocol/src/packets/configuration.rs @@ -45,6 +45,54 @@ packet!( packet!(KnownPack { namespace: String => Str, id: String => Str, version: String => Str }); packet!(SelectKnownPacks { packs: Vec => List }); packet!(CodeOfConduct { text: String => Str }); + +/// ServerLinks.UntrustedEntry's `type` (Either): a +/// known link's id, or a custom label component. +#[derive(Clone, Debug, PartialEq)] +pub enum ServerLinkKind { + /// ServerLinks.KnownLinkType by id (ByIdMap: unknown ids read as 0). + Known(i32), + /// A custom label (TRUSTED_CONTEXT_FREE_STREAM_CODEC: a component in NBT). + Custom(Tag), +} +/// ServerLinks.UntrustedEntry: the label and the unchecked link string (the +/// client keeps only `http`/`https` URIs, Util.parseAndValidateUntrustedUri). +#[derive(Clone, Debug, PartialEq)] +pub struct ServerLink { + pub kind: ServerLinkKind, + pub link: String, +} +impl Codec for ServerLink { + fn read(r: &mut Reader) -> Result { + let kind = if Bool::read(r)? { + ServerLinkKind::Known(VarInt::read(r)?) + } else { + ServerLinkKind::Custom(Nbt::read(r)?) + }; + Ok(Self { + kind, + link: Str::read(r)?, + }) + } + fn write(v: &Self, w: &mut Writer) { + match &v.kind { + ServerLinkKind::Known(id) => { + Bool::write(&true, w); + VarInt::write(id, w); + } + ServerLinkKind::Custom(tag) => { + Bool::write(&false, w); + Nbt::write(tag, w); + } + } + Str::write(&v.link, w); + } +} +packet!( + /// ClientboundServerLinksPacket (a common packet: configuration and play). + /// Links the pause menu's "Server Links..." screen lists. + ServerLinks { links: Vec => List } +); packet!( /// ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC: the dialog /// itself (`Dialog.DIRECT_CODEC` as NBT). @@ -89,6 +137,7 @@ packet_enum!(Clientbound, crate::ids::configuration::clientbound { UpdateTags(UpdateTags) = UPDATE_TAGS, SelectKnownPacks(SelectKnownPacks) = SELECT_KNOWN_PACKS, ClearDialog(Empty) = CLEAR_DIALOG, + ServerLinks(ServerLinks) = SERVER_LINKS, ShowDialog(ShowDialog) = SHOW_DIALOG, CodeOfConduct(CodeOfConduct) = CODE_OF_CONDUCT, }); @@ -236,6 +285,18 @@ mod tests { Clientbound::CodeOfConduct(CodeOfConduct { text: "Be nice".into(), }), + Clientbound::ServerLinks(ServerLinks { + links: vec![ + ServerLink { + kind: ServerLinkKind::Known(0), + link: "https://bugs.example.net".into(), + }, + ServerLink { + kind: ServerLinkKind::Custom(crate::text::literal("Custom link")), + link: "https://example.net".into(), + }, + ], + }), Clientbound::ShowDialog(ShowDialog { dialog: Tag::compound([("type", Tag::String("minecraft:notice".into()))]), }), diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 26bc09a84..a21171693 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -15,7 +15,7 @@ use crate::{ configuration::{ ClientInformation, CookieRequest, CookieResponse, CustomClickAction, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse, - StoreCookie, Transfer, UpdateTags, + ServerLinks, StoreCookie, Transfer, UpdateTags, }, login::Property, }, @@ -1903,6 +1903,9 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { StopSound(StopSound) = STOP_SOUND, StartConfiguration(Empty) = START_CONFIGURATION, StoreCookie(StoreCookie) = STORE_COOKIE, + // A common packet: the same `configuration::ServerLinks` the + // configuration phase sends. + ServerLinks(ServerLinks) = SERVER_LINKS, SwingAnimation(SwingAnimation) = SWING_ANIMATION, SystemChat(SystemChat) = SYSTEM_CHAT, TabList(TabList) = TAB_LIST, From 5179f975826747290826b6453cc4f1be59899265 Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:01:04 +0100 Subject: [PATCH 154/475] Server packs: show a progress screen while a pushed pack's resources reload The download toast already matched vanilla, but once a server pack was downloaded the whole resource reload ran synchronously on the render thread: the frame froze on "Joining world" for seconds where vanilla's Minecraft.reloadResourcePacks shows a LoadingOverlay with a progress bar. The reload is split like the startup loader: a worker builds the overlay root, models and sprites, the menu bundle and the pack's tables while a progress screen (Preparing resources -> Loading assets -> Loading pack tables -> Applying resources) covers the frame, then the renderer swap and resource application land on the render thread and ServerPacks.reloaded drives the feedback replies, including the recovery reload on failure. The pack screen's own reload reuses the same staged load, so its behaviour is unchanged. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game.rs | 35 +++- crates/horizon-client/src/game/packs.rs | 152 ++++++++++++++---- .../horizon-client/src/game/server_packs.rs | 105 ++++++++++-- docs/resource-packs.md | 3 +- 4 files changed, 236 insertions(+), 59 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 755a00f6f..abaa639eb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10250,6 +10250,21 @@ impl Game<'_> { } return Ok(false); } + // A server pack's reload covers the frame with its progress + // (`LoadingOverlay`) until `tick_server_packs` applies it. + if let Some(reload) = &self.server_packs.reload { + if let Some(renderer) = &mut self.renderer { + let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); + horizon_client::level_loading::draw_progress( + &mut hud, + &self.gui.atlas, + reload.progress.line(), + reload.progress.fraction(), + ); + return renderer.render(&loading_frame(&hud)); + } + return Ok(false); + } let Some(size) = self .renderer .as_ref() @@ -12742,7 +12757,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [ KeyCode::Numpad9, ]; -const STAGES: u32 = 10; const STAGE_LINES: [&str; 10] = [ "Loading menu", "Loading assets", @@ -12758,19 +12772,28 @@ const STAGE_LINES: [&str; 10] = [ /// The background loader's checkpoint, read by the loading screen each frame. #[derive(Clone)] -struct Progress(Arc); +struct Progress { + value: Arc, + lines: &'static [&'static str], +} impl Progress { fn new() -> Self { - Self(Arc::new(AtomicU32::new(0))) + Self::with(&STAGE_LINES) + } + fn with(lines: &'static [&'static str]) -> Self { + Self { + value: Arc::new(AtomicU32::new(0)), + lines, + } } fn set(&self, stage: u32) { - self.0.store(stage, Ordering::Relaxed); + self.value.store(stage, Ordering::Relaxed); } fn fraction(&self) -> f32 { - self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32 + self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32 } fn line(&self) -> &'static str { - STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)] + self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)] } } diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index ee57e9f2b..995ebc8ca 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -1,14 +1,23 @@ //! Resource pack selection bound to the game: the pack screen lists, and a //! full resource reload after a change, while playing. -use super::Game; +use super::{ + Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures, +}; use horizon_client::{ + assets::Assets, + gfx::PreparedTextures, hud::HudAtlas, lang, pack_screen::{self, Compat, PackInfo, Source as PackSource}, packs::{self, Pack, Position}, + streaming::WorkerContext, text::{Style, Text}, }; -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::Arc, +}; /// Where overlay roots are built (beside options.txt). pub fn cache_dir() -> PathBuf { @@ -94,7 +103,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec { infos } impl Game<'_> { - fn vanilla_root(&self) -> PathBuf { + pub(super) fn vanilla_root(&self) -> PathBuf { if self.ui.vanilla_root.as_os_str().is_empty() { self.options.resources.clone() } else { @@ -134,35 +143,36 @@ impl Game<'_> { self.ui.options.resource_error = Some(e.to_string()); } } - /// Reload from the selected packs and the server's. - pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + /// The CPU half of the reload, safe on a worker (`PackReload` runs it): + /// `apply_staged_pack` finishes the GPU half and the swap. + pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result { let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); } - let server = &self.server_packs.active; - let root = startup_root(&vanilla, &self.settings.resource_packs, server)?; - let root = - super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods); - self.reload_resources(&root) + stage_pack( + &vanilla, + &self.settings.resource_packs, + &self.server_packs.active, + &self.settings.mods.enabled, + &self.options.dev_mods, + &self.pack.assets, + &self.res, + &self.pack.context, + self.settings.force_unicode, + self.settings.japanese_glyphs, + &super::Progress::new(), + ) } - fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> { - let render = std::sync::Arc::new(self.pack.assets.reload(root)?); - let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?; - atlas.font.uniform = self.settings.force_unicode; - atlas.font.japanese = self.settings.japanese_glyphs; - let gui = super::UiResources { - atlas, - panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)), - }; - let pack = super::PackResources::load( + /// The render-thread half: the renderer on the new pack's textures, then + /// the swap (`ReloadInstance`'s application listeners). + pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> { + let StagedPack { root, - self.pack.assets.clone(), - render, - self.res.facts(), - self.pack.context.biomes.clone(), - self.pack.context.light.clone(), - )?; + gui, + pack, + textures, + } = staged; // Keep the working GPU resources, but release its exclusive window surface. if let Some(renderer) = &mut self.renderer { renderer.release_surface(); @@ -170,7 +180,17 @@ impl Game<'_> { let renderer = match self .window .clone() - .map(|window| self.renderer_for(window, &pack, &gui)) + .map(|window| { + build_renderer( + window, + self.options.offscreen, + &pack, + &gui, + self.renderer.as_ref(), + Some(textures), + self.settings.horizon.api, + ) + }) .transpose() { Ok(renderer) => renderer, @@ -186,18 +206,18 @@ impl Game<'_> { } }; // Everything loaded: switch over. - self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root); - self.armor_stand_assets.reload_trims(root); + self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root); + self.armor_stand_assets.reload_trims(&root); self.random_tick_sections.clear(); self.portals.sections.clear(); - self.options.resources = root.to_path_buf(); + self.options.resources = root.clone(); let context = pack.context.clone(); self.pack = pack; self.gui = gui; // The atlas's sprites moved: the particle engine re-reads every rect // and the packs' sprite sets, or every particle draws a stale region. self.particles - .reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); + .reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); horizon_client::ui_theme::reset_atlas(); // Villager hats come from the pack's texture metadata (shared data, swapped in place). *self @@ -206,11 +226,11 @@ impl Game<'_> { .villager_hats .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = - horizon_client::mobs::villager_hats::VillagerHats::load(root); - horizon_client::ui::load_metadata(root); - lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code())); + horizon_client::mobs::villager_hats::VillagerHats::load(&root); + horizon_client::ui::load_metadata(&root); + lang::set(lang::Language::load(&[&root], &lang::code())); self.refresh_remote_language(); - self.audio.reload(root); + self.audio.reload(&root); self.clear_lod(); self.streaming.reload_resources(&mut self.world, context); self.pending_uploads.clear(); @@ -223,4 +243,66 @@ impl Game<'_> { self.ui.chat.rescale(&self.gui.atlas); Ok(()) } + /// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then + /// applied (`PackReload` runs the same two phases across frames). + pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + let staged = self.stage_selected_packs()?; + self.apply_staged_pack(staged) + } +} + +/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a +/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay +/// root, the models and sprites, the menu bundle, the pack's tables and its +/// CPU mip chains. +pub(super) fn stage_pack( + vanilla_root: &Path, + selected: &[String], + server: &[Pack], + mods_enabled: &BTreeSet, + dev_mods: &[PathBuf], + assets: &Arc, + res: &Resources, + context: &WorkerContext, + force_unicode: bool, + japanese: bool, + progress: &super::Progress, +) -> horizon_client::Result { + let root = startup_root(vanilla_root, selected, server)?; + let root = super::mods::content_root(&root, mods_enabled, dev_mods); + progress.set(1); + let render = Arc::new(assets.reload(&root)?); + let mut atlas = HudAtlas::build_over(&root, vanilla_root)?; + atlas.font.uniform = force_unicode; + atlas.font.japanese = japanese; + let gui = UiResources { + atlas, + panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)), + }; + progress.set(2); + let pack = PackResources::load( + &root, + assets.clone(), + render, + res.facts(), + context.biomes.clone(), + context.light.clone(), + )?; + let textures = prepare_renderer_textures(&pack); + progress.set(3); + Ok(StagedPack { + root, + gui, + pack, + textures, + }) +} + +/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes +/// it on the render thread). +pub(super) struct StagedPack { + pub root: PathBuf, + pub gui: UiResources, + pub pack: PackResources, + pub textures: PreparedTextures, } diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 58d25de0e..12b73f4f9 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -3,7 +3,7 @@ //! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the //! server list's setting, downloads into `downloads/`, and a resource reload //! with the server's packs on top of the player's own. -use super::Game; +use super::{Game, Progress}; use horizon_client::{ Result, confirm_screen::Confirm, @@ -82,6 +82,9 @@ pub(super) struct ServerPackState { pub(super) active: Vec, /// The download batches' notifiers, oldest first. notifiers: Vec, + /// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the + /// frame draws its progress until `tick_server_packs` applies it. + pub(super) reload: Option, } /// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its /// own `SystemToast`. @@ -191,6 +194,21 @@ fn saves() -> PathBuf { std::env::current_dir().unwrap_or_default().join("saves") } +/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. +const RELOAD_LINES: [&str; 4] = [ + "Preparing resources", + "Loading assets", + "Loading pack tables", + "Applying resources", +]; +/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a +/// worker while `progress` feeds the loading screen, then the apply lands +/// on the render thread. +pub(super) struct PackReload { + pub(super) progress: Progress, + handle: std::thread::JoinHandle>, +} + impl Game<'_> { /// `ConnectScreen`: the server's setting decides what its packs do /// (`configureForServerControl`); single player's own server takes them @@ -314,6 +332,7 @@ impl Game<'_> { if self.rest_loader.is_some() || self.world_loader.is_some() { return; } + self.drive_pack_reload(); let finished = self .server_packs .queue @@ -328,13 +347,8 @@ impl Game<'_> { if !tick.download.is_empty() { self.start_pack_downloads(tick.download); } - if let Some(mut load) = tick.reload { - while let Some(recovery) = { - let ok = self.reload_server_packs(&load); - self.server_packs.packs.reloaded(ok) - } { - load = recovery; - } + if let Some(load) = tick.reload { + self.start_pack_reload(&load); } } for (id, status) in self.server_packs.packs.take_replies() { @@ -379,24 +393,83 @@ impl Game<'_> { } } /// `DownloadedPackSource.startReload`: the packs on top of the player's - /// own, then `Minecraft.reloadResourcePacks`. False when a pack has no - /// readable `pack.mcmeta` or the reload failed. - fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool { - let packs: Option> = load + /// own, staged on a worker (`Minecraft.reloadResourcePacks` under a + /// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no + /// readable `pack.mcmeta` fails the reload at once, as `onFailure`. + fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) { + let server: Option> = load .iter() .map(|(id, path)| packs::server_pack(*id, path)) .collect(); - let Some(packs) = packs else { + let Some(server) = server else { log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all"); - return false; + self.finish_pack_reload(false); + return; }; - self.server_packs.active = packs; - match self.reload_selected_packs() { + self.server_packs.active = server.clone(); + let vanilla = self.vanilla_root(); + if self.ui.vanilla_root.as_os_str().is_empty() { + self.ui.vanilla_root = vanilla.clone(); + } + let (selected, mods, dev_mods) = ( + self.settings.resource_packs.clone(), + self.settings.mods.enabled.clone(), + self.options.dev_mods.clone(), + ); + let (assets, res, context) = ( + self.pack.assets.clone(), + self.res.clone(), + self.pack.context.clone(), + ); + let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); + let progress = Progress::with(&RELOAD_LINES); + let worker = progress.clone(); + let handle = std::thread::Builder::new() + .name("server-pack-reload".into()) + .spawn(move || { + super::packs::stage_pack( + &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, + unicode, japanese, &worker, + ) + .map_err(|e| e.to_string()) + }); + match handle { + Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), + Err(e) => { + log::error!("Horizon: could not start the pack reload worker: {e}"); + self.finish_pack_reload(false); + } + } + } + /// `PackReloadConfig.Callbacks`: applies the staged reload once its + /// worker is done; on failure the packs active before reload again. + fn drive_pack_reload(&mut self) { + let Some(reload) = &self.server_packs.reload else { + return; + }; + if !reload.handle.is_finished() { + return; + } + let reload = self.server_packs.reload.take().unwrap(); + let staged = reload + .handle + .join() + .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); + let ok = match staged + .and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())) + { Ok(()) => true, Err(e) => { log::warn!("Horizon: server resource pack reload failed: {e}"); false } + }; + self.finish_pack_reload(ok); + } + /// `ServerPacks::reloaded` and the recovery reload it can ask for. + fn finish_pack_reload(&mut self, ok: bool) { + if let Some(recovery) = self.server_packs.packs.reloaded(ok) { + self.start_pack_reload(&recovery); } } diff --git a/docs/resource-packs.md b/docs/resource-packs.md index 783c381dd..69eee3393 100644 --- a/docs/resource-packs.md +++ b/docs/resource-packs.md @@ -33,7 +33,7 @@ harness named checks that loader against vanilla). | Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) | | Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) | | Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` | -| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server | +| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), download toasts, a progress screen while the packs reload, applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server | | Other namespaces | `assets//` | works | `test_resource_pack_parity` | | Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` | | Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` | @@ -84,7 +84,6 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p | Gap | Size | Where | | --- | --- | --- | | Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) | -| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` | | GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) | | Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) | | `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) | From fc9305bb155c090b35daa3534bcdf4d4daacb92f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:03:42 +0100 Subject: [PATCH 155/475] mods: browser.set-recipes, recipes in the item panel's place A mod gives the recipes to show (a layout: crafting grid, cooking, single input, smithing or brewing; the slots, each cycling through its stacks; the results; a note; and an optional action button, enabled or greyed) under a title; an empty list shows the items again. The game pages them and sends the slot-clicked, action, page and back events. At most 256 recipes, 9 slots each and 64 stacks a slot. Same level as the rest of browser (5): none of it has shipped yet. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/wit/horizon.wit | 33 +++++++++++ crates/horizon-mods/src/api_level.rs | 3 +- crates/horizon-mods/src/runtime.rs | 56 ++++++++++++++++-- crates/horizon-mods/tests/sandbox.rs | 52 ++++++++++++++++ crates/horizon-mods/tests/ui_guest/src/lib.rs | 59 ++++++++++++++++++- docs/api/mods-reference.md | 27 +++++++++ docs/api/stability.md | 2 +- docs/mods.md | 2 +- 8 files changed, 225 insertions(+), 9 deletions(-) diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 38a0e5879..4896c3e21 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -558,8 +558,41 @@ interface browser { /// The back button of `set-recipes`. back, } + /// How the game lays out a recipe of `set-recipes`. + enum layout { + /// A crafting grid of `width` x `height` slots, an arrow and the + /// result. + grid, + /// One input over a flame, an arrow and the result (furnaces, + /// smokers, campfires). + cooking, + /// One input, an arrow and the result (the stonecutter). + single, + /// Template, base and addition, an arrow and the result. + smithing, + /// A potion and an ingredient, an arrow and the result. + brewing, + } + /// A recipe to show. Each slot, and the result, cycles through its + /// stacks, one a second; an empty slot shows nothing. + record recipe-view { + layout: layout, + /// The grid's size (`grid` only), at most 3 x 3. + width: u32, + height: u32, + slots: list>, + results: list, + /// A short line under the recipe (a cooking time), or "". + note: string, + /// The action button beside it: none, enabled, or shown greyed. + action: option, + } /// The panel's items, at most 8192 (an empty list hides the panel). set-items: func(items: list); + /// Shows these recipes in the panel instead of its items, as many as + /// fit a page, under `title` and a back button; an empty list shows the + /// items again. At most 256 recipes. + set-recipes: func(title: string, recipes: list); /// What the player did on the panel since the last poll, at most 256 /// kept. poll: func() -> list; diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 2474dfddd..7bb87b674 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -13,7 +13,7 @@ pub const LEVEL: u32 = 5; /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 39] = [ +pub const FUNCTIONS: [(&str, u32); 40] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -53,6 +53,7 @@ pub const FUNCTIONS: [(&str, u32); 39] = [ ("recipes.known", 4), ("browser.set-items", 5), ("browser.poll", 5), + ("browser.set-recipes", 5), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 0f2de0246..43c9e3fac 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -88,7 +88,8 @@ mod recipes_bindings { /// Capability `ui`'s recipe viewer panel (WIT `interface browser`). pub use recipes_bindings::horizon::mods::browser::{ Click as BrowserClick, Event as BrowserEvent, ItemKey as BrowserItemKey, - SlotClick as BrowserSlotClick, Stack as BrowserStack, + Layout as BrowserLayout, RecipeView as BrowserRecipe, SlotClick as BrowserSlotClick, + Stack as BrowserStack, }; /// What capability `recipes` reads (WIT `interface recipes`). pub use recipes_bindings::horizon::mods::recipes::{ @@ -318,14 +319,20 @@ struct UiState { #[derive(Default)] struct BrowserState { items: Vec, - /// Which `set-items` call the items are from, unique across mods and - /// reloads, so the game converts them once. + /// Which `set-items` or `set-recipes` call the panel is from, unique + /// across mods and reloads, so the game converts them once. version: u64, + /// `set-recipes`' title and recipes, shown instead of the items. + recipes: Option<(String, Vec)>, events: std::collections::VecDeque, } static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Items a panel may hold, and events kept for `browser.poll`. const BROWSER_ITEMS: usize = 8192; +/// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles. +const BROWSER_RECIPES: usize = 256; +const RECIPE_SLOTS: usize = 9; +const SLOT_STACKS: usize = 64; const BROWSER_EVENTS: usize = 256; impl BrowserState { fn push(&mut self, event: BrowserEvent) { @@ -829,13 +836,26 @@ impl Mods { .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) } /// The panel beside container screens (`browser.set-items`): the first - /// running mod with items, its index, the items' version and the items. + /// running mod with items or recipes, its index, the panel's version and + /// the items. pub fn browser_items(&self) -> Option<(usize, u64, &[BrowserStack])> { self.entries.iter().enumerate().find_map(|(i, e)| { let b = &e.run.as_ref()?.store.data().browser; - (!b.items.is_empty()).then_some((i, b.version, b.items.as_slice())) + (!b.items.is_empty() || b.recipes.is_some()).then_some(( + i, + b.version, + b.items.as_slice(), + )) }) } + /// The recipes mod `owner`'s panel shows instead of its items + /// (`browser.set-recipes`): the title and the recipes. + pub fn browser_recipes(&self, owner: usize) -> Option<(&str, &[BrowserRecipe])> { + let b = &self.entries.get(owner)?.run.as_ref()?.store.data().browser; + b.recipes + .as_ref() + .map(|(title, list)| (title.as_str(), list.as_slice())) + } /// The player did `event` on mod `owner`'s panel. pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) { if let Some(run) = self.entries.get_mut(owner).and_then(|e| e.run.as_mut()) { @@ -1241,6 +1261,32 @@ impl browser::Host for State { .collect(); self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } + fn set_recipes(&mut self, title: String, recipes: Vec) { + let stack = |s: BrowserStack| BrowserStack { + item: clip(s.item), + components: clip_to(s.components, 4096), + ..s + }; + let cycle = |list: Vec| -> Vec { + list.into_iter().take(SLOT_STACKS).map(stack).collect() + }; + self.browser.recipes = (!recipes.is_empty()).then(|| { + let recipes = recipes + .into_iter() + .take(BROWSER_RECIPES) + .map(|r| BrowserRecipe { + width: r.width.clamp(1, 3), + height: r.height.clamp(1, 3), + slots: r.slots.into_iter().take(RECIPE_SLOTS).map(cycle).collect(), + results: cycle(r.results), + note: clip(r.note), + ..r + }) + .collect(); + (clip(title), recipes) + }); + self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } fn poll(&mut self) -> Vec { self.browser.events.drain(..).collect() } diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 576dc5942..081884dc2 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1157,6 +1157,58 @@ fn browser_lists_items_and_hears_the_player() { assert_ne!(mods.browser_items().unwrap().1, version); assert_eq!(said(&mut mods, "browser"), ["panel of 0"]); assert!(mods.browser_items().is_none()); + // Recipes in the panel's place: the guest shows those making planks. + use horizon_mods::{BrowserLayout, Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; + let item = |id: &str| RecipeItem { + id: id.into(), + name: id.into(), + tags: Vec::new(), + }; + let stack = |item| RecipeStack { + item, + count: 1, + components: String::new(), + }; + mods.set_snapshot(Snapshot { + recipes: Some(std::sync::Arc::new(Recipes { + items: vec![item("minecraft:oak_log"), item("minecraft:oak_planks")], + recipes: vec![Recipe { + id: "minecraft:oak_planks".into(), + kind: RecipeKind::CraftingShapeless, + category: "building".into(), + group: "planks".into(), + width: 0, + height: 0, + slots: vec![vec![stack(0)]], + results: vec![RecipeStack { + count: 4, + ..stack(1) + }], + cooking_time: 0, + experience: 0.0, + book_id: 1, + }], + })), + ..Snapshot::default() + }); + said(&mut mods, "browser minecraft:dirt"); + assert_eq!( + said(&mut mods, "browser_recipes minecraft:oak_planks"), + ["recipes shown: 1"] + ); + let (title, recipes) = mods.browser_recipes(owner).unwrap(); + assert_eq!(title, "Recipes for minecraft:oak_planks"); + let r = &recipes[0]; + assert_eq!( + (r.layout, r.width, r.height, r.action), + (BrowserLayout::Grid, 1, 1, Some(false)) + ); + assert_eq!( + (r.slots[0][0].item.as_str(), r.results[0].count), + ("minecraft:oak_log", 4) + ); + assert_eq!(said(&mut mods, "browser_recipes"), ["recipes shown: 0"]); + assert!(mods.browser_recipes(owner).is_none()); // At most 256 events wait for a poll: the oldest go. for _ in 0..300 { mods.browser_event(owner, BrowserEvent::Page(1)); diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 43aa207e0..751169565 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -10,7 +10,9 @@ //! counts what `recipes` reads; `/recipes ID` describes that recipe. //! `/browser ID...` fills `browser`'s panel (`/browser N`: the first N //! items, `/browser` alone empties it) and registers the keys `recipe` (R) -//! and `uses` (U); `/browser_poll` says what the player did on it. +//! and `uses` (U); `/browser_poll` says what the player did on it; +//! `/browser_recipes ID` shows the recipes making ID (`/browser_recipes` +//! alone: the items again). use horizon_mod_sdk::{ Mod, Registration, browser, chat, command, game, input::{self, Event}, @@ -110,6 +112,7 @@ impl Mod for Guest { "recipes", "browser", "browser_poll", + "browser_recipes", "corner", "readings", ] @@ -310,6 +313,60 @@ impl Mod for Guest { input::register_key("uses", "Show uses", "key.keyboard.u")?; chat::show(&format!("panel of {}", items.len())); } + "browser_recipes" => { + let items = recipes::items(); + let id = |i: u32| { + items + .get(i as usize) + .map_or(String::new(), |x| x.id.clone()) + }; + let stack = |s: &recipes::Stack| browser::Stack { + item: id(s.item), + count: s.count, + components: s.components.clone(), + }; + let views: Vec = recipes::all() + .iter() + .filter(|r| !args.is_empty() && r.results.iter().any(|s| id(s.item) == args)) + .map(|r| { + use recipes::Kind; + let crafting = + matches!(r.kind, Kind::CraftingShaped | Kind::CraftingShapeless); + let shapeless = (r.slots.len() as u32).clamp(1, 3); + browser::RecipeView { + layout: match r.kind { + Kind::CraftingShaped | Kind::CraftingShapeless => { + browser::Layout::Grid + } + Kind::Stonecutting => browser::Layout::Single, + Kind::Smithing => browser::Layout::Smithing, + Kind::Brewing => browser::Layout::Brewing, + _ => browser::Layout::Cooking, + }, + width: if r.width == 0 { shapeless } else { r.width }, + height: if r.height == 0 { + (r.slots.len() as u32).div_ceil(3).max(1) + } else { + r.height + }, + slots: r + .slots + .iter() + .map(|s| s.iter().map(stack).collect()) + .collect(), + results: r.results.iter().map(stack).collect(), + note: if r.cooking_time > 0 { + format!("{} ticks", r.cooking_time) + } else { + String::new() + }, + action: crafting.then_some(r.kind == Kind::CraftingShaped), + } + }) + .collect(); + browser::set_recipes(&format!("Recipes for {args}"), &views); + chat::show(&format!("recipes shown: {}", views.len())); + } "browser_poll" => { let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components); // A hook shows at most 8 chat lines: the count, then the first 7. diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 298db26ba..6af12fa5b 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -588,9 +588,36 @@ A click on a slot of a recipe the panel shows (`set-recipes`). | `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. | | `back` | | The back button of `set-recipes`. | +### enum `layout` + +How the game lays out a recipe of `set-recipes`. + +| Case | | +| --- | --- | +| `grid` | A crafting grid of `width` x `height` slots, an arrow and the result. | +| `cooking` | One input over a flame, an arrow and the result (furnaces, smokers, campfires). | +| `single` | One input, an arrow and the result (the stonecutter). | +| `smithing` | Template, base and addition, an arrow and the result. | +| `brewing` | A potion and an ingredient, an arrow and the result. | + +### record `recipe-view` + +A recipe to show. Each slot, and the result, cycles through its stacks, one a second; an empty slot shows nothing. + +| Field | Type | | +| --- | --- | --- | +| `layout` | `layout` | | +| `width` | `u32` | The grid's size (`grid` only), at most 3 x 3. | +| `height` | `u32` | | +| `slots` | `list>` | | +| `results` | `list` | | +| `note` | `string` | A short line under the recipe (a cooking time), or "". | +| `action` | `option` | The action button beside it: none, enabled, or shown greyed. | + | Function | Signature | What it does | | --- | --- | --- | | `set-items` | `func(items: list)` | The panel's items, at most 8192 (an empty list hides the panel). | +| `set-recipes` | `func(title: string, recipes: list)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button; an empty list shows the items again. At most 256 recipes. | | `poll` | `func() -> list` | What the player did on the panel since the last poll, at most 256 kept. | ## `players` diff --git a/docs/api/stability.md b/docs/api/stability.md index bb0ddf501..1314d9813 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`). A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`, `set-recipes`). A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index 9aed4897b..bbd14dc84 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -88,7 +88,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | | `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen. **Experimental:** the manifest needs `experimental = ["ui"]` | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | | `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | From ad557fef4708767d3e60ab24f173192af04716e6 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:03:42 +0100 Subject: [PATCH 156/475] game: a mod's recipe pages in the item panel browser.set-recipes replaces the panel's items with the mod's recipes, laid out with the game's slot, furnace arrow and flame sprites and small buttons: a crafting grid, one input over a flame, one input, three inputs or two, then an arrow and the result, a note under it and the action button beside the result (under it when the panel is narrow). Slots cycle through their stacks once a second. As many recipes as fit the panel's height make a page, with the recipe book's arrows; a title row holds a Back button and the title. The same title sent again keeps the page. Clicks on the slots, the action button, the arrows and Back reach the mod; tooltips and the mods' keys work on the recipes' stacks as on the items. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/mod_browser.rs | 89 +++- .../src/game/mod_browser_recipes.rs | 419 ++++++++++++++++++ 3 files changed, 486 insertions(+), 24 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_browser_recipes.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 6779d367a..ec21bdbb9 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -100,6 +100,8 @@ mod minecarts; mod mob_blocks; mod mob_gear; mod mod_browser; +#[cfg(feature = "mods")] +mod mod_browser_recipes; mod mod_input; #[cfg(feature = "mods")] mod mod_recipes; diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 2f317792f..3121eb05b 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -53,27 +53,29 @@ pub(super) struct Browser { search: Option, /// The search text the mod last heard. sent: String, + /// `set-recipes`' recipes, shown instead of the items. + pub(super) recipes: Option, } /// Where the panel's parts are this frame. #[cfg(feature = "mods")] -struct Layout { +pub(super) struct Layout { /// The panel's whole area, for clicks that hit none of its parts. - area: [f32; 4], + pub(super) area: [f32; 4], /// The grid's top left corner, its columns and rows. - grid: (f32, f32), - columns: usize, - rows: usize, + pub(super) grid: (f32, f32), + pub(super) columns: usize, + pub(super) rows: usize, /// The page arrows' row. - arrows_y: f32, - search: [f32; 4], + pub(super) arrows_y: f32, + pub(super) search: [f32; 4], } #[cfg(feature = "mods")] impl Layout { fn per_page(&self) -> usize { self.columns * self.rows } - fn width(&self) -> f32 { + pub(super) fn width(&self) -> f32 { self.columns as f32 * SLOT } fn slot(&self, n: usize) -> (f32, f32) { @@ -83,7 +85,7 @@ impl Layout { ) } /// The page arrows: backward, then forward. - fn arrows(&self) -> [(f32, f32); 2] { + pub(super) fn arrows(&self) -> [(f32, f32); 2] { [ (self.grid.0, self.arrows_y), (self.grid.0 + self.width() - 12.0, self.arrows_y), @@ -92,7 +94,7 @@ impl Layout { } #[cfg(feature = "mods")] -fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { +pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h } @@ -123,6 +125,12 @@ impl Game<'_> { list.iter().map(|s| browser_stack(items, s)).collect() }); self.mods.browser.stacks = stacks; + // The same title again (the mod updating its buttons) keeps the page. + let old = self.mods.browser.recipes.take(); + self.mods.browser.recipes = + self.mods.mods.browser_recipes(owner).map(|(title, list)| { + super::mod_browser_recipes::Pages::new(items, title, list, old) + }); self.mods.browser.of = Some((owner, version)); } Some(owner) @@ -159,8 +167,22 @@ impl Game<'_> { .div_ceil(layout.per_page()) .max(1) } + /// The stack under the pointer on the panel, an item's or a recipe's, + /// as the mod sent it and as a stack. + fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> { + if self.mods.browser.recipes.is_some() { + let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?; + return Some((sent, stack)); + } + let (index, stack) = self.browser_hovered(layout)?; + let (_, _, items) = self.mods.mods.browser_items()?; + Some((items.get(index)?.clone(), stack)) + } /// The panel's item under the pointer: its index and stack. fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> { + if self.mods.browser.recipes.is_some() { + return None; + } let first = self.mods.browser.page * layout.per_page(); (0..layout.per_page()).find_map(|n| { let (x, y) = layout.slot(n); @@ -182,16 +204,9 @@ impl Game<'_> { search } - /// Draws the panel over the open container screen, then the carried - /// stack or the tooltip again so they stay on top of it. - pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { - if self.browser_sync().is_none() { - return; - } - let Some(layout) = self.browser_layout(batch) else { - return; - }; - let pages = self.browser_pages(&layout); + /// The items' page arrows and grid. + fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) { + let pages = self.browser_pages(layout); self.mods.browser.page = self.mods.browser.page.min(pages - 1); let page = self.mods.browser.page; let cursor = self.cursor; @@ -255,6 +270,22 @@ impl Game<'_> { horizon_client::widgets::Kind::Button, ); } + } + /// Draws the panel over the open container screen, then the carried + /// stack or the tooltip again so they stay on top of it. + pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { + if self.browser_sync().is_none() { + return; + } + let Some(layout) = self.browser_layout(batch) else { + return; + }; + let cursor = self.cursor; + if self.mods.browser.recipes.is_some() { + self.draw_browser_recipes(batch, &layout); + } else { + self.draw_browser_items(batch, &layout); + } let hint = self.translate("gui.recipebook.search_hint", "Search..."); let search = self.browser_search(&layout); // The hint shows in an empty box without focus, as the recipe book's. @@ -264,8 +295,8 @@ impl Game<'_> { self.mods.browser.search = Some(search); if self.carried.is_some() { self.draw_carried(batch); - } else if let Some((_, stack)) = self.browser_hovered(&layout) { - let lines = self.stack_tooltip(&stack); + } else if let Some(stack) = self.browser_pointed_stack(&layout) { + let lines = self.stack_tooltip(&stack.1); self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1); } else { self.draw_slot_tooltip(batch); @@ -301,6 +332,10 @@ impl Game<'_> { return true; } search.focused = false; + if self.mods.browser.recipes.is_some() { + return self.browser_recipes_press(&layout, owner, right) + || inside(cursor, layout.area); + } let pages = self.browser_pages(&layout); let page = self.mods.browser.page; let [back, forward] = layout.arrows(); @@ -405,7 +440,10 @@ impl Game<'_> { .browser_sync() .and_then(|_| self.browser_layout(&batch)) { - Some(layout) if inside(self.cursor, layout.area) => self.browser_hovered(&layout)?.1, + // The panel's stacks as the mod sent them. + Some(layout) if inside(self.cursor, layout.area) => { + return Some(self.browser_pointed_stack(&layout)?.0); + } _ => { let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?; self.slot_view(slot)? @@ -422,7 +460,10 @@ impl Game<'_> { /// A mod's item as a stack: an item the game has, its components read as /// `/give` reads them (ones it can't read are left out). #[cfg(feature = "mods")] -fn browser_stack(items: &horizon_client::items::Items, s: &BrowserStack) -> Option { +pub(super) fn browser_stack( + items: &horizon_client::items::Items, + s: &BrowserStack, +) -> Option { items.get(&s.item)?; let (damage, extra) = if s.components.is_empty() { (0, None) diff --git a/crates/horizon-client/src/game/mod_browser_recipes.rs b/crates/horizon-client/src/game/mod_browser_recipes.rs new file mode 100644 index 000000000..14848de90 --- /dev/null +++ b/crates/horizon-client/src/game/mod_browser_recipes.rs @@ -0,0 +1,419 @@ +//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item +//! panel's place, laid out by the game with its own slots, arrows and +//! buttons, paged to the panel's height, under a title and a back button. +//! The mod hears clicks on their slots, their action buttons, the back +//! button and page turns. +use super::{ + Game, + mod_browser::{Layout, browser_stack, inside}, +}; +use horizon_client::{ + hud::{HudBatch, rgba}, + items::ItemStack, +}; +use horizon_mods::{BrowserEvent, BrowserLayout, BrowserRecipe, BrowserSlotClick, BrowserStack}; +use std::time::Instant; + +const SLOT: f32 = 18.0; +/// The title row's height, and the gap under each recipe. +const TITLE: f32 = 18.0; +const GAP: f32 = 6.0; + +/// One recipe as the game draws it. +pub(super) struct Shown { + layout: BrowserLayout, + width: usize, + height: usize, + /// Each slot's stacks (the result last), as the mod sent them and as + /// stacks; a stack the game doesn't have is left out of the cycle. + slots: Vec>, + note: String, + action: Option, +} + +/// The recipes a panel shows, and its page. +pub(super) struct Pages { + title: String, + recipes: Vec, + page: usize, + since: Instant, +} + +impl Pages { + pub(super) fn new( + items: &horizon_client::items::Items, + title: &str, + list: &[BrowserRecipe], + old: Option, + ) -> Self { + let kept = old.filter(|o| o.title == title); + let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> { + stacks + .iter() + .filter_map(|s| Some((s.clone(), browser_stack(items, s)?))) + .collect() + }; + let recipes = list + .iter() + .map(|r| { + let mut slots: Vec<_> = r.slots.iter().map(|s| cycle(s)).collect(); + slots.push(cycle(&r.results)); + Shown { + layout: r.layout, + width: r.width as usize, + height: r.height as usize, + slots, + note: r.note.clone(), + action: r.action, + } + }) + .collect(); + Self { + title: title.to_owned(), + recipes, + page: kept.as_ref().map_or(0, |o| o.page), + since: kept.map_or_else(Instant::now, |o| o.since), + } + } +} + +impl Shown { + /// The inputs' block: its size and each input slot's place in it. + fn inputs(&self) -> ((f32, f32), Vec<(f32, f32)>) { + let row = |n: usize| (0..n).map(|i| (i as f32 * SLOT, 0.0)).collect(); + match self.layout { + BrowserLayout::Grid => { + let (w, h) = (self.width.max(1), self.height.max(1)); + let at = (0..w * h) + .map(|i| ((i % w) as f32 * SLOT, (i / w) as f32 * SLOT)) + .collect(); + ((w as f32 * SLOT, h as f32 * SLOT), at) + } + // The input over the flame. + BrowserLayout::Cooking => ((SLOT, 2.0 * SLOT), vec![(0.0, 0.0)]), + BrowserLayout::Single => ((SLOT, SLOT), row(1)), + BrowserLayout::Smithing => ((3.0 * SLOT, SLOT), row(3)), + BrowserLayout::Brewing => ((2.0 * SLOT, SLOT), row(2)), + } + } + /// The height of its slots, arrow and button, `room` wide. + fn block(&self, room: f32) -> f32 { + let h = self.inputs().0.1; + let (_, _, action) = self.places(room); + match self.action { + Some(_) => h.max(action.1 + 12.0), + None => h, + } + } + fn height(&self, room: f32) -> f32 { + let note = if self.note.is_empty() { 0.0 } else { 11.0 }; + self.block(room) + note + GAP + } + /// Every slot's place from the recipe's corner (the result last), the + /// arrow's and the action button's: beside the result, or under it + /// when the panel is `room` wide and that doesn't fit. + fn places(&self, room: f32) -> (Vec<(f32, f32)>, (f32, f32), (f32, f32)) { + let ((w, h), mut at) = self.inputs(); + // The mod's slots beyond the layout's are not shown. + at.truncate(self.slots.len() - 1); + let arrow = (w + 4.0, ((h - 16.0) / 2.0).floor()); + let result = (w + 32.0, ((h - SLOT) / 2.0).floor()); + let action = if result.0 + SLOT + 16.0 <= room { + (result.0 + SLOT + 4.0, ((h - 12.0) / 2.0).floor()) + } else { + (result.0 + 3.0, result.1 + SLOT + 2.0) + }; + while at.len() < self.slots.len() - 1 { + at.push((f32::NAN, f32::NAN)); + } + at.push(result); + (at, arrow, action) + } +} + +impl Game<'_> { + /// The first recipe of each page, filling the panel from the title row + /// to the search box. + fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec { + let room = layout.search[1] - 4.0 - (layout.grid.1 + TITLE); + let mut starts = vec![0]; + let mut used = 0.0; + for (i, r) in pages.recipes.iter().enumerate() { + let h = r.height(layout.width()); + if used > 0.0 && used + h > room { + starts.push(i); + used = 0.0; + } + used += h; + } + starts + } + /// The recipes on the open page, each with its corner. + fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> { + let Some(pages) = &self.mods.browser.recipes else { + return Vec::new(); + }; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len()); + let mut y = layout.grid.1 + TITLE; + (starts[page]..end) + .map(|i| { + let at = (layout.grid.0, y); + y += pages.recipes[i].height(layout.width()); + (i, at) + }) + .collect() + } + fn back_button(&self, layout: &Layout) -> ([f32; 4], String) { + let label = self.translate("gui.back", "Back"); + let w = HudBatch::text_width(&self.gui.atlas, &label) + 8.0; + ([layout.grid.0, layout.grid.1, w, 14.0], label) + } + /// What a slot of a shown recipe holds now: its stacks cycle once a second. + fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + let stacks = pages.recipes.get(recipe)?.slots.get(slot)?; + let n = pages.since.elapsed().as_secs() as usize; + stacks.get(n % stacks.len().max(1)) + } + /// The recipe slot under the pointer: recipe, slot, and its stack now. + pub(super) fn recipe_slot_hovered( + &self, + layout: &Layout, + ) -> Option<(usize, usize, BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + self.recipes_shown(layout) + .into_iter() + .find_map(|(i, (x, y))| { + let (at, _, _) = pages.recipes[i].places(layout.width()); + at.iter().enumerate().find_map(|(k, &(sx, sy))| { + if !inside(self.cursor, [x + sx, y + sy, SLOT, SLOT]) { + return None; + } + let (mod_stack, stack) = self.recipe_slot_now(i, k)?.clone(); + Some((i, k, mod_stack, stack)) + }) + }) + } + + /// Draws the title row, the page's recipes and the page arrows. + pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) { + let Some(pages) = &self.mods.browser.recipes else { + return; + }; + let cursor = self.cursor; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let (title, pages_n) = (pages.title.clone(), starts.len()); + let (back, label) = self.back_button(layout); + let atlas = &self.gui.atlas; + let hover = inside(cursor, back); + let sprite = if hover { + "gui/sprites/widget/button_highlighted" + } else { + "gui/sprites/widget/button" + }; + horizon_client::ui::nine_slice( + batch, + atlas, + sprite, + back[0], + back[1], + back[2], + back[3], + u32::MAX, + ); + batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true); + horizon_client::widgets::record(&label, back, true); + let room = layout.width() - back[2] - 4.0; + let fit = atlas.font.fit(&title, room); + let shown: String = title.chars().take(fit).collect(); + batch.text( + atlas, + &shown, + back[0] + back[2] + 4.0, + back[1] + 3.0, + u32::MAX, + true, + ); + if pages_n > 1 { + let labels = [ + self.translate("gui.recipebook.previous_page", "Previous Page"), + self.translate("gui.recipebook.next_page", "Next Page"), + ]; + let visible = [page > 0, page + 1 < pages_n]; + let atlas = &self.gui.atlas; + for (((x, y), name), (label, visible)) in layout + .arrows() + .into_iter() + .zip(["page_backward", "page_forward"]) + .zip(labels.iter().zip(visible)) + { + if !visible { + continue; + } + let suffix = if inside(cursor, [x, y, 12.0, 17.0]) { + "_highlighted" + } else { + "" + }; + batch.sprite( + atlas, + &format!("gui/sprites/recipe_book/{name}{suffix}"), + x, + y, + ); + horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true); + } + let text = format!("{}/{pages_n}", page + 1); + let w = HudBatch::text_width(atlas, &text); + let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor(); + batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true); + } + for (i, (x, y)) in self.recipes_shown(layout) { + let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else { + break; + }; + let (at, arrow, action) = r.places(layout.width()); + let h = r.block(layout.width()); + let (cooking, note, button) = + (r.layout == BrowserLayout::Cooking, r.note.clone(), r.action); + let atlas = &self.gui.atlas; + for &(sx, sy) in at.iter().filter(|p| p.0.is_finite()) { + batch.sprite(atlas, "gui/sprites/container/slot", x + sx, y + sy); + } + if cooking { + batch.sprite( + atlas, + "gui/sprites/container/furnace/lit_progress", + x + 2.0, + y + SLOT + 2.0, + ); + } + batch.sprite( + atlas, + "gui/sprites/container/furnace/burn_progress", + x + arrow.0, + y + arrow.1, + ); + if let Some(enabled) = button { + let rect = [x + action.0, y + action.1, 12.0, 12.0]; + let sprite = match (enabled, inside(cursor, rect)) { + (false, _) => "gui/sprites/widget/button_disabled", + (true, true) => "gui/sprites/widget/button_highlighted", + (true, false) => "gui/sprites/widget/button", + }; + horizon_client::ui::nine_slice( + batch, + atlas, + sprite, + rect[0], + rect[1], + 12.0, + 12.0, + u32::MAX, + ); + let color = if enabled { + u32::MAX + } else { + rgba(160, 160, 160, 255) + }; + batch.text(atlas, "+", rect[0] + 3.0, rect[1] + 2.0, color, true); + horizon_client::widgets::record(&format!("+ {i}"), rect, enabled); + } + if !note.is_empty() { + batch.text(atlas, ¬e, x, y + h + 2.0, rgba(224, 224, 224, 255), true); + } + for (k, &(sx, sy)) in at.iter().enumerate().filter(|(_, p)| p.0.is_finite()) { + let Some((_, stack)) = self.recipe_slot_now(i, k).cloned() else { + continue; + }; + self.draw_item(batch, &stack, x + sx + 1.0, y + sy + 1.0); + if inside(cursor, [x + sx, y + sy, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + sx + 1.0, + y + sy + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &format!("{name} {i}:{k}"), + [x + sx + 1.0, y + sy + 1.0, 16.0, 16.0], + true, + horizon_client::widgets::Kind::Button, + ); + } + } + } + + /// A press on the recipes: true when it hit one of their parts. + pub(super) fn browser_recipes_press( + &mut self, + layout: &Layout, + owner: usize, + right: bool, + ) -> bool { + let Some(pages) = &self.mods.browser.recipes else { + return false; + }; + let cursor = self.cursor; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let [back_arrow, forward] = layout.arrows(); + let turn = if page > 0 && inside(cursor, [back_arrow.0, back_arrow.1, 12.0, 17.0]) { + Some(page - 1) + } else if page + 1 < starts.len() && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) { + Some(page + 1) + } else { + None + }; + if let Some(to) = turn { + if let Some(p) = self.mods.browser.recipes.as_mut() { + p.page = to; + } + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Page(starts[to] as u32)); + return true; + } + if inside(cursor, self.back_button(layout).0) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods.mods.browser_event(owner, BrowserEvent::Back); + return true; + } + if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) { + let event = BrowserEvent::SlotClicked(BrowserSlotClick { + recipe: recipe as u32, + slot: slot as u32, + stack, + right, + }); + self.mods.mods.browser_event(owner, event); + return true; + } + let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| { + let r = &pages.recipes[i]; + let (_, _, (ax, ay)) = r.places(layout.width()); + r.action.is_some() && inside(cursor, [x + ax, y + ay, 12.0, 12.0]) + }); + if let Some((i, _)) = action { + if pages.recipes[i].action == Some(true) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Action(i as u32)); + } + return true; + } + false + } +} From 8526305faa2a15aa0b3f737ad5b0c4ea5d0e9bc2 Mon Sep 17 00:00:00 2001 From: Senjin Date: Mon, 26 Oct 2026 18:04:54 +0100 Subject: [PATCH 157/475] HDR: mark only the sun's and moon's discs, not their whole quads The celestial pass wrote alpha 0 over each quad, so HDR output lifted a square of bright daytime sky around the sun. It now draws premultiplied (one, one), which looks the same, and its alpha is how much a texel adds; the blend takes that off the frame's alpha, so only the discs shine. The dim End sky, drawn by the same pass, stays unmarked. fs_celestial keeps its own blend for lightning and additive entities. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity.wgsl | 17 +++++++++++++++-- crates/horizon-client/src/gfx.rs | 13 +++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 2e865dfb1..93c2a4105 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -208,9 +208,22 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 { return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a); } +fn celestial_color(in: Out) -> vec4 { + return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color; +} @fragment fn fs_celestial(in: Out) -> @location(0) vec4 { - let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color; - return color; + return celestial_color(in); +} + +// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light, +// premultiplied for an additive (one, one) blend, and as alpha how much of it +// the texel adds, which the blend takes off the frame's alpha. HDR output +// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not +// the dark rest of their quads or the dim End sky. +@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4 { + let color = celestial_color(in); + let added = color.rgb * color.a; + return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b)))); } // RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off). diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 904370c04..c18f2cb6c 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -1939,22 +1939,23 @@ impl Renderer { multisample: Default::default(), fragment: Some(wgpu::FragmentState { module: &entity_shader, - entry_point: Some("fs_celestial"), + entry_point: Some("fs_sky_celestial"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: config.format.remove_srgb_suffix(), - // RenderPipelines.CELESTIAL: additive (src alpha, one). The - // alpha goes to 0: HDR output (hdr.wgsl) lets the sun and - // moon shine; nothing else reads the frame's alpha. + // RenderPipelines.CELESTIAL: additive (src alpha, one), with + // the colour premultiplied in fs_sky_celestial. Its alpha is + // how much a texel adds: HDR output (hdr.wgsl) lets the sun's + // and moon's discs shine; nothing else reads the frame's alpha. blend: Some(wgpu::BlendState { color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::SrcAlpha, + src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add, }, alpha: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::Zero, - dst_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add, }, }), From e8ae26c27016790f088eb6b4eeb1650ac5dffee5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:09:01 +0100 Subject: [PATCH 158/475] Carry the exploding mob's kind with its blast and anger bees at guests too --- .../examples/probe/mob_ai_probe.rs | 2 +- crates/horizon-client/src/game/beehives.rs | 34 ++++++++++++++++--- crates/horizon-client/src/game/creatures.rs | 9 +++-- crates/horizon-client/src/mobs.rs | 4 ++- crates/horizon-client/src/mobs/hostile.rs | 1 + crates/horizon-client/src/mobs/sulfur_cube.rs | 1 + crates/horizon-client/src/mobs/wither.rs | 1 + 7 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..2f3cc70da 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool { match e { MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0, // ServerExplosion.hurtEntities: within twice the radius every hit does damage. - MobEvent::Explode(at, radius) => { + MobEvent::Explode(at, radius, _) => { let d = Vec3::new(at[0], at[1], at[2]) .distance_squared(p.position) .sqrt(); diff --git a/crates/horizon-client/src/game/beehives.rs b/crates/horizon-client/src/game/beehives.rs index 258610f5e..1335377c5 100644 --- a/crates/horizon-client/src/game/beehives.rs +++ b/crates/horizon-client/src/game/beehives.rs @@ -195,7 +195,8 @@ impl Game<'_> { self.anger_nearby_bees(at); } /// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down) - /// without a target go for a player in the same box. + /// without a target go for a random player in the same box, the window's + /// own or a guest (`None` is the window's player). pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) { let min = Vec3::new( f64::from(at[0]) - 8.0, @@ -203,10 +204,24 @@ impl Game<'_> { f64::from(at[2]) - 8.0, ); let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0)); - if !self + let mut players: Vec> = Vec::new(); + if self .player_view() .is_some_and(|p| p.bounds.intersects(area)) { + players.push(None); + } + if let Some(host) = self.lan.as_ref() { + players.extend( + host.hub + .guests + .iter() + .filter(|g| host.hub.here(g) && g.loaded && !g.dead) + .filter(|g| super::lan_mobs::guest_bounds(g).intersects(area)) + .map(|g| Some(g.conn)), + ); + } + if players.is_empty() { return; } let mut bees: Vec = (0..self.mobs.len()) @@ -219,9 +234,20 @@ impl Game<'_> { .collect(); bees.retain(|&i| self.mobs[i].ai.target.is_none()); for i in bees { - // Util.getRandom over the one player. - let _ = self.world.random.next_int_bound(1); + // Util.getRandom over the players. + let pick = self + .world + .random + .next_int_bound(players.len() as i32) + .unwrap_or(0) as usize; self.mobs[i].ai.target = Some(Who::Player); + // The player a mob sees is its guest binding, else the window's. + if let Some(host) = self.lan.as_mut() { + match players[pick] { + Some(conn) => host.mob_players.insert(self.mobs[i].id, conn), + None => host.mob_players.remove(&self.mobs[i].id), + }; + } } } } diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 6e6610dcc..fd0f5f5de 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -901,7 +901,7 @@ impl Game<'_> { owner, } => self.add_fangs(at, yaw, delay, owner), MobEvent::Shoot(..) => {} - MobEvent::Explode(at, radius) => { + MobEvent::Explode(at, radius, species) => { // The exploding mob causes the damage (Creeper.explodeCreeper). let source_mob = self .mobs @@ -916,10 +916,9 @@ impl Game<'_> { } self.combat.explosion_source = source_mob; // Only a creeper or a wither empties the hives it blows up. - let empties_hives = source_mob - .and_then(|id| self.mobs.iter().find(|m| m.id == id)) - .is_some_and(|m| matches!(m.species(), Species::Creeper | Species::Wither)); - let result = if empties_hives { + // The creeper is already gone from the list, so the event + // carries its kind. + let result = if matches!(species, Species::Creeper | Species::Wither) { self.with_hive_emptying_blast(|g| g.explode(at, radius)) } else { self.explode(at, radius) diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..a97d3514e 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -965,7 +965,9 @@ pub enum MobEvent { owner: u32, }, Shoot([f64; 3], [f64; 3]), - Explode([f64; 3], f32), + /// The centre, the radius and the kind of the mob blowing up (it may be + /// gone from the list by the time the blast is handled). + Explode([f64; 3], f32, Species), /// Melee or ranged damage to another mob. AttackMob { target: u32, diff --git a/crates/horizon-client/src/mobs/hostile.rs b/crates/horizon-client/src/mobs/hostile.rs index 0d3feaa6c..8ad69b030 100644 --- a/crates/horizon-client/src/mobs/hostile.rs +++ b/crates/horizon-client/src/mobs/hostile.rs @@ -180,6 +180,7 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec) { events.push(MobEvent::Explode( [m.position.x, m.position.y, m.position.z], radius, + m.species(), )); m.removed = true; } diff --git a/crates/horizon-client/src/mobs/sulfur_cube.rs b/crates/horizon-client/src/mobs/sulfur_cube.rs index 00b846b86..2af0d7362 100644 --- a/crates/horizon-client/src/mobs/sulfur_cube.rs +++ b/crates/horizon-client/src/mobs/sulfur_cube.rs @@ -370,6 +370,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { events.push(MobEvent::Explode( [m.position.x, m.position.y + 0.0625, m.position.z], power, + m.species(), )); m.removed = true; return; diff --git a/crates/horizon-client/src/mobs/wither.rs b/crates/horizon-client/src/mobs/wither.rs index 6702686c5..7f1fc0db2 100644 --- a/crates/horizon-client/src/mobs/wither.rs +++ b/crates/horizon-client/src/mobs/wither.rs @@ -221,6 +221,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec Date: Mon, 26 Oct 2026 17:11:51 +0000 Subject: [PATCH 159/475] Fix recipe book settings persistence on the server Send open/filter changes to the authoritative player's recipe book and cover packet settings plus save/reopen in both single-player modes. Co-Authored-By: OpenCode GPT-6.1 Sol --- .../examples/probe/recipe_book_fixture.rs | 23 ++++++- crates/horizon-client/src/game/agent.rs | 3 + crates/horizon-client/src/game/recipe_book.rs | 18 +++++ crates/horizon-client/src/lan/hub.rs | 10 +++ crates/horizon-client/tests/lan_bot.rs | 69 +++++++++++++++++++ crates/horizon-protocol/src/packets/play.rs | 1 + .../horizon-protocol/src/packets/recipes.rs | 27 ++++++++ .../parity/VanillaRecipeBookOracle.java | 21 +++++- tools/lan_e2e.py | 59 ++++++++++++++++ tools/test_recipe_book_parity.py | 11 ++- 10 files changed, 236 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/examples/probe/recipe_book_fixture.rs b/crates/horizon-client/examples/probe/recipe_book_fixture.rs index ce2afe624..2dda3b109 100644 --- a/crates/horizon-client/examples/probe/recipe_book_fixture.rs +++ b/crates/horizon-client/examples/probe/recipe_book_fixture.rs @@ -5,6 +5,10 @@ //! //! usage: recipe_book_fixture RESOURCES CASES OUTPUT use horizon_client::recipe_book::{BookType, RecipeIndex, Settings}; +use horizon_protocol::{ + buf::{Codec, Reader, Writer}, + packets::recipes::RecipeBookChangeSettings, +}; use horizon_world::nbt::Tag; use serde_json::Value; use std::{collections::BTreeSet, fs, path::Path}; @@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> { _ => BookType::Smoker, } .index(); - book.open[i] = op["open"].as_bool().unwrap_or(false); - book.filtering[i] = op["filtering"].as_bool().unwrap_or(false); + let packet = RecipeBookChangeSettings { + book_type: i as i32, + open: op["open"].as_bool().unwrap_or(false), + filtering: op["filtering"].as_bool().unwrap_or(false), + }; + let mut wire = Writer::new(); + RecipeBookChangeSettings::write(&packet, &mut wire); + extra = format!( + " packet={}", + wire.data + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?; + book.open[decoded.book_type as usize] = decoded.open; + book.filtering[decoded.book_type as usize] = decoded.filtering; } "save" => { let tag = book.write(); diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 0d2d218ab..1433e92e8 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -686,9 +686,12 @@ impl Game<'_> { // the recipe buttons of the page, page arrows, filter and tabs. let book = self.recipe_book_targets(); if !book.is_empty() { + let (open, filtering) = self.recipe_book_flags().unwrap_or_default(); v["recipe_book"] = json!({ "targets": book.iter().map(|t| t.rect).collect::>(), "known": self.recipe_book_known(), + "open": open, + "filtering": filtering, }); } v["slot_ids"] = json!( diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 96e12d79b..bf0d1c370 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -590,6 +590,7 @@ impl Game<'_> { if inside(cursor, bx, by, 20.0, 18.0) { let open = !self.book_open(book_type); self.level.recipe_book.open[book_type.index()] = open; + self.send_book_settings(book_type); if !open { self.recipe_ui.overlay = None; } @@ -675,6 +676,7 @@ impl Game<'_> { if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) { let filtering = !self.book_filtering(book_type); self.level.recipe_book.filtering[book_type.index()] = filtering; + self.send_book_settings(book_type); self.recipe_ui.page = 0; self.audio .play("minecraft:ui.button.click", None, 0.25, 1.0); @@ -695,6 +697,22 @@ impl Game<'_> { } false } + /// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book. + fn send_book_settings(&self, book_type: BookType) { + self.send( + horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings( + horizon_protocol::packets::recipes::RecipeBookChangeSettings { + book_type: book_type.index() as i32, + open: self.book_open(book_type), + filtering: self.book_filtering(book_type), + }, + ), + ); + } + pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> { + let book = self.active_book()?; + Some((self.book_open(book), self.book_filtering(book))) + } /// How many displays the client's book holds. pub(super) fn recipe_book_known(&self) -> usize { self.recipe_ui.client.displays().len() diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b462536b9..dc0dbe89d 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1930,6 +1930,7 @@ impl Hub { | S::PlayerLoaded(_) | S::ChunkBatchReceived(_) | S::ClientInformation(_) + | S::RecipeBookChangeSettings(_) | S::ClientTickEnd(_) | S::KeepAlive(_) => false, S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]), @@ -1996,6 +1997,15 @@ impl Hub { g.max_unacknowledged = 10; } S::ClientTickEnd(_) => self.guests[i].position_this_tick = false, + // ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket. + S::RecipeBookChangeSettings(p) => { + if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize) + { + let settings = &mut self.guests[i].recipe_book; + settings.open[book.index()] = p.open; + settings.filtering[book.index()] = p.filtering; + } + } S::ClientInformation(info) => { let g = &mut self.guests[i]; g.view_distance = diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 34e8265a1..81ec192f5 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -278,6 +278,75 @@ fn run( handle.join().unwrap().unwrap() } +#[test] +fn recipe_settings_packets_update_only_the_players_book_and_survive_save() { + let Some(resources) = resources() else { + return; + }; + let mut s = server(&resources); + let port = s.hub.lan.port; + let _bot = run(&mut s, move || connect(port, "RecipeSettings", true)); + let deadline = Instant::now() + Duration::from_secs(5); + while s.hub.guests.is_empty() { + s.tick(&[]); + assert!(Instant::now() < deadline, "guest did not join"); + thread::yield_now(); + } + for book in 0..4 { + for flags in 0..4 { + let open = flags & 1 != 0; + let filtering = flags & 2 != 0; + let before = s.hub.guests[0].recipe_book.clone(); + // Vanilla's wire payload: RecipeBookType VarInt, open, filtering. + let packet = S::decode( + horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS, + &[book, u8::from(open), u8::from(filtering)], + ) + .unwrap(); + s.hub.guests[0].inbox.push_back(packet); + s.tick(&[]); + let settings = &s.hub.guests[0].recipe_book; + for i in 0..4 { + assert_eq!( + settings.open[i], + if i == usize::from(book) { + open + } else { + before.open[i] + } + ); + assert_eq!( + settings.filtering[i], + if i == usize::from(book) { + filtering + } else { + before.filtering[i] + } + ); + } + let mut saved = horizon_client::save::Level::default(); + horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved); + let mut reopened = horizon_client::save::Level::default(); + reopened.read_player(&saved.player_tag()); + assert_eq!(reopened.recipe_book.open, settings.open); + assert_eq!(reopened.recipe_book.filtering, settings.filtering); + } + } + for book_type in [-1, 4] { + let mut wire = horizon_protocol::buf::Writer::new(); + wire.varint(book_type); + wire.bool(true); + wire.bool(true); + assert!( + S::decode( + horizon_protocol::ids::play::serverbound::RECIPE_BOOK_CHANGE_SETTINGS, + &wire.data, + ) + .is_err() + ); + } +} + #[test] fn sprint_commands_survive_keyboard_input_changes() { let Some(resources) = resources() else { diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 67373cf4b..1c20ed36c 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -2219,6 +2219,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound { ChatCommandSigned(ChatCommandSigned) = CHAT_COMMAND_SIGNED, ChatSessionUpdate(ChatSessionUpdate) = CHAT_SESSION_UPDATE, PlaceRecipe(super::recipes::PlaceRecipe) = PLACE_RECIPE, + RecipeBookChangeSettings(super::recipes::RecipeBookChangeSettings) = RECIPE_BOOK_CHANGE_SETTINGS, ChunkBatchReceived(ChunkBatchReceived) = CHUNK_BATCH_RECEIVED, ClientCommand(ClientCommand) = CLIENT_COMMAND, ClientInformation(ClientInformation) = CLIENT_INFORMATION, diff --git a/crates/horizon-protocol/src/packets/recipes.rs b/crates/horizon-protocol/src/packets/recipes.rs index c289b8943..dcbe5ffa0 100644 --- a/crates/horizon-protocol/src/packets/recipes.rs +++ b/crates/horizon-protocol/src/packets/recipes.rs @@ -500,6 +500,33 @@ impl Codec for RecipeBookSettings { } } +/// ServerboundRecipeBookChangeSettingsPacket: the active book's open and +/// filtering flags, with its `RecipeBookType` ordinal as a VarInt. +#[derive(Clone, Debug, PartialEq)] +pub struct RecipeBookChangeSettings { + pub book_type: i32, + pub open: bool, + pub filtering: bool, +} +impl Codec for RecipeBookChangeSettings { + fn read(r: &mut Reader) -> Result { + let book_type = r.varint()?; + if !(0..4).contains(&book_type) { + return Err(crate::Error::new("invalid recipe book type")); + } + Ok(Self { + book_type, + open: r.bool()?, + filtering: r.bool()?, + }) + } + fn write(v: &Self, w: &mut Writer) { + w.varint(v.book_type); + w.bool(v.open); + w.bool(v.filtering); + } +} + /// ClientboundPlaceGhostRecipePacket: the recipe to show faded in a menu /// whose items were missing. #[derive(Clone, Debug, PartialEq)] diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java index 60640010b..2cf9d7d48 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRecipeBookOracle.java @@ -1,12 +1,15 @@ package dev.horizonlab.parity; import com.google.gson.*; +import io.netty.buffer.Unpooled; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import net.minecraft.core.registries.Registries; import net.minecraft.nbt.*; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.game.ServerboundRecipeBookChangeSettingsPacket; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerPlayer; @@ -102,8 +105,22 @@ public final class VanillaRecipeBookOracle { count = kind.equals("give") ? book.addRecipes(holders, player) : book.removeRecipes(holders, player); } case "seen" -> book.removeHighlight(key(op.get("id").getAsString())); - case "set" -> book.setBookSetting(RecipeBookType.valueOf(op.get("type").getAsString()), - op.get("open").getAsBoolean(), op.get("filtering").getAsBoolean()); + case "set" -> { + var packet = new ServerboundRecipeBookChangeSettingsPacket( + RecipeBookType.valueOf(op.get("type").getAsString()), + op.get("open").getAsBoolean(), op.get("filtering").getAsBoolean()); + var buf = new FriendlyByteBuf(Unpooled.buffer()); + try { + ServerboundRecipeBookChangeSettingsPacket.STREAM_CODEC.encode(buf, packet); + byte[] wire = new byte[buf.readableBytes()]; + buf.getBytes(buf.readerIndex(), wire); + extra = " packet=" + HexFormat.of().formatHex(wire); + var decoded = ServerboundRecipeBookChangeSettingsPacket.STREAM_CODEC.decode(buf); + book.setBookSetting(decoded.getBookType(), decoded.isOpen(), decoded.isFiltering()); + } finally { + buf.release(); + } + } case "save" -> { saved = (CompoundTag) ServerRecipeBook.Packed.CODEC.encodeStart(NbtOps.INSTANCE, book.pack()).getOrThrow(); extra = compound(saved); diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 0ec7218d8..1866a6ee3 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -5877,6 +5877,65 @@ CHECKS += ['recipe_removal'] CHECK_FNS.update(recipe_removal=check_recipe_removal) +def check_recipe_settings(host, guest, out): + """RecipeBookComponent sends both settings to the server; each player's + own book keeps them when the authoritative world saves and reopens (#2290).""" + def inventory(game): + back_to_game(game) + game.raw('key', 'e', 'tap') + game.wait(5) + return game('screen')['recipe_book'] + + def flags(book): + return {'open': book['open'], 'filtering': book['filtering']} + + def set_flags(game, opened, filtering): + book = inventory(game) + if not book['open']: + game.raw('click-at', *centre(book['targets'][0])) + game.wait(5) + book = game('screen')['recipe_book'] + if book['filtering'] != filtering: + target = next(t for t in book['targets'] if t[2:] == [26.0, 16.0]) + game.raw('click-at', *centre(target)) + game.wait(5) + if not opened: + book = game('screen')['recipe_book'] + game.raw('click-at', *centre(book['targets'][0])) + game.wait(5) + actual = flags(game('screen')['recipe_book']) + assert actual == {'open': opened, 'filtering': filtering}, f'{game.name}: {actual}' + game.shot(out, 'recipe-settings-before') + back_to_game(game) + return actual + + # Deliberately different settings ensure neither player's book overwrites the other's. + before = {host.name: set_flags(host, True, True), + guest.name: set_flags(guest, False, True)} + host.wait(20) + guest.raw('disconnect') + host.raw('pause') + host.raw('click-button', 'Save and Quit to Title') + host.raw('wait-for', 'screen', 'title', '--timeout', 120) + host.raw('open', 'Lan') + host('wait_ready', seconds=400) + port = host.raw('lan', 'open', 0, 'commands')['port'] + guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME) + guest.raw('wait-for', 'loaded', '--timeout', 300) + guest.wait(20) + after = {} + for game in (host, guest): + after[game.name] = flags(inventory(game)) + game.shot(out, 'recipe-settings-reopened') + back_to_game(game) + assert after == before, f'saved recipe settings reset: before={before}, after={after}' + return {'before': before, 'reopened': after} + + +CHECKS += ['recipe_settings'] +CHECK_FNS.update(recipe_settings=check_recipe_settings) + + def check_shulker_bullets(host, guest, out): """ShulkerAttackGoal's bullets reach ClientPacketListener.handleAddEntity, then the existing projectile renderer and interpolation, with distinct ids from the server's entity counter.""" diff --git a/tools/test_recipe_book_parity.py b/tools/test_recipe_book_parity.py index c25f180ac..291adeb86 100644 --- a/tools/test_recipe_book_parity.py +++ b/tools/test_recipe_book_parity.py @@ -5,8 +5,9 @@ Each case runs a sequence of operations on a fresh book: awarding recipes (advancement rewards, /recipe give, crafting; special recipes are never learnt, and the count is the displays added), taking them back, the client having seen a highlighted recipe, the open and filter settings of the four -book types, and saving and loading the player's recipeBook compound (with -unknown ids dropped, and a compound missing a list ignored). The Java oracle +book types (through the serverbound settings packet's codec), and saving and +loading the player's recipeBook compound (with unknown ids dropped, and a +compound missing a list ignored). The Java oracle (VanillaRecipeBookOracle) drives ServerRecipeBook and its codec; examples/probe/recipe_book_fixture drives horizon_client::recipe_book. After each operation the count, the known and highlighted recipes, the settings and @@ -35,6 +36,12 @@ def main(): ids = sorted('minecraft:' + p.stem for p in (lib.RESOURCES / 'data/minecraft/recipe').glob('*.json')) rng = random.Random(args.seed) cases = [] + # Every book and flag combination crosses the wire, then survives a save/load. + for book_type in TYPES: + for opened in (False, True): + for filtering in (False, True): + cases.append({'ops': [{'op': 'set', 'type': book_type, 'open': opened, + 'filtering': filtering}, {'op': 'save'}, {'op': 'load'}]}) for _ in range(args.cases): ops, known = [], set() for _ in range(rng.randrange(1, 12)): From 61ff050927187a07dc52c5083ac46e24d58ef230 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:11:56 +0100 Subject: [PATCH 160/475] mods: drop the sandbox test's unused LEVEL import Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mods/tests/sandbox.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index a5e95ddfa..96842e439 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -205,7 +205,7 @@ fn undeclared_capabilities_are_refused() { /// from the functions they import. #[test] fn a_mod_for_a_newer_horizon_is_refused() { - use horizon_mods::api_level::{LEVEL, imports, needed}; + use horizon_mods::api_level::{imports, needed}; use wit_parser::{ManglingAndAbi, Resolve}; let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let wit = std::fs::read_to_string(root.join("crates/horizon-mod-sdk/wit/horizon.wit")) From 7a3a19697a75e9f7bcf42da70d9f665b7937239c Mon Sep 17 00:00:00 2001 From: socksprox Date: Mon, 26 Oct 2026 18:13:22 +0100 Subject: [PATCH 161/475] Resolve dynamic head profiles, and re-upload evicted skins Player heads whose profile carries only a name or only a UUID (ResolvableProfile.Dynamic) resolved to nothing, so heads some servers send that way kept the default skin. Adds skins::Resolver (ProfileResolver.Cached: profiles/getByName then session/getProfileById through the services discovery document, on background threads) used by placed heads, head items and mannequins; the profile-derived DefaultPlayerSkin stands in until the download lands, with the name's offline UUID keying it for name-only profiles. A skin evicted from the atlas's 128 slots could never return: the decoded image was dropped and show_skin simply failed. The downloader now keeps the images (and the disk cache backs them), and again queues the upload again where the tab list, level players, mannequins and heads notice the missing sprite. profile_tag keeps id so id-only and name+id profiles survive into the item data JSON, and the player-head item fallback matches vanilla's slim/steve (was wide/steve). Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/auth.rs | 2 +- .../horizon-client/src/block_entity_render.rs | 9 +- crates/horizon-client/src/containers.rs | 16 +- crates/horizon-client/src/game.rs | 4 + .../src/game/block_entity_anims.rs | 31 +- crates/horizon-client/src/game/item_draw.rs | 18 +- crates/horizon-client/src/game/remote.rs | 41 +- .../horizon-client/src/item_render/special.rs | 2 +- crates/horizon-client/src/item_sync.rs | 11 + crates/horizon-client/src/skins.rs | 512 +++++++++++++++++- 10 files changed, 601 insertions(+), 45 deletions(-) diff --git a/crates/horizon-client/src/auth.rs b/crates/horizon-client/src/auth.rs index b899ea5d5..a86992735 100644 --- a/crates/horizon-client/src/auth.rs +++ b/crates/horizon-client/src/auth.rs @@ -179,7 +179,7 @@ fn agent() -> ureq::Agent { } /// The discovery document, fetched once per game (a failure is retried next time). -fn discovery() -> Result, JoinError> { +pub(crate) fn discovery() -> Result, JoinError> { static CACHE: Mutex>> = Mutex::new(None); if let Some(d) = CACHE.lock().unwrap().clone() { return Ok(d); diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs index 28a876d80..a6f7b1991 100644 --- a/crates/horizon-client/src/block_entity_render.rs +++ b/crates/horizon-client/src/block_entity_render.rs @@ -734,7 +734,7 @@ pub struct Scene<'a> { /// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer` /// profile → skin): a downloaded `skins/` slot or the profile's /// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve). - pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>, + pub skull: &'a dyn Fn([i32; 3]) -> Option, /// Font glyph cell (none for blank glyphs such as the space) and advance. pub glyph: &'a dyn Fn(char) -> (Option, f32), } @@ -1286,11 +1286,12 @@ impl BlockEntityRenderer { }; // SkullBlockRenderer.getRenderType: the profile's skin when it // resolves, `SKIN_BY_TYPE` (steve) otherwise. - let texture = if *kind == SkullType::Player { - (scene.skull)(pos).unwrap_or(texture) + let skin = if *kind == SkullType::Player { + (scene.skull)(pos) } else { - texture + None }; + let texture = skin.as_deref().unwrap_or(texture); let (Some(model), Some(paint)) = (self.models.get(model), paint(texture, 0xFFFFFF, packed)) else { diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index 6aba0970e..e93626584 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -187,6 +187,9 @@ impl Skull { #[derive(Clone, Debug)] pub struct SkullSkin { pub download: Option<(String, String)>, + /// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or + /// an id only): the key `skins::Resolver` resolves it by. + pub dynamic: Option, pub fallback: &'static str, } impl SkullSkin { @@ -195,10 +198,21 @@ impl SkullSkin { let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?); Some((url, location)) }); + // A `Dynamic` profile (a name or id only) resolves through the + // services when the skull is drawn. + let dynamic = crate::skins::dynamic_nbt(profile); // DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id. let fallback = crate::skins::profile_uuid_nbt(profile) + .or_else(|| { + crate::skins::nbt_text(profile.get("name")) + .map(|name| horizon_protocol::crypto::offline_uuid(&name)) + }) .map_or("entity/player/slim/steve", crate::tab_list::default_skin); - Self { download, fallback } + Self { + download, + dynamic, + fallback, + } } } /// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 755a00f6f..734a06df5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1471,6 +1471,9 @@ struct Game<'a> { /// Player skin downloads, and which skin each atlas skin slot holds /// (least recently shown first). skin_downloads: RefCell, + /// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads + /// and mannequins) resolve through the services in the background. + skin_profiles: RefCell, skin_slots: Vec<(String, usize)>, /// Atlas trim slots in use: (sprite name, slot), least recently drawn first. trim_slots: Vec<(String, usize)>, @@ -12411,6 +12414,7 @@ impl<'a> Game<'a> { .unwrap_or_default() .join("assets/skins"), )), + skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()), skin_slots: Vec::new(), trim_slots: Vec::new(), cart_models: prepared.cart_models, diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index 3d452596e..d384238cc 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -474,21 +474,38 @@ impl Game<'_> { /// the player head at `pos`. The skin download is started here; while it /// is in flight (or when it cannot be got) the profile's default skin /// stands in. A head with no profile answers None: `SKIN_BY_TYPE`. - pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> { + pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option { let Some(BlockEntity::Skull(skull)) = self.world.block_entities.get(&(pos[0], pos[1], pos[2])) else { return None; }; let skin = skull.skin.as_ref()?; - if let Some((url, location)) = &skin.download { - self.skin_downloads - .borrow_mut() - .state(skins::Kind::Skin, url); - if self.gui.atlas.sprites.contains_key(location) { + // The texture a packed or resolved `textures` property fetches: the + // download of a static profile, or the one the services answer for a + // `Dynamic` (name- or id-only) profile. + let download = skin.download.clone().or_else(|| { + let key = skin.dynamic.as_ref()?; + let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key) + else { + return None; + }; + let (value, signature) = profile.textures()?; + let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?; + let location = skins::skin_location(&skins::texture_hash(&url)?); + Some((url, location)) + }); + if let Some((url, location)) = download { + let mut downloads = self.skin_downloads.borrow_mut(); + if self.gui.atlas.sprites.contains_key(&location) { + let _ = downloads.state(skins::Kind::Skin, &url); return Some(location); } + // Its slot was evicted: offer the image again. + if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready { + downloads.again(&location); + } } - Some(skin.fallback) + Some(skin.fallback.to_owned()) } } diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index 1d79f077d..7bf60e3c0 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -129,6 +129,7 @@ pub(super) struct Host<'a> { banners: &'a horizon_client::banner::Banners, sprites: &'a HashMap, skins: &'a RefCell, + profiles: &'a RefCell, } impl SpecialHost for Host<'_> { fn banner_texture(&self, pattern: &str) -> Option { @@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> { fn player_skin(&self, profile: &Value) -> Option { // SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation // accepts a texture property without a signature, unlike the tab list. - let url = horizon_client::skins::profile_skin_url(profile)?; - let location = - horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?); - let _ = self - .skins - .borrow_mut() - .state(horizon_client::skins::Kind::Skin, &url); - self.sprites.contains_key(&location).then_some(location) + // A `Dynamic` profile (a name or id only) resolves through the + // services (`PlayerSkinRenderCache.getOrDefault`). + Some(horizon_client::skins::head_skin( + profile, + &mut self.profiles.borrow_mut(), + &mut self.skins.borrow_mut(), + |location| self.sprites.contains_key(location), + )) } } @@ -194,6 +195,7 @@ impl Game<'_> { banners: &self.res.stations().banners, sprites: &self.gui.atlas.sprites, skins: &self.skin_downloads, + profiles: &self.skin_profiles, } } /// The client level as item models read it. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3ef87c201..3628b9773 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2778,7 +2778,7 @@ impl Game<'_> { resolved.push((uuid, skin)); } } - resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin)); + resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin)); let Some((level, skins, own)) = self.remote.as_mut().map(|r| { r.skins = resolved.into_iter().collect(); ( @@ -2817,11 +2817,9 @@ impl Game<'_> { .iter() .filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) .map(|m| { - let t = m - .profile - .as_ref() - .and_then(|p| p.properties.iter().find(|p| p.name == "textures")); - let textures = t.map_or_else(Default::default, |t| { + let profile = m.profile.as_ref(); + let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures")); + let textures = if let Some(t) = t { let signature = if t.signature.is_some() { horizon_client::skins::Signature::Signed } else { @@ -2832,7 +2830,30 @@ impl Game<'_> { signature, &horizon_client::skins::TEXTURE_URIS, ) - }); + } else { + // `ResolvableProfile.resolveProfile`: no `textures` and + // only a name or an id resolves through the services. + let key = profile.and_then(|p| match (&p.name, p.uuid) { + (Some(name), None) => { + Some(horizon_client::skins::NameOrId::Name(name.clone())) + } + (None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)), + _ => None, + }); + key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) { + horizon_client::skins::Resolve::Done(Some(profile)) => { + profile.textures().map(|(value, signature)| { + horizon_client::skins::unpack( + &value, + signature, + &horizon_client::skins::TEXTURE_URIS, + ) + }) + } + _ => None, + }) + .unwrap_or_default() + }; (m.id, textures) }) .collect(); @@ -2847,7 +2868,7 @@ impl Game<'_> { resolved.push((id, skin)); } for (id, skin) in resolved { - let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s)); + let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s)); if let Some(mob) = self .mobs .iter_mut() @@ -2897,6 +2918,10 @@ impl Game<'_> { self.skin_slots.push(e); true } + /// `show_skin`, or — its slot evicted — the image queued for upload again. + fn again_skin(&mut self, location: &str) -> bool { + self.show_skin(location) || self.skin_downloads.borrow_mut().again(location) + } /// ChatType decoration: `chat.translation_key` with sender/content parameters. /// ChatType.Bound.decorate: the chat type's translation, with the /// sender, the target and the message as its parameters, in its style. diff --git a/crates/horizon-client/src/item_render/special.rs b/crates/horizon-client/src/item_render/special.rs index d42378db1..9e69e9d44 100644 --- a/crates/horizon-client/src/item_render/special.rs +++ b/crates/horizon-client/src/item_render/special.rs @@ -498,7 +498,7 @@ pub(super) fn submit( let skin = (!argument.is_null()) .then(|| host.player_skin(argument)) .flatten() - .unwrap_or_else(|| "entity/player/wide/steve".to_owned()); + .unwrap_or_else(|| "entity/player/slim/steve".to_owned()); part("player", &none, &skin, white, Foil::None); } SpecialKind::Trident => { diff --git a/crates/horizon-client/src/item_sync.rs b/crates/horizon-client/src/item_sync.rs index fc35e15e7..01d90246f 100644 --- a/crates/horizon-client/src/item_sync.rs +++ b/crates/horizon-client/src/item_sync.rs @@ -229,6 +229,17 @@ fn profile_tag(p: &Profile) -> Tag { if let Some(name) = &p.name { entries.push(("name", Tag::String(name.as_str().into()))); } + if let Some(id) = p.id { + entries.push(( + "id", + Tag::IntArray(vec![ + (id >> 96) as i32, + (id >> 64) as i32, + (id >> 32) as i32, + id as i32, + ]), + )); + } entries.push(("properties", Tag::List(properties))); Tag::compound(entries) } diff --git a/crates/horizon-client/src/skins.rs b/crates/horizon-client/src/skins.rs index ee6851e00..239e1c3ac 100644 --- a/crates/horizon-client/src/skins.rs +++ b/crates/horizon-client/src/skins.rs @@ -116,20 +116,9 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture /// heads and mannequins read their profile this way, and `SkinManager` lets /// those textures be unsigned (`getInsecureSkinInformation`). pub fn profile_skin_url(profile: &serde_json::Value) -> Option { - let value = profile - .get("properties")? - .as_array()? - .iter() - .find_map(|p| { - if p.get("name")?.as_str()? == "textures" { - p.get("value")?.as_str() - } else { - None - } - })?; - unpack(value, Signature::Unsigned, &TEXTURE_URIS).skin + let (value, signature) = parse_profile(profile).textures?; + unpack(&value, signature, &TEXTURE_URIS).skin } - /// `profile_skin_url` on a block entity's `profile` NBT (`SkullBlockEntity`): /// the same `properties` list, held as a compound. A name-only profile (a /// string tag) has no properties and no skin URL. @@ -164,13 +153,329 @@ pub fn profile_uuid_nbt(profile: &horizon_world::nbt::Tag) -> Option { } } -fn nbt_text(tag: Option<&horizon_world::nbt::Tag>) -> Option { +pub(crate) fn nbt_text(tag: Option<&horizon_world::nbt::Tag>) -> Option { match tag { Some(horizon_world::nbt::Tag::String(s)) => Some(s.to_string_lossy()), _ => None, } } +/// `Either`: the key a `Dynamic` `ResolvableProfile` resolves +/// by (`ResolvableProfile.nameOrId`). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum NameOrId { + Name(String), + Id(u128), +} + +/// `GameProfile` as `fetchProfile` returns it: the account's id, name and +/// property map (its `textures` property is the skin). +#[derive(Clone, Debug)] +pub struct ResolvedProfile { + pub id: u128, + pub name: String, + /// `(name, value, signature)` properties. + pub properties: Vec<(String, String, Option)>, +} +impl ResolvedProfile { + /// `SessionService.getPackedTextures`: the `textures` property. + pub fn textures(&self) -> Option<(String, Signature)> { + self.properties + .iter() + .find(|(n, _, _)| n == "textures") + .map(|(_, v, s)| { + ( + v.clone(), + if s.is_some() { + Signature::Signed + } else { + Signature::Unsigned + }, + ) + }) + } +} + +/// Where a `Dynamic` profile's resolution stands (`resolveProfile`'s future). +#[derive(Clone, Debug)] +pub enum Resolve { + Pending, + /// The services' answer: `None` for no profile or no answer, which the + /// cache holds like any result (`Optional.empty`). + Done(Option), +} + +/// `ProfileResolver.Cached`: resolves a `Dynamic` profile through the +/// services — `profileCacheByName` asks `profiles`/`getByName` for the id, +/// `profileCacheById` asks `session`/`getProfileById` for the profile — +/// at most 256 entries kept 10 minutes after their last use, fetched on +/// background threads. +pub struct Resolver { + entries: std::collections::HashMap, + done: std::sync::mpsc::Receiver<(NameOrId, Option)>, + send: std::sync::mpsc::Sender<(NameOrId, Option)>, + last_sweep: std::time::Instant, +} +struct Entry { + touched: std::time::Instant, + /// `None` while the fetch is in flight. + resolved: Option>, +} +/// `ProfileResolver.Cached`'s cache bounds. +const MAX_PROFILES: usize = 256; +const PROFILE_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60); + +/// `StringUtil.isValidPlayerName` (the check `fetchByName` makes first). +fn valid_player_name(name: &str) -> bool { + name.encode_utf16().count() <= 16 && name.encode_utf16().all(|c| c > 32 && c < 127) +} + +/// `fetchByNameOrId`: `getByName` answers `{id, name}`; `getProfileById` +/// (`fetchProfile(id, requireSecure)`) answers `{id, name, properties}` — +/// asked `unsigned=false` so the `textures` property is signed. +fn resolve_profile(key: &NameOrId, doc: &serde_json::Value) -> Option { + let id = match key { + NameOrId::Id(id) => *id, + NameOrId::Name(name) => { + let url = crate::auth::endpoint(doc, "profiles", "getByName")? + .replace("{name}", &name.to_lowercase()); + let body = http_get(&url)?; + let v: serde_json::Value = serde_json::from_slice(&body).ok()?; + crate::auth::parse_uuid(v.get("id")?.as_str()?)? + } + }; + let url = crate::auth::endpoint(doc, "session", "getProfileById")? + .replace("{profileId}", &format!("{id:032x}")); + let separator = if url.contains('?') { '&' } else { '?' }; + let body = http_get(&format!("{url}{separator}unsigned=false"))?; + let v: serde_json::Value = serde_json::from_slice(&body).ok()?; + Some(ResolvedProfile { + id: crate::auth::parse_uuid(v.get("id")?.as_str()?)?, + name: v.get("name")?.as_str()?.to_owned(), + properties: v + .get("properties")? + .as_array()? + .iter() + .map(|p| { + Some(( + p.get("name")?.as_str()?.to_owned(), + p.get("value")?.as_str()?.to_owned(), + p.get("signature") + .and_then(|s| s.as_str()) + .map(str::to_owned), + )) + }) + .collect::>>()?, + }) +} + +impl Default for Resolver { + fn default() -> Self { + Self::new() + } +} +impl Resolver { + pub fn new() -> Self { + let (send, done) = std::sync::mpsc::channel(); + Self { + entries: Default::default(), + done, + send, + last_sweep: std::time::Instant::now(), + } + } + /// `CacheBuilder.expireAfterAccess` with `maximumSize`. + fn sweep(&mut self) { + if self.last_sweep.elapsed() < std::time::Duration::from_secs(30) { + return; + } + self.last_sweep = std::time::Instant::now(); + self.entries + .retain(|_, e| e.touched.elapsed() < PROFILE_TTL); + while self.entries.len() > MAX_PROFILES + && let Some(oldest) = self + .entries + .iter() + .min_by_key(|(_, e)| e.touched) + .map(|(k, _)| k.clone()) + { + self.entries.remove(&oldest); + } + } + /// How the resolution of `key` stands, starting it the first time. + pub fn state(&mut self, key: &NameOrId) -> Resolve { + while let Ok((key, resolved)) = self.done.try_recv() { + self.entries.insert( + key, + Entry { + touched: std::time::Instant::now(), + resolved: Some(resolved), + }, + ); + } + self.sweep(); + if let Some(entry) = self.entries.get_mut(key) { + entry.touched = std::time::Instant::now(); + return match &entry.resolved { + Some(resolved) => Resolve::Done(resolved.clone()), + None => Resolve::Pending, + }; + } + // `fetchByName` refuses an invalid name without a fetch; like every + // other empty answer that is the cached result. + let resolved = matches!(key, NameOrId::Name(name) if !valid_player_name(name)); + self.entries.insert( + key.clone(), + Entry { + touched: std::time::Instant::now(), + resolved: resolved.then_some(None), + }, + ); + if resolved { + return Resolve::Done(None); + } + let (key, send) = (key.clone(), self.send.clone()); + std::thread::spawn(move || { + let resolved = crate::auth::discovery() + .ok() + .and_then(|doc| resolve_profile(&key, &doc)); + let _ = send.send((key, resolved)); + }); + Resolve::Pending + } +} + +/// `ResolvableProfile` (an item's `minecraft:profile` component, a skull's +/// `profile` tag): the `textures` property it carries, the key a `Dynamic` +/// profile resolves by, and `partialProfile.id` — the fallback skin's key. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ProfileInfo { + /// `GameProfile.id` (`DefaultPlayerSkin.get`). + pub id: u128, + /// The `textures` property's value and signature state, when present. + pub textures: Option<(String, Signature)>, + /// `Dynamic` profiles resolve by name or id. + pub dynamic: Option, +} + +/// A profile's `id`: `UUIDUtil.CODEC` (an int array of four, or a UUID +/// string). +fn profile_id(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Array(a) => { + let ints: Option> = a.iter().map(|x| x.as_i64()).collect(); + let i = ints?; + (i.len() == 4).then(|| { + ((i[0] as u32 as u128) << 96) + | ((i[1] as u32 as u128) << 64) + | ((i[2] as u32 as u128) << 32) + | i[3] as u32 as u128 + }) + } + serde_json::Value::String(s) => crate::auth::parse_uuid(s), + _ => None, + } +} + +/// `ResolvableProfile`'s read of a `profile` value: a bare name is +/// `createUnresolved` (the `PLAYER_NAME` codec's alternative), a compound +/// `name` + `id` + `properties` (`Partial.MAP_CODEC` or a `GameProfile`). +pub fn parse_profile(v: &serde_json::Value) -> ProfileInfo { + if let serde_json::Value::String(name) = v { + return ProfileInfo { + id: horizon_protocol::crypto::offline_uuid(name), + textures: None, + dynamic: valid_player_name(name).then(|| NameOrId::Name(name.clone())), + }; + } + let name = v.get("name").and_then(|n| n.as_str()).map(str::to_owned); + let id = v.get("id").and_then(profile_id); + let properties = v.get("properties").and_then(|p| p.as_array()); + let textures = properties + .into_iter() + .flatten() + .find(|p| p.get("name").and_then(|n| n.as_str()) == Some("textures")) + .and_then(|p| { + Some(( + p.get("value")?.as_str()?.to_owned(), + if p.get("signature").is_some() { + Signature::Signed + } else { + Signature::Unsigned + }, + )) + }); + // `ResolvableProfile.create`: a Partial is Dynamic with no properties + // and exactly one of a name or an id. + let dynamic = + (properties.is_none_or(|p| p.is_empty()) && name.is_some() != id.is_some()).then(|| { + name.as_deref().map_or_else( + || NameOrId::Id(id.unwrap()), + |n| NameOrId::Name(n.to_owned()), + ) + }); + ProfileInfo { + // `createPartialProfile`: the id, else the name's offline UUID, else nil. + id: id + .or_else(|| name.as_deref().map(horizon_protocol::crypto::offline_uuid)) + .unwrap_or(0), + textures, + dynamic, + } +} + +/// `ResolvableProfile.create`'s `Dynamic` key from a skull's `profile` tag: +/// a bare name string is `createUnresolved`, and a compound without +/// properties and with exactly one of a name or an id is `Dynamic`. +pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option { + if let horizon_world::nbt::Tag::String(s) = profile { + let name = s.to_string_lossy(); + return valid_player_name(&name).then_some(NameOrId::Name(name)); + } + if matches!(profile.get("properties"), Some(horizon_world::nbt::Tag::List(l)) if !l.is_empty()) + { + return None; + } + match (nbt_text(profile.get("name")), profile_uuid_nbt(profile)) { + (Some(name), None) => Some(NameOrId::Name(name)), + (None, Some(id)) => Some(NameOrId::Id(id)), + _ => None, + } +} + +/// `PlayerSkinRenderCache.getOrDefault` for a head: the resolved skin once +/// its texture is in the atlas (`atlas`), else `partialProfile`'s default. +pub fn head_skin( + profile: &serde_json::Value, + resolver: &mut Resolver, + downloads: &mut Downloader, + atlas: impl Fn(&str) -> bool, +) -> String { + let p = parse_profile(profile); + let property = p.textures.or_else(|| { + let key = p.dynamic.as_ref()?; + match resolver.state(key) { + Resolve::Done(Some(profile)) => profile.textures(), + _ => None, + } + }); + if let Some((value, signature)) = property + && let Some(url) = unpack(&value, signature, &TEXTURE_URIS).skin + && let Some(hash) = texture_hash(&url) + { + let location = skin_location(&hash); + match downloads.state(Kind::Skin, &url) { + Download::Ready if atlas(&location) => return location, + // Its slot was evicted: offer the image again. + Download::Ready => { + downloads.again(&location); + } + _ => {} + } + } + crate::tab_list::default_skin(p.id).to_owned() +} + /// `MinecraftProfileTexture.getHash`: the base name of the URL's path. pub fn texture_hash(url: &str) -> Option { let rest = url.split_once("://")?.1; @@ -313,7 +618,16 @@ pub struct Downloader { done: std::sync::mpsc::Receiver<((Kind, String), Option>)>, send: std::sync::mpsc::Sender<((Kind, String), Option>)>, ready: Vec<(String, Vec)>, + /// Locations already in `ready`, so `again` does not double-queue. + queued: std::collections::HashSet, + /// Decoded skin images by location, kept so a skin evicted from the + /// atlas uploads again (`again`); the disk cache backs anything dropped. + images: std::collections::HashMap>, + image_order: std::collections::VecDeque, } +/// `images` is bounded only so a long session on a busy server does not grow +/// without end; the disk cache holds everything it drops. +const MAX_SKIN_IMAGES: usize = 1024; impl Downloader { pub fn new(root: std::path::PathBuf) -> Self { let (send, done) = std::sync::mpsc::channel(); @@ -323,6 +637,9 @@ impl Downloader { done, send, ready: Vec::new(), + queued: Default::default(), + images: Default::default(), + image_order: Default::default(), } } /// How the download of `url` stands, starting it the first time. @@ -331,7 +648,9 @@ impl Downloader { let state = match image { Some(rgba) => { if key.0 == Kind::Skin { - self.ready.push((skin_location(&key.1), rgba)); + let location = skin_location(&key.1); + self.remember(location.clone(), rgba.clone()); + self.ready.push((location, rgba)); } Download::Ready } @@ -370,8 +689,48 @@ impl Downloader { }); Download::Pending } + /// Keeps a decoded skin for `again`, dropping the oldest past the bound. + fn remember(&mut self, location: String, rgba: Vec) { + if !self.images.contains_key(&location) { + self.image_order.push_back(location.clone()); + while self.image_order.len() > MAX_SKIN_IMAGES + && let Some(old) = self.image_order.pop_front() + { + self.images.remove(&old); + } + } + self.images.insert(location, rgba); + } + /// A ready skin whose atlas slot was evicted (`show_skin` answered no): + /// its image for upload again, from memory or the disk cache. + pub fn again(&mut self, location: &str) -> bool { + if self.queued.contains(location) { + return true; + } + let Some(digest) = location.strip_prefix("skins/") else { + return false; + }; + let image = self.images.get(location).cloned().or_else(|| { + let file = self + .root + .join(if digest.len() > 2 { &digest[..2] } else { "xx" }) + .join(digest); + let (w, h, rgba) = crate::hud::decode_bytes(&std::fs::read(&file).ok()?).ok()?; + process_legacy_skin(w, h, &rgba) + }); + let Some(image) = image else { + return false; + }; + self.remember(location.to_owned(), image.clone()); + self.queued.insert(location.to_owned()); + self.ready.push((location.to_owned(), image)); + true + } /// Skins that finished since the last call: texture id and 64x64 RGBA. pub fn take_ready(&mut self) -> Vec<(String, Vec)> { + for (location, _) in &self.ready { + self.queued.remove(location); + } std::mem::take(&mut self.ready) } } @@ -452,6 +811,129 @@ pub fn sha1(data: &[u8]) -> [u8; 20] { #[cfg(test)] mod tests { + #[test] + fn profiles_parse_like_resolvable_profiles() { + use super::{NameOrId, Signature, parse_profile}; + // A bare name is `createUnresolved`: dynamic by name, and its partial + // id is the name's offline UUID. + let p = parse_profile(&serde_json::json!("Notch")); + assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into()))); + assert_eq!(p.id, horizon_protocol::crypto::offline_uuid("Notch")); + assert!(p.textures.is_none()); + // A name-only compound resolves the same way. + let p = parse_profile(&serde_json::json!({"name": "Notch"})); + assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into()))); + // An id-only compound resolves by id (int array or UUID string). + let id = 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef_u128; + let ints: Vec = [ + (id >> 96) as i32 as i64, + (id >> 64) as i32 as i64, + (id >> 32) as i32 as i64, + id as i32 as i64, + ] + .into(); + let p = parse_profile(&serde_json::json!({"id": ints})); + assert_eq!(p.dynamic, Some(NameOrId::Id(id))); + let p = parse_profile(&serde_json::json!({"id": "01234567-89ab-cdef-0123-4567abcdef00"})); + assert_eq!( + p.dynamic, + Some(NameOrId::Id(0x0123_4567_89ab_cdef_0123_4567_abcd_ef00)) + ); + // Name and id but no properties is Static: it never resolves, and its + // default skin keys off the id. + let p = parse_profile(&serde_json::json!({"name": "Notch", "id": ints})); + assert!(p.dynamic.is_none()); + assert_eq!(p.id, id); + // A `textures` property makes it Static (unsigned and signed). + let p = parse_profile(&serde_json::json!({ + "properties": [{"name": "textures", "value": "v"}] + })); + assert!(p.dynamic.is_none()); + assert_eq!(p.textures, Some(("v".into(), Signature::Unsigned))); + let p = parse_profile(&serde_json::json!({ + "properties": [{"name": "textures", "value": "v", "signature": "s"}] + })); + assert_eq!(p.textures, Some(("v".into(), Signature::Signed))); + // Empty properties leave it dynamic. + let p = parse_profile(&serde_json::json!({"name": "Notch", "properties": []})); + assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into()))); + } + + /// `getByName` and `getProfileById` answer from the services the + /// discovery document names; a profile resolves through both. + #[test] + fn profiles_resolve_through_the_services() { + use std::io::{BufRead, BufReader, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + let reply = |status: &str, body: &str| { + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + }; + for stream in listener.incoming().take(3) { + let mut stream = stream.unwrap(); + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + while reader.read_line(&mut String::new()).unwrap() > 2 {} + let body = if line.contains("/users/profiles/minecraft/") { + r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch"}"# + } else if line.contains("/session/minecraft/profile/") + && line.contains("unsigned=false") + { + r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch","properties":[{"name":"textures","value":"dmFsdWU=","signature":"c2ln"}]}"# + } else { + "{}" + }; + stream.write_all(reply("200 OK", body).as_bytes()).unwrap(); + } + }); + let doc = serde_json::json!({"discovery": { + "profiles": {"endpoints": {"getByName": {"uri": format!("{base}/users/profiles/minecraft/{{name}}")}}}, + "session": {"endpoints": {"getProfileById": {"uri": format!("{base}/session/minecraft/profile/{{profileId}}")}}}, + }}); + let resolved = + super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).unwrap(); + assert_eq!(resolved.id, 0x069a_79f4_44e9_4726_a5be_fca9_0e38_aaf5); + assert_eq!(resolved.name, "Notch"); + assert_eq!( + resolved.properties, + vec![( + "textures".to_owned(), + "dmFsdWU=".to_owned(), + Some("c2ln".to_owned()) + )] + ); + let by_id = super::resolve_profile(&super::NameOrId::Id(resolved.id), &doc).unwrap(); + assert_eq!(by_id.name, "Notch"); + // A discovery without `profiles`/`getByName` resolves no name. + let doc = serde_json::json!({"discovery": { + "session": {"endpoints": {"getProfileById": {"uri": format!("{base}/session/minecraft/profile/{{profileId}}")}}}, + }}); + assert!(super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).is_none()); + } + + /// A skin evicted from the atlas re-uploads (`again`), once per ask. + #[test] + fn evicted_skins_upload_again() { + let mut d = super::Downloader::new(std::env::temp_dir().join("horizon-test-skins")); + d.remember("skins/abc".into(), vec![1, 2, 3]); + assert!(d.again("skins/abc")); + assert!(!d.again("skins/missing")); + // Once queued, more asks do not pile up uploads. + assert!(d.again("skins/abc")); + assert_eq!( + d.take_ready(), + vec![("skins/abc".to_owned(), vec![1, 2, 3])] + ); + assert!(d.take_ready().is_empty()); + assert!(d.again("skins/abc")); + assert_eq!(d.take_ready().len(), 1); + } + #[test] fn profile_components_supply_unsigned_head_skins() { let value = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYzEyMyJ9fX0="; From 0300ad8922780e964b1bab11488aed8b6bdb77f1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:13:33 +0100 Subject: [PATCH 162/475] Keep the current attack target regardless of invisibility --- crates/horizon-client/src/mobs/goals.rs | 27 +++++++++++++++---- .../horizon-client/src/mobs/monster_brain.rs | 3 +++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index c44614571..2341c11d4 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -588,21 +588,38 @@ impl Mob { if p.creative || p.allies.contains(&self.id) { return false; } - self.player_in_range(w, range, check_sight) + self.player_in_range(w, range, check_sight, true) + } + /// forCombat().ignoreInvisibilityTesting() for the player: the check for + /// a target the mob already has, which ignores the visibility scaling. + pub fn can_keep_target_player(&mut self, w: &MobWorld, range: f64) -> bool { + let Some(p) = &w.player else { return false }; + if p.creative || p.allies.contains(&self.id) { + return false; + } + self.player_in_range(w, range, true, false) } /// TargetingConditions.forNonCombat().range(range).test for the player /// (seen by anyone, within range scaled by visibility, in sight). pub fn can_see_player(&mut self, w: &MobWorld, range: f64) -> bool { - self.player_in_range(w, range, true) + self.player_in_range(w, range, true, true) } - fn player_in_range(&mut self, w: &MobWorld, range: f64, check_sight: bool) -> bool { + /// `scale` is `TargetingConditions.testInvisible`: scale the range by + /// the player's visibility. + fn player_in_range( + &mut self, + w: &MobWorld, + range: f64, + check_sight: bool, + scale: bool, + ) -> bool { let Some(p) = &w.player else { return false }; if !p.alive || p.spectator { return false; } // LivingEntity.getVisibilityPercent - let mut visibility = p.visibility_percent(); - if let Some(head) = &p.head { + let mut visibility = if scale { p.visibility_percent() } else { 1.0 }; + if let Some(head) = p.head.as_ref().filter(|_| scale) { let skull = match self.species() { Species::Zombie | Species::Husk | Species::Drowned | Species::ZombieVillager => { "minecraft:zombie_head" diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index 9456c00a1..4990913eb 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -359,11 +359,14 @@ fn can_attack(m: &Mob, w: &MobWorld, who: Who) -> bool { /// `Sensor.isEntityAttackable`: `TargetingConditions.forCombat()` within the /// follow range, in sight. fn attackable(m: &mut Mob, w: &MobWorld, who: Who) -> bool { + // The current ATTACK_TARGET is tested with ignoreInvisibilityTesting. + let current = mem_ref(m).attack_target.value() == Some(who); let range = m.attrs.follow_range; if !can_attack(m, w, who) { return false; } match who { + Who::Player if current => m.can_keep_target_player(w, range), Who::Player => m.can_target_player(w, range, true), Who::Mob(id) => { let Some(s) = w.seen(id) else { return false }; From 004ad993d9e77d8262094afea01b2351b1bf3403 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:16:15 +0000 Subject: [PATCH 163/475] Reject boarding a horse with an existing mob passenger Fixes #1759 Co-Authored-By: OpenAI Codex GPT-6.1 Sol --- crates/horizon-client/src/game/mounts.rs | 11 ++++++ tools/lan_e2e.py | 47 +++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index fa4bbc209..7da98135f 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -175,6 +175,17 @@ impl Game<'_> { return true; } // doPlayerRide + // Entity.canAddPassenger: ordinary horses have one seat. Camels + // override that capacity; equipment and feeding above are separate. + if !matches!(species, Species::Camel | Species::CamelHusk) + && self.mobs.iter().any(|passenger| { + passenger.extra.mount == Some(self.mobs[index].id) + && !passenger.removed + && passenger.alive() + }) + { + return false; + } let m = &mut self.mobs[index]; m.extra.flag = false; m.extra.counter = 0; diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index c508a67d6..f179a201c 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -299,7 +299,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'si 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', - 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', + 'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks'] @@ -4199,6 +4199,49 @@ def check_riding(host, guest, out): return result +def check_occupied_horse(host, guest, out): + """#1759: a mob passenger fills a zombie horse's seat, for both real players.""" + spots = apart(host, guest) + result = {} + tag = 'occupied_horse' + host('command', command='time set midnight') + for label, player in (('host', host), ('guest', guest)): + x, y, z = spots[label] + at = (x + 0.5, y, z + 2.5) + player('command', command='gamemode creative') + player('command', command='clear @s') + result[label] = {} + for occupied in (True, False): + passengers = ',Passengers:[{id:"minecraft:zombie",NoAI:1b,PersistenceRequired:1b}]' if occupied else '' + try: + player('command', command=f'summon minecraft:zombie_horse {at[0]} {y} {at[2]} ' + f'{{Tame:1b,NoAI:1b,NoGravity:1b,Tags:["{tag}"]{passengers}}}') + player.wait(5) + horse = next(m for m in player.raw('mobs', 16)['near'] + if m['kind'] == 'minecraft:zombie_horse' and m['health'] > 0 + and math.dist(m['pos'], at) < 0.1) + if occupied: + # Assert the setup too: a missing passenger would make this a false positive. + passengers_nbt = player('command', command=f'data get entity @e[tag={tag},limit=1] Passengers') + assert 'minecraft:zombie' in json.dumps(passengers_nbt), passengers_nbt + player.raw('look-at', at[0], y + 0.8, at[2]) + player.raw('mouse', 'right', 'click') + player.wait(10) + riding = player('status').get('riding') + expected = None if occupied else horse['id'] + assert riding == expected, f'{label}: occupied={occupied}, riding={riding}, expected={expected}' + result[label]['occupied' if occupied else 'free'] = riding + player.shot(out, f'occupied-horse-{label}-{occupied}') + if not occupied: + hold(player, 'shift', 5) + finally: + player('command', command=f'kill @e[tag={tag}]') + player('command', command=f'kill @e[type=minecraft:zombie,x={x},y={y},z={z},distance=..6]') + player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0') + player.wait(25) + return result + + # Mount inventories: (kind, summon NBT, what goes in by shift-click and the menu slot it lands # in: 700 the saddle, 701 the body armour or carpet, 710 the chest's first slot). MOUNT_MENUS = [ @@ -4688,7 +4731,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'hands': check_hands, - 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts, + 'lightning': check_lightning, 'riding': check_riding, 'occupied_horse': check_occupied_horse, 'mounts': check_mounts, 'worldspawn': check_world_spawn, 'beds': check_beds, 'shield': check_shield, 'deathmining': check_death_mining, 'contacts': check_contacts, 'absorption': check_absorption, 'breath': check_breath, 'lava': check_lava, 'levels': check_levels, 'drops': check_drops, From a6d2eb9d6421d1bbf54c4fb366438a70682946de Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:17:27 +0100 Subject: [PATCH 164/475] Shulkers keep their body still while the head turns --- crates/horizon-client/src/mobs/control.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/mobs/control.rs b/crates/horizon-client/src/mobs/control.rs index 3df557362..70a40b23f 100644 --- a/crates/horizon-client/src/mobs/control.rs +++ b/crates/horizon-client/src/mobs/control.rs @@ -389,6 +389,13 @@ impl Mob { } /// BodyRotationControl.clientTick (Mob.tickHeadTurn). pub fn body_rotation_tick(&mut self) { + // Shulker.ShulkerBodyRotationControl.clientTick is empty: the body + // stays at 0 (recreateFromPacket), only the head turns. + if self.species() == super::Species::Shulker { + self.body_yaw = 0.0; + self.previous_body_yaw = 0.0; + return; + } let dx = self.position.x - self.previous.x; let dz = self.position.z - self.previous.z; let max = self.kind.max_head_y_rot; From 442d52a038aa50a715e5517d59f3a1d78ec075e6 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:19:04 +0000 Subject: [PATCH 165/475] Release notes: preserve wrapped known issues Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol --- tests/test_release_train.py | 28 ++++++++++++++++++++++++++-- tools/release_train.py | 12 +++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/tests/test_release_train.py b/tests/test_release_train.py index 56064fa42..58fc591b1 100644 --- a/tests/test_release_train.py +++ b/tests/test_release_train.py @@ -219,10 +219,34 @@ class ReleaseTrainTests(unittest.TestCase): def test_known_issues_are_the_bullets_of_the_file(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp, "known-issues.md") - path.write_text("# Known issues\n\nSome text.\n\n- One.\n- Two, with **bold**.\n continued\n") - self.assertEqual(train.known_issues(path), ["- One.", "- Two, with **bold**."]) + path.write_text("# Known issues\n\nSome text.\n\n- One.\n- Two, with **bold**. \n" + " continued with `code`\n\tand a [link](https://example.com). \n- Three.\n") + self.assertEqual(train.known_issues(path), + ["- One.", "- Two, with **bold**. continued with `code` and a [link](https://example.com).", + "- Three."]) self.assertEqual(train.known_issues(Path(temp, "missing.md")), []) + def test_known_issues_do_not_include_unrelated_text(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp, "known-issues.md") + for separator in ["\n", " \n", "# Heading\n", "Some text.\n"]: + with self.subTest(separator=separator): + path.write_text(" Intro.\n- One.\n continued.\n" + separator + " Not an issue.\n- Two.\n") + self.assertEqual(train.known_issues(path), ["- One. continued.", "- Two."]) + path.write_text("# Known issues\n\nNo issues.\n") + self.assertEqual(train.known_issues(path), []) + path.write_text("") + self.assertEqual(train.known_issues(path), []) + + def test_notes_keep_the_wrapped_shaderpacks_issue(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp, "known-issues.md") + path.write_text("- Shader packs have been removed for now. Packs in your\n" + " `shaderpacks` folder are left where they are.\n") + _, body = train.notes("game", "v0.2.70", [pr(1)], train.known_issues(path)) + self.assertIn("### Known issues\n- Shader packs have been removed for now. Packs in your " + "`shaderpacks` folder are left where they are.\n", body) + if __name__ == "__main__": unittest.main() diff --git a/tools/release_train.py b/tools/release_train.py index 48d4d46cf..1e4fa5a19 100644 --- a/tools/release_train.py +++ b/tools/release_train.py @@ -347,7 +347,17 @@ def bullet(pr): def known_issues(path=ROOT / "docs" / "known-issues.md"): if not path.exists(): return [] - return [line.rstrip() for line in path.read_text().splitlines() if line.startswith("- ")] + issues = [] + in_bullet = False + for line in path.read_text().splitlines(): + if line.startswith("- "): + issues.append(line.rstrip()) + in_bullet = True + elif in_bullet and line.startswith((" ", "\t")) and line.strip(): + issues[-1] += " " + line.strip() + else: + in_bullet = False + return issues def line_intro(kind, version, previous): From f661443fa993eb4d26649cb6b7c9c67d76cd683e Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:22:50 +0000 Subject: [PATCH 166/475] Read LAN passenger fixture output through the host Co-Authored-By: OpenAI Codex GPT-6.1 Sol --- tools/lan_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index f179a201c..f16e6312b 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -4222,7 +4222,7 @@ def check_occupied_horse(host, guest, out): and math.dist(m['pos'], at) < 0.1) if occupied: # Assert the setup too: a missing passenger would make this a false positive. - passengers_nbt = player('command', command=f'data get entity @e[tag={tag},limit=1] Passengers') + passengers_nbt = host('command', command=f'data get entity @e[tag={tag},limit=1] Passengers') assert 'minecraft:zombie' in json.dumps(passengers_nbt), passengers_nbt player.raw('look-at', at[0], y + 0.8, at[2]) player.raw('mouse', 'right', 'click') From 5589edb118c0a9152c2dabb2ff22bfaf5aae5b3c Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:26:50 +0100 Subject: [PATCH 167/475] Server packs: swallow gameplay input while the reload progress shows The progress overlay only changed drawing: a pack pushed while playing left movement, attack and use live behind it. Input events are now dropped while the staged reload runs (as vanilla's LoadingOverlay does), and start_pack_reload releases held keys and mouse buttons up front so nothing keeps acting across the frame it covers. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game.rs | 13 +++++++++++++ crates/horizon-client/src/game/server_packs.rs | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index abaa639eb..e5ab50256 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11314,6 +11314,19 @@ impl ApplicationHandler for Game<'_> { { return; } + // The pack reload's progress overlay takes no input + // (`LoadingOverlay`): `start_pack_reload` released what was held. + if self.server_packs.reload.is_some() + && !matches!( + &event, + WindowEvent::CloseRequested + | WindowEvent::Focused(_) + | WindowEvent::Resized(_) + | WindowEvent::RedrawRequested + ) + { + return; + } if self.pending_save.is_some() { match event { WindowEvent::CloseRequested => { diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 12b73f4f9..d0769b894 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -407,6 +407,13 @@ impl Game<'_> { return; }; self.server_packs.active = server.clone(); + // The overlay swallows input until the apply (`LoadingOverlay`): + // release what is held so nothing keeps acting while it covers the + // frame (`KeyMapping.releaseAll` when a screen opens). + self.keys.clear(); + self.physical_keys.clear(); + self.left = false; + self.right = false; let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); From db13c68585d2f491e0329f6897279176bf18d8ec Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:28:26 +0100 Subject: [PATCH 168/475] mods: recipes.place and recipes.give, a recipe viewer's actions recipes.place(book-id, all) is the recipe book's click: in the open inventory's or crafting table's grid it sends ServerboundPlaceRecipePacket on a server, or places it in this world, refused as handlePlaceRecipe refuses it (no crafting screen, a recipe the player's book doesn't hold, an impossible one); a recipe missing items shows as a ghost. recipes.give(stack) gives a creative player the stack as the creative inventory does; a server hears it as SetCreativeModeSlot through the inventory's sync. Both answer at once from the tick's snapshot (placing, creative, known) and take at most 36 calls a hook call. Co-Authored-By: Claude Opus 5.5 --- crates/cargo-horizon-mod/src/check.rs | 4 ++ crates/horizon-client/src/game/mod_recipes.rs | 18 ++++++ crates/horizon-client/src/game/mods.rs | 14 ++++ crates/horizon-client/src/game/recipe_book.rs | 28 ++++++++ crates/horizon-mod-sdk/wit/horizon.wit | 11 ++++ crates/horizon-mods/src/api_level.rs | 4 +- crates/horizon-mods/src/manifest.rs | 4 +- crates/horizon-mods/src/runtime.rs | 63 ++++++++++++++++++ crates/horizon-mods/tests/sandbox.rs | 43 ++++++++++++- crates/horizon-mods/tests/ui_guest/src/lib.rs | 64 +++++++++++++++++-- docs/api/mods-reference.md | 4 +- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 13 files changed, 251 insertions(+), 10 deletions(-) diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index 33095a115..cb3981955 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -268,6 +268,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")), Output::OpenScreen { .. } => "opened a screen".to_string(), Output::CloseScreen { .. } => "closed its screen".to_string(), + Output::PlaceRecipe { book_id, all } => { + format!("placed recipe {book_id}{}", if all { " (all)" } else { "" }) + } + Output::Give { item, count, .. } => format!("gave {count} {item}"), Output::Shake { strength, seconds } => { format!("shook the camera: {strength:.2} for {seconds:.1} s") } diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index f02eb6424..0f65b0b42 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -70,6 +70,24 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe { } impl Game<'_> { + /// `recipes.give`: a creative player takes the stack as from the + /// creative inventory; a server hears it as `SetCreativeModeSlot` + /// through the inventory's sync, and refuses it unless creative. + pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) { + if !self.creative || self.res.items().get(item).is_none() { + return; + } + let (damage, extra) = horizon_client::item_data::command_components(components) + .map_or((0, None), |tag| { + horizon_client::item_data::decode_components(Some(&tag)) + }); + let _ = self.give(ItemStack { + item: item.into(), + count, + damage, + extra, + }); + } /// Capability `recipes`'s tables and known displays for this tick's /// snapshot; nothing while no running mod reads them. pub(super) fn mod_recipes(&mut self) -> (Option>, Arc<[i32]>) { diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 40298a922..147a01df3 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -434,6 +434,8 @@ impl Game<'_> { view_hit: self.mod_view_hit(), recipes, known_recipes, + placing: self.mod_recipe_screen(), + creative: self.creative, } } /// The first block along the player's view within @@ -646,6 +648,18 @@ impl Game<'_> { self.mods.shake.add(strength, seconds); continue; } + Output::PlaceRecipe { book_id, all } => { + self.mod_place_recipe(book_id, all); + continue; + } + Output::Give { + item, + count, + components, + } => { + self.mod_give(&item, count, &components); + continue; + } Output::CloseScreen { owner } => { if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) { self.close_mod_screen(false); diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 045abeb6f..024d45734 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -845,6 +845,34 @@ impl Game<'_> { self.recipe_ui.ghost.clear(); self.place_recipe(book_type, entry_index, use_max); } + /// Whether the open screen takes `recipes.place`: a crafting grid's + /// book (the inventory's or a crafting table's). + #[cfg(feature = "mods")] + pub(super) fn mod_recipe_screen(&self) -> bool { + self.screen == Screen::Inventory + && self.active_book() == Some(BookType::Crafting) + && !self.spectator() + } + /// `recipes.place`: the book's click on the recipe with this display + /// id, refused as `handlePlaceRecipe` refuses it (no crafting screen, + /// not in the player's book; an impossible one in `place_recipe`). + #[cfg(feature = "mods")] + pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) { + if !self.mod_recipe_screen() || !self.recipe_ui.client.displays().contains(&display_id) { + return; + } + self.ensure_book(); + let Some(index) = self + .recipe_ui + .book + .as_ref() + .and_then(|b| b.entries.iter().position(|e| e.display_id == display_id)) + else { + return; + }; + self.recipe_ui.ghost.clear(); + self.place_recipe(BookType::Crafting, index, all); + } /// `ServerboundPlaceRecipePacket`: the server places the recipe /// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost /// recipe when the items are missing. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 4896c3e21..897f669e5 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -507,6 +507,17 @@ interface recipes { all: func() -> list; /// The `book-id`s of the recipes the player's recipe book holds now. known: func() -> list; + /// Places a recipe in the open screen's grid as a click in the recipe + /// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or + /// a crafting table. `all` places as many as the items allow, as with + /// Shift. Refused while no such screen is open, for a recipe the + /// player's book doesn't hold and for one that can't be placed; one + /// missing items shows as a ghost in the grid, as in the book. + place: func(book-id: s32, all: bool) -> result<_, string>; + /// Creative mode only: gives the player the stack, as taking it from + /// the creative inventory does (`ServerboundSetCreativeModeSlot`): the + /// selected slot first, then the hotbar, then the inventory. + give: func(stack: stack) -> result<_, string>; } /// Capability `ui` (experimental): a recipe viewer's panel beside the diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 7bb87b674..87984da02 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -13,7 +13,7 @@ pub const LEVEL: u32 = 5; /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 40] = [ +pub const FUNCTIONS: [(&str, u32); 42] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -51,6 +51,8 @@ pub const FUNCTIONS: [(&str, u32); 40] = [ ("recipes.items", 4), ("recipes.all", 4), ("recipes.known", 4), + ("recipes.place", 5), + ("recipes.give", 5), ("browser.set-items", 5), ("browser.poll", 5), ("browser.set-recipes", 5), diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 4c12fd805..6b2cfdf03 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -158,7 +158,9 @@ impl Capability { "see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys" } Capability::Players => "see who is on the server and message them", - Capability::Recipes => "read the game's recipes and items, and which recipes you know", + Capability::Recipes => { + "read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items" + } } } /// The capability a component import needs, `Ok(None)` for one every diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 43c9e3fac..fed4e5c4f 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -156,6 +156,10 @@ pub struct Snapshot { pub recipes: Option>, /// The `book-id`s of the recipes the player's book holds. pub known_recipes: Arc<[i32]>, + /// The open screen takes recipes (`recipes.place`). + pub placing: bool, + /// The player is in creative mode (`recipes.give`). + pub creative: bool, } /// Capability `recipes`'s tables: `items` in the registry's order, which @@ -220,6 +224,15 @@ pub enum Output { CloseScreen { owner: usize }, /// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10). Shake { strength: f32, seconds: f32 }, + /// `recipes.place`: the recipe book's click on this display id, all of + /// it when `all`. + PlaceRecipe { book_id: i32, all: bool }, + /// `recipes.give`: a stack for a creative player. + Give { + item: String, + count: u32, + components: String, + }, } /// A mod's text panel, drawn every frame until it changes. @@ -329,6 +342,8 @@ struct BrowserState { static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Items a panel may hold, and events kept for `browser.poll`. const BROWSER_ITEMS: usize = 8192; +/// `recipes.place` and `give` calls a hook call may make. +const RECIPE_ACTIONS: usize = 36; /// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles. const BROWSER_RECIPES: usize = 256; const RECIPE_SLOTS: usize = 9; @@ -1307,6 +1322,54 @@ impl recipes::Host for State { fn known(&mut self) -> Vec { self.snapshot.known_recipes.to_vec() } + fn place(&mut self, book_id: i32, all: bool) -> Result<(), String> { + self.recipe_action()?; + if !self.snapshot.placing { + return Err("no screen that takes recipes is open".into()); + } + if !self.snapshot.known_recipes.contains(&book_id) { + return Err(format!( + "recipe {book_id} is not in the player's recipe book" + )); + } + self.out.push(Output::PlaceRecipe { book_id, all }); + Ok(()) + } + fn give(&mut self, stack: RecipeStack) -> Result<(), String> { + self.recipe_action()?; + if !self.snapshot.creative { + return Err("only in creative mode".into()); + } + let item = self + .snapshot + .recipes + .as_ref() + .and_then(|r| r.items.get(stack.item as usize)) + .ok_or_else(|| format!("no item {}", stack.item))? + .id + .clone(); + self.out.push(Output::Give { + item, + count: stack.count.clamp(1, 99), + components: clip_to(stack.components, 4096), + }); + Ok(()) + } +} +impl State { + /// `recipes.place` and `give` take at most [`RECIPE_ACTIONS`] a call. + fn recipe_action(&self) -> Result<(), String> { + let done = self + .out + .iter() + .filter(|o| matches!(o, Output::PlaceRecipe { .. } | Output::Give { .. })) + .count(); + if done < RECIPE_ACTIONS { + Ok(()) + } else { + Err(format!("at most {RECIPE_ACTIONS} places and gives a call")) + } + } } impl State { /// Queues a chat line unless the call already sent [`CHAT_PER_CALL`]. diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 081884dc2..ea8cc0536 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1059,8 +1059,9 @@ fn recipes_reads_the_tables() { recipes[1].kind = RecipeKind::Brewing; recipes[1].results[0].components = "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]".into(); + let mods_recipes = Some(std::sync::Arc::new(Recipes { items, recipes })); mods.set_snapshot(Snapshot { - recipes: Some(std::sync::Arc::new(Recipes { items, recipes })), + recipes: mods_recipes.clone(), known_recipes: vec![1, 5, 9].into(), ..Snapshot::default() }); @@ -1091,6 +1092,46 @@ fn recipes_reads_the_tables() { .all(|e| e.status == horizon_mods::Status::Running), "it ran out of fuel" ); + // Actions: refused without a crafting screen, for an unknown recipe, + // and outside creative mode. + assert_eq!( + said(&mut mods, "recipe_place minecraft:recipe_5"), + ["place minecraft:recipe_5: no screen that takes recipes is open"] + ); + let mut snap = Snapshot { + recipes: mods_recipes.clone(), + known_recipes: vec![1, 5, 9].into(), + placing: true, + ..Snapshot::default() + }; + mods.set_snapshot(snap.clone()); + assert_eq!( + said(&mut mods, "recipe_place minecraft:recipe_6"), + ["place minecraft:recipe_6: recipe 6 is not in the player's recipe book"] + ); + assert!(mods.command("recipe_place minecraft:recipe_5 all")); + assert_eq!( + mods.drain()[0], + Output::PlaceRecipe { + book_id: 5, + all: true + } + ); + assert_eq!( + said(&mut mods, "recipe_give minecraft:item_7 3"), + ["give minecraft:item_7: only in creative mode"] + ); + snap.creative = true; + mods.set_snapshot(snap); + assert!(mods.command("recipe_give minecraft:item_7 3")); + assert_eq!( + mods.drain()[0], + Output::Give { + item: "minecraft:item_7".into(), + count: 3, + components: String::new() + } + ); // Without the game's tables (no mod read them yet): empty, not a trap. mods.set_snapshot(Snapshot::default()); assert_eq!( diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 751169565..2b1646614 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -12,7 +12,9 @@ //! items, `/browser` alone empties it) and registers the keys `recipe` (R) //! and `uses` (U); `/browser_poll` says what the player did on it; //! `/browser_recipes ID` shows the recipes making ID (`/browser_recipes` -//! alone: the items again). +//! alone: the items again). `/recipe_place ID [all]` places that recipe +//! with `recipes.place`; `/recipe_give ITEM [COUNT]` gives with +//! `recipes.give`. use horizon_mod_sdk::{ Mod, Registration, browser, chat, command, game, input::{self, Event}, @@ -69,7 +71,11 @@ fn shapes() -> Vec { ] } -struct Guest; +struct Guest { + /// `/recipe_place ID [all] later`: placed on the first tick a crafting + /// screen is open. + pending: Option<(i32, bool, String)>, +} fn widgets() -> Vec { vec![ @@ -113,12 +119,15 @@ impl Mod for Guest { "browser", "browser_poll", "browser_recipes", + "recipe_place", + "recipe_give", "corner", "readings", ] .into_iter() - .fold(Registration::new(), Registration::command); - Ok((Guest, hooks)) + .fold(Registration::new(), Registration::command) + .on_tick(); + Ok((Guest { pending: None }, hooks)) } fn command(&mut self, name: &str, args: &str) -> Result, String> { match name { @@ -367,6 +376,44 @@ impl Mod for Guest { browser::set_recipes(&format!("Recipes for {args}"), &views); chat::show(&format!("recipes shown: {}", views.len())); } + "recipe_place" => { + let mut words = args.split_whitespace(); + let id = words.next().unwrap_or_default(); + let rest: Vec<&str> = words.collect(); + let all = rest.contains(&"all"); + let book = recipes::all() + .iter() + .find(|r| r.id == id) + .map(|r| r.book_id); + if let Some(b) = book.filter(|_| rest.contains(&"later")) { + self.pending = Some((b, all, id.to_owned())); + chat::show(&format!("place {id}: waiting")); + return Ok(None); + } + chat::show(&match book.map(|b| recipes::place(b, all)) { + Some(Ok(())) => format!("place {id}: ok"), + Some(Err(e)) => format!("place {id}: {e}"), + None => format!("place {id}: no such recipe"), + }); + } + "recipe_give" => { + let mut words = args.split_whitespace(); + let id = words.next().unwrap_or_default(); + let count = words.next().and_then(|c| c.parse().ok()).unwrap_or(1); + let item = recipes::items().iter().position(|i| i.id == id); + let result = item.map(|i| { + recipes::give(&recipes::Stack { + item: i as u32, + count, + components: String::new(), + }) + }); + chat::show(&match result { + Some(Ok(())) => format!("give {id}: ok"), + Some(Err(e)) => format!("give {id}: {e}"), + None => format!("give {id}: no such item"), + }); + } "browser_poll" => { let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components); // A hook shows at most 8 chat lines: the count, then the first 7. @@ -402,6 +449,15 @@ impl Mod for Guest { } Ok(None) } + fn tick(&mut self, _tick: u64) { + if let Some((book, all, id)) = self.pending.take() { + match recipes::place(book, all) { + Err(e) if e.starts_with("no screen") => self.pending = Some((book, all, id)), + Ok(()) => chat::show(&format!("place {id}: ok")), + Err(e) => chat::show(&format!("place {id}: {e}")), + } + } + } fn ui_select(&mut self, id: u32, row: u32) { chat::show(&format!("selected {id}:{row}")); } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 6af12fa5b..b79ea5247 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -17,7 +17,7 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`, | `render` | [`render`](#render) | draw on your screen, shake and zoom the camera | | `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys | | `players` | [`players`](#players) | see who is on the server and message them | -| `recipes` | [`recipes`](#recipes) | read the game's recipes and items, and which recipes you know | +| `recipes` | [`recipes`](#recipes) | read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items | ## World `horizon-mod` @@ -530,6 +530,8 @@ The station a recipe is made at. | `items` | `func() -> list` | Every item, in the registry's order: `stack.item` indexes this list. | | `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | | `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | +| `place` | `func(book-id: s32, all: bool) -> result<_, string>` | Places a recipe in the open screen's grid as a click in the recipe book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a crafting table. `all` places as many as the items allow, as with Shift. Refused while no such screen is open, for a recipe the player's book doesn't hold and for one that can't be placed; one missing items shows as a ghost in the grid, as in the book. | +| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. | ## `browser` diff --git a/docs/api/stability.md b/docs/api/stability.md index 1314d9813..616767689 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`, `set-recipes`). A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`. A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index bbd14dc84..706e48bd6 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -91,7 +91,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | -| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | +| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | | Hook (export) | When | |---|---| From 077da2e06b16bcfded6224765b838c3177b45d38 Mon Sep 17 00:00:00 2001 From: socksprox Date: Mon, 26 Oct 2026 18:30:09 +0100 Subject: [PATCH 169/475] Queue the first skin upload as well as re-uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completed-download push into ready never marked the location queued, so an again() before the next take_ready() put the same skin in twice: store_skin allocated two slots, and evicting one removed the sprite while show_skin still found the other entry — the skin never uploaded again. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/skins.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/skins.rs b/crates/horizon-client/src/skins.rs index 239e1c3ac..740208973 100644 --- a/crates/horizon-client/src/skins.rs +++ b/crates/horizon-client/src/skins.rs @@ -649,8 +649,7 @@ impl Downloader { Some(rgba) => { if key.0 == Kind::Skin { let location = skin_location(&key.1); - self.remember(location.clone(), rgba.clone()); - self.ready.push((location, rgba)); + self.queue(location, rgba); } Download::Ready } @@ -701,6 +700,13 @@ impl Downloader { } self.images.insert(location, rgba); } + /// An image off to the atlas: remembered for `again` and marked queued + /// so nothing uploads the same location twice. + fn queue(&mut self, location: String, rgba: Vec) { + self.remember(location.clone(), rgba.clone()); + self.queued.insert(location.clone()); + self.ready.push((location, rgba)); + } /// A ready skin whose atlas slot was evicted (`show_skin` answered no): /// its image for upload again, from memory or the disk cache. pub fn again(&mut self, location: &str) -> bool { @@ -721,9 +727,7 @@ impl Downloader { let Some(image) = image else { return false; }; - self.remember(location.to_owned(), image.clone()); - self.queued.insert(location.to_owned()); - self.ready.push((location.to_owned(), image)); + self.queue(location.to_owned(), image); true } /// Skins that finished since the last call: texture id and 64x64 RGBA. @@ -932,6 +936,11 @@ mod tests { assert!(d.take_ready().is_empty()); assert!(d.again("skins/abc")); assert_eq!(d.take_ready().len(), 1); + // The first upload marks the location queued too: an `again` before + // `take_ready` does not put the same skin in the atlas twice. + d.queue("skins/def".into(), vec![4]); + assert!(d.again("skins/def")); + assert_eq!(d.take_ready(), vec![("skins/def".to_owned(), vec![4])]); } #[test] From 2c8b933be483bcf6d44437fb0763facfee707095 Mon Sep 17 00:00:00 2001 From: socksprox Date: Mon, 26 Oct 2026 18:30:53 +0100 Subject: [PATCH 170/475] Apply vanilla's view scale to entity render distance Entity.shouldRenderAtSqrDistance scales the bounding box's mean edge by 64 * viewScale, and LevelExtractor sets viewScale to the entity distance option times clamp(renderDistance / 8, 1, 2.5). Horizon used the option alone, so above render distance 8 entities vanished sooner than vanilla: a chicken at 32 blocks instead of 48 at the default 12. RemotePlayer's box also counts ten times. Fixes #2273 Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game/boats.rs | 2 +- crates/horizon-client/src/game/creatures.rs | 13 ++++++++++--- crates/horizon-client/src/game/cushions.rs | 3 +-- crates/horizon-client/src/game/entity_culling.rs | 16 +++++++++++++--- crates/horizon-client/src/game/frames.rs | 5 +++-- crates/horizon-client/src/game/minecarts.rs | 5 ++--- crates/horizon-client/src/game/paintings.rs | 3 +-- crates/horizon-client/src/game/text_displays.rs | 3 ++- crates/horizon-client/src/game/tnt.rs | 2 +- crates/horizon-client/src/game/trident.rs | 2 +- 10 files changed, 35 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index 06dfab22d..7e80f6f58 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -862,7 +862,7 @@ impl Game<'_> { let a = alpha as f32; let range = super::entity_culling::default_range( glam::DVec3::new(WIDTH, HEIGHT, WIDTH), - self.settings.entity_distance, + self.view_scale(), ); for b in &self.boats { let entity_start = out.len(); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index a2ed7fd66..f8c28732a 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3032,7 +3032,7 @@ impl Game<'_> { pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec) { let atlas = &self.gui.atlas; let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); - let scale = f64::from(self.settings.entity_distance); + let scale = self.view_scale(); for mob in &self.mobs { let entity_start = out.len(); // Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws. @@ -3041,9 +3041,16 @@ impl Game<'_> { } let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera; // Entity.shouldRenderAtSqrDistance: 64 times the bounding box's - // mean edge, scaled by the entity distance option. + // mean edge, scaled by the view scale. RemotePlayer's box counts + // ten times. let (w, h, _) = mob.dims(); - let range = (w + h + w) / 3.0 * 64.0 * scale; + let edge = (w + h + w) / 3.0 + * if &*mob.kind.id == "minecraft:player" { + 10.0 + } else { + 1.0 + }; + let range = edge * 64.0 * scale; if d.length_squared() >= range * range { continue; } diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs index 0847321d6..216e2cc1b 100644 --- a/crates/horizon-client/src/game/cushions.rs +++ b/crates/horizon-client/src/game/cushions.rs @@ -455,8 +455,7 @@ impl Game<'_> { glam::DVec3::new(lo.x, lo.y, lo.z), glam::DVec3::new(hi.x, hi.y, hi.z), ); - let range = - super::entity_culling::default_range(hi - lo, self.settings.entity_distance); + let range = super::entity_culling::default_range(hi - lo, self.view_scale()); if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) { continue; } diff --git a/crates/horizon-client/src/game/entity_culling.rs b/crates/horizon-client/src/game/entity_culling.rs index 66fbaa3e4..4377b2f8d 100644 --- a/crates/horizon-client/src/game/entity_culling.rs +++ b/crates/horizon-client/src/game/entity_culling.rs @@ -60,12 +60,22 @@ impl Game<'_> { } } +impl Game<'_> { + /// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity + /// distance option times the render distance's boost — 1 at 8 chunks, 2.5 + /// at 20 or more (`streaming.radius` is the effective render distance). + pub(super) fn view_scale(&self) -> f64 { + (f64::from(self.streaming.radius) / 8.0).clamp(1.0, 2.5) + * f64::from(self.settings.entity_distance) + } +} + /// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding -/// box's mean edge, scaled by the entity distance option. -pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 { +/// box's mean edge, scaled by the view scale. +pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 { let edge = (size.x + size.y + size.z) / 3.0; let edge = if edge.is_nan() { 1.0 } else { edge }; - edge * 64.0 * f64::from(view_scale) + edge * 64.0 * view_scale } impl Game<'_> { diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index d08c5f283..b4e6bb158 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -311,8 +311,9 @@ impl Game<'_> { let sprites = &self.gui.atlas.sprites; let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new(); let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new(); - // ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%. - let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance); + // ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale + // of 1. + let range = 16.0 * 64.0 * self.view_scale(); for f in &self.item_frames { let entity_start = out.len(); let map = f.item.as_ref().and_then(super::maps::map_id); diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 072059e49..781dd6b7c 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -1340,9 +1340,8 @@ impl Game<'_> { return; }; // Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean - // edge, scaled by the entity distance option. - let range = - (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance); + // edge, scaled by the view scale. + let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale(); for c in &self.carts { let entity_start = out.len(); let Some(model) = self.cart_models.models.get(&c.kind) else { diff --git a/crates/horizon-client/src/game/paintings.rs b/crates/horizon-client/src/game/paintings.rs index 321e59f7d..1b839f5b0 100644 --- a/crates/horizon-client/src/game/paintings.rs +++ b/crates/horizon-client/src/game/paintings.rs @@ -267,8 +267,7 @@ impl Game<'_> { // measured from the box's centre (HangingEntity's position). let (lo, hi) = p.bounds(); let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi)); - let range = - super::entity_culling::default_range(hi - lo, self.settings.entity_distance); + let range = super::entity_culling::default_range(hi - lo, self.view_scale()); if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) { continue; } diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index 45bd64fa7..587db3382 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -164,11 +164,12 @@ impl Game<'_> { } else { [self.yaw, self.pitch] }; + let scale = self.view_scale(); for display in self.text_displays.values_mut() { display.last_progress = display.progress(partial as f32); let d = display.shown.blend(&display.from, display.last_progress); let position = display.previous.lerp(display.position, partial); - let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance); + let range = f64::from(d.view_range) * 64.0 * scale; if position.distance_squared(camera) >= range * range || d.text.is_empty() { continue; } diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index 6bd2ae956..177e3bf80 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -252,7 +252,7 @@ impl Game<'_> { } let range = super::entity_culling::default_range( glam::DVec3::splat(PrimedTnt::SIZE), - self.settings.entity_distance, + self.view_scale(), ); for t in &self.tnts { // EntityRenderer.shouldRender. diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index c31094596..cfb8d807c 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -419,7 +419,7 @@ impl Game<'_> { let range = 10.0 * super::entity_culling::default_range( glam::DVec3::splat(2.0 * HALF), - self.settings.entity_distance, + self.view_scale(), ); for t in &self.tridents { // EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5. From 1825b3991cae222b6aac29951b5542abfb7c7f8a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:30:56 +0100 Subject: [PATCH 171/475] Campfires are no Container for droppers and hoppers Since campfires got their block entity (#2015), the container lookup took it for a zero-slot container: a dropper facing one kept its item instead of dropping it, as vanilla's HopperBlockEntity.getContainerAt finds no Container there. The dispense fixture also filled campfires through the generic slots, which a campfire doesn't have, so block_removal saw them empty. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dispense_fixture.rs | 5 +++++ crates/horizon-client/src/game/machines.rs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index 267f323c3..43f41d201 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -725,6 +725,11 @@ impl Game<'_> { match self.world.block_entities.get_mut(&p) { Some(BlockEntity::Jukebox(j)) => j.record = stack, Some(BlockEntity::Lectern(l)) => l.book = stack, + Some(BlockEntity::Campfire(c)) => { + if let Some(cell) = c.items.get_mut(slot) { + *cell = stack; + } + } Some(e) => { if let Some(cell) = e.slots_mut().get_mut(slot) { *cell = stack; diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 8742962e9..bb936744b 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -159,8 +159,10 @@ impl Containers<'_> { }); } let entity = self.world.block_entities.get(&pos)?; - // A beehive keeps bees, not items: it is no `Container`. - if matches!(entity, BlockEntity::Beehive(_)) { + // A beehive keeps bees, not items, and a campfire cooks its own: neither + // is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops + // in front of them and a hopper leaves them alone. + if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) { return None; } let kind = match (entity, name) { From 1862395e4b72d7ed7d5e6ee1769727ac38964031 Mon Sep 17 00:00:00 2001 From: Carlos Nunez Date: Mon, 26 Oct 2026 13:32:44 -0400 Subject: [PATCH 172/475] Keep a leaving guest's play state until their statistics are saved lan_crafting_left removed the guest's GuestPlay from host.play before the leave_game award and save_guest_records ran, so a normal guest disconnect awarded nothing and wrote no statistics. It now takes the crafting table and workstation state out of the entry and leaves the entry for HostEvent::Left to remove after saving. New lan_e2e check `leave`: a guest leaves twice and the host's saved statistics show leave_game 1, then 2. It fails on the previous commit (0, then [0, 0]) and passes here. Co-Authored-By: Claude Sonnet 5.5 --- .../horizon-client/src/game/lan_crafting.rs | 10 +++--- tools/lan_e2e.py | 35 +++++++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index db6235c4d..839390529 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -576,16 +576,18 @@ impl Game<'_> { /// A guest left: what their crafting grids and workstation held goes back into their /// inventory before it is saved (dropped where they stood if full). pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) { - let play = host.play.remove(&g.conn); + // The guest's play state stays: their statistics are awarded and saved + // after this (`HostEvent::Left`), which removes it. + let play = host.play.get_mut(&g.conn); let mut grid: Vec = g.craft_grid.iter_mut().filter_map(Option::take).collect(); g.craft_result = None; if let Some(play) = play { - if let Some((_, _, menu, _)) = play.crafting.table { + if let Some((_, _, menu, _)) = play.crafting.table.take() { grid.extend((1..10).filter_map(|s| menu.slot(s).cloned())); } // A workstation's inputs too. - if let Some(station) = &play.station { - grid.extend(super::lan_stations::station_inputs(station)); + if let Some(station) = play.station.take() { + grid.extend(super::lan_stations::station_inputs(&station)); } } if grid.is_empty() { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 866602c9b..2c8885b81 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -22,7 +22,7 @@ address. The script then checks, from both sides, what a guest's game used to ge - pvp: the guest and the host hit each other with a sword; with the pvp game rule off, or on one team without friendly fire, nothing; - kills: a pig the guest kills counts in the guest's killed statistic, not the host's (#484); - +- leave: a guest who leaves has leave_game in their saved statistics, and again after the next leave; - arrows: the guest and the host shoot each other with a bow; with the pvp game rule off, nothing (#484); - looks: mobs look on both sides as the server has them (their synched data): a calf is a @@ -290,7 +290,7 @@ ROOT = Path(__file__).resolve().parents[1] GUEST_NAME = 'Guest' # Match normal single player; --in-frame is the comparison path. SERVER_THREAD = True -CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater'] class Game: @@ -1134,6 +1134,35 @@ def check_pvp(host, guest, out): return hits +def leave_game_total(host): + """The leave_game statistic summed over the stats files the host's world holds.""" + total = 0 + for path in (host.work / 'saves' / 'Lan' / 'players' / 'stats').glob('*.json'): + custom = json.loads(path.read_text()).get('stats', {}).get('minecraft:custom', {}) + total += custom.get('minecraft:leave_game', 0) + return total + + +def check_leave(host, guest, out): + # PlayerList.remove awards Stats.LEAVE_GAME before it saves the player: a guest who leaves has it in + # their saved statistics, and it counts again the next time they leave. + port = host.raw('lan', 'status')['port'] + before = leave_game_total(host) + counts = [] + for _ in range(2): + guest.raw('disconnect') + for _ in range(20): + time.sleep(0.5) + if leave_game_total(host) > (counts[-1] if counts else before): + break + counts.append(leave_game_total(host)) + guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME) + guest.raw('wait-for', 'loaded', '--timeout', 300) + guest.wait(20) + assert counts == [before + 1, before + 2], f'leave_game was {before}, then {counts} after two leaves' + return {'leave_game': counts} + + def check_kills(host, guest, out): # A mob the guest kills is the guest's kill (#484): LivingEntity.die credits lastHurtByPlayer, so the # killed statistic (and its scoreboard criterion) counts for the guest, not the host. @@ -4616,7 +4645,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'light': check_light, 'plates': check_plates, 'swings': check_swings, 'cracks': check_cracks, 'time': check_time, 'growth': check_growth, 'doors': check_doors, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, - 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, + 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'leave': check_leave, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'hands': check_hands, 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts, From 6a62ad67cce8fd23254f285ef6ba7881809216c7 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:39:46 +0100 Subject: [PATCH 173/475] mods: set-recipes takes a row of tabs, and the tab event says which one the player picked Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/wit/horizon.wit | 10 +++-- crates/horizon-mods/src/runtime.rs | 37 ++++++++++++++----- crates/horizon-mods/tests/sandbox.rs | 10 +++-- crates/horizon-mods/tests/ui_guest/src/lib.rs | 21 ++++++++++- docs/api/mods-reference.md | 3 +- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 4896c3e21..5f8677df0 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -557,6 +557,8 @@ interface browser { page(u32), /// The back button of `set-recipes`. back, + /// The player picked the tab at this index of `set-recipes`' tabs. + tab(u32), } /// How the game lays out a recipe of `set-recipes`. enum layout { @@ -590,9 +592,11 @@ interface browser { /// The panel's items, at most 8192 (an empty list hides the panel). set-items: func(items: list); /// Shows these recipes in the panel instead of its items, as many as - /// fit a page, under `title` and a back button; an empty list shows the - /// items again. At most 256 recipes. - set-recipes: func(title: string, recipes: list); + /// fit a page, under `title` and a back button, with a row of `tabs` + /// (each an icon; `tab` is the selected one) between them; an empty + /// list of recipes shows the items again. At most 256 recipes and 16 + /// tabs. + set-recipes: func(title: string, tabs: list, tab: u32, recipes: list); /// What the player did on the panel since the last poll, at most 256 /// kept. poll: func() -> list; diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 43c9e3fac..3b3acf5db 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -323,15 +323,25 @@ struct BrowserState { /// across mods and reloads, so the game converts them once. version: u64, /// `set-recipes`' title and recipes, shown instead of the items. - recipes: Option<(String, Vec)>, + recipes: Option, events: std::collections::VecDeque, } +/// What `browser.set-recipes` shows: a title, tabs (`tab` the selected +/// one) and the recipes. +#[derive(Clone, Debug)] +pub struct RecipePage { + pub title: String, + pub tabs: Vec, + pub tab: u32, + pub recipes: Vec, +} static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Items a panel may hold, and events kept for `browser.poll`. const BROWSER_ITEMS: usize = 8192; /// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles. const BROWSER_RECIPES: usize = 256; const RECIPE_SLOTS: usize = 9; +const RECIPE_TABS: usize = 16; const SLOT_STACKS: usize = 64; const BROWSER_EVENTS: usize = 256; impl BrowserState { @@ -848,13 +858,11 @@ impl Mods { )) }) } - /// The recipes mod `owner`'s panel shows instead of its items - /// (`browser.set-recipes`): the title and the recipes. - pub fn browser_recipes(&self, owner: usize) -> Option<(&str, &[BrowserRecipe])> { + /// What mod `owner`'s panel shows instead of its items + /// (`browser.set-recipes`). + pub fn browser_recipes(&self, owner: usize) -> Option<&RecipePage> { let b = &self.entries.get(owner)?.run.as_ref()?.store.data().browser; - b.recipes - .as_ref() - .map(|(title, list)| (title.as_str(), list.as_slice())) + b.recipes.as_ref() } /// The player did `event` on mod `owner`'s panel. pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) { @@ -1261,7 +1269,13 @@ impl browser::Host for State { .collect(); self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } - fn set_recipes(&mut self, title: String, recipes: Vec) { + fn set_recipes( + &mut self, + title: String, + tabs: Vec, + tab: u32, + recipes: Vec, + ) { let stack = |s: BrowserStack| BrowserStack { item: clip(s.item), components: clip_to(s.components, 4096), @@ -1283,7 +1297,12 @@ impl browser::Host for State { ..r }) .collect(); - (clip(title), recipes) + RecipePage { + title: clip(title), + tabs: tabs.into_iter().take(RECIPE_TABS).map(stack).collect(), + tab, + recipes, + } }); self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 081884dc2..28fd73ec6 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1196,9 +1196,13 @@ fn browser_lists_items_and_hears_the_player() { said(&mut mods, "browser_recipes minecraft:oak_planks"), ["recipes shown: 1"] ); - let (title, recipes) = mods.browser_recipes(owner).unwrap(); - assert_eq!(title, "Recipes for minecraft:oak_planks"); - let r = &recipes[0]; + let page = mods.browser_recipes(owner).unwrap(); + assert_eq!(page.title, "Recipes for minecraft:oak_planks"); + assert_eq!( + (page.tabs[0].item.as_str(), page.tab), + ("minecraft:crafting_table", 0) + ); + let r = &page.recipes[0]; assert_eq!( (r.layout, r.width, r.height, r.action), (BrowserLayout::Grid, 1, 1, Some(false)) diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 751169565..f879f4302 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -364,7 +364,25 @@ impl Mod for Guest { } }) .collect(); - browser::set_recipes(&format!("Recipes for {args}"), &views); + // A tab per layout shown, the first selected. + let mut tabs: Vec = Vec::new(); + for v in &views { + let icon = match v.layout { + browser::Layout::Grid => "minecraft:crafting_table", + browser::Layout::Cooking => "minecraft:furnace", + browser::Layout::Single => "minecraft:stonecutter", + browser::Layout::Smithing => "minecraft:smithing_table", + browser::Layout::Brewing => "minecraft:brewing_stand", + }; + if !tabs.iter().any(|t| t.item == icon) { + tabs.push(browser::Stack { + item: icon.into(), + count: 1, + components: String::new(), + }); + } + } + browser::set_recipes(&format!("Recipes for {args}"), &tabs, 0, &views); chat::show(&format!("recipes shown: {}", views.len())); } "browser_poll" => { @@ -389,6 +407,7 @@ impl Mod for Guest { browser::Event::Action(i) => format!("action {i}"), browser::Event::Page(first) => format!("page {first}"), browser::Event::Back => "back".into(), + browser::Event::Tab(i) => format!("tab {i}"), }); } } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 6af12fa5b..5e9593525 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -587,6 +587,7 @@ A click on a slot of a recipe the panel shows (`set-recipes`). | `action` | `u32` | The action button of the recipe at this index in `set-recipes`'s list. | | `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. | | `back` | | The back button of `set-recipes`. | +| `tab` | `u32` | The player picked the tab at this index of `set-recipes`' tabs. | ### enum `layout` @@ -617,7 +618,7 @@ A recipe to show. Each slot, and the result, cycles through its stacks, one a se | Function | Signature | What it does | | --- | --- | --- | | `set-items` | `func(items: list)` | The panel's items, at most 8192 (an empty list hides the panel). | -| `set-recipes` | `func(title: string, recipes: list)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button; an empty list shows the items again. At most 256 recipes. | +| `set-recipes` | `func(title: string, tabs: list, tab: u32, recipes: list)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button, with a row of `tabs` (each an icon; `tab` is the selected one) between them; an empty list of recipes shows the items again. At most 256 recipes and 16 tabs. | | `poll` | `func() -> list` | What the player did on the panel since the last poll, at most 256 kept. | ## `players` From 76d748caf8b5d5a5e8e5446c0533d862197f3cf3 Mon Sep 17 00:00:00 2001 From: Pavel Mikhailovin Date: Mon, 26 Oct 2026 20:43:35 +0300 Subject: [PATCH 174/475] Server books: positional page indicator and component-list pages The page indicator only replaced bare %s, but 26.3's en_us template is "Page %1$s of %2$s"; it now goes through format_java like other translations (BookViewScreen.getPageNumberMessage). Pages sent as a component list (Hypixel through ViaVersion) were dropped by item_data::read_text, which only took a string or a compound, so the book opened blank. ComponentSerialization.CODEC also takes a list. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/books.rs | 9 ++++--- crates/horizon-client/src/item_data.rs | 35 ++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 2f38dffd9..b09803e49 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -1009,9 +1009,12 @@ impl Game<'_> { } }; let indicator = |batch: &mut HudBatch, page: usize, count: usize| { - let text = tr("book.pageIndicator", "Page %s of %s") - .replacen("%s", &(page + 1).to_string(), 1) - .replacen("%s", &count.max(1).to_string(), 1); + // BookViewScreen.getPageNumberMessage: 26.3's en_us template is + // positional ("Page %1$s of %2$s"). + let text = horizon_protocol::text::format_java( + &tr("book.pageIndicator", "Page %1$s of %2$s"), + &[(page + 1).to_string(), count.max(1).to_string()], + ); let tw = HudBatch::text_width(atlas, &text); batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false); }; diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index ac624e85b..3d4fb11e5 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -305,7 +305,9 @@ fn read_text(t: &Tag) -> Option { // A component's root `text` may be empty with the visible words in // `extra` (every legacy display name ViaVersion produces looks like // this), so flatten the whole component instead of reading one key. - Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()), + // ComponentSerialization.CODEC also takes a list (its first element + // styling the rest), as servers behind ViaVersion send book pages. + Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()), _ => None, } } @@ -899,6 +901,37 @@ mod tests { ); } + #[test] + fn written_book_pages_may_be_component_lists() { + // A page as Hypixel sends it through ViaVersion: a list, not a compound. + let page = Tag::List(vec![ + Tag::compound([("text", Tag::String("".into()))]), + Tag::compound([("text", Tag::String("Please read ".into()))]), + Tag::compound([("text", Tag::String("the rules".into()))]), + ]); + let book = Tag::compound([ + ( + "title", + Tag::compound([("raw", Tag::String("Rules".into()))]), + ), + ("author", Tag::String("Server".into())), + ( + "pages", + Tag::List(vec![ + page, + Tag::compound([("raw", Tag::String("Two".into()))]), + ]), + ), + ]); + let components = Tag::compound([("minecraft:written_book_content", book)]); + let (_, extra) = decode_components(Some(&components)); + let pages = extra.and_then(|d| d.written.clone()).map(|w| w.pages); + assert_eq!( + pages, + Some(vec!["Please read the rules".to_owned(), "Two".to_owned()]) + ); + } + #[test] fn banner_layers_and_shield_base_round_trip() { let mut stack = ItemStack::new("minecraft:shield", 1); From e92937d956f8f2d5bb57bd945f80de91cece120c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:44:23 +0100 Subject: [PATCH 175/475] Apply Slowness with Bane of Arthropods Post-attack effects only handled ignite, so the apply_mob_effect effect of Bane of Arthropods never did anything. Handle apply_mob_effect like vanilla. --- .../src/game/enchant_effects.rs | 80 ++++++++++++++----- crates/horizon-client/src/game/potions.rs | 2 +- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 7cdb3e526..ac194dcba 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -153,8 +153,10 @@ impl Game<'_> { items: Some(self.res.items()), ..Default::default() }; - // minecraft:post_attack ignite effects of the attacker's weapon (fire aspect). + // minecraft:post_attack effects of the attacker's weapon on the victim: + // ignite (fire aspect) and apply_mob_effect (bane of arthropods). let mut ignite = 0.0f32; + let mut mob_effects = Vec::new(); for (id, level) in held.enchantments() { let Some(def) = self.res.stations().enchantments.get(id) else { continue; @@ -164,24 +166,33 @@ impl Game<'_> { .into_iter() .flatten() { - if effect["enchanted"] == "attacker" - && effect["affected"] == "victim" - && effect["effect"]["type"] == "minecraft:ignite" + let e = &effect["effect"]; + let kind_of = e["type"].as_str(); + if effect["enchanted"] != "attacker" + || effect["affected"] != "victim" + || !matches!( + kind_of, + Some("minecraft:ignite" | "minecraft:apply_mob_effect") + ) { - let mut random = self.world.random.clone(); - let ok = effect.get("requirements").is_none_or(|r| { - self.res - .stations() - .enchantments - .condition(r, *level, &ctx, &mut random) - }); - self.world.random = random; - if ok { - ignite = ignite.max(horizon_client::enchant::level_value( - &effect["effect"]["duration"], - *level, - )); - } + continue; + } + let mut random = self.world.random.clone(); + let ok = effect.get("requirements").is_none_or(|r| { + self.res + .stations() + .enchantments + .condition(r, *level, &ctx, &mut random) + }); + self.world.random = random; + if !ok { + continue; + } + if kind_of == Some("minecraft:ignite") { + ignite = + ignite.max(horizon_client::enchant::level_value(&e["duration"], *level)); + } else { + mob_effects.push((e.clone(), *level)); } } } @@ -191,8 +202,41 @@ impl Game<'_> { { m.ignite_for_ticks((ignite * 20.0) as i32); } + for (effect, level) in mob_effects { + self.apply_mob_effect(mob, &effect, level); + } self.post_attack_bursts(&held, &kind); } + /// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a + /// random duration and amplifier between the level-based bounds. + fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) { + use horizon_client::enchant::level_value; + let Some(m) = self.mobs.get_mut(mob) else { + return; + }; + let ids: Vec<&str> = match &effect["to_apply"] { + serde_json::Value::String(id) => vec![id.as_str()], + serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(), + _ => Vec::new(), + }; + if ids.is_empty() { + return; + } + // HolderSet.getRandomElement, then Mth.randomBetween for both values. + let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize; + let mut between = |min: &str, max: &str| { + let (lo, hi) = ( + level_value(&effect[min], level), + level_value(&effect[max], level), + ); + m.random.next_float() * (hi - lo) + lo + }; + let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32; + let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0); + if let Some(id) = super::potions::mob_effect_id(ids[pick]) { + m.add_effect(id, ticks, amplifier); + } + } /// `minecraft:post_attack` explode effects on the attacker (wind burst): /// a burst at the player's feet that only knocks back and triggers /// blocks (`ExplodeEffect` with `block_interaction: trigger`). diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 13eae7d3e..834f6f708 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -28,7 +28,7 @@ pub(super) struct ThrownPotion { age: u32, } /// A mob's effect ids carry the `minecraft:` namespace. -fn mob_effect_id(id: &str) -> Option<&'static str> { +pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> { use std::{collections::HashMap, sync::OnceLock}; static IDS: OnceLock> = OnceLock::new(); let id = id.trim_start_matches("minecraft:"); From 555ddb94bf8a20bf241dde847137fea335f2b2eb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:45:13 +0100 Subject: [PATCH 176/475] Use the impaling entity tag for thrown tridents The bonus went to every swimming or water-breathing mob, including undead, blazes, ghasts and iron golems. Read the damage from the enchantment data so only #sensitive_to_impaling mobs take it. --- crates/horizon-client/src/game/trident.rs | 29 +++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index c31094596..9e7f9f65d 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -5,6 +5,7 @@ use super::{Game, view_vector}; use horizon_client::{ collisions::SceneCollisions, + enchant::EffectContext, items::ItemStack, mesher::Vertex, mobs::{Damage, DamageKind, Who, anim::Poses}, @@ -305,11 +306,29 @@ impl Game<'_> { } } if let Some((id, _)) = hit_mob { - let impaling = t.stack.enchantment_level("minecraft:impaling") as f32; - let aquatic = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| { - m.kind.flags.aquatic_swimmer || m.kind.flags.breathes_water - }); - let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 }; + // ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8 + // (impaling hits `#sensitive_to_impaling`, the aquatic tag). + let victim = self + .mobs + .iter() + .find(|m| m.id == id) + .map(|m| m.kind.id.clone()); + let ctx = EffectContext { + this: victim.as_deref(), + attacker: Some("minecraft:player"), + direct_attacker: Some("minecraft:trident"), + is_direct: true, + damage_type: Some("minecraft:trident"), + items: Some(self.res.items()), + ..Default::default() + }; + let damage = self.res.stations().enchantments.modify_value( + Some(&t.stack), + "minecraft:damage", + &ctx, + &mut self.world.random, + 8.0, + ); t.dealt_damage = true; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { let from = Some([t.position.x, t.position.y, t.position.z]); From 564b230ab168a3be7c0e468ae546acd72fd8dc7e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:45:13 +0100 Subject: [PATCH 177/475] Make a warden's hit disable the player's shield A warden holds no weapon, so its blows never put the shield on cooldown. Warden.getSecondsToDisableBlocking is a flat 5 seconds, so use that when the attacker is a warden. --- crates/horizon-client/src/game/shield.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/shield.rs b/crates/horizon-client/src/game/shield.rs index c4f7f98d1..a26bc5d75 100644 --- a/crates/horizon-client/src/game/shield.rs +++ b/crates/horizon-client/src/game/shield.rs @@ -182,12 +182,17 @@ impl Game<'_> { return; }; let (mx, mz) = (m.position.x, m.position.z); - let disable = m - .extra - .main_hand - .as_deref() - .and_then(|item| self.res.items().get(item)) - .map_or(0.0, |i| i.disable_blocking_seconds); + // LivingEntity.getSecondsToDisableBlocking: the held weapon's value; + // Warden overrides it with a flat 5 seconds. + let disable = if m.species() == horizon_client::mobs::Species::Warden { + 5.0 + } else { + m.extra + .main_hand + .as_deref() + .and_then(|item| self.res.items().get(item)) + .map_or(0.0, |i| i.disable_blocking_seconds) + }; // Ravager.blockedByItem: stunned half the time (the defender pushed // apart), else the defender flung away; no roar in progress. if m.species() == horizon_client::mobs::Species::Ravager { From dbef78bcb82f2b123f17c3358aa65aa7953a4f41 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:45:54 +0100 Subject: [PATCH 178/475] Apply Unbreaking to armor wear from Thorns Thorns always cost the worn piece 2 durability. Run it through the same durability loss as other wear so Unbreaking can cancel points. --- crates/horizon-client/src/game/enchant_effects.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 7cdb3e526..4417f0df8 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -303,8 +303,10 @@ impl Game<'_> { continue; } hits.push(1.0 + self.world.random.next_float() * 4.0); + // ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies. + let loss = self.durability_loss(&stack, 2); if let Some(s) = self.armor[slot].as_mut() { - s.damage += 2; + s.damage += loss; if self .res .items() From bf527e7a13ccf3b6477dbc49004086c44a8832a7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:46:36 +0100 Subject: [PATCH 179/475] Apply knockback resistance to Punch arrows --- crates/horizon-client/src/game/creatures.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2b65a3289..38dd455c8 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2123,10 +2123,24 @@ impl Game<'_> { hurt } }; - // doKnockback: Punch pushes along the flight. + // doKnockback: Punch pushes along the flight, less the target's + // knockback resistance. let flight = (p.velocity.x * p.velocity.x + p.velocity.z * p.velocity.z).sqrt(); - if hurt && p.bonus.punch > 0 && flight > 1e-6 { - let k = f64::from(p.bonus.punch) * 0.6 / flight; + let resistance = match who { + Who::Player => self + .player + .as_mut() + .and_then(|pl| pl.attribute("minecraft:knockback_resistance").ok()) + .unwrap_or(0.0), + Who::Mob(id) => self + .mobs + .iter() + .find(|m| m.id == id) + .map_or(0.0, |m| m.attrs.knockback_resistance), + }; + let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0); + if hurt && push > 0.0 && flight > 1e-6 { + let k = push / flight; self.handle_mob_events(vec![MobEvent::Push { target: who, velocity: [p.velocity.x * k, 0.1, p.velocity.z * k], From c1844dfe57e5b3f02b90f9fabad813753aa7e7a4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:46:37 +0100 Subject: [PATCH 180/475] Let mobs trample farmland when they land on it A cow dropped onto farmland left it untouched. A landing mob now turns farmland to dirt with chance fall distance - 0.5, when mob griefing is on and the mob is big enough, and is lifted onto the dirt. --- crates/horizon-client/src/mobs/living.rs | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 394821ace..afd0c26db 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -440,11 +440,36 @@ impl Mob { } if self.on_ground { if self.fall_distance > 0.0 { + self.trample_farmland(w, events); self.cause_fall_damage(events); } self.fall_distance = 0.0; } } + /// `FarmlandBlock.fallOn`: a landing on farmland turns it to dirt with + /// chance `fallDistance - 0.5`, if mobs may grief and the mob's + /// `width * width * height` is over 0.512. `Block.pushEntitiesUp` lifts + /// the mob onto the dirt, which is a full block. + fn trample_farmland(&mut self, w: &MobWorld, events: &mut Vec) { + let p = self.block_on_legacy(w); + if w.block_name(p[0], p[1], p[2]) != "minecraft:farmland" { + return; + } + let roll = f64::from(self.random.next_float()); + let Some(dirt) = w.registry.default_state("minecraft:dirt") else { + return; + }; + if roll < self.fall_distance - 0.5 + && w.mob_griefing + && self.width() * self.width() * self.height() > 0.512 + { + events.push(MobEvent::SetBlock { + pos: p, + state: dirt, + }); + self.position.y = self.position.y.max(f64::from(p[1]) + 1.0); + } + } /// Entity.updateFluidInteraction: the fluid state, then touching water /// resets the fall and entering it (after the first tick) splashes. pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec) { From 90501f9ab4ab5cb04e85d6aa5d032d25e8e6bda2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:46:46 +0100 Subject: [PATCH 181/475] Keep leashed mobs and carrying endermen from despawning Mob.requiresCustomPersistence also covers a leashed mob and an enderman holding a block, and the spawn caps skip such mobs. Gather the existing cases in one helper, add the missing ones, and use it for both the despawn check and the cap count. --- .../src/game/natural_spawning.rs | 3 ++- crates/horizon-client/src/mobs/ai.rs | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 0035f5082..5afa72416 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -504,7 +504,8 @@ impl<'a> Game<'a> { m.position.z.floor() as i32, ], is_mob: true, - persistent: m.persistent, + persistent: m.persistent + || horizon_client::mobs::ai::requires_custom_persistence(m), }) .collect(); let players = players diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 2676c3a37..3b9a8ed04 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -1017,6 +1017,19 @@ pub fn despawn_check(m: &mut Mob, w: &MobWorld) -> bool { } true } +/// `Mob.requiresCustomPersistence` and its overrides: a passenger or a leashed +/// mob, a raider in a raid, an enderman carrying a block, a tame nautilus, a +/// sulfur cube holding a block or let out of a bucket. The spawn caps do not +/// count these mobs either (`NaturalSpawner.createState`). +pub fn requires_custom_persistence(m: &Mob) -> bool { + super::riders::keeps_alive(m) + || m.extra.leash.is_some() + || super::raider::state(m).is_some_and(|s| s.raid_id.is_some()) + || m.species() == Species::Enderman && m.extra.carried.is_some() + || super::nautilus::is_nautilus(m.species()) && m.extra.tame + || m.species() == Species::SulfurCube + && (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0) +} /// Mob.checkDespawn, before the tick (players are the only despawn /// reference). Returns false when the mob is discarded. fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { @@ -1032,12 +1045,7 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { return true; }; let d = m.distance_sqr(p); - // requiresCustomPersistence: raiders in a raid, a tame nautilus, a sulfur - // cube holding a block or let out of a bucket. - let persistent = m.persistent - || super::nautilus::is_nautilus(m.species()) && m.extra.tame - || m.species() == Species::SulfurCube - && (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0); + let persistent = m.persistent || requires_custom_persistence(m); // removeWhenFarAway: animals, villagers, golems, patrols and raiders, // zombie villagers being cured and ridden mobs stay. let creature = m.kind.category == "creature"; From a4407a4bccccf75769926530e6ac8bd7b981de64 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:47:34 +0100 Subject: [PATCH 182/475] Elytra: Unbreaking for remote players, keep wall hit events on the server copy --- crates/horizon-client/src/game/lan_actions.rs | 8 ++++++-- crates/horizon-client/src/motion.rs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..cd0d2ea46 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2326,9 +2326,13 @@ impl Game<'_> { } if (next / 10) % 2 == 0 && !creative - && let Some(elytra) = game.armor[1].as_mut() + && let Some(stack) = game.armor[1].clone() { - elytra.damage += 1; + // `ItemStack.hurtAndBreak`: Unbreaking can save the wear. + let loss = game.durability_loss(&stack, 1); + if let Some(elytra) = game.armor[1].as_mut() { + elytra.damage += loss; + } } let ctx = game.player_context(None); game.post_game_event("minecraft:elytra_glide", at, ctx); diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 29e9d3e06..5f6db8248 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -311,6 +311,9 @@ pub struct PlayerMotion { /// it travels on the server: `isLocalInstanceAuthoritative` is false, so /// its moves check no fall damage. pub server_copy: bool, + /// The span of `events` a wall hit on the server's copy pushed (sound and + /// damage): `server_travel` keeps them while it drops the simulated moves. + pub wall_hit_events: Option>, /// A client's copy of its own player (`Level.isClientSide`): only the /// server hurts it (`Entity.hurt` does nothing on a client) and ticks its /// food (`ServerPlayer.tick`). Its hurt animation comes from the server. @@ -480,6 +483,7 @@ impl PlayerMotion { freeze_immune: false, old_position: position, server_copy: false, + wall_hit_events: None, client_side: false, block_effects: true, powder_snow_entered: Vec::new(), @@ -1073,7 +1077,14 @@ impl MotionWorld<'_, '_, '_> { p.server_copy = false; (p.position, p.ground, p.collision) = (position, ground, collision); (p.history, p.fall_distance, p.steps) = (history, fall_distance, steps); + // The wall hit is the server's own: its sound and damage events stay. + let kept: Vec<_> = p + .wall_hit_events + .take() + .map(|span| p.events.drain(span).collect()) + .unwrap_or_default(); p.events.truncate(events); + p.events.extend(kept); travelled } /// `LivingEntity.checkAutoSpinAttack`: the first living entity in the @@ -1590,6 +1601,7 @@ impl MotionWorld<'_, '_, '_> { }; let dmg = ((move_h - new_h) * 10.0 - 3.0) as f32; if dmg > 0.0 { + let first = self.player.events.len(); // getFallDamageSound((int) dmg) let sound = if dmg as i32 > 4 { crate::landing::SceneSound::BigFall @@ -1612,6 +1624,9 @@ impl MotionWorld<'_, '_, '_> { .ok_or("missing fly_into_wall damage type")?; let rules = self.scene.health_rules; self.player.hurt(&self.resources.health, rules, id, dmg)?; + if self.player.server_copy { + self.player.wall_hit_events = Some(first..self.player.events.len()); + } } } Ok(()) From 7569f9615a193b50443eaff428e126651a647ce2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:47:58 +0100 Subject: [PATCH 183/475] Treat unbreakable items as undamageable in the anvil and grindstone --- crates/horizon-client/src/stations.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index a307b35cf..3fa480656 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -151,8 +151,12 @@ pub struct AnvilResult { pub repair_item_count_cost: u32, pub only_renaming: bool, } +/// `ItemStack.isDamageableItem`: durability, and not `minecraft:unbreakable`. pub fn is_damageable(items: &Items, stack: &ItemStack) -> bool { items.get(&stack.item).is_some_and(|i| i.max_damage > 0) + && stack + .data() + .is_none_or(|d| d.other("minecraft:unbreakable").is_none()) } fn max_damage(items: &Items, stack: &ItemStack) -> i32 { items.get(&stack.item).map_or(0, |i| i.max_damage as i32) From e60cbe8d4df0ad5ea87d571984f2bfd8119cfca8 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:48:03 +0100 Subject: [PATCH 184/475] Keep whole nugget stacks and picked-up enchantments on piglins --- crates/horizon-client/src/game/piglins.rs | 24 ++++++++++++++-------- crates/horizon-client/src/mobs/finalize.rs | 8 ++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/game/piglins.rs b/crates/horizon-client/src/game/piglins.rs index 7e58620bf..edfd9a3a0 100644 --- a/crates/horizon-client/src/game/piglins.rs +++ b/crates/horizon-client/src/game/piglins.rs @@ -6,7 +6,10 @@ use horizon_client::{ items::ItemStack, mobs::{ Mob, Species, Who, - finalize::{SLOT_NAMES, set_drop_chance, set_item, stored_drop_chance}, + finalize::{ + SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments, + stored_drop_chance, + }, piglin::{ADMIRE, ATE_RECENTLY}, }, }; @@ -88,7 +91,8 @@ impl Game<'_> { /// `Mob.equipItemIfPossible` for a piglin: put it in its slot when that /// slot takes it (an armour piece that cannot replace what is worn goes /// to an empty main hand), dropping what it replaces by drop chance. - fn piglin_equip(&mut self, index: usize, item: &str) -> bool { + fn piglin_equip(&mut self, index: usize, stack: &ItemStack) -> bool { + let item = &*stack.item; let mut slot = self.piglin_slot(item); let m = &self.mobs[index]; let mut replace = self.piglin_can_replace(m, item, slot); @@ -103,18 +107,18 @@ impl Game<'_> { let old = horizon_client::mobs::finalize::item(m, slot); let chance = stored_drop_chance(m, slot); let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance; + let old_enchantments = slot_enchantments(m, slot); set_item(m, slot, item); + // The piglin keeps what the stack carried (Protection IV stays on). + set_slot_enchantments(m, slot, stack.enchantments().to_vec()); // setItemSlotAndDropWhenKilled, then persistenceRequired. set_drop_chance(m, slot, 2.0); m.persistent = true; let at = [m.position.x, m.position.y, m.position.z]; if let (true, Some(old)) = (drop_old, old) { - self.spawn_item( - ItemStack::new(&old, 1), - at, - super::dispenser::new_item_motion(), - 10, - ); + let mut dropped = ItemStack::new(&old, 1); + dropped.set_crafting_enchantments(old_enchantments); + self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10); } true } @@ -153,7 +157,9 @@ impl Game<'_> { /// `PiglinAi.stopHoldingOffHandItem` for something that is not /// bartering currency: wear it if it can, else keep it in the inventory. fn piglin_keep(&mut self, index: usize, stack: ItemStack) { - if !self.piglin_equip(index, &stack.item) { + // Nuggets keep their whole stack in the inventory (a hand slot here + // holds one item id only). + if &*stack.item == "minecraft:gold_nugget" || !self.piglin_equip(index, &stack) { self.piglin_stash(index, stack); } } diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 4eeed885b..58aaa02bb 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -829,6 +829,14 @@ pub fn set_item(m: &mut Mob, slot: usize, id: &str) { } gear::set(m, gear_slot(slot), Vec::new()); } +/// The enchantments on what `m` has in `slot`. +pub fn slot_enchantments(m: &Mob, slot: usize) -> gear::Levels { + gear::gear(m).map_or_else(Vec::new, |g| g.slots[gear_slot(slot)].clone()) +} +/// Replace the enchantments of what `m` has in `slot` (after `set_item`). +pub fn set_slot_enchantments(m: &mut Mob, slot: usize, levels: gear::Levels) { + gear::set(m, gear_slot(slot), levels); +} /// `Mob.setDropChance`. pub fn set_drop_chance(m: &mut Mob, slot: usize, chance: f32) { let c = m From e2b1dc66914a82108611316e978a8aa379463de1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:49:12 +0100 Subject: [PATCH 185/475] Soul Speed: run its effects for each server-side player --- crates/horizon-client/src/game.rs | 15 +++++++- .../src/game/enchant_effects.rs | 34 +++++++++---------- crates/horizon-client/src/game/lan_actions.rs | 8 +++++ .../horizon-client/src/game/lan_particles.rs | 29 ++++++++++++++++ crates/horizon-client/src/motion.rs | 9 ++++- 5 files changed, 75 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 515a69cc8..6f856d1cb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4342,7 +4342,20 @@ impl Game<'_> { }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; - self.tick_soul_speed(); + if let Some((at, velocity)) = self.tick_soul_speed() { + self.particles.send_particles( + &horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::Soul, + ), + at, + velocity, + 1.0, + 0, + ); + if let Some(host) = self.lan.as_ref() { + lan_particles::send_soul_particle(host, at, velocity); + } + } self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 8ae54dba7..ad9894da8 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -9,7 +9,6 @@ use super::Game; use horizon_client::{ enchant::{EffectContext, Enchantments, level_value}, items::ItemStack, - particles::{ParticleOptions, ParticleType}, }; use horizon_core::{position::BlockPos, random::RandomSource}; @@ -474,15 +473,16 @@ impl Game<'_> { /// (`enchantment_active_check`); /// - the boots lose 1 durability with a 4% chance when on such a block. /// - /// Every fifth tick the player walking on one also gets soul particles and - /// sometimes a sound. A remote server applies and syncs all of this itself. - pub(super) fn tick_soul_speed(&mut self) { + /// Every fifth tick the player walking on one also gets a soul particle + /// (returned as its position and velocity, for the caller to show or send + /// to the players around) and sometimes a sound. Runs for the window's own + /// player and for each guest on the server (`guest_server_tick`); a remote + /// client leaves it to its server. + pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> { const ID: &str = "minecraft:soul_speed"; - let Some(player) = self.player.as_mut() else { - return; - }; + let player = self.player.as_mut()?; if player.client_side { - return; + return None; } let level = self.armor[3] .as_ref() @@ -533,22 +533,19 @@ impl Game<'_> { self.wear_boots(1); } if level == 0 { - return; + return None; } let r = &mut self.world.random; - if tick_effects { - // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown - // back against the movement (-0.2) and up (0.1). + // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown + // back against the movement (-0.2) and up (0.1). + let particle = tick_effects.then(|| { let x = position.x + (r.next_double() - 0.5) * f64::from(width); let z = position.z + (r.next_double() - 0.5) * f64::from(width); - self.particles.send_particles( - &ParticleOptions::simple(ParticleType::Soul), + ( [x, position.y + 0.1, z], [moved.x * -0.2, 0.1, moved.z * -0.2], - 1.0, - 0, - ); - } + ) + }); // `play_sound`: its `random_chance` (35%) comes first in its `all_of`, // so the level random is drawn every tick the boots are worn; volume // 0.6, pitch 0.6 to 1.0. @@ -561,6 +558,7 @@ impl Game<'_> { pitch, ); } + particle } /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): /// Unbreaking applies, and they break when worn out. diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..78227b475 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2115,6 +2115,7 @@ impl Game<'_> { let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height()); let at = g.position; let sneaking = g.sneaking; + let mut soul = None; let events = self.as_guest(host, conn, |game| { let (rules, motion) = (game.world.health_rules, game.motion); let health = &motion.health; @@ -2184,6 +2185,7 @@ impl Game<'_> { w.player.shift_key_down = sneaking; w.player.freeze_immune = freeze_immune; let _ = w.server_block_damage(start); + let _ = w.update_soul_speed_block(); let _ = w.tick_environment_damage(); let _ = w.apply_fire_contact(); let _ = w.server_hazards_tick(in_wall); @@ -2211,6 +2213,9 @@ impl Game<'_> { // LivingEntity.baseTick: outside the world border, then tickEffects. game.border_damage(); game.tick_mob_effects(); + // Soul Speed's effects (LivingEntity.baseTick's location_changed + // and tick effects); its particle goes to the players around. + soul = game.tick_soul_speed(); let Some(player) = game.player.as_mut() else { return (Vec::new(), None); }; @@ -2238,6 +2243,9 @@ impl Game<'_> { let Some((events, marked)) = events else { return; }; + if let Some((at, velocity)) = soul { + super::lan_particles::send_soul_particle(host, at, velocity); + } self.as_guest(host, conn, |game| game.tick_player_sculk_events()); for event in events { match event { diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 719fe79ab..a11547759 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -24,6 +24,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] { [c[0], c[1], c[2]] } +/// `ServerLevel.sendParticles` of one soul particle moving by `velocity` +/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks. +pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) { + let Some(id) = host + .hub + .registry_id("minecraft:particle_type", "minecraft:soul") + else { + return; + }; + let packet = C::LevelParticles(play::LevelParticles { + particle: Particle { id, data: None }, + override_limiter: false, + always_show: false, + pos: at, + dist: velocity.map(|v| v as f32), + max_speed: [1.0; 3], + count: 0, + randomization: 0, + }); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + let distance: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if distance < 32.0 * 32.0 { + g.send(&packet); + } + } +} + impl Game<'_> { /// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point. /// Keep the local event's rendering; connected players receive one at rest. diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 156fc2549..084793049 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1007,7 +1007,7 @@ impl MotionWorld<'_, '_, '_> { ) }); self.player.try_add_frost(on_air)?; - self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; + self.update_soul_speed_block()?; if self.player.health.tick_count % 40 == 0 && self.player.is_fully_frozen() && self.player.can_freeze() @@ -1020,6 +1020,13 @@ impl MotionWorld<'_, '_, '_> { } Ok(()) } + /// Refresh whether the player stands on a soul speed block, for the + /// `location_changed` enchantment effects (a server's player too, whose + /// client does the walking). + pub fn update_soul_speed_block(&mut self) -> Result<()> { + self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; + Ok(()) + } /// `MovementAffectedByPredicate` for `#minecraft:soul_speed_blocks`. fn on_soul_speed_block(&mut self) -> Result { let on = self.support(movement_support::MOVEMENT_SUPPORT_OFFSET)?; From d7ec7b6077b73fa0159597d5f8ebdbf057701978 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:55:45 +0100 Subject: [PATCH 186/475] Shulker bullet split: record whether the shell was open at hit time --- crates/horizon-client/src/mobs/shulker.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index 124d1b299..6f0edc2d7 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -166,9 +166,10 @@ pub fn after_hurt(m: &mut Mob) { } /// `Shulker.hurtServer` after a shulker bullet's damage: unless the /// low-health teleport was drawn instead (`else if`), `hitByShulkerBullet` -/// runs on the next tick. +/// runs on the next tick. Whether the shell is open is read here, at hurt +/// time, so a goal opening it before that tick cannot change the outcome. pub fn hit_by_bullet(m: &mut Mob) { - if m.species() == Species::Shulker && !m.extra.flag { + if m.species() == Species::Shulker && !m.extra.flag && m.extra.counter != 0 { m.extra.flag2 = true; } } @@ -188,9 +189,10 @@ pub fn ai_step( hit_by_shulker_bullet(m, w, level_random, events); } } -/// `Shulker.hitByShulkerBullet`: an open shulker teleports, and leaves a -/// new one of its colour behind unless shulkers crowd the place (one in -/// five fewer chances for every other shulker within 8 blocks). +/// `Shulker.hitByShulkerBullet` (only queued by an open shulker): it +/// teleports, and leaves a new one of its colour behind unless shulkers +/// crowd the place (one in five fewer chances for every other shulker +/// within 8 blocks). fn hit_by_shulker_bullet( m: &mut Mob, w: &MobWorld, @@ -199,7 +201,7 @@ fn hit_by_shulker_bullet( ) { let old_position = m.position; let old_box = m.bounds(); - if m.extra.counter == 0 || !teleport(m, w, events) { + if !teleport(m, w, events) { return; } let area = old_box.inflate(Vec3::new(8.0, 8.0, 8.0)); From 2277c85fc3e486fa36ef6f4569a826a73db0cbd4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:07:02 +0100 Subject: [PATCH 187/475] Run neighbour updates when a mob trampling farmland places dirt --- crates/horizon-client/src/game/creatures.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2b65a3289..f08f41d01 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1037,9 +1037,9 @@ impl Game<'_> { [0.0, 0.2, 0.0], 10, ), - MobEvent::SetBlock { pos, state } => { - self.world.set(pos[0], pos[1], pos[2], state)? - } + // `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop + // above trampled farmland breaks and drops. + MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?, MobEvent::SetDoorOpen { pos, open } => { let pos = BlockPos::new(pos[0], pos[1], pos[2]); self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open)); From 7f5e3384b375c822d634f00a020f13f4992263f1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:07:11 +0100 Subject: [PATCH 188/475] Play mining hit sounds once, from the server's destroy level events The server now sends level events 2019 and 2020 every tick a player digs (ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler: a crack on the face, plus the hit sound for 2020. A remote client plays only the first hit itself and no longer adds its own sound every fourth tick, so the hit was heard twice, out of phase, for every block. --- crates/horizon-client/src/game.rs | 45 ++++++++++++------- crates/horizon-client/src/game/lan.rs | 5 ++- .../src/game/level_particles.rs | 24 ++++++++++ crates/horizon-client/src/game/remote.rs | 17 ++----- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8448f0837..a0bc6c339 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2844,6 +2844,16 @@ impl Game<'_> { data, } => self.sculk_charge_event(pos, data), LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos), + // LevelEventHandler 2019 and 2020: the crack on a face of the + // block being mined, with its hit sound for 2020. + LevelEvent::LevelEvent { + id: id @ (2019 | 2020), + pos, + data, + } => { + let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize]; + self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020); + } LevelEvent::LevelEvent { id: 2004, pos, .. } => { self.spawner_burst([pos.x, pos.y, pos.z]) } @@ -4042,6 +4052,17 @@ impl Game<'_> { _ => {} } } + /// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every + /// fourth tick since the START (the hit sound), else 2019 (the crack only), + /// for everyone around, the digger included (`levelEvent(null, ...)`). + fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) { + let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 }; + let direction = horizon_core::geometry::Direction::ALL + .iter() + .position(|d| d.normal() == face) + .unwrap_or(1); + self.level_event_at(id, pos, direction as i32); + } fn tick_mining(&mut self) -> Result<()> { if self.destroy_delay > 0 { self.destroy_delay -= 1; @@ -4085,6 +4106,11 @@ impl Game<'_> { let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); let facts = self.res.facts().get(state); if facts.hardness < 0.0 { + // The server still counts its destroy ticks on an unbreakable block. + let m = self.mining.as_mut().ok_or("mining")?; + m.ticks += 1; + let (pos, ticks) = (m.pos, m.ticks); + self.destroy_progress_event(pos, hit.face, ticks); return Ok(()); } // Player.getDestroySpeed / BlockBehaviour.getDestroyProgress. @@ -4115,26 +4141,15 @@ impl Game<'_> { } else { speed / facts.hardness / if harvest { 30.0 } else { 100.0 } }; - let hit_sound = facts.sounds.clone(); self.swing_arm(); let m = self.mining.as_mut().ok_or("mining")?; m.progress += progress; m.ticks += 1; - let crack = (m.pos, hit.face); - if m.ticks % 4 == 1 { - let c = [ - f64::from(m.pos[0]) + 0.5, - f64::from(m.pos[1]) + 0.5, - f64::from(m.pos[2]) + 0.5, - ]; - self.audio.play( - &hit_sound.hit, - Some(c), - (hit_sound.volume + 1.0) / 8.0, - hit_sound.pitch * 0.5, - ); + // An instant break at START sends no destroy events. + if m.progress < 1.0 || m.ticks > 1 { + let (pos, ticks) = (m.pos, m.ticks); + self.destroy_progress_event(pos, hit.face, ticks); } - self.crack_particle(crack.0, crack.1, state); let m = self.mining.as_mut().ok_or("mining")?; // ServerPlayerGameMode breaks a player's block at once only when it is instant (START's // insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..3a172073d 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1188,8 +1188,9 @@ impl Game<'_> { horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => { // The acting guest does not predict the egg's teleport // particles, bone meal's growth, or anvil result sounds - // (these levelEvent calls name no excluded player). - let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) { + // (these levelEvent calls name no excluded player), nor + // the digger's own crack and hit sound (2019, 2020). + let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) { None } else { self.lan_acting diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index f12aedc5b..69fbcfc2e 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -134,6 +134,30 @@ impl Game<'_> { // Each guest plays it from the global packet. self.audio.play_unshared(sound, at, volume, 1.0); } + /// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for + /// `play_sound`) and one crumb on the face being mined; nothing for air. + pub(super) fn breaking_block_effects( + &mut self, + pos: [i32; 3], + face: [i32; 3], + play_sound: bool, + ) { + let state = self.world.block(pos[0], pos[1], pos[2]); + if self.res.facts().is_air(state) { + return; + } + if play_sound { + let sounds = self.res.facts().get(state).sounds.clone(); + // Each guest plays it from the level event itself. + self.audio.play_unshared( + &sounds.hit, + Some(pos.map(|v| f64::from(v) + 0.5)), + (sounds.volume + 1.0) / 8.0, + sounds.pitch * 0.5, + ); + } + self.crack_particle(pos, face, state); + } /// Queue and play level event `id` at `pos` (`Level.levelEvent`). pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) { self.level_events.push(LevelEvent::LevelEvent { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..1cc659cc0 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3866,6 +3866,9 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.destroying = Some((hit.pos, face, 0.0)); } + // startDestroyBlock plays the first hit and crack itself; the + // server's level events 2019 and 2020 do the rest. + self.breaking_block_effects(hit.pos, hit.face, true); self.mining = Some(Mining { pos: hit.pos, progress: 0.0, @@ -3878,20 +3881,6 @@ impl Game<'_> { }; let done = done + progress; let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1; - if ticks % 4 == 1 { - let c = [ - f64::from(pos[0]) + 0.5, - f64::from(pos[1]) + 0.5, - f64::from(pos[2]) + 0.5, - ]; - self.audio.play( - &facts.sounds.hit, - Some(c), - (facts.sounds.volume + 1.0) / 8.0, - facts.sounds.pitch * 0.5, - ); - } - self.crack_particle(pos, hit.face, state); if done >= 1.0 { let seq = self.next_sequence(); self.predict_break(pos, state, seq); From 91d6bdba85be4bc2f81b5f44ab896257799ecbf5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:11:21 +0100 Subject: [PATCH 189/475] Fly firework rockets on by themselves between server updates --- crates/horizon-client/src/game/fireworks.rs | 89 +++++++++++-------- crates/horizon-client/src/game/remote.rs | 9 ++ .../src/game/remote_projectiles.rs | 16 +++- 3 files changed, 72 insertions(+), 42 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index af226335e..716fa7d60 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -62,11 +62,56 @@ impl Rocket { item, } } - /// One tick of a server's client: the previous position, then `step`. - pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) { + /// One tick of a server's client: the rocket flies on by itself between + /// the server's updates (a rocket that follows an entity is placed by the + /// caller). + pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) { self.previous = self.position; + if self.attached_to.is_none() { + self.free_flight(scene); + } self.life += 1; - self.position = step(self.position); + } + /// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed + /// up (an angled one keeps its motion) and rise, then move. True when a + /// block stopped it. + fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool { + let h = 0.125; + let bounds = Aabb::new( + Vec3::new(self.position.x - h, self.position.y, self.position.z - h), + Vec3::new( + self.position.x + h, + self.position.y + 2.0 * h, + self.position.z + h, + ), + ); + let wanted = if self.angled { + self.velocity + } else { + Vec3::new( + self.velocity.x * 1.15, + self.velocity.y + 0.04, + self.velocity.z * 1.15, + ) + }; + let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else { + return false; + }; + let blocked = (moved.x - wanted.x).abs() > 1e-7 + || (moved.y - wanted.y).abs() > 1e-7 + || (moved.z - wanted.z).abs() > 1e-7; + let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7; + self.position = Vec3::new( + self.position.x + moved.x, + self.position.y + moved.y, + self.position.z + moved.z, + ); + self.velocity = if horizontal { + Vec3::new(self.velocity.x, wanted.y, self.velocity.z) + } else { + wanted + }; + blocked } /// Position and motion (the dispenser parity fixture). pub(super) fn motion(&self) -> (Vec3, Vec3) { @@ -191,46 +236,14 @@ impl Game<'_> { r.velocity = p.velocity; } } else { - // Not shot at an angle: accelerate (unless blocked sideways) and rise. let mut scene = SceneCollisions { world: &self.world, binding: self.collision, subject: None, }; - let h = 0.125; - let bounds = Aabb::new( - Vec3::new(r.position.x - h, r.position.y, r.position.z - h), - Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h), - ); - let wanted = if r.angled { - r.velocity - } else { - Vec3::new( - r.velocity.x * 1.15, - r.velocity.y + 0.04, - r.velocity.z * 1.15, - ) - }; - if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) { - let blocked = (moved.x - wanted.x).abs() > 1e-7 - || (moved.y - wanted.y).abs() > 1e-7 - || (moved.z - wanted.z).abs() > 1e-7; - if r.angled && blocked { - // hitTargetOrDeflectSelf: explode on the block. - r.life = r.lifetime; - } - let horizontal = - (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7; - r.position = Vec3::new( - r.position.x + moved.x, - r.position.y + moved.y, - r.position.z + moved.z, - ); - r.velocity = if horizontal { - Vec3::new(r.velocity.x, wanted.y, r.velocity.z) - } else { - wanted - }; + if r.free_flight(&mut scene) && r.angled { + // hitTargetOrDeflectSelf: explode on the block. + r.life = r.lifetime; } } let at = [r.position.x, r.position.y, r.position.z]; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..1c86da46b 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1406,6 +1406,13 @@ impl Game<'_> { { return; } + // So does a firework rocket: its own tick flies it on. + if path.is_some() + && let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32) + { + f.position = Vec3::new(position[0], position[1], position[2]); + return; + } // A dragon fireball keeps Entity's NO_OP InterpolationHandler too: // the server's position replaces its own at once. if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) { @@ -3016,6 +3023,8 @@ impl Game<'_> { p.velocity = v; } else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) { b.velocity = v; + } else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) { + r.velocity = v; } else if let Some(p) = self .remote .as_mut() diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index b12cb4cd6..1dc9da095 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -3,8 +3,11 @@ //! and `handleSetEntityData`): the server simulates them, the client draws //! them where it says and interpolates their moves. use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector}; -use horizon_client::particles::{ParticleOptions, ParticleType}; use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld}; +use horizon_client::{ + collisions::SceneCollisions, + particles::{ParticleOptions, ParticleType}, +}; use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource}; use horizon_protocol::entity_data::Value; use horizon_world::registry::BlockStateId; @@ -361,9 +364,13 @@ impl Game<'_> { .remote .as_ref() .and_then(|r| u32::try_from(r.entity_id).ok()); - for r in &mut self.rockets { - let id = r.id; - r.remote_step(|at| step(id, at)); + let mut rockets = std::mem::take(&mut self.rockets); + for r in &mut rockets { + r.remote_step(&mut SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }); if let Some(target) = r.attached_to { if me == Some(target) { if let Some(p) = self.player.as_mut() { @@ -401,6 +408,7 @@ impl Game<'_> { v, ); } + self.rockets = rockets; for c in &mut self.crystals { c.time += 1; c.position = step(c.id, c.position); From a45c73bb36609301725aafb7fa66ae7a57387d3c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:12:49 +0100 Subject: [PATCH 190/475] Credit the player who kills the ender dragon --- crates/horizon-client/src/game/dragon.rs | 80 ++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index fff03ef1c..b2f04d77c 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -262,6 +262,9 @@ pub(super) struct Dragon { /// only damage above the last hit lands, and only the excess. invulnerable: i32, last_hurt: f32, + /// `lastHurtByPlayer` with its 100 tick memory: the player credited with + /// the kill, and the dragon tick of their last hit. + hurt_by_player: Option<(FightWho, i32)>, pub death_time: i32, pub flap: f32, pub old_flap: f32, @@ -385,6 +388,7 @@ impl Dragon { hurt_time: 0, invulnerable: 0, last_hurt: 0.0, + hurt_by_player: None, death_time: 0, flap: 0.0, old_flap: 0.0, @@ -426,6 +430,10 @@ impl Dragon { pub fn alive(&self) -> bool { self.health > 0.0 } + /// `isDeadOrDying` for the killing blow: the dying flight, or no health. + fn down(&self) -> bool { + matches!(self.phase, Phase::Dying { .. }) || !self.alive() + } fn part_box(&self, i: usize) -> Aabb { let (w, h) = PART_SIZES[i]; let p = self.parts[i].0; @@ -903,7 +911,7 @@ impl Game<'_> { if let Some(d) = self.dragon.as_mut() { if d.nearest_crystal == Some(index) { d.nearest_crystal = None; - self.hurt_dragon(0, 10.0, true, false); + self.hurt_dragon(0, 10.0, true, by, false); } else if let Some(n) = d.nearest_crystal.as_mut() && *n > index { @@ -975,17 +983,40 @@ impl Game<'_> { self.destroy_crystal(index, by, CrystalDamage::Direct); Hurt::Landed { full: true } } else if id >= SPECIAL { - self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow) + let by = by_player.then(|| self.acting_player()); + self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow) } else { Hurt::Refused } } /// `EnderDragon.hurt(part, source, damage)` on the dragon in the level. - fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt { + /// `by` is the player behind the hit, remembered for the kill credit. + fn hurt_dragon( + &mut self, + part: usize, + damage: f32, + counts: bool, + by: Option, + arrow: bool, + ) -> Hurt { let Some(d) = self.dragon.as_mut() else { return Hurt::Refused; }; + let was_down = d.down(); let hurt = d.hurt(part, damage, counts, arrow); + // LivingEntity.hurtServer: a player's hit sets lastHurtByPlayer (100 ticks). + if counts + && matches!(hurt, Hurt::Landed { .. }) + && let Some(who) = by + { + d.hurt_by_player = Some((who, d.ticks)); + } + if !was_down && d.down() { + self.credit_dragon_kill(); + } + let Some(d) = self.dragon.as_ref() else { + return hurt; + }; if hurt == (Hurt::Landed { full: true }) { let at = [d.position.x, d.position.y, d.position.z]; let pitch = 0.8 + self.world.random.next_float() * 0.3; @@ -994,6 +1025,47 @@ impl Game<'_> { } hurt } + /// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player + /// who hurt the dragon within the last 100 ticks. A guest's kill is + /// theirs: their statistics, scores and advancements. + fn credit_dragon_kill(&mut self) { + let Some(d) = self.dragon.as_ref() else { + return; + }; + let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else { + return; + }; + let view = horizon_client::advancements::EntityView { + kind: "minecraft:ender_dragon".to_owned(), + position: [d.position.x, d.position.y, d.position.z], + ..Default::default() + }; + let id = d.id; + let guest = match who { + FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn), + _ => None, + }; + let credited = guest.is_some_and(|conn| { + let Some(mut host) = self.lan.take() else { + return false; + }; + let done = self + .as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view)) + .is_some(); + self.lan = Some(host); + done + }); + if !credited { + self.award_dragon_kill(id, &view); + } + } + /// `ServerPlayer.awardKillScore` for the dragon, on the killer's state. + fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) { + self.stats.add("killed", "minecraft:ender_dragon", 1); + self.stats.custom("mob_kills", 1); + self.score_player_killed(super::commands::Target::Mob(id)); + self.trigger_entity("minecraft:player_killed_entity", "entity", view); + } /// Player melee on the dragon's parts or a crystal under the crosshair. /// The dragon part under the crosshair within reach: its id and distance. pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> { @@ -2716,7 +2788,7 @@ impl Game<'_> { self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]) }); for (part, amount) in hits { - self.hurt_dragon(part, amount, true, false); + self.hurt_dragon(part, amount, true, None, false); } } } From e6587dce6a005413b2617d281826dde4ca6663f0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:17:05 +0100 Subject: [PATCH 191/475] Respawn at once without a death screen when immediate_respawn is on --- crates/horizon-client/src/game.rs | 11 ++- crates/horizon-client/src/game/lan.rs | 2 + crates/horizon-client/src/game/remote.rs | 29 ++++++-- crates/horizon-client/src/game/screens.rs | 86 ++++++++++++----------- crates/horizon-client/src/lan/hub.rs | 17 ++++- 5 files changed, 95 insertions(+), 50 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8448f0837..2d0efc34d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4614,9 +4614,14 @@ impl Game<'_> { if let Some(host) = &self.lan { host.hub.entity_event(host.host_entity, 3, feet); } - self.screen = Screen::Dead; - self.capture(false); - self.ui.death_time = 0; + if self.ui.rules.bool("immediate_respawn") { + // Gui.setScreen: without the death screen the player respawns at once. + self.respawn_player()?; + } else { + self.screen = Screen::Dead; + self.capture(false); + self.ui.death_time = 0; + } } else if dead { // LivingEntity.tickDeath: 20 ticks after dying the player is // removed, and Player.remove's inventoryMenu.removed drops what diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..9b52cc46e 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -974,6 +974,8 @@ impl Game<'_> { self.ui.rules.bool("player_movement_check"), self.ui.rules.bool("elytra_movement_check"), ); + host.hub + .set_immediate_respawn(self.ui.rules.bool("immediate_respawn")); // A server with several levels runs a pass for each // (`tick_server`): the network is read once, before the first. let pass = self.level_pass; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..1a684cb75 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -192,6 +192,8 @@ pub(super) struct Remote { stop_mining: bool, placing: Option<([i32; 3], u64)>, death_message: Option, + /// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`. + show_death_screen: bool, player_type: Option>, /// Inventory as last synchronised, for creative-mode slot updates. synced: Vec>, @@ -476,6 +478,7 @@ impl Game<'_> { stop_mining: false, placing: None, death_message: None, + show_death_screen: true, player_type, synced: vec![None; 36], decode_errors: 0, @@ -1623,6 +1626,7 @@ impl Game<'_> { r.online_mode = p.online_mode; r.entity_id = p.player_id; r.view_distance = p.chunk_radius; + r.show_death_screen = p.show_death_screen; } self.streaming.radius = self.settings.render_distance.min(p.chunk_radius + 1).max(2); @@ -2182,7 +2186,11 @@ impl Game<'_> { pl.health.flash_health_change(p.health); } self.apply_remote_health(); - if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() { + // The death screen opens (or the respawn is asked for) with the + // death packet when the rule hides it. + let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen); + if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some() + { self.screen = Screen::Dead; self.ui.death_time = 0; self.capture(false); @@ -2198,11 +2206,16 @@ impl Game<'_> { r.death_message = Some(message); } // ClientPacketListener.handlePlayerCombatKill: the death - // screen, whatever health was last sent. + // screen, whatever health was last sent, or `respawn()` at + // once when it is hidden. if me && self.screen != Screen::Dead && self.player.is_some() { - self.screen = Screen::Dead; - self.ui.death_time = 0; - self.capture(false); + if self.remote.as_ref().is_none_or(|r| r.show_death_screen) { + self.screen = Screen::Dead; + self.ui.death_time = 0; + self.capture(false); + } else { + self.respawn_player()?; + } } } C::TrackedWaypoint(p) => self.handle_waypoint(&p), @@ -2278,6 +2291,12 @@ impl Game<'_> { r.game_mode = mode; } } + // IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen. + 11 => { + if let Some(r) = self.remote.as_mut() { + r.show_death_screen = p.param == 0.0; + } + } 7 => { self.world.weather.rain_level = (p.param, p.param); self.world.weather.raining = p.param > 0.0; diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index f8885f83a..3a2c3deda 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -286,6 +286,50 @@ impl Game<'_> { ); } } + /// LocalPlayer.respawn: the player comes back (the death screen's + /// button, or at once with `immediate_respawn`). + pub(super) fn respawn_player(&mut self) -> Result<()> { + if self.remote.is_none() { + let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); + self.experience.respawn(keep); + // The guests are sent the respawned host as a new entity, not the dead body. + if let Some(host) = self.lan.as_mut() { + let id = host.host_entity; + host.hub.respawned(id); + } + // Autosaves keep a login position; death must resolve the respawn point instead. + self.level.position = None; + } + if self.remote.is_some() { + self.ui.death_text = None; + self.toggles.respawn(); + self.remote_respawn(); + } else if self.respawn_config.is_some() { + // LocalPlayer.respawn: KeyMapping.resetToggleKeys. + self.toggles.respawn(); + // PlayerList.respawn at the respawn anchor, once it is loaded. + self.begin_anchor_respawn()?; + self.screen = Screen::Playing; + self.ui.death_text = None; + self.spawn_player()?; + self.capture(true); + } else { + // LocalPlayer.respawn: KeyMapping.resetToggleKeys. + self.toggles.respawn(); + self.respawn_out_of_end()?; + self.player = None; + self.screen = Screen::Playing; + self.ui.death_text = None; + self.spawn_player()?; + self.capture(true); + } + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if self.remote.is_none() && self.level.hardcore { + self.set_mode(GameMode::Spectator); + } + Ok(()) + } pub(super) fn death_click(&mut self) -> Result<()> { if self .ui @@ -303,51 +347,11 @@ impl Game<'_> { .into_iter() .find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0)) .map(|d| d.3); - if hit == Some(0) && self.remote.is_none() { - let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); - self.experience.respawn(keep); - // The guests are sent the respawned host as a new entity, not the dead body. - if let Some(host) = self.lan.as_mut() { - let id = host.host_entity; - host.hub.respawned(id); - } - // Autosaves keep a login position; death must resolve the respawn point instead. - self.level.position = None; - } match hit { - Some(0) if self.remote.is_some() => { - self.ui.death_text = None; - self.toggles.respawn(); - self.remote_respawn(); - } - Some(0) if self.respawn_config.is_some() => { - // LocalPlayer.respawn: KeyMapping.resetToggleKeys. - self.toggles.respawn(); - // PlayerList.respawn at the respawn anchor, once it is loaded. - self.begin_anchor_respawn()?; - self.screen = Screen::Playing; - self.ui.death_text = None; - self.spawn_player()?; - self.capture(true); - } - Some(0) => { - // LocalPlayer.respawn: KeyMapping.resetToggleKeys. - self.toggles.respawn(); - self.respawn_out_of_end()?; - self.player = None; - self.screen = Screen::Playing; - self.ui.death_text = None; - self.spawn_player()?; - self.capture(true); - } + Some(0) => self.respawn_player()?, Some(1) => self.close_world()?, _ => {} } - // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's - // respawned player only watches. - if hit == Some(0) && self.remote.is_none() && self.level.hardcore { - self.set_mode(GameMode::Spectator); - } if hit.is_some() { self.audio.play_button_click(); } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1a8cbc62..8c60dc75f 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -43,6 +43,7 @@ pub mod game_event { pub const RAIN_LEVEL: u8 = 7; pub const THUNDER_LEVEL: u8 = 8; pub const GUARDIAN_ELDER_EFFECT: u8 = 10; + pub const IMMEDIATE_RESPAWN: u8 = 11; pub const LEVEL_CHUNKS_LOAD_START: u8 = 13; } @@ -149,6 +150,8 @@ pub struct Hub { /// The `player_movement_check` and `elytra_movement_check` game rules, /// set by the game before each tick. pub movement_checks: (bool, bool), + /// The `immediate_respawn` game rule, as the players were last told. + immediate_respawn: bool, /// An integrated server's owner (`MinecraftServer.isSingleplayerOwner`), /// whose moves are never checked. pub singleplayer_owner: Option, @@ -324,6 +327,7 @@ impl Hub { guests: Vec::new(), sync, ticks: 0, + immediate_respawn: false, movement_checks: (true, true), singleplayer_owner: None, riding: Default::default(), @@ -399,6 +403,17 @@ impl Hub { g.sender.send(body.clone()); } } + /// MinecraftServer.onGameRuleChanged: every player is told when + /// `immediate_respawn` changes; a joining player's Login carries it. + pub fn set_immediate_respawn(&mut self, on: bool) { + if self.immediate_respawn != on { + self.immediate_respawn = on; + self.broadcast(&C::GameEvent(play::GameEvent { + event: game_event::IMMEDIATE_RESPAWN, + param: if on { 1.0 } else { 0.0 }, + })); + } + } /// PlayerList.setViewDistance: every player is told the new radius, /// and each one's chunks follow it, up to what they asked for /// (ChunkMap.getPlayerViewDistance). @@ -633,7 +648,7 @@ impl Hub { chunk_radius: self.config.view_distance, simulation_distance: self.config.simulation_distance, reduced_debug_info: false, - show_death_screen: true, + show_death_screen: !self.immediate_respawn, limited_crafting: false, spawn: self.spawn_info(mode), // MinecraftServer.usesAuthentication, enforceSecureProfile. From 5d7097877bccd89bd0e448b875706ec8006ecdbf Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:19:30 +0100 Subject: [PATCH 192/475] game: station tabs on the recipe pages A row of tabs above the recipe pages shows `set-recipes`' stations (their item in a slot, a white frame on the selected one). A click sends `tab(i)` to the mod, R/U over a tab works like over any slot, and horizon-ctl lists them as " tab". Changing tab resets the page. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mod_browser.rs | 12 +- .../src/game/mod_browser_recipes.rs | 116 ++++++++++++++++-- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 3121eb05b..645415ccb 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -127,10 +127,11 @@ impl Game<'_> { self.mods.browser.stacks = stacks; // The same title again (the mod updating its buttons) keeps the page. let old = self.mods.browser.recipes.take(); - self.mods.browser.recipes = - self.mods.mods.browser_recipes(owner).map(|(title, list)| { - super::mod_browser_recipes::Pages::new(items, title, list, old) - }); + self.mods.browser.recipes = self + .mods + .mods + .browser_recipes(owner) + .map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old)); self.mods.browser.of = Some((owner, version)); } Some(owner) @@ -171,6 +172,9 @@ impl Game<'_> { /// as the mod sent it and as a stack. fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> { if self.mods.browser.recipes.is_some() { + if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) { + return Some((sent, stack)); + } let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?; return Some((sent, stack)); } diff --git a/crates/horizon-client/src/game/mod_browser_recipes.rs b/crates/horizon-client/src/game/mod_browser_recipes.rs index 14848de90..13b3b78e1 100644 --- a/crates/horizon-client/src/game/mod_browser_recipes.rs +++ b/crates/horizon-client/src/game/mod_browser_recipes.rs @@ -11,12 +11,14 @@ use horizon_client::{ hud::{HudBatch, rgba}, items::ItemStack, }; -use horizon_mods::{BrowserEvent, BrowserLayout, BrowserRecipe, BrowserSlotClick, BrowserStack}; +use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage}; use std::time::Instant; const SLOT: f32 = 18.0; /// The title row's height, and the gap under each recipe. const TITLE: f32 = 18.0; +/// A tab's step: an 18 px slot and a gap. +const TAB: f32 = 20.0; const GAP: f32 = 6.0; /// One recipe as the game draws it. @@ -34,6 +36,9 @@ pub(super) struct Shown { /// The recipes a panel shows, and its page. pub(super) struct Pages { title: String, + /// The tabs' icons and the selected one. + tabs: Vec<(BrowserStack, Option)>, + tab: usize, recipes: Vec, page: usize, since: Instant, @@ -42,11 +47,11 @@ pub(super) struct Pages { impl Pages { pub(super) fn new( items: &horizon_client::items::Items, - title: &str, - list: &[BrowserRecipe], + shown: &RecipePage, old: Option, ) -> Self { - let kept = old.filter(|o| o.title == title); + let (title, list, tab) = (&shown.title, &shown.recipes, shown.tab as usize); + let kept = old.filter(|o| o.title == *title && o.tab == tab); let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> { stacks .iter() @@ -69,7 +74,13 @@ impl Pages { }) .collect(); Self { - title: title.to_owned(), + title: title.clone(), + tabs: shown + .tabs + .iter() + .map(|t| (t.clone(), browser_stack(items, t))) + .collect(), + tab, recipes, page: kept.as_ref().map_or(0, |o| o.page), since: kept.map_or_else(Instant::now, |o| o.since), @@ -131,11 +142,30 @@ impl Shown { } } +/// The tabs' places, in rows under the title row. +fn tab_places(pages: &Pages, layout: &Layout) -> Vec<(f32, f32)> { + let per_row = ((layout.width() / TAB).floor() as usize).max(1); + (0..pages.tabs.len()) + .map(|i| { + ( + layout.grid.0 + (i % per_row) as f32 * TAB, + layout.grid.1 + TITLE + 3.0 + (i / per_row) as f32 * TAB, + ) + }) + .collect() +} +/// Where the recipes start: under the title row and the tabs. +fn recipes_top(pages: &Pages, layout: &Layout) -> f32 { + let tabs = tab_places(pages, layout); + tabs.last() + .map_or(layout.grid.1 + TITLE, |&(_, y)| y + TAB + 3.0) +} + impl Game<'_> { - /// The first recipe of each page, filling the panel from the title row - /// to the search box. + /// The first recipe of each page, filling the panel from the tabs to + /// the search box. fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec { - let room = layout.search[1] - 4.0 - (layout.grid.1 + TITLE); + let room = layout.search[1] - 4.0 - recipes_top(pages, layout); let mut starts = vec![0]; let mut used = 0.0; for (i, r) in pages.recipes.iter().enumerate() { @@ -156,7 +186,7 @@ impl Game<'_> { let starts = Self::recipe_pages(pages, layout); let page = pages.page.min(starts.len() - 1); let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len()); - let mut y = layout.grid.1 + TITLE; + let mut y = recipes_top(pages, layout); (starts[page]..end) .map(|i| { let at = (layout.grid.0, y); @@ -177,6 +207,21 @@ impl Game<'_> { let n = pages.since.elapsed().as_secs() as usize; stacks.get(n % stacks.len().max(1)) } + /// The tab under the pointer: its index and icon. + pub(super) fn recipe_tab_hovered( + &self, + layout: &Layout, + ) -> Option<(usize, BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + let places = tab_places(pages, layout); + places.iter().enumerate().find_map(|(i, &(x, y))| { + if !inside(self.cursor, [x, y, SLOT, SLOT]) { + return None; + } + let (sent, stack) = pages.tabs.get(i)?; + Some((i, sent.clone(), stack.clone()?)) + }) + } /// The recipe slot under the pointer: recipe, slot, and its stack now. pub(super) fn recipe_slot_hovered( &self, @@ -237,6 +282,7 @@ impl Game<'_> { u32::MAX, true, ); + self.draw_recipe_tabs(batch, layout); if pages_n > 1 { let labels = [ self.translate("gui.recipebook.previous_page", "Previous Page"), @@ -351,6 +397,50 @@ impl Game<'_> { } } + /// The tabs, on slots; the selected one framed. + fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) { + let Some(pages) = &self.mods.browser.recipes else { + return; + }; + let places = tab_places(pages, layout); + let tabs: Vec> = pages.tabs.iter().map(|t| t.1.clone()).collect(); + let selected = pages.tab; + for (i, (&(x, y), stack)) in places.iter().zip(tabs).enumerate() { + let atlas = &self.gui.atlas; + batch.sprite(atlas, "gui/sprites/container/slot", x, y); + let Some(stack) = stack else { + continue; + }; + self.draw_item(batch, &stack, x + 1.0, y + 1.0); + // The selected tab: a white frame around its slot. + if i == selected { + let white = rgba(255, 255, 255, 255); + let atlas = &self.gui.atlas; + batch.fill(atlas, x - 1.0, y - 1.0, SLOT + 2.0, 1.0, white); + batch.fill(atlas, x - 1.0, y + SLOT, SLOT + 2.0, 1.0, white); + batch.fill(atlas, x - 1.0, y, 1.0, SLOT, white); + batch.fill(atlas, x + SLOT, y, 1.0, SLOT, white); + } + if inside(self.cursor, [x, y, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &format!("{name} tab"), + [x + 1.0, y + 1.0, 16.0, 16.0], + true, + horizon_client::widgets::Kind::Tab(i == selected), + ); + } + } + /// A press on the recipes: true when it hit one of their parts. pub(super) fn browser_recipes_press( &mut self, @@ -383,6 +473,14 @@ impl Game<'_> { .browser_event(owner, BrowserEvent::Page(starts[to] as u32)); return true; } + if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Tab(tab as u32)); + return true; + } if inside(cursor, self.back_button(layout).0) { self.audio .play("minecraft:ui.button.click", None, 0.25, 1.0); From 0c127ec1d9285a225d0cee0f29755b8f8fcaff8b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:23:34 +0100 Subject: [PATCH 193/475] Stub breaking_block_effects in the remote mining test fixture --- tests/test_remote_mining.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_remote_mining.py b/tests/test_remote_mining.py index 9feb255fa..4c77f989d 100644 --- a/tests/test_remote_mining.py +++ b/tests/test_remote_mining.py @@ -90,6 +90,7 @@ impl Game { fn predict_break(&mut self, pos: [i32; 3], _: u32, _: i32) { self.world.broken.push(pos); } fn send(&mut self, S::PlayerAction(packet): S) { self.packets.push(packet); } fn crack_particle(&mut self, _: [i32; 3], _: u8, _: u32) {} + fn breaking_block_effects(&mut self, _: [i32; 3], _: u8, _: bool) {} METHOD } fn main() { From 484cc945aa0b4c9cf558da2262169e8ae09642fb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:23:52 +0100 Subject: [PATCH 194/475] Add the rockets list to the remote motion test fixture --- tests/test_remote_motion.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_remote_motion.py b/tests/test_remote_motion.py index b00e5f0a9..59526b476 100644 --- a/tests/test_remote_motion.py +++ b/tests/test_remote_motion.py @@ -35,7 +35,8 @@ impl End { fn remote_eye_motion(&mut self, _id: u32, _v: [f64; 3]) {} } #[derive(Default)] struct Game { mobs: Vec, dropped: Vec, falling: Vec, tnts: Vec, - creatures: Creatures, dragon_fireballs: Vec, remote: Option, + creatures: Creatures, dragon_fireballs: Vec, rockets: Vec, + remote: Option, potions: Potions, end: End, } impl Game { From 705e3d10cf00e1fd4e504c6a974432531f5f983a Mon Sep 17 00:00:00 2001 From: FusselTV Date: Mon, 26 Oct 2026 14:11:07 +0100 Subject: [PATCH 195/475] Force-loaded chunks decorate in vanilla's order Chunks no player ticket reached were decorated nearest the centre first. Vanilla's ForceLoadCommand adds a FORCED ticket per chunk, x-outer and z-inner, and the FULL tasks run in that order, so a chunk decorates before its neighbour at higher x or z. Both orders agree only in the +x, +z quarter of the map. Elsewhere features crossing chunk borders overwrote each other the other way round: a geode next door ate a trial chamber's dispensers, sculk or a clay pool closed a dungeon so it was never placed, and ore, stone and tree blobs swapped. The forceload command now ranks the chunks it adds in vanilla's order (decoration_order::forced_order, which shares its scheduler with vanilla_order); chunks without a rank still go last, nearest first. Co-Authored-By: Claude Opus 5.5 --- .../src/game/commands/pregen.rs | 6 +- .../src/game/commands/terrain_cmds.rs | 3 + crates/horizon-client/src/game/levels.rs | 3 +- crates/horizon-client/src/streaming.rs | 8 +++ .../src/worldgen/decoration_order.rs | 67 +++++++++++++++---- 5 files changed, 71 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/commands/pregen.rs b/crates/horizon-client/src/game/commands/pregen.rs index d497e2734..eb3c37688 100644 --- a/crates/horizon-client/src/game/commands/pregen.rs +++ b/crates/horizon-client/src/game/commands/pregen.rs @@ -244,7 +244,11 @@ impl Game<'_> { /// hand them the next jobs as soon as each one is done, rather than once /// a tick. pub(in crate::game) fn pump_pregen(&mut self, until: Instant) { - while self.pregen_runs_here() { + // Force-loaded chunks too: decorating in vanilla's order, a chunk + // waits for its neighbours far more often than once a tick allows. + while self.pregen_runs_here() + || (!self.streaming.forced.is_empty() && self.streaming.busy()) + { let now = Instant::now(); if now >= until { break; diff --git a/crates/horizon-client/src/game/commands/terrain_cmds.rs b/crates/horizon-client/src/game/commands/terrain_cmds.rs index d4b1c125c..824ca4d60 100644 --- a/crates/horizon-client/src/game/commands/terrain_cmds.rs +++ b/crates/horizon-client/src/game/commands/terrain_cmds.rs @@ -120,6 +120,9 @@ impl Game<'_> { tracker.track(&holder(chunk_text(x, z)), i32::from(changed)); } } + if add { + self.streaming.rank_forced(cx0, cz0, cx1, cz1); + } let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1)); let verb = if add { "added" } else { "removed" }; let (message, total) = tracker.message( diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 8d2e0a4a2..8ba18ac22 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -383,7 +383,8 @@ impl Game<'_> { } *next += self.clock.tick_duration(); if *next > now { - // A `/pregen` task keeps the workers fed until the next tick. + // A `/pregen` task or force-loaded chunks keep the workers fed + // until the next tick. self.pump_pregen(*next); let now = Instant::now(); if *next > now { diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 643a604bc..bc43d1c94 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -1381,6 +1381,14 @@ impl Streaming { pub fn band(&self) -> Option { self.band } + /// Rank the chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)` + /// decorates in vanilla's order (`forced_order`), after every chunk + /// ranked before. + pub fn rank_forced(&mut self, x0: i32, z0: i32, x1: i32, z1: i32) { + for (x, z) in horizon_world::worldgen::decoration_order::forced_order(x0, z0, x1, z1) { + self.decoration_order.rank_or_append(x, z); + } + } /// Generate, light and keep the chunks of `band` (none: stop), and let /// go of what earlier bands held as it falls out of range. The chunks a /// band reaches are ranked for decoration row by row, as they first come diff --git a/crates/horizon-world/src/worldgen/decoration_order.rs b/crates/horizon-world/src/worldgen/decoration_order.rs index 6b0fb2252..0e53e7d09 100644 --- a/crates/horizon-world/src/worldgen/decoration_order.rs +++ b/crates/horizon-world/src/worldgen/decoration_order.rs @@ -29,7 +29,7 @@ //! overlap), so a chunk may be decorated as soon as no lower-ranked chunk //! within two of it is still waiting: [`DecorationOrder::blocked`]. Chunks //! further apart still decorate in parallel. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; #[derive(Default)] pub struct DecorationOrder { @@ -121,7 +121,6 @@ fn ticket_hash(x: i32, z: i32) -> u64 { /// The chunks decorated around a player at `center`, in vanilla's order. pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> { use std::cmp::Reverse; - use std::collections::{BinaryHeap, VecDeque}; let mut tickets: Vec<(i32, i32)> = (-radius..=radius) .flat_map(|dx| (-radius..=radius).map(move |dz| (center.0 + dx, center.1 + dz))) .collect(); @@ -132,16 +131,31 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> { Reverse(ticket_hash(x, z) & (TICKET_SET_CAPACITY - 1)), ) }); - let mut tickets: VecDeque<_> = tickets.into(); // Every chunk this touches lies within `half` of the centre: tickets // reach `LIGHT_LEVEL - TICKET_LEVEL` further, and FULL tasks decorate - // one more ring. Dense grids over that square replace hash maps. + // one more ring. + let half = radius.max(0) + LIGHT_LEVEL - TICKET_LEVEL + 1; + ticket_order( + tickets.into(), + TICKETS_IN_FLIGHT, + (center.0 - half, center.1 - half), + (2 * half + 1) as usize, + ) +} + +/// The decoration order of `tickets` (level 31) added in turn, at most +/// `in_flight` at a time. Dense grids of `side` chunks from `origin` hold +/// every chunk this touches, in place of hash maps. +fn ticket_order( + mut tickets: VecDeque<(i32, i32)>, + in_flight: usize, + origin: (i32, i32), + side: usize, +) -> Vec<(i32, i32)> { + use std::cmp::Reverse; + use std::collections::BinaryHeap; let reach = LIGHT_LEVEL - TICKET_LEVEL; - let half = radius.max(0) + reach + 1; - let side = (2 * half + 1) as usize; - let cell = |(x, z): (i32, i32)| { - ((x - center.0 + half) as usize) * side + (z - center.1 + half) as usize - }; + let cell = |(x, z): (i32, i32)| ((x - origin.0) as usize) * side + (z - origin.1) as usize; let mut active: Vec<(i32, i32)> = Vec::new(); // Queued task per chunk (level, sequence), `NONE` when there is none; // stale heap entries are skipped. @@ -177,10 +191,9 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> { } } }; - for _ in 0..TICKETS_IN_FLIGHT { - if let Some(t) = tickets.pop_front() { - add(t, &mut active, &mut queued, &mut heap, &full); - } + while active.len() < in_flight { + let Some(t) = tickets.pop_front() else { break }; + add(t, &mut active, &mut queued, &mut heap, &full); } while let Some(Reverse((l, s, chunk))) = heap.pop() { let i = cell(chunk); @@ -202,7 +215,7 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> { full[i] = true; } active.retain(|t| !(-2..=2).all(|dx| (-2..=2).all(|dz| full[cell((t.0 + dx, t.1 + dz))]))); - while active.len() < TICKETS_IN_FLIGHT { + while active.len() < in_flight { let Some(t) = tickets.pop_front() else { break }; add(t, &mut active, &mut queued, &mut heap, &full); } @@ -210,10 +223,36 @@ pub fn vanilla_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> { order } +/// The chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)` decorates, +/// in vanilla's order: ForceLoadCommand adds a FORCED ticket (level 31) per +/// chunk, x-outer, z-inner, all at once. +pub fn forced_order(x0: i32, z0: i32, x1: i32, z1: i32) -> Vec<(i32, i32)> { + let tickets = (x0..=x1) + .flat_map(|x| (z0..=z1).map(move |z| (x, z))) + .collect(); + let pad = LIGHT_LEVEL - TICKET_LEVEL + 1; + let side = ((x1 - x0).max(z1 - z0) + 2 * pad + 1) as usize; + ticket_order(tickets, usize::MAX, (x0 - pad, z0 - pad), side) +} + #[cfg(test)] mod tests { use super::*; + /// Seed 8675309's geodes in chunks (-26, -1) and (14, -20) run into the + /// trial chambers of (-26, 0) and (14, -19); vanilla decorates the lower + /// z first, wherever the batch lies, so the chambers are drawn over them. + #[test] + fn forced_chunks_decorate_x_outer_z_inner() { + let rank = |order: &[(i32, i32)], c| order.iter().position(|&o| o == c).unwrap(); + let west = forced_order(-32, -16, -17, -1); + assert!(rank(&west, (-26, -1)) < rank(&west, (-26, 0))); + let east = forced_order(0, -32, 15, -17); + assert!(rank(&east, (14, -20)) < rank(&east, (14, -19))); + assert_eq!(west[0], (-33, -17)); + assert_eq!(west.len(), 22 * 22); + } + /// The hash map version this module had before its dense grids. fn hashed_order(center: (i32, i32), radius: i32) -> Vec<(i32, i32)> { use std::cmp::Reverse; From 8c2ef15171d2bdfd31a07c83f5ea80df315068e1 Mon Sep 17 00:00:00 2001 From: FusselTV Date: Mon, 26 Oct 2026 13:58:26 +0100 Subject: [PATCH 196/475] Falling suspicious sand and gravel break where they land BrushableBlock.tick makes the falling block entity with disableDrop, so vanilla breaks it on landing (onBrokenAfterFall: level event 2001 and the block_destroy game event) and places nothing. Horizon landed it as a plain block without its archaeology block entity. Seen in pre-generated worlds: suspicious gravel of cold ocean ruins that sits over water fell during the first ticks and landed one block lower without its block entity, so with no loot. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/falling_block.rs | 20 +++++++++++++++++++ crates/horizon-client/src/game.rs | 8 +++----- .../horizon-client/src/game/falling_damage.rs | 19 ++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/falling_block.rs b/crates/horizon-client/src/falling_block.rs index 79ca28f7a..4faf105b9 100644 --- a/crates/horizon-client/src/falling_block.rs +++ b/crates/horizon-client/src/falling_block.rs @@ -35,6 +35,18 @@ impl Damage { ..Self::default() } } + /// A block that starts to fall: anvils hurt, and a brushable block + /// breaks where it lands (BrushableBlock.tick: disableDrop). + pub fn falling(block: &str) -> Self { + if block.ends_with("anvil") { + Self::anvil() + } else { + Self { + cancel_drop: block.starts_with("minecraft:suspicious_"), + ..Self::default() + } + } + } pub fn from_nbt(tag: &Tag, anvil: bool) -> Self { Self { hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0), @@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) { #[cfg(test)] mod tests { use super::*; + #[test] + fn only_brushable_blocks_break_where_they_land() { + assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop); + assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop); + assert!(!Damage::falling("minecraft:gravel").cancel_drop); + assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities); + } + #[test] fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() { let damage = Damage::anvil(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 58b89a140..b961b6d16 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2971,11 +2971,9 @@ impl Game<'_> { drop_item: true, fall_distance: 0.0, stuck: Vec3::ZERO, - damage: if self.res.facts().get(state).name.ends_with("anvil") { - horizon_client::falling_block::Damage::anvil() - } else { - Default::default() - }, + damage: horizon_client::falling_block::Damage::falling( + &self.res.facts().get(state).name, + ), saved: None, }); } diff --git a/crates/horizon-client/src/game/falling_damage.rs b/crates/horizon-client/src/game/falling_damage.rs index 8eec28d1d..08d96d7d1 100644 --- a/crates/horizon-client/src/game/falling_damage.rs +++ b/crates/horizon-client/src/game/falling_damage.rs @@ -64,6 +64,25 @@ impl Game<'_> { pos: BlockPos::new(pos[0], pos[1], pos[2]), data: 0, }); + } else if self + .res + .facts() + .get(block.state) + .name + .starts_with("minecraft:suspicious_") + { + // BrushableBlock.onBrokenAfterFall. + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.level_events.push(LevelEvent::LevelEvent { + id: 2001, + pos: at, + data: block.state.0 as i32, + }); + self.level_events.push(LevelEvent::GameEvent { + pos: at, + event: "minecraft:block_destroy", + state: None, + }); } return; } From 55a066e88a7b0739903a6ef6f46d33ed4f19b7c3 Mon Sep 17 00:00:00 2001 From: FusselTV Date: Mon, 26 Oct 2026 16:20:45 +0100 Subject: [PATCH 197/475] End: spike crystals are saved with the chunk that generates them SpikeFeature.placeSpike adds each crystal to the WorldGenRegion, so a pre-generated End holds its ten crystals before anyone ticks it. Horizon's decoration dropped spawn_end_crystal and the game placed the crystals later, once the arena was loaded and ticking. An End generated with ticks frozen, as a seed generator does, had none. The decoration region now writes the crystal like any placed entity; the game's own placement still skips a spike that has its crystal. Co-Authored-By: Claude Opus 5.5 --- .../src/worldgen/generator_decoration.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/horizon-world/src/worldgen/generator_decoration.rs b/crates/horizon-world/src/worldgen/generator_decoration.rs index fc1cd8bcf..426cd98a4 100644 --- a/crates/horizon-world/src/worldgen/generator_decoration.rs +++ b/crates/horizon-world/src/worldgen/generator_decoration.rs @@ -851,6 +851,33 @@ impl PlacementContext for World<'_> { self.entities.push(nbt); Ok(()) } + /// SpikeFeature.placeSpike: the crystal is a fresh entity of the region, + /// saved with the chunk. + fn spawn_end_crystal( + &mut self, + position: [f64; 3], + yaw: f32, + beam_target: Option<[i32; 3]>, + invulnerable: bool, + ) -> Result<(), PlacementQueryError> { + use crate::nbt::Tag; + let mut nbt = crate::nbt::Compound::new(); + nbt.insert("id".into(), Tag::String("minecraft:end_crystal".into())); + nbt.insert( + "Pos".into(), + Tag::List(position.iter().map(|v| Tag::Double(v.to_bits())).collect()), + ); + nbt.insert( + "Rotation".into(), + Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(0)]), + ); + nbt.insert("Invulnerable".into(), Tag::Byte(i8::from(invulnerable))); + nbt.insert("ShowBottom".into(), Tag::Byte(1)); + if let Some(b) = beam_target { + nbt.insert("beam_target".into(), Tag::IntArray(b.to_vec())); + } + self.add_entity(nbt) + } fn set_block_entity( &mut self, position: [i32; 3], From 1eb7a04a4e17cfd56cf2b6f530c123c1c8ec83cb Mon Sep 17 00:00:00 2001 From: FusselTV Date: Mon, 26 Oct 2026 13:41:57 +0100 Subject: [PATCH 198/475] End: no exit portal while nobody is in the fight With force-loaded levels ticking without players (#1525), Horizon placed the exit portal as soon as the End's main island was loaded. Vanilla only places it from scanState and the dragon's death, both with players in the fight, so a pre-generated End has no portal until someone arrives. test_forced_end_parity force-loads the End's arena on vanilla's and Horizon's dedicated servers with nobody online and compares what is around 0, 0. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dragon.rs | 4 +- docs/architecture.md | 2 +- tools/test_forced_end_parity.py | 91 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100755 tools/test_forced_end_parity.py diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index fff03ef1c..a2602fb96 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2598,7 +2598,9 @@ impl Game<'_> { } pub(super) fn fight_ready(&mut self) -> bool { self.load_fight(); - self.fight.loaded + // spawnExitPortal runs from scanState and the dragon's death, both + // only with players in the fight. + self.fight.loaded && self.fight.players } pub(super) fn exit_portal(&self) -> Option<[i32; 3]> { self.fight.exit_portal diff --git a/docs/architecture.md b/docs/architecture.md index 0350bd1ea..8e19046f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ file selects ~103. | 29 | Title, worlds, startup (49) | `L/title.rs` + `L/title/`, `G/startup_menu.rs`, `L/title_edition.rs` | world_options; `cargo test -p horizon-client --lib title` | | 30 | Dedicated server, horizon-server (48) | `G/dedicated.rs`, `G/dedicated/status.rs`, `crates/horizon-server/`, `crates/horizon-server-files/` | dedicated_server, server_files, user_cache, online_mode, server_ops_feedback | | 31 | Particles and client effects (46) | `L/particles.rs`, `G/render_tick.rs`, `G/level_particles.rs`, `L/animate_tick.rs` | particle_providers, block_particles, entity_particles, emitter_particles, marker_particles | -| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, end_podium, end_gateway | +| 32 | Bosses and the End (45) | `G/dragon.rs`, `G/wither.rs`, `G/end.rs` | server_dragon, forced_end, end_podium, end_gateway | | 33 | Protocol and packets (44) | `crates/horizon-protocol/src/` (`packets/play.rs`, `entity_data.rs`, `connection.rs`) | dedicated_server, online_mode, secure_chat; `cargo test -p horizon-protocol` | | 34 | Multiplayer and LAN screens, friends (44) | `L/multiplayer.rs`, `G/lan_screen.rs`, `G/friends.rs`, `L/friends_screen.rs`, `crates/horizon-friends/`, `crates/horizon-relay/` | lan, world_options; `python3 tools/test_friends.py` | | 35 | Dispensers (43) | `G/dispenser.rs`, `G/dispense_fixture.rs` (the harness side) | dispenser | diff --git a/tools/test_forced_end_parity.py b/tools/test_forced_end_parity.py new file mode 100755 index 000000000..81a92e4ed --- /dev/null +++ b/tools/test_forced_end_parity.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""The End of a dedicated server nobody has entered, force-loaded from the console, against vanilla's. + +A vanilla dedicated server and Horizon's each open a copy of one world vanilla generated (its End not made yet). +With no player online, the console force-loads the End's main island and its arena. A FORCED ticket keeps its level active +(`ServerChunkCache.hasActiveTickets`, `TicketType.FORCED`), so the chunks are generated, loaded and ticked +without a player in the dimension. With nobody in the fight, `EnderDragonFight.tick` neither scans the state nor +places the exit portal: no bedrock and no torches around 0, 0. Both servers must answer the same. +""" +import os +import re +import subprocess +import sys +import time + +try: + from . import lab, test_rust_parity as base + from .test_dedicated_server_parity import Server, wait_ticks + from .test_locator_bar_parity import generate_world, server_dir +except ImportError: + import lab + import test_rust_parity as base + from test_dedicated_server_parity import Server, wait_ticks + from test_locator_bar_parity import generate_world, server_dir + +NAME = 'forced-end-parity' +END = 'execute in minecraft:the_end' +QUERIES = [ + f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:bedrock', + f'{END} run fill -3 0 -3 3 255 3 minecraft:air replace minecraft:wall_torch', +] + + +def answer(server, command, n): + start = len(server.lines) + server.console(command) + server.console(f'say done {n}') + server.wait_for(rf'\[Server\] done {n}$', timeout=120) + replies = [l.split(']: ', 1)[-1] for l in server.lines[start:] if 'done ' not in l] + return ' / '.join(r for r in replies if r) or '(no reply)' + + +def run_side(label, command, build, world): + directory = build / label + server_dir(directory, world) + server = Server(command, directory, label) + try: + server.wait_for(r'Done \(') + # The main island's arena (EnderDragonFight.isArenaLoaded: chunks -8..8), in pieces of at most 256 chunks. + for area in ('-128 -128 143 -1', '-128 0 143 143'): + server.console(f'{END} run forceload add {area}') + deadline = time.time() + 600 + for poll in range(1_000_000): + reply = answer(server, f'{END} if loaded 0 0 0', f'loaded {poll}') + if 'passed' in reply: + break + if time.time() > deadline: + raise AssertionError(f'{label}: the End centre never loaded') + time.sleep(2) + wait_ticks(server, 200) + return {q: answer(server, q, i) for i, q in enumerate(QUERIES)} + finally: + server.stop() + + +def main(): + _, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / NAME + build.mkdir(parents=True, exist_ok=True) + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, + check=True, capture_output=True) + binary = lab.cargo_target() / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client') + vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] + horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui'] + world = generate_world(vanilla_server, build) + vanilla = run_side('vanilla', vanilla_server, build, world) + horizon = run_side('horizon', horizon_server, build, world) + normal = lambda s: re.sub(r'\s+', ' ', s).strip() + problems = [f'{q}\n vanilla: {vanilla[q]}\n horizon: {horizon[q]}' + for q in QUERIES if normal(vanilla[q]) != normal(horizon[q])] + if problems: + raise AssertionError('the forced End differs:\n' + '\n'.join(problems)) + print(f'forced End: loaded and ticked with nobody in it, no exit portal, as vanilla ({vanilla[QUERIES[0]]})') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From ec81bea5bfc0270e4b597eff4d0dc2f320258874 Mon Sep 17 00:00:00 2001 From: FusselTV Date: Mon, 26 Oct 2026 16:32:02 +0100 Subject: [PATCH 199/475] Worldgen: blocks with a block entity get vanilla's default one WorldGenRegion.setBlock gives every block that has a block entity a new one, so vanilla saves the sculk sensors, catalysts and shriekers of the deep dark and potent sulfur with their block entities. Horizon only saved block entities that a structure or feature placed data for, and these kinds have none, so a Horizon-made world had none of them (3,035 sculk sensors, 753 catalysts, 329 shriekers and 21 potent sulfur over three 1,024-block seeds). A finished column now adds vanilla's defaults (block_entity_nbt::canonical) for every such block: as the modelled block entity where Horizon has one (sculk), as raw NBT otherwise. Potent sulfur gets its block entity type. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/chunks.rs | 1 + crates/horizon-client/src/vanillagen.rs | 85 ++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index 122d37bcd..9ca20b894 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -66,6 +66,7 @@ pub fn block_entity_type(name: &str) -> Option<&'static str> { "sculk_catalyst" => "minecraft:sculk_catalyst", "sculk_shrieker" => "minecraft:sculk_shrieker", "crafter" => "minecraft:crafter", + "potent_sulfur" => "minecraft:potent_sulfur", "chiseled_bookshelf" => "minecraft:chiseled_bookshelf", "trial_spawner" => "minecraft:trial_spawner", "vault" => "minecraft:vault", diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index 923818a08..a634d5e28 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -39,6 +39,8 @@ pub struct VanillaTerrain { tick_blocks: Vec, /// Block index -> default state id: `tick_blocks` the other way. tick_states: Vec, + /// Block state id -> the block entity type of its block, if it has one. + block_entity_types: Vec>, seed: i64, dimension: String, /// When the last proto chunk was generated, in milliseconds since @@ -51,6 +53,51 @@ enum VanillaProto { Ready(Box), Failed(i32, i32), } +/// WorldGenRegion.setBlock gives every block with a block entity a new +/// one: the blocks nothing placed data for (a sculk patch's sensors) get +/// vanilla's defaults. +fn add_default_block_entities(kinds: &[Option<&str>], out: &mut Chunk, raw: &mut Vec) { + let kind = |v: u32| kinds.get(v as usize).copied().flatten(); + let mut states = [0u32; 4096]; + for (si, section) in out.sections.iter().enumerate() { + if !section.blocks.maybe_has(|v| kind(v).is_some()) { + continue; + } + section.unpack(&mut states); + for (i, &v) in states.iter().enumerate() { + let Some(id) = kind(v) else { + continue; + }; + let i = i as i32; + let pos = [ + out.x * 16 + (i & 15), + MIN_Y + si as i32 * 16 + (i >> 8), + out.z * 16 + ((i >> 4) & 15), + ]; + let at = |t: &Tag| { + ["x", "y", "z"] + .iter() + .zip(pos) + .all(|(k, p)| t.get(k) == Some(&Tag::Int(p))) + }; + if raw.iter().any(at) || out.block_entities.iter().any(|(p, _)| *p == pos.into()) { + continue; + } + let tag = Tag::compound([ + ("id", Tag::String(id.into())), + ("x", Tag::Int(pos[0])), + ("y", Tag::Int(pos[1])), + ("z", Tag::Int(pos[2])), + ]); + let tag = crate::block_entity_nbt::canonical(&tag).unwrap_or(tag); + match crate::save::decode_block_entity(&tag) { + Some(modelled) => out.block_entities.push(modelled), + None => raw.push(tag), + } + } + } +} + impl VanillaTerrain { fn client_surface(&self, surface: SurfaceSample) -> crate::lod::Surface { crate::lod::Surface { @@ -119,6 +166,7 @@ impl VanillaTerrain { } let mut tick_blocks = Vec::with_capacity(registry.len()); let mut tick_states = Vec::new(); + let mut block_entity_types = Vec::with_capacity(registry.len()); let mut last: Option<&str> = None; let mut index = 0u32; for id in 0..registry.len() { @@ -133,6 +181,7 @@ impl VanillaTerrain { } last = Some(name); tick_blocks.push(index); + block_entity_types.push(crate::lan::chunks::block_entity_type(name)); } Ok(Self { generator, @@ -141,6 +190,7 @@ impl VanillaTerrain { query: std::sync::Mutex::new(None), tick_blocks, tick_states, + block_entity_types, seed, dimension: dimension.to_owned(), last_proto: std::sync::atomic::AtomicU64::new(0), @@ -553,7 +603,7 @@ impl ChunkGenerator for VanillaTerrain { // The block entities Horizon does not model (decorated pots with their // sherds and loot table, brushable blocks) stay as placed, like a // loaded chunk's. - let unmodelled: Vec = chunk + let mut unmodelled: Vec = chunk .block_entities .iter() .filter(|(pos, _)| { @@ -573,6 +623,7 @@ impl ChunkGenerator for VanillaTerrain { Some(Tag::Compound(nbt)) }) .collect(); + add_default_block_entities(&self.block_entity_types, &mut out, &mut unmodelled); if !unmodelled.is_empty() || !rolled_loot.is_empty() { let mut extras = out .saved @@ -596,3 +647,35 @@ impl ChunkGenerator for VanillaTerrain { out } } + +#[cfg(test)] +mod default_block_entity_tests { + use super::*; + + #[test] + fn generated_blocks_get_their_block_entities() { + let mut out = Chunk::empty(-2, 3); + out.set(4, -40, 9, 7); + out.set(5, -40, 9, 6); + let mut kinds = vec![None; 8]; + kinds[6] = Some("minecraft:potent_sulfur"); + kinds[7] = Some("minecraft:sculk_sensor"); + let mut raw = vec![]; + add_default_block_entities(&kinds, &mut out, &mut raw); + assert!(matches!( + out.block_entities[..], + [((-28, -40, 57), crate::containers::BlockEntity::Sculk(_))] + )); + assert_eq!(raw.len(), 1); + assert_eq!( + raw[0].get("id"), + Some(&Tag::String("minecraft:potent_sulfur".into())) + ); + add_default_block_entities(&kinds, &mut out, &mut raw); + assert_eq!( + (out.block_entities.len(), raw.len()), + (1, 1), + "a placed block entity is kept, not doubled" + ); + } +} From 97f8bca9daf9eb3f18b4bab27c3bd36cc8ca56e2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:32:41 +0100 Subject: [PATCH 200/475] Raiders skip ominous banners they cannot reach A captainless raider walked towards the nearest fallen ominous banner even when no path led to it. Check that a path reaches the banner before going for it, and skip a banner without one for 600 ticks, like vanilla. --- crates/horizon-client/src/game/raids.rs | 17 +++++++---- crates/horizon-client/src/mobs/raider.rs | 37 ++++++++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index bedca5708..e34d9feed 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -762,7 +762,7 @@ impl Game<'_> { } positions.push((m.id, m.position, s.active_raid)); } - let banners: Vec<(usize, Vec3)> = self + let banners: Vec<(usize, u32, Vec3)> = self .dropped .iter() .enumerate() @@ -771,7 +771,7 @@ impl Game<'_> { && e.stack.data().is_some_and(|d| d.ominous_banner) && e.pickup_delay == 0 }) - .map(|(i, e)| (i, e.position)) + .map(|(i, e)| (i, e.id, e.position)) .collect(); let mut taken: Vec = Vec::new(); let mut collectors: Vec<(usize, u32)> = Vec::new(); @@ -858,18 +858,23 @@ impl Game<'_> { { let near = banners .iter() - .filter(|(i, p)| { + .filter(|(i, item, p)| { !taken.contains(i) + && !s + .unreachable_banners + .iter() + .any(|&(b, until)| b == *item && now < until) && (p.x - position.x).abs() <= range && (p.z - position.z).abs() <= range && (p.y - position.y).abs() <= 8.0 }) .min_by(|a, b| { - a.1.distance_squared(position) - .total_cmp(&b.1.distance_squared(position)) + a.2.distance_squared(position) + .total_cmp(&b.2.distance_squared(position)) }); - if let Some((i, p)) = near { + if let Some((i, item, p)) = near { s.banner_at = Some([p.x, p.y, p.z]); + s.banner_item = *item; if p.distance_squared(position) < 1.414 * 1.414 { // pickUpItem: the banner becomes the wave's leader. taken.push(*i); diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 6c3ab9b64..a2c3ffe33 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -38,6 +38,11 @@ pub struct State { pub in_village: bool, /// A dropped ominous banner this raider may fetch. pub banner_at: Option<[f64; 3]>, + /// The item entity of `banner_at`. + pub banner_item: u32, + /// Banners with no path to them, and until when (game time): + /// `unreachableBannerCache`. + pub unreachable_banners: Vec<(u32, i64)>, /// Beds around the raid's centre. pub homes: Option>>, /// A ravager carrying a raider, set every tick from `Extra::rider`. @@ -113,7 +118,7 @@ pub fn register(m: &mut Mob) { } m.extra.raider = Some(Box::default()); m.ai.goals.add(4, LongDistancePatrol::new(0.7, 0.595)); - m.ai.goals.add(1, ObtainBanner); + m.ai.goals.add(1, ObtainBanner::default()); m.ai.goals.add(3, PathfindToRaid); m.ai.goals.add(4, MoveThroughVillage::new(1.05, 1.0)); m.ai.goals.add(5, Celebration); @@ -336,23 +341,37 @@ fn close_to_center(p: [i32; 3], at: Vec3, distance: f64) -> bool { /// `Raider.ObtainRaidLeaderBannerGoal`: walk to a fallen ominous banner when /// the wave has no living leader (the game finds the banner and picks it up). -#[derive(Clone, Debug)] -pub struct ObtainBanner; +#[derive(Clone, Debug, Default)] +pub struct ObtainBanner { + path_to_banner: Option, +} impl Goal for ObtainBanner { goal_clone!(); fn flags(&self) -> u8 { MOVE } - fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { - state(m).is_some_and(|s| s.banner_at.is_some()) + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let Some((at, item)) = state(m).and_then(|s| s.banner_at.map(|at| (at, s.banner_item))) + else { + return false; + }; + // A banner with no path to it is skipped for 600 ticks. + let path = m.create_path_to_entity(cx.w, at, 1); + if path.as_ref().is_some_and(|p| p.reached) { + self.path_to_banner = path; + return true; + } + let now = cx.w.game_time; + let s = state_mut(m); + s.unreachable_banners.retain(|&(_, until)| now < until); + s.unreachable_banners.push((item, now + 600)); + false } fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { !m.ai.nav.is_done() && state(m).is_some_and(|s| s.banner_at.is_some()) } - fn start(&mut self, m: &mut Mob, cx: &mut Cx) { - if let Some(at) = state(m).and_then(|s| s.banner_at) { - m.nav_move_to(cx.w, at, 1.15); - } + fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { + m.nav_move_to_path(self.path_to_banner.take(), 1.15); } } From 61df807b33290aa01e0bebb928ea7449252213df Mon Sep 17 00:00:00 2001 From: FusselTV Date: Mon, 26 Oct 2026 13:41:57 +0100 Subject: [PATCH 201/475] Structures: a chunk saves its starts also when no piece reaches it A chunk's STRUCTURE_STARTS are the starts made in it (StructureManager.getAllStarts), whether or not one of their pieces reaches it. own_starts filtered the chunk's references, which only hold starts with a piece in the chunk, so a start whose pieces all lie next door (seed 8675309's giant ruined portal in chunk -26, 23) was not saved. The proto chunk now keeps the starts made at its position. Co-Authored-By: Claude Opus 5.5 --- .../horizon-world/src/worldgen/generator.rs | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index 7a38608be..8630a4ef8 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -23,6 +23,7 @@ use super::{ climate::{ClimateSampler, decode_parameter, quantize}, decode_noise, density::DensityRegistry, + generator_structures::Starts, material_condition::{ MaterialConditionRegistry, MaterialConditions, MaterialDensitySources, MaterialRule, MaterialRuleError, MaterialRuleLibrary, MaterialSurfaceQueries, @@ -567,6 +568,9 @@ pub struct ProtoChunk { pub block_entities: Vec<([i32; 3], crate::nbt::Compound)>, /// Structure starts referencing this chunk (STRUCTURE_REFERENCES). pub(super) structure_starts: Vec>, + /// The starts made in this chunk (STRUCTURE_STARTS), also those none of + /// whose pieces reach it (a giant ruined portal next door). + own_starts: Starts, /// Entities placed by structures (`ProtoChunk.addEntity`), as NBT with /// absolute `Pos`. pub entities: Vec, @@ -620,12 +624,7 @@ impl ProtoChunk { } /// The structure starts this chunk holds itself (`structures.starts`). pub fn own_starts(&self) -> Vec> { - let pos = self.position(); - self.structure_starts - .iter() - .filter(|s| s.chunk == pos) - .cloned() - .collect() + self.own_starts.to_vec() } /// Also restores a saved proto chunk that was decorated before it was saved. pub fn set_decorated(&mut self) { @@ -1114,16 +1113,19 @@ where ) .map_err(stage("noise"))?; chunk.set_generation_stage(PreLightChunkStage::Biomes); - let references = match &self.structures { - Some(structures) => structures.references( - &mut StructureQueries { + let (references, own_starts) = match &self.structures { + Some(structures) => { + let mut queries = StructureQueries { pipeline: self, worker, valid: BTreeSet::new(), - }, - pos, - ), - None => Vec::new(), + }; + ( + structures.references(&mut queries, pos), + structures.starts_at(&mut queries, pos), + ) + } + None => (Vec::new(), Starts::default()), }; if let Some(beard) = self .structures @@ -1259,6 +1261,7 @@ where decorated: false, block_entities: Vec::new(), structure_starts: references, + own_starts, entities: Vec::new(), }) } @@ -2975,6 +2978,26 @@ mod tests { } } + /// Seed 8675309's giant ruined portal starts in chunk (-26, 23) and lies + /// wholly in (-26, 24): its own chunk still holds the start, as vanilla's + /// getAllStarts does, though no piece reaches it. + #[test] + fn a_chunk_holds_its_starts_whose_pieces_lie_elsewhere() { + let Some(data) = prepared_data() else { + return; + }; + let generator = OverworldGenerator::new(data, 8675309).expect("generator"); + let mut worker = generator.worker(); + let proto = worker + .generate_proto(ChunkPos::new(-26, 23)) + .expect("proto"); + let own: Vec<_> = proto.own_starts().iter().map(|s| s.name.clone()).collect(); + assert!( + own.iter().any(|n| n == "minecraft:ruined_portal_mountain"), + "{own:?}" + ); + } + /// Chunks no ring search can end near are decided without the rings, /// which stay unsearched, and get the starts they get with them. #[test] From c74626448c544dae0101e005eafa4c23fab19981 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:40:06 +0100 Subject: [PATCH 202/475] Show the crosshair to spectators over containers, stations and portals --- crates/horizon-client/src/game.rs | 6 ++- crates/horizon-client/src/game/hand.rs | 56 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 58b89a140..91d953722 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5141,7 +5141,11 @@ impl Game<'_> { let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed()); let strength = self.hand_strength.scale(0.0, self.main_hand_attack_speed()); // The 3D crosshair (F3) takes the 2D one's place. - if self.camera_type.first_person() && !spectator && !self.debug_crosshair() { + // Hud.extractCrosshair: a spectator only sees it over something to open. + if self.camera_type.first_person() + && (!spectator || self.spectator_crosshair()) + && !self.debug_crosshair() + { let attack = (indicator == 1).then_some(strength); let full = attack.is_some_and(|a| { hud_layout::full_attack_indicator(a, delay, self.crosshair_living()) diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index d91d3b979..c3fd78310 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -3,7 +3,7 @@ //! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing //! (`LivingEntity.SwingState`) and the first-person equip heights //! (`FirstPersonHandsAndItems`) with the item swap ticker they read. -use super::{Game, Mining, Screen}; +use super::{Game, Mining, Screen, minecarts::CartKind}; use horizon_client::{ Result, attack_strength::HeldStack, @@ -289,6 +289,60 @@ impl Game<'_> { // Only alive mobs are hit, so a picked living entity is alive. Some(true) } + /// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an + /// entity that is a `MenuProvider` (a chest or hopper minecart, a chest + /// boat), or a block whose `showAsInteractableInSpectatorMode` holds. + pub(super) fn spectator_crosshair(&self) -> bool { + let cart = self.cart_hit().map(|(i, d)| { + let kind = &self.carts[i].kind; + (d, matches!(kind, CartKind::Chest | CartKind::Hopper)) + }); + let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest)); + let others = [ + self.mob_hit().map(|h| h.1), + self.armor_stand_hit().map(|h| h.1), + self.cushion_hit().map(|h| h.1), + self.painting_hit().map(|h| h.1), + self.frame_hit().map(|h| h.1), + self.dragon_hit().map(|h| h.1), + ]; + let nearest = cart + .into_iter() + .chain(boat) + .chain(others.into_iter().flatten().map(|d| (d, false))) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((distance, menu)) = nearest + && self.before_block(distance) + { + return menu; + } + let Some(hit) = self.pick() else { + return false; + }; + let name = &*self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])) + .name; + let name = name.trim_start_matches("minecraft:"); + match name { + // BlockBehaviour.getMenuProvider and `instanceof Portal`. + "crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil" + | "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table" + | "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper" + | "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal" + | "end_portal" | "end_gateway" => true, + // ChestBlock.getMenuProvider: none while a redstone conductor + // sits on top (a sitting cat aside). + "chest" | "trapped_chest" => { + let above = self.world.block(hit.pos[0], hit.pos[1] + 1, hit.pos[2]); + self.res.behaviours().facts(above).flags + & horizon_world::level::data::state_flags::CONDUCTOR + == 0 + } + _ => name.ends_with("shulker_box"), + } + } /// `Attributes.ATTACK_SPEED` with the main-hand item's modifier. pub(super) fn main_hand_attack_speed(&self) -> f64 { horizon_client::attack_strength::BASE_ATTACK_SPEED From b28b4991359b3111ac2249d0c3b9b6276684cc66 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:41:35 +0100 Subject: [PATCH 203/475] mods: recipes.give hands out one stack at most As the creative inventory does: a count over the item's stack size (64, 16 or 1) is cut to it, so a mod can ask for a full stack without knowing it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mod_recipes.rs | 8 ++++++-- crates/horizon-mod-sdk/wit/horizon.wit | 3 ++- docs/api/mods-reference.md | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index 0f65b0b42..b97944e39 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -74,7 +74,10 @@ impl Game<'_> { /// creative inventory; a server hears it as `SetCreativeModeSlot` /// through the inventory's sync, and refuses it unless creative. pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) { - if !self.creative || self.res.items().get(item).is_none() { + let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else { + return; + }; + if !self.creative { return; } let (damage, extra) = horizon_client::item_data::command_components(components) @@ -83,7 +86,8 @@ impl Game<'_> { }); let _ = self.give(ItemStack { item: item.into(), - count, + // One stack at most, as the creative inventory hands out. + count: count.min(max), damage, extra, }); diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 3edc44872..1c1431273 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -516,7 +516,8 @@ interface recipes { place: func(book-id: s32, all: bool) -> result<_, string>; /// Creative mode only: gives the player the stack, as taking it from /// the creative inventory does (`ServerboundSetCreativeModeSlot`): the - /// selected slot first, then the hotbar, then the inventory. + /// selected slot first, then the hotbar, then the inventory. The count + /// is cut to one stack of the item (64, 16 or 1). give: func(stack: stack) -> result<_, string>; } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index ed8d8d4ad..6416245ad 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -531,7 +531,7 @@ The station a recipe is made at. | `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | | `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | | `place` | `func(book-id: s32, all: bool) -> result<_, string>` | Places a recipe in the open screen's grid as a click in the recipe book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a crafting table. `all` places as many as the items allow, as with Shift. Refused while no such screen is open, for a recipe the player's book doesn't hold and for one that can't be placed; one missing items shows as a ghost in the grid, as in the book. | -| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. | +| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. The count is cut to one stack of the item (64, 16 or 1). | ## `browser` From 752138e866916b5f116b7fbb933f07bee2138144 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:43:27 +0100 Subject: [PATCH 204/475] Cats sit on a chest only while nobody has it open, and on a bed's foot only --- crates/horizon-client/examples/probe/mob_ai_probe.rs | 3 +++ crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs.rs | 2 ++ crates/horizon-client/src/mobs/tame.rs | 7 +++++-- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..2fdb12ce9 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1069,6 +1069,8 @@ pub fn main() -> horizon_client::Result<()> { for (i, m) in mobs.iter().enumerate() { print(&mut out, 0, i, m, s.creative, world.game_time); } + // No player has a chest open in the scenarios. + let lid_openers = std::collections::HashMap::new(); // The MobWorld every step sees, over a snapshot of the mobs. macro_rules! mob_world { ($snapshot:expr) => { @@ -1103,6 +1105,7 @@ pub fn main() -> horizon_client::Result<()> { // FlatLevelSource.getSeaLevel. sea_level: -63, items: Some(&items), + lid_openers: &lid_openers, } }; } diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 845d2aee6..88f3fd669 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -422,6 +422,7 @@ impl Game<'_> { sea_level: if self.dim == Dim::Nether { 32 } else { 63 }, teams: std::sync::Arc::new(self.mob_teams()), items: Some(self.res.items()), + lid_openers: &self.lid_openers, difficulty: self.local_difficulty( self.player .as_ref() diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 6b1e16f5c..06930be93 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1529,6 +1529,8 @@ pub struct MobWorld<'a> { pub teams: std::sync::Arc>, /// Item data (max stack sizes), when the host has it. pub items: Option<&'a crate::items::Items>, + /// `ChestBlockEntity.getOpenCount`: the players with each chest open. + pub lid_openers: &'a HashMap<(i32, i32, i32), i32>, } /// `Entity.getTeam` of the player and of the mobs on a team. #[derive(Clone, Debug, Default)] diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 1e725df80..30ac762b9 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -835,7 +835,7 @@ impl Goal for CatLieOnBlock { m.extra.flag = self.0.reached; } } -/// `CatSitOnBlockGoal`: chests, lit furnaces and beds. +/// `CatSitOnBlockGoal`: unopened chests, lit furnaces and the foot of beds. #[derive(Clone, Debug)] struct CatSitOnBlock(super::goals::MoveToBlock); fn can_sit_on(w: &MobWorld, p: [i32; 3]) -> bool { @@ -846,7 +846,10 @@ fn can_sit_on(w: &MobWorld, p: [i32; 3]) -> bool { match f.name.as_ref() { // The lit state is the only light source a furnace has. "minecraft:furnace" => f.emission > 0, - _ => true, + // `ChestBlockEntity.getOpenCount(level, pos) < 1`. + "minecraft:chest" => !w.lid_openers.contains_key(&(p[0], p[1], p[2])), + // A bed's foot only (`BedBlock.PART != HEAD`). + _ => w.prop(p[0], p[1], p[2], "part") != Some("head"), } } impl Goal for CatSitOnBlock { From 8aad511579676fa39072a685482a9ed3b9a1bacc Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:46:01 +0100 Subject: [PATCH 205/475] Drop the cursor stack when the dead player respawns --- crates/horizon-client/src/game/screens.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index 3a2c3deda..69ae69633 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -290,6 +290,14 @@ impl Game<'_> { /// button, or at once with `immediate_respawn`). pub(super) fn respawn_player(&mut self) -> Result<()> { if self.remote.is_none() { + // PlayerList.respawn removes the dead player, and Player.remove's + // inventoryMenu.removed drops what the cursor held, whatever + // keep_inventory says (the 20-tick branch handles a later respawn). + if let Some(stack) = self.carried.take() + && let Some(eye) = self.eye_position() + { + self.throw_from_eyes(stack, eye); + } let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); self.experience.respawn(keep); // The guests are sent the respawned host as a new entity, not the dead body. From 766bfbcdfa6d602518e0fe70657092c54f63b692 Mon Sep 17 00:00:00 2001 From: mahirsn Date: Mon, 26 Oct 2026 21:13:00 +0300 Subject: [PATCH 206/475] fix(client): answer the server's held slot like vanilla ClientboundSetHeldSlotPacket only changes the selected hotbar slot (and ignores slots outside the hotbar), so the next tick sends ServerboundSetCarriedItemPacket as vanilla does. Each login starts again from slot 0, as handleLogin's new MultiPlayerGameMode and LocalPlayer do. --- crates/horizon-client/src/game/remote.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..d53420db9 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1619,7 +1619,11 @@ impl Game<'_> { } C::Login(p) => { self.level.hardcore = p.hardcore; + // handleLogin makes a new MultiPlayerGameMode (carried index 0) and a + // new LocalPlayer (slot 0). + self.selected = 0; if let Some(r) = self.remote.as_mut() { + r.carried = Some(0); r.online_mode = p.online_mode; r.entity_id = p.player_id; r.view_distance = p.chunk_radius; @@ -2295,10 +2299,11 @@ impl Game<'_> { } self.apply_remote_abilities(); } + // ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the + // carried index stays, so the next tick answers with SetCarriedItem. C::SetHeldSlot(p) => { - self.selected = p.slot.clamp(0, 8) as usize; - if let Some(r) = self.remote.as_mut() { - r.carried = Some(self.selected); + if (0..9).contains(&p.slot) { + self.selected = p.slot as usize; } } C::ContainerSetContent(p) => { From 2b2aecd9757305af6e124daeaf55b5327440c02e Mon Sep 17 00:00:00 2001 From: mahirsn Date: Mon, 26 Oct 2026 21:40:03 +0300 Subject: [PATCH 207/475] fix(client): send sign editor text like vanilla A new editor or a removed sign closes the open editor (Minecraft.setScreen, AbstractSignEditScreen.tick), and closing sends ServerboundSignUpdatePacket with the lines it opened with when the sign is gone. Sign lines resolve key binds and translations (Component.getString) instead of keeping only plain text. --- crates/horizon-client/src/game/remote.rs | 9 +++++++ crates/horizon-client/src/game/sign_edit.rs | 29 +++++++++++++++------ crates/horizon-client/src/signs.rs | 8 +++--- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..4e279de6c 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3129,6 +3129,15 @@ impl Game<'_> { fn tick_remote_level(&mut self) -> Result<()> { self.ticks += 1; + // AbstractSignEditScreen.tick: the editor closes once its sign is removed. + if let Some(edit) = &self.sign_edit + && !matches!( + self.world.block_entities.get(&edit.pos), + Some(BlockEntity::Sign(_)) + ) + { + self.close_sign_editor(); + } self.track_experience_display(); // Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s // for a server's client (multiplayer and the default single player), diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 2d010f1f5..529e511f2 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -20,6 +20,9 @@ pub(super) struct SignEditor { pub line: usize, /// The line being edited (`AbstractSignEditScreen`'s text field). edit: EditBox, + /// `AbstractSignEditScreen.messages`: the lines when it opened, sent if + /// the sign is gone by the time the editor closes. + lines: [String; 4], } fn sign_box() -> EditBox { @@ -33,11 +36,19 @@ fn sign_box() -> EditBox { impl Game<'_> { /// `SignBlock.openTextEdit` for the front side after placement. pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) { + // Minecraft.setScreen: the editor already open is removed first. + self.close_sign_editor(); + let lines = match self.world.block_entities.get(&pos) { + Some(BlockEntity::Sign(sign)) if front => sign.front.lines.clone(), + Some(BlockEntity::Sign(sign)) => sign.back.lines.clone(), + _ => Default::default(), + }; self.sign_edit = Some(SignEditor { pos, front, line: 0, edit: sign_box(), + lines, }); self.sign_load(); self.keys.clear(); @@ -50,14 +61,16 @@ impl Game<'_> { pub(super) fn close_sign_editor(&mut self) { if let Some(edit) = self.sign_edit.take() { if self.remote.is_some() { - if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) { - let side = if edit.front { &sign.front } else { &sign.back }; - self.send(S::SignUpdate(play::SignUpdate { - pos: [edit.pos.0, edit.pos.1, edit.pos.2], - lines: side.lines.clone(), - front: edit.front, - })); - } + let lines = match self.world.block_entities.get(&edit.pos) { + Some(BlockEntity::Sign(sign)) if edit.front => sign.front.lines.clone(), + Some(BlockEntity::Sign(sign)) => sign.back.lines.clone(), + _ => edit.lines, + }; + self.send(S::SignUpdate(play::SignUpdate { + pos: [edit.pos.0, edit.pos.1, edit.pos.2], + lines, + front: edit.front, + })); } else { self.mark_block_entity_updated(edit.pos); } diff --git a/crates/horizon-client/src/signs.rs b/crates/horizon-client/src/signs.rs index 07217ed79..799210797 100644 --- a/crates/horizon-client/src/signs.rs +++ b/crates/horizon-client/src/signs.rs @@ -147,10 +147,10 @@ fn component_text(tag: &Tag) -> String { _ => s, } } - Tag::Compound(_) => match tag.get("text") { - Some(Tag::String(t)) => t.to_string_lossy(), - _ => String::new(), - }, + // Component.getString: translations and key binds resolved. + Tag::Compound(_) => { + crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(tag)).plain() + } _ => String::new(), } } From 818b377c02105e8662225a8ec7072867834ed9ef Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:54:32 +0100 Subject: [PATCH 208/475] Add honey drips, poplar and azalea leaves, falling dust and campfire food smoke --- crates/horizon-client/src/animate_tick.rs | 148 ++++++++++++++++-- crates/horizon-client/src/game/render_tick.rs | 5 + 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index 28b28544b..0e0a3319b 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -35,8 +35,9 @@ enum Animate { Campfire { spawn_particles: bool, }, - /// Leaves: optional falling-leaf particle and chance, tinted by foliage. - Leaves(Option<(T, f32)>), + /// Leaves: optional falling-leaf particle and chance; a tinted leaf takes + /// the biome foliage colour unless a fixed colour is given (azalea). + Leaves(Option<(T, f32)>, Option), NetherPortal, EnchantingTable, EndRod([i32; 3]), @@ -49,6 +50,8 @@ enum Animate { EnderChest, /// FallingBlock with its dust colour. Falling(u32), + /// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`). + Beehive, RedstoneOre, RedstoneWire { power: u8, @@ -104,10 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { spawn_particles: false, }, "oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves" - | "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))), - "cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))), - "pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))), - "spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None), + | "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None), + "cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None), + "pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None), + "red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None), + "orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None), + "yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None), + // The azalea blocks drop tinted leaves of the fixed colour -9399763. + "azalea_leaves" | "flowering_azalea_leaves" => { + Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D)) + } + "spruce_leaves" => Animate::Leaves(None, None), "nether_portal" => Animate::NetherPortal, "enchanting_table" => Animate::EnchantingTable, "end_rod" => Animate::EndRod(facing()), @@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { "sand" => Animate::Falling(0xDBD3A0), "red_sand" => Animate::Falling(0xA95821), "gravel" => Animate::Falling(0x807C7B), + // AnvilBlock/ConcretePowderBlock.getDustColor is the map colour; + // DragonEggBlock's is black. + "anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7), + "dragon_egg" => Animate::Falling(0x000000), + _ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color( + name.trim_end_matches("_concrete_powder"), + )), + "beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive, "redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre, "redstone_wire" => { let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0); @@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { _ => Animate::None, } } +/// `DyeColor.getMapColor` of a concrete powder colour. +fn concrete_powder_color(dye: &str) -> u32 { + match dye { + "white" => 0xFFFFFF, + "orange" => 0xD87F33, + "magenta" => 0xB24CD8, + "light_blue" => 0x6699D8, + "yellow" => 0xE5E533, + "lime" => 0x7FCC19, + "pink" => 0xF27FA5, + "gray" => 0x4C4C4C, + "light_gray" => 0x999999, + "cyan" => 0x4C7F99, + "purple" => 0x7F3FB2, + "blue" => 0x334CB2, + "brown" => 0x664C33, + "green" => 0x667F33, + "red" => 0x993333, + _ => 0x191919, + } +} /// `RedstoneWireBlock.COLORS`. fn redstone_color(power: u8) -> [f32; 3] { let f = f32::from(power) / 15.0; @@ -504,6 +543,53 @@ fn try_spawn_drip( } } } +/// `BeehiveBlock.trySpawnDripParticles`. +fn beehive_drip( + tables: &AnimateTables, + engine: &mut ParticleEngine, + ctx: &AnimateContext, + state: BlockStateId, + pos: [i32; 3], +) { + if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 { + return; + } + let Some((bottom, top, bounds)) = ctx.shape_y(pos) else { + return; + }; + if top < 1.0 + || tables + .impermeable + .get(state.0 as usize) + .copied() + .unwrap_or(false) + { + return; + } + let y = if bottom > 0.0 { + f64::from(pos[1]) + bottom - 0.05 + } else { + let below = [pos[0], pos[1] - 1, pos[2]]; + let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1); + // `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below. + if (below_top < 1.0 || !ctx.sturdy(below)) + && ctx.facts.get(ctx.state(below)).fluid == Fluid::None + { + f64::from(pos[1]) - 0.05 + } else { + return; + } + }; + let r = engine.random(); + let (a, b) = (r.next_double(), r.next_double()); + let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2])); + let p = [ + fx + bounds[0] + (bounds[1] - bounds[0]) * a, + y, + fz + bounds[2] + (bounds[3] - bounds[2]) * b, + ]; + add(engine, T::DrippingHoney, p, [0.0; 3]); +} /// `WaterFluid.animateTick` / `LavaFluid.animateTick`. fn fluid_animate_tick( engine: &mut ParticleEngine, @@ -685,7 +771,7 @@ fn block_animate_tick( } } } - Animate::Leaves(falling) => { + Animate::Leaves(falling, fixed_tint) => { let below = [pos[0], pos[1] - 1, pos[2]]; // LeavesBlock.makeDrippingWaterParticles. if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1 @@ -705,7 +791,7 @@ fn block_animate_tick( let r = engine.random(); let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()]; let options = if ty == T::TintedLeaves { - let c = ctx.foliage(pos); + let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos)); let f = |s: u32| ((c >> s) & 255) as f32 / 255.0; ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0]) } else { @@ -977,6 +1063,13 @@ fn block_animate_tick( } } } + Animate::Beehive => { + // BeehiveBlock.animateTick: nextInt(1) + 1 attempts. + let attempts = next_int(engine.random(), 1) + 1; + for _ in 0..attempts { + beehive_drip(tables, engine, ctx, state, pos); + } + } Animate::RedstoneOre => { for d in [ [0, -1, 0], @@ -1209,21 +1302,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) { } add(engine, T::SmallFlame, p, [0.0; 3]); } -/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items). +/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says +/// which of a campfire's four slots hold an item. pub fn campfire_particles<'a>( tables: &AnimateTables, engine: &mut ParticleEngine, ctx: &AnimateContext, blocks: impl Iterator, + cooking: &dyn Fn([i32; 3]) -> [bool; 4], ) { for &(pos, state) in blocks { if !tables.is_lit_campfire(state) { continue; } - let signal = ctx + let properties = ctx .registry .state(BlockStateId(state)) - .is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true")); + .map(|s| &s.properties); + let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str); + let signal = prop("signal_fire") == Some("true"); + // Direction.get2DDataValue: south, west, north, east. + let rotation = match prop("facing") { + Some("west") => 1, + Some("north") => 2, + Some("east") => 3, + _ => 0, + }; let r = engine.random(); if r.next_float() < 0.11 { let count = next_int(r, 2) + 2; @@ -1231,6 +1335,28 @@ pub fn campfire_particles<'a>( make_campfire_smoke(engine, pos, signal, false); } } + // The smoke over each cooking item, at the slot's corner of the fire. + let occupied = cooking(pos); + for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) { + if engine.random().next_float() >= 0.2 { + continue; + } + // Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side. + let (step, clockwise) = match (slot + rotation) % 4 { + 0 => ([0.0, 1.0], [-1.0, 0.0]), + 1 => ([-1.0, 0.0], [0.0, -1.0]), + 2 => ([0.0, -1.0], [1.0, 0.0]), + _ => ([1.0, 0.0], [0.0, 1.0]), + }; + let at = [ + f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125, + ]; + for _ in 0..4 { + add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]); + } + } } } /// `CampfireBlock.makeParticles` (always visible, overriding the limiter); diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index e013c8d29..3f5862f49 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -7,6 +7,7 @@ use horizon_client::{ biome::Biomes, blocks::{BlockFacts, Fluid}, chunk::{HEIGHT, MIN_Y}, + containers::BlockEntity, particles::{ParticleLevel, ParticleOptions, ParticleType}, world::World, }; @@ -344,6 +345,10 @@ impl Game<'_> { &mut self.particles, &ctx, r.block_entities(), + &|pos| match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) { + Some(BlockEntity::Campfire(c)) => c.items.each_ref().map(Option::is_some), + _ => [false; 4], + }, ); } let level = ParticleLevel { From 7112d0cd6498c6e16020230a2868e8f0d0ba4e3f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:55:27 +0100 Subject: [PATCH 209/475] Mobs standing in a wither rose get wither, bees in an open eyeblossom get poison --- crates/horizon-client/src/mobs/ai.rs | 51 ++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 2676c3a37..189625541 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -413,6 +413,7 @@ pub fn tick( super::axolotl::tick_animation(m); m.anim.end_tick(m.ai.swinging); tick_swing(m); + flower_contact_effects(m, w); if m.species() == Species::CopperGolem { // CopperGolem.tick: updateWeathering after the living tick. super::copper_golem::update_weathering(m, w, level_random, events); @@ -1464,6 +1465,56 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec = Vec::new(); + for at in [m.previous, m.position] { + let cells = |lo: f64, hi: f64| lo.floor() as i32..=hi.floor() as i32; + for x in cells(at.x - half, at.x + half) { + for y in cells(at.y + 1.0e-5, at.y + height) { + for z in cells(at.z - half, at.z + half) { + let p = [x, y, z]; + if visited.contains(&p) { + continue; + } + match w.block_name(x, y, z) { + "minecraft:wither_rose" => { + // isInvulnerableTo(wither): the invulnerable time + // after a hit; WitherBoss and WitherSkeleton + // refuse the effect (`canBeAffected`). + if m.invulnerable <= 0 + && !m.invulnerable_flag + && !matches!(m.species(), Species::Wither | Species::WitherSkeleton) + { + m.add_effect("minecraft:wither", 40, 0); + } + } + "minecraft:open_eyeblossom" + if m.species() == Species::Bee + && super::bee::attracts_bees(w, p) + && !m.has_effect("minecraft:poison") => + { + m.add_effect("minecraft:poison", 25, 0); + } + _ => continue, + } + visited.push(p); + } + } + } + } +} + /// MobEffect ticking for the effects mobs receive (poison, wither, regeneration). fn tick_effects(m: &mut Mob) { let mut effects = std::mem::take(&mut m.extra.effects); From 4ebfaef0d3976c2e003acddeded25c891028d1d4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:56:07 +0100 Subject: [PATCH 210/475] Slide slowly down the sides of honey blocks --- crates/horizon-client/src/game.rs | 26 +++++++++++++++ crates/horizon-client/src/motion.rs | 51 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index de7eb448b..ab63124d6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4544,6 +4544,32 @@ impl Game<'_> { MotionEvent::Fall(event) => self.landing_block_updates(&event), MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity), MotionEvent::EnterBlock { state } => self.trigger_enter_block(state), + MotionEvent::HoneySlide { + pos, + sound, + particles, + } => { + if sound { + self.audio + .play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0); + } + // HoneyBlock.showSlideParticles: five block particles. + if particles + && let Some((sprite, tint)) = self.particle_sprite( + self.world.block(pos.x, pos.y, pos.z), + [pos.x, pos.y, pos.z], + ) + { + use horizon_client::particles::{ParticleOptions, ParticleType}; + for _ in 0..5 { + self.particles.add( + &ParticleOptions::block(ParticleType::Block, sprite.clone(), tint), + feet, + [0.0; 3], + ); + } + } + } MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos), MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos), MotionEvent::InsideWitherRose => self.wither_rose_contact(), diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index fcdd32cbd..04341b66f 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -203,6 +203,14 @@ pub enum MotionEvent { bubble: bool, position: Vec3, }, + /// `HoneyBlock.maybeDoSlideEffects` for a player sliding down the side of + /// the honey block at `pos`: its slide sound, and the client's five + /// block particles (entity event 53) at the player. + HoneySlide { + pos: BlockPos, + sound: bool, + particles: bool, + }, /// Entity event 67 (`LivingEntity.makeDrownParticles`): eight bubbles /// around a drowning player. Drown, @@ -1386,6 +1394,7 @@ impl MotionWorld<'_, '_, '_> { entered.push((step, p)); } "minecraft:cobweb" => self.web_inside(), + "minecraft:honey_block" => self.honey_block_inside(p)?, "minecraft:sweet_berry_bush" => self.sweet_berry_bush_inside(state)?, "minecraft:bubble_column" => { let precise = moved_far || deflated.intersects(Aabb::block(p)); @@ -1403,6 +1412,48 @@ impl MotionWorld<'_, '_, '_> { } Ok(iterations + 1) } + /// `HoneyBlock.entityInside`: a falling player touching a side of the block + /// slides down it (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects`). + fn honey_block_inside(&mut self, pos: BlockPos) -> Result<()> { + let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08; + let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32); + let position = self.player.position; + let velocity = self.player.velocity; + if self.player.ground.on_ground + || position.y > f64::from(pos.y) + 0.9375 - 1.0e-7 + || old_delta_y(velocity.y) >= -0.08 + { + return Ok(()); + } + let dx = (f64::from(pos.x) + 0.5 - position.x).abs(); + let dz = (f64::from(pos.z) + 0.5 - position.z).abs(); + let overlap = 0.4375 + f64::from(self.player.dimensions.width / 2.0); + if !(dx + 1.0e-7 > overlap || dz + 1.0e-7 > overlap) { + return Ok(()); + } + // doSlideMovement + let old = old_delta_y(velocity.y); + if old < -0.13 { + let factor = -0.05 / old; + self.player.velocity = + Vec3::new(velocity.x * factor, new_delta_y(-0.05), velocity.z * factor); + } else { + self.player.velocity = Vec3::new(velocity.x, new_delta_y(-0.05), velocity.z); + } + self.player.fall_distance = 0.0; + // maybeDoSlideEffects: the sound, then the server's event 53. + let random = &mut self.scene.random; + let sound = random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0; + let particles = random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0; + if sound || particles { + self.player.events.push(MotionEvent::HoneySlide { + pos, + sound, + particles, + }); + } + Ok(()) + } /// `Player.makeStuckInBlock`: the next move is scaled by `multiplier` /// (and the fall forgotten) unless flying; the impulse context resets /// either way. From e7e6e5bc07556c8c4e15fcda5768517007001e59 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:56:22 +0100 Subject: [PATCH 211/475] Scale entity tracking range by entity-broadcast-range-percentage The property was parsed and ignored. The tracking range of each entity type is now scaled by it on a dedicated server, like DedicatedServer.getScaledTrackingDistance. --- crates/horizon-client/src/game/dedicated.rs | 3 +++ crates/horizon-client/src/lan/entity.rs | 14 ++++++++++---- crates/horizon-client/src/lan/hub.rs | 4 ++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index fa5365195..49b653ae1 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -654,6 +654,9 @@ impl Game<'_> { // DedicatedServer.initServer ends with saveEverything(false, true, true). self.save_game()?; self.log_saved_levels(); + if let Some(host) = self.lan.as_mut() { + host.hub.entity_range_percent = props.entity_broadcast_range_percentage; + } let mut state = ServerState { lists, console, diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index ae3089006..920052b5a 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -611,8 +611,9 @@ pub struct Viewer<'a> { } /// One entity as this tick's range checks read it: where it is -/// horizontally, its id, its type's tracking range in chunks and the chunk -/// it is in (an index into the tick's chunks). +/// horizontally, its id, its tracking range in blocks (scaled by +/// `entity-broadcast-range-percentage`) and the chunk it is in (an index +/// into the tick's chunks). #[derive(Clone, Copy)] struct Probe { x: f64, @@ -627,7 +628,7 @@ struct Probe { /// each of the tick's chunks whether the client has it (0: not looked up /// yet, 1: no, 2: yes), so each chunk is looked up once per client. fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool { - let range = f64::from(p.range.min(v.view_distance) * 16); + let range = f64::from(p.range.min(v.view_distance * 16)); let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z); dx * dx + dz * dz <= range * range && { let held = &mut held[p.chunk as usize]; @@ -641,6 +642,9 @@ fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool { /// One level's tracked entities (ChunkMap.entityMap). #[derive(Default)] pub struct Entities { + /// `entity-broadcast-range-percentage` (None: 100), which scales every + /// type's tracking range (MinecraftServer.getScaledTrackingDistance). + pub range_percent: Option, states: crate::fast_hash::FxHashMap, /// The level tick, stamped on every entity still in the level. tick: u32, @@ -732,7 +736,9 @@ impl Entities { x: e.position[0], z: e.position[2], id: e.id, - range: s.range, + range: self + .range_percent + .map_or(s.range * 16, |p| p * (s.range * 16) / 100), chunk, }); } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1a8cbc62..ac0530d86 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -168,6 +168,8 @@ pub struct Hub { level_sync: std::collections::HashMap, /// Operators and their permission levels (dedicated servers). pub ops: std::collections::HashMap, + /// `entity-broadcast-range-percentage` (dedicated servers). + pub entity_range_percent: i32, chat_type: i32, dimension_type: i32, clock_ids: Vec, @@ -332,6 +334,7 @@ impl Hub { ticking: (20.0, false), stepping: 0, ops: Default::default(), + entity_range_percent: 100, weather: (false, 0.0, 0.0), level_sync: Default::default(), chat_type, @@ -1678,6 +1681,7 @@ impl Hub { out, }) .collect(); + level.range_percent = Some(self.entity_range_percent); let (types, sync) = (&mut self.entity_types, &self.sync); level.tick(&entities, &self.riding, &mut viewers, |kind| { entity_type(types, sync, kind) From 5a70691d63980600cd6de1fc03b6ad0c03c02e60 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:56:39 +0100 Subject: [PATCH 212/475] Show the note particle above a note block when it plays --- crates/horizon-client/src/game.rs | 25 ++++++++++++++++++++++ crates/horizon-world/src/level/redstone.rs | 9 +++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index de7eb448b..118256915 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3085,6 +3085,31 @@ impl Game<'_> { LevelEvent::BlockEntityEvent { pos, a, b, .. } => { self.block_entity_event([pos.x, pos.y, pos.z], a, b) } + // NoteBlock.triggerEvent: a NOTE particle above, coloured by + // the note (`note / 24.0` in the x speed). + LevelEvent::BlockEvent { + pos, + state, + kind: "note_particle", + } => { + use horizon_client::particles::{ParticleOptions, ParticleType}; + let note = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("note")?.parse::().ok()) + .unwrap_or(0.0); + self.particles.add( + &ParticleOptions::simple(ParticleType::Note), + [ + f64::from(pos.x) + 0.5, + f64::from(pos.y) + 1.2, + f64::from(pos.z) + 0.5, + ], + [note / 24.0, 0.0, 0.0], + ); + } LevelEvent::GrowTree { .. } | LevelEvent::PlaceFeature { .. } | LevelEvent::BlockEvent { .. } => {} diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index a2a02081f..3965a57a2 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -1108,7 +1108,8 @@ impl Sim<'_, L> { self.game_event(pos, "minecraft:note_block_play"); } } - /// `NoteBlock.triggerEvent`: the note's sound. + /// `NoteBlock.triggerEvent`: the note's particle (tunable instruments) + /// and sound. pub(crate) fn note_trigger_event(&mut self, pos: BlockPos, state: BlockStateId) { let b = self.b; let instrument = b.table.text(state, b.props.instrument).to_owned(); @@ -1120,6 +1121,12 @@ impl Sim<'_, L> { let pitch = if mob_head { 1.0 } else { + // `isTunable`: the client adds the NOTE particle above the block. + self.emit(LevelEvent::BlockEvent { + pos, + state, + kind: "note_particle", + }); horizon_core::jmath::pow(2.0, f64::from(note - 12) / 12.0) as f32 }; let sound = match instrument.as_str() { From dbd24e2e16534cbdadb9cd56118d987d25b0ec07 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:57:02 +0100 Subject: [PATCH 213/475] A creaking heart blown up by an explosion makes its creaking die --- crates/horizon-client/src/game.rs | 2 +- crates/horizon-client/src/game/creaking.rs | 7 ++++--- crates/horizon-client/src/game/explosion.rs | 5 +++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a51bdcbcb..0ebf5bfaf 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3493,7 +3493,7 @@ impl Game<'_> { } else if let Some(tag) = spill::saved_entity(&self.world, pos) { self.pending.record_saved(pos, &tag); } - self.creaking_heart_broken(pos); + self.creaking_heart_broken(pos, true); let hive = match self.world.block_entities.get(&key) { Some(BlockEntity::Beehive(h)) => self.hive_state(pos).map(|s| ((**h).clone(), s)), _ => None, diff --git a/crates/horizon-client/src/game/creaking.rs b/crates/horizon-client/src/game/creaking.rs index c75325c2f..19b3442c2 100644 --- a/crates/horizon-client/src/game/creaking.rs +++ b/crates/horizon-client/src/game/creaking.rs @@ -218,8 +218,9 @@ impl Game<'_> { ); } /// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to - /// death and a natural heart drops experience. - pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) { + /// death and a natural heart drops experience. `onExplosionHit` does the + /// same for the creaking (`by_player` false), without the experience. + pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) { let key = (pos[0], pos[1], pos[2]); if !matches!( self.world.block_entities.get(&key), @@ -242,7 +243,7 @@ impl Game<'_> { .state(state) .and_then(|s| s.properties.get("natural").cloned()) .is_some_and(|v| v == "true"); - if natural && !self.creative && !self.spectator() { + if by_player && natural && !self.creative && !self.spectator() { let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32; self.spawn_orbs( [ diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 8800e29d8..886310d91 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -599,6 +599,11 @@ impl Game<'_> { } let name = self.res.facts().get(state).name.clone(); let is_tnt = &*name == "minecraft:tnt"; + // CreakingHeartBlock.onExplosionHit: its creaking dies (no + // experience, as Horizon does not track a TNT's owner). + if &*name == "minecraft:creaking_heart" { + self.creaking_heart_broken(pos, false); + } // Block.onExplosionHit: the drops copy from the block entity, which // then spills its contents as the block turns to air. let key = (pos[0], pos[1], pos[2]); From 9e848670dbb543e2e0338b0290804390fd6cd577 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:57:11 +0100 Subject: [PATCH 214/475] Send swimming, gliding, fire, invisible and glowing for LAN players --- crates/horizon-client/src/game/lan.rs | 11 +++- crates/horizon-client/src/lan/hub.rs | 77 ++++++++++++++++++++++---- crates/horizon-client/tests/lan_bot.rs | 1 + 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..4ccbb0ec8 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -11,7 +11,7 @@ use horizon_client::{ chunks::ChunkEncoder, data::SyncData, entity::{EntityState, States}, - hub::{HostEvent, HostState, Hub, HubConfig, TickCtx}, + hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx}, playerdata, }, mobs::{Mob, Who, leash::Holder}, @@ -835,6 +835,15 @@ impl Game<'_> { living_flags: living_flags(self.using) | if p.auto_spin_attack_ticks > 0 { 4 } else { 0 }, bed: self.sleeping, + body: BodyFlags { + gliding: p.fall_flying, + swimming: p.swimming, + crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming, + spin_attack: p.auto_spin_attack_ticks > 0, + on_fire: p.fire_ticks > 0, + invisible: self.potions.effects.get("invisibility").is_some(), + glowing: self.potions.effects.get("glowing").is_some(), + }, }) } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1a8cbc62..c3a18ee86 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -87,6 +87,27 @@ pub struct HostState { pub living_flags: i8, /// The bed the host sleeps in (`LivingEntity.SLEEPING_POS`). pub bed: Option<[i32; 3]>, + pub body: BodyFlags, +} + +/// What the other players see of a player's body besides sneaking and +/// sprinting: the pose and the rest of `Entity.DATA_SHARED_FLAGS_ID`. +#[derive(Clone, Copy, Debug, Default)] +pub struct BodyFlags { + /// `Entity.isFallFlying`: gliding with elytra. + pub gliding: bool, + /// `Entity.isSwimming` (the swimming flag). + pub swimming: bool, + /// The swimming pose of a player crawling under a block. + pub crawling: bool, + /// `LivingEntity.isAutoSpinAttack`: Riptide's spin. + pub spin_attack: bool, + /// `Entity.isOnFire`. + pub on_fire: bool, + /// `Entity.isInvisible`: the invisibility effect. + pub invisible: bool, + /// The glowing effect. + pub glowing: bool, } /// Everything the hub needs from the game for one tick. @@ -1507,7 +1528,8 @@ impl Hub { position: [f64; 3], rot: (f32, f32, f32), on_ground: bool, - flags: (bool, bool, bool, bool), + flags: (bool, bool, bool), + body: BodyFlags, living_flags: i8, bed: Option<[i32; 3]>, skin: u8, @@ -1515,12 +1537,18 @@ impl Hub { equipment: Vec<(u8, Option)>, attributes: Vec, ) -> EntityState { - let (sneaking, sprinting, gliding, sleeping) = flags; - let shared = shared_flags(sneaking, sprinting, gliding); + let (sneaking, sprinting, sleeping) = flags; + let shared = shared_flags(sneaking, sprinting, &body); + // Player.getDesiredPose (and updatePlayerPose's crawl): sleeping, + // swimming, gliding, Riptide's spin, crouching. let pose = if sleeping { 2 - } else if gliding { + } else if body.swimming || body.crawling { + 3 + } else if body.gliding { 1 + } else if body.spin_attack { + 4 } else if sneaking { 5 } else { @@ -1607,7 +1635,8 @@ impl Hub { h.position, (h.yaw, h.pitch, h.head_yaw), h.on_ground, - (h.sneaking, h.sprinting, false, h.bed.is_some()), + (h.sneaking, h.sprinting, h.bed.is_some()), + h.body, h.living_flags, h.bed, 0x7F, @@ -1639,7 +1668,8 @@ impl Hub { g.position, (g.yaw, g.pitch, g.head_yaw), g.on_ground, - (g.sneaking, g.sprinting, g.fall_flying, g.sleeping), + (g.sneaking, g.sprinting, g.sleeping), + guest_body(g), g.living_flags, g.bed, g.info.model_customisation, @@ -1798,7 +1828,7 @@ impl Hub { // too, so the sprint a hit stops (Player.causeExtraKnockback) stops // on its client. let g = &mut self.guests[i]; - let shared = shared_flags(g.sneaking, g.sprinting, g.fall_flying); + let shared = shared_flags(g.sneaking, g.sprinting, &guest_body(g)); if shared != g.sent_shared { g.sent_shared = shared; g.send(&C::SetEntityData(play::SetEntityData { @@ -2578,16 +2608,43 @@ fn ticking_state(rate: f32, frozen: bool) -> C { raw(ids::TICKING_STATE, w.data) } -/// Entity.DATA_SHARED_FLAGS_ID of a player: crouching, sprinting, gliding. -fn shared_flags(sneaking: bool, sprinting: bool, gliding: bool) -> i8 { +/// What the others see of a guest's body, from its server-side state. +fn guest_body(g: &Guest) -> BodyFlags { + let m = g.motion.as_deref(); + BodyFlags { + gliding: g.fall_flying, + swimming: m.is_some_and(|m| m.swimming), + crawling: m.is_some_and(|m| m.pose == crate::pose::Pose::Swimming && !m.swimming), + spin_attack: m.is_some_and(|m| m.auto_spin_attack_ticks > 0), + on_fire: m.is_some_and(|m| m.fire_ticks > 0), + invisible: g.effects.get("invisibility").is_some(), + glowing: g.effects.get("glowing").is_some(), + } +} + +/// Entity.DATA_SHARED_FLAGS_ID of a player: on fire 0x01, crouching 0x02, +/// sprinting 0x08, swimming 0x10, invisible 0x20, glowing 0x40, gliding 0x80. +fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 { let mut shared = 0i8; + if body.on_fire { + shared |= 0x01; + } if sneaking { shared |= 0x02; } if sprinting { shared |= 0x08; } - if gliding { + if body.swimming { + shared |= 0x10; + } + if body.invisible { + shared |= 0x20; + } + if body.glowing { + shared |= 0x40; + } + if body.gliding { shared |= -128; } shared diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 34e8265a1..c4ed2884b 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -186,6 +186,7 @@ impl Server { attributes: Vec::new(), bed: None, living_flags: 0, + body: Default::default(), }; let mut ctx = TickCtx { world: &mut self.world, From 00cb813dc88ff067912e574ae02c7fcbc6cd10b3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:57:18 +0100 Subject: [PATCH 215/475] Show the missing item tooltip lines in vanilla order Custom names are italic. Unbreakable, dyed, written book author and generation, music disc songs, goat horn instruments, loaded crossbow projectiles, hive bees and honey, the potion "When Applied" block and enchantment attribute modifiers (Efficiency V gives +26 Mining Efficiency) now appear, and advanced tooltips (F3+H) end with the item id and the component count. Attribute lines use the attribute's sentiment colour. --- crates/horizon-client/src/enchant.rs | 26 ++ crates/horizon-client/src/game/stations.rs | 253 +++++++++++++---- crates/horizon-client/src/item_tooltip.rs | 311 ++++++++++++++++++--- crates/horizon-client/src/items.rs | 6 + 4 files changed, 498 insertions(+), 98 deletions(-) diff --git a/crates/horizon-client/src/enchant.rs b/crates/horizon-client/src/enchant.rs index 2f418ae82..73b9bf090 100644 --- a/crates/horizon-client/src/enchant.rs +++ b/crates/horizon-client/src/enchant.rs @@ -545,6 +545,32 @@ impl Enchantments { } out } + /// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the + /// `minecraft:attributes` effects of `stack`'s enchantments as rows of + /// (attribute, id, amount, operation, slot group), one per slot group the + /// enchantment lists. + pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> { + let mut rows = Vec::new(); + for (id, level) in stack.enchantments() { + let Some(e) = self.get(id) else { continue }; + for a in e.effects["minecraft:attributes"] + .as_array() + .into_iter() + .flatten() + { + for slot in &e.slots { + rows.push(( + a["attribute"].as_str().unwrap_or("").to_owned(), + a["id"].as_str().unwrap_or("").to_owned(), + f64::from(level_value(&a["amount"], *level)), + a["operation"].as_str().unwrap_or("add_value").to_owned(), + slot.clone(), + )); + } + } + } + rows + } /// Sum of `add_value` amounts of one attribute from enchantments on `stack`. pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 { stack.map_or(0.0, |s| { diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 56b19e393..686ad0c02 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -277,6 +277,10 @@ fn argb(c: i32) -> u32 { ) } +/// A text style's `0xRRGGBB` colour from a HUD colour (`rgba`). +fn rgb_of(rgba: u32) -> u32 { + (rgba & 0xFF) << 16 | (rgba >> 8 & 0xFF) << 8 | (rgba >> 16 & 0xFF) +} impl Game<'_> { /// `Component.translatable`: the selected language (which `lang` layers /// over `en_us`), the caller's English text last. @@ -1689,20 +1693,34 @@ impl Game<'_> { &self, stack: &ItemStack, ) -> Vec<(horizon_client::text::Text, u32)> { - let e: &Enchantments = &self.res.stations().enchantments; - let props = e.props(&stack.item); + self.stack_tooltip_lines(stack, false) + } + /// `ItemStack.getRarity`'s colour for the name line and a `[name]` link. + fn rarity_color(&self, stack: &ItemStack) -> u32 { + let props = self.res.stations().enchantments.props(&stack.item); let rarity = horizon_client::item_tooltip::rarity( stack, props.map_or("common", |p| p.rarity.as_str()), ); - let color = match rarity { + match rarity { "uncommon" => rgba(255, 255, 85, 255), "rare" => rgba(85, 255, 255, 255), "epic" => rgba(255, 85, 255, 255), _ => u32::MAX, - }; - let name = if let Some(n) = stack.custom_name() { + } + } + /// `ItemStack.getHoverName`: the custom name, a written book's title, the + /// item name component, else the item's or potion's name. + fn stack_hover_name(&self, stack: &ItemStack) -> String { + if let Some(n) = stack.custom_name() { n.to_owned() + } else if let Some(title) = stack + .data() + .and_then(|d| d.written.as_ref()) + .map(|w| w.title.as_str()) + .filter(|t| !t.trim().is_empty()) + { + title.to_owned() } else if let Some(n) = stack.item_name() { n } else if stack.data().is_some_and(|d| d.ominous_banner) { @@ -1718,16 +1736,47 @@ impl Game<'_> { .unwrap_or_else(|| self.pack.names.display_item(&stack.item)) } else { self.pack.names.display_item(&stack.item) - }; + } + } + /// `ItemStack.getTooltipLines`. A `nested` stack is a crossbow's + /// projectile: `ChargedProjectiles.addProjectileTooltip` takes its details + /// without the name, the player and the advanced flag. + fn stack_tooltip_lines( + &self, + stack: &ItemStack, + nested: bool, + ) -> Vec<(horizon_client::text::Text, u32)> { + let e: &Enchantments = &self.res.stations().enchantments; + let color = self.rarity_color(stack); + let advanced = !nested && self.options_advanced_tooltips(); let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack); let mut lines = Vec::new(); - if display.name_visible(self.screen == Screen::Creative) { - lines.push((horizon_client::text::Text::legacy(&name), color)); + if !nested && display.name_visible(self.screen == Screen::Creative) { + // `getStyledHoverName`: a custom name is italic. + let name = self.stack_hover_name(stack); + let name = if stack.custom_name().is_some() { + horizon_client::text::Text::literal(name).italic() + } else { + horizon_client::text::Text::legacy(&name) + }; + lines.push((name, color)); } let translate = |k: &str| self.pack.names.display_get(k); let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| { lines.push((horizon_client::text::Text::legacy(&s), c)); }; + let gray = rgba(170, 170, 170, 255); + let item_info = self.res.items().get(&stack.item); + // InstrumentComponent.addToTooltip. + if let Some(text) = + horizon_client::item_tooltip::instrument_line(stack, &display, &translate) + { + push(&mut lines, text, gray); + } + // Bees.addToTooltip. + if let Some(text) = horizon_client::item_tooltip::bees_line(stack, &display, &translate) { + push(&mut lines, text, gray); + } // ItemContainerContents.addToTooltip. let hover_name = |s: &ItemStack| { s.custom_name() @@ -1742,6 +1791,70 @@ impl Game<'_> { push(&mut lines, text, u32::MAX); } } + // BannerPatternLayers.addToTooltip: up to six layer names. + if display.shows("minecraft:banner_patterns") { + for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) { + let key = self.res.stations().banners.layer_key(layer); + push( + &mut lines, + self.translate(&key, &key), + rgba(170, 170, 170, 255), + ); + } + } + // WrittenBookContent.addToTooltip. + for text in horizon_client::item_tooltip::written_book_lines(stack, &display, &translate) { + push(&mut lines, text, gray); + } + // ChargedProjectiles.addToTooltip: each run of equal projectiles, then + // the projectile's own details indented under it. + if display.shows("minecraft:charged_projectiles") { + let charged = stack.data().map_or(&[][..], |d| &d.charged[..]); + for run in charged.chunk_by(|a, b| a.same_item_same_components(b)) { + let projectile = &run[0]; + let name = self.stack_hover_name(projectile); + let line = if run.len() == 1 { + // `getDisplayName`: `[name]` in the rarity colour. + let mut shown = horizon_client::text::Text::literal(format!("[{name}]")) + .with_color(rgb_of(self.rarity_color(projectile))); + if projectile.custom_name().is_some() { + shown = shown.italic(); + } + horizon_client::text::format_translation( + &self.translate( + "item.minecraft.crossbow.projectile.single", + "Projectile: %s", + ), + &[shown], + ) + } else { + horizon_client::text::format_translation( + &self.translate( + "item.minecraft.crossbow.projectile.multiple", + "Projectile: %s x %s", + ), + &[ + horizon_client::text::Text::literal(run.len().to_string()), + horizon_client::text::Text::literal(name), + ], + ) + }; + lines.push((line, u32::MAX)); + for (text, c) in self.stack_tooltip_lines(projectile, true) { + // `literal(" ").append(line).withStyle(GRAY)`: the line's + // own colours win over the gray. + let rgb = if c == u32::MAX { + horizon_client::text::GRAY + } else { + rgb_of(c) + }; + let indented = horizon_client::text::Text::literal(" ") + .append(text) + .with_color(rgb); + lines.push((indented, u32::MAX)); + } + } + } // Fireworks / FireworkExplosion.addToTooltip. for text in horizon_client::item_tooltip::firework_lines(stack, &translate) { push(&mut lines, text, rgba(170, 170, 170, 255)); @@ -1764,6 +1877,17 @@ impl Game<'_> { }; push(&mut lines, text, c); } + // The attribute modifiers of the effects (`When Applied:`). + for (text, c) in horizon_client::item_tooltip::potion_modifier_lines(stack, &translate) + { + push(&mut lines, text, c); + } + } + // JukeboxPlayable.addToTooltip. + if let Some(text) = + horizon_client::item_tooltip::jukebox_line(stack, item_info, &display, &translate) + { + push(&mut lines, text, gray); } if display.shows("minecraft:trim") && let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone()) @@ -1811,16 +1935,12 @@ impl Game<'_> { ); } } - // BannerPatternLayers.addToTooltip: up to six layer names. - if display.shows("minecraft:banner_patterns") { - for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) { - let key = self.res.stations().banners.layer_key(layer); - push( - &mut lines, - self.translate(&key, &key), - rgba(170, 170, 170, 255), - ); - } + // DyedItemColor.addToTooltip. + if let Some((text, italic)) = + horizon_client::item_tooltip::dyed_line(stack, &display, advanced, &translate) + { + let text = horizon_client::text::Text::legacy(&text); + lines.push((if italic { text.italic() } else { text }, gray)); } // ItemLore.addToTooltip: after the enchantments, before the attributes // (ItemStack.addDetailsToTooltip). @@ -1829,6 +1949,50 @@ impl Game<'_> { lines.push((text, u32::MAX)); } } + if display.shows("minecraft:attribute_modifiers") { + // The item's own modifiers, then its enchantments' for each slot + // group (`ItemStack.forEachModifier`). + let mut modifiers = item_info.map(|i| i.modifiers.clone()).unwrap_or_default(); + modifiers.extend(e.modifier_rows(stack)); + let base = |name: &str, default| { + self.player + .as_ref() + .and_then(|player| { + let key = player.attributes.key(name)?; + let handle = player.attributes.lookup(Some(&key))?; + player + .attributes + .instance(&handle) + .ok() + .map(|value| value.base_value()) + }) + .unwrap_or(default) + }; + let player_base = (!nested).then(|| { + [ + base("minecraft:attack_damage", 1.0), + base( + "minecraft:attack_speed", + horizon_client::attack_strength::BASE_ATTACK_SPEED, + ), + ] + }); + for (text, c) in + horizon_client::item_tooltip::attribute_lines(&modifiers, player_base, &translate) + { + push(&mut lines, text, c); + } + } + // ItemStack.UNBREAKABLE_TOOLTIP. + if let Some(text) = + horizon_client::item_tooltip::unbreakable_line(stack, &display, &translate) + { + push(&mut lines, text, rgba(85, 85, 255, 255)); + } + // BlockItemStateProperties.addToTooltip. + if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) { + push(&mut lines, text, gray); + } // Spawner.appendHoverText: what a spawner or trial spawner item spawns. if display.shows("minecraft:block_entity_data") && matches!( @@ -1852,43 +2016,11 @@ impl Game<'_> { } } } - if display.shows("minecraft:attribute_modifiers") - && let Some(info) = self.res.items().get(&stack.item) - { - let base = |name: &str, default| { - self.player - .as_ref() - .and_then(|player| { - let key = player.attributes.key(name)?; - let handle = player.attributes.lookup(Some(&key))?; - player - .attributes - .instance(&handle) - .ok() - .map(|value| value.base_value()) - }) - .unwrap_or(default) - }; - let player_base = [ - base("minecraft:attack_damage", 1.0), - base( - "minecraft:attack_speed", - horizon_client::attack_strength::BASE_ATTACK_SPEED, - ), - ]; - for (text, c) in horizon_client::item_tooltip::attribute_lines( - &info.modifiers, - Some(player_base), - &translate, - ) { - push(&mut lines, text, c); - } - } if display.shows("minecraft:damage") - && let Some(info) = self.res.items().get(&stack.item) + && let Some(info) = item_info && info.max_damage > 0 && stack.damage > 0 - && self.options_advanced_tooltips() + && advanced { push( &mut lines, @@ -1900,8 +2032,23 @@ impl Game<'_> { u32::MAX, ); } + // ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id + // and how many components the stack has. + if advanced { + let dark_gray = rgba(85, 85, 85, 255); + push(&mut lines, stack.item.to_string(), dark_gray); + let count = horizon_client::item_tooltip::component_count(stack, item_info); + if count > 0 { + let text = self.translate("item.components", "%s component(s)"); + push( + &mut lines, + text.replacen("%s", &count.to_string(), 1), + dark_gray, + ); + } + } // ItemStack.OP_NBT_WARNING, for a player with game master permission. - if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) { + if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) { for (key, fallback) in [ ("item.op_warning.line1", "Warning:"), ( diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 88f01439e..63a19b114 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -108,6 +108,32 @@ fn modifier_line( ) } +/// `Attribute.getStyle`: the colour of a line for an attribute whose value +/// rises or falls. Gravity, scale and the waypoint ranges are neutral, burning +/// time and fall damage are negative, every other attribute is positive. +fn attribute_color(attribute: &str, rises: bool) -> u32 { + let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255)); + match attribute { + "burning_time" | "fall_damage_multiplier" => { + if rises { + red + } else { + blue + } + } + "gravity" | "scale" | "waypoint_transmit_range" | "waypoint_receive_range" => { + rgba(170, 170, 170, 255) + } + _ => { + if rises { + blue + } else { + red + } + } + } +} + /// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player /// attack damage/speed bases. Recipe search has no player context. pub fn attribute_lines( @@ -150,10 +176,8 @@ pub fn attribute_lines( ) { let (line, color) = if base.is_some() { (format!(" {line}"), rgba(0, 170, 0, 255)) - } else if *amount > 0.0 { - (line, rgba(85, 85, 255, 255)) } else { - (line, rgba(255, 85, 85, 255)) + (line, attribute_color(path, *amount > 0.0)) }; lines.push((line, color)); } @@ -231,6 +255,235 @@ pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str { } } +fn is_beehive(path: &str) -> bool { + matches!(path, "beehive" | "bee_nest") +} + +/// `InstrumentComponent.addToTooltip`: the instrument's description. A goat +/// horn without the component has the item's default, the ponder horn. +pub fn instrument_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:instrument") { + return None; + } + let id = match stack.data().and_then(|d| d.other("minecraft:instrument")) { + Some(Tag::String(id)) => id.to_string_lossy(), + // An inline instrument carries its own description. + Some(inline) => { + return inline + .get("description") + .map(|d| crate::entity_nbt::name_text(d).plain()); + } + None if &*stack.item == "minecraft:goat_horn" => "minecraft:ponder_goat_horn".to_owned(), + None => return None, + }; + let key = format!("instrument.{}", id.replacen(':', ".", 1)); + Some(translate(&key).unwrap_or(key)) +} + +/// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without +/// the component has the item's default, none). +pub fn bees_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:bees") { + return None; + } + let bees = match stack.data().and_then(|d| d.other("minecraft:bees")) { + Some(Tag::List(bees)) => bees.len(), + Some(_) => return None, + None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0, + None => return None, + }; + let text = + translate("container.beehive.bees").unwrap_or_else(|| "container.beehive.bees".into()); + Some( + text.replacen("%s", &bees.to_string(), 1) + .replacen("%s", "3", 1), + ) +} + +/// `BlockItemStateProperties.addToTooltip`: a hive item's honey level (a hive +/// item without the component has the item's default, 0). +pub fn honey_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:block_state") { + return None; + } + let level = match stack.data().and_then(|d| d.other("minecraft:block_state")) { + Some(Tag::Compound(state)) => { + match state.get(&horizon_world::nbt::JavaString::from("honey_level")) { + Some(Tag::String(level)) => level.to_string_lossy().parse::().ok()?, + _ => return None, + } + } + Some(_) => return None, + None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0, + None => return None, + }; + let text = + translate("container.beehive.honey").unwrap_or_else(|| "container.beehive.honey".into()); + Some( + text.replacen("%s", &level.to_string(), 1) + .replacen("%s", "5", 1), + ) +} + +/// `WrittenBookContent.addToTooltip`: "by " and the generation. +pub fn written_book_lines( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Vec { + let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned()); + let Some(book) = stack + .data() + .and_then(|d| d.written.as_ref()) + .filter(|_| display.shows("minecraft:written_book_content")) + else { + return Vec::new(); + }; + let mut lines = Vec::new(); + if !book.author.trim().is_empty() { + lines.push( + crate::text::format_translation( + &text("book.byAuthor"), + &[crate::text::Text::literal(&book.author)], + ) + .plain(), + ); + } + lines.push(text(&format!("book.generation.{}", book.generation))); + lines +} + +/// `JukeboxPlayable.addToTooltip`: the song's description. +pub fn jukebox_line( + stack: &ItemStack, + info: Option<&ItemInfo>, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:jukebox_playable") { + return None; + } + let song = match stack + .data() + .and_then(|d| d.other("minecraft:jukebox_playable")) + { + Some(Tag::String(song)) => song.to_string_lossy(), + // An inline song carries its own description. + Some(inline) => { + return inline + .get("description") + .map(|d| crate::entity_nbt::name_text(d).plain()); + } + None => info?.jukebox_song.clone()?, + }; + let key = format!("jukebox_song.{}", song.replacen(':', ".", 1)); + Some(translate(&key).unwrap_or(key)) +} + +/// `DyedItemColor.addToTooltip`: "Dyed" (italic, the flag), or with advanced +/// tooltips the colour's hex code. +pub fn dyed_line( + stack: &ItemStack, + display: &TooltipDisplay, + advanced: bool, + translate: &dyn Fn(&str) -> Option, +) -> Option<(String, bool)> { + let rgb = stack.data()?.dyed_color?; + if !display.shows("minecraft:dyed_color") { + return None; + } + let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned()); + Some(if advanced { + let hex = format!("#{:06X}", rgb & 0xFF_FFFF); + (text("item.color").replacen("%s", &hex, 1), false) + } else { + (text("item.dyed"), true) + }) +} + +/// `ItemStack.UNBREAKABLE_TOOLTIP`. +pub fn unbreakable_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + (stack.data()?.other("minecraft:unbreakable").is_some() + && display.shows("minecraft:unbreakable")) + .then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into())) +} + +/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When +/// Applied:" and the modifiers of the potion's effects (blue for a gain, red +/// for a loss); nothing for a potion whose effects add none. +pub fn potion_modifier_lines( + stack: &ItemStack, + translate: &dyn Fn(&str) -> Option, +) -> Vec<(String, u32)> { + if !is_potion_item(&stack.item) { + return Vec::new(); + } + let applied: Vec<(String, u32)> = stack + .potion() + .and_then(crate::brewing::potion) + .into_iter() + .flat_map(|p| p.effects.iter()) + .filter_map(|(effect, _, amplifier)| { + let (attribute, amount, operation) = effect_modifier(effect)?; + let amount = amount * (*amplifier + 1) as f64; + let line = modifier_line(translate, attribute, amount, operation, false)?; + let color = if amount > 0.0 { + rgba(85, 85, 255, 255) + } else { + rgba(255, 85, 85, 255) + }; + Some((line, color)) + }) + .collect(); + if applied.is_empty() { + return applied; + } + let mut lines = vec![ + (String::new(), u32::MAX), + ( + translate("potion.whenDrank").unwrap_or_else(|| "potion.whenDrank".into()), + rgba(170, 0, 170, 255), + ), + ]; + lines.extend(applied); + lines +} + +/// `ItemStack.components.size()`: the item's default components with the +/// stack's own changes applied. +pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize { + let mut keys: std::collections::HashSet = info + .map(|i| i.components.iter().cloned().collect()) + .unwrap_or_default(); + if let Some(Tag::Compound(patch)) = crate::item_data::components_tag(stack.damage, stack.data()) + { + for key in patch.keys() { + let key = key.to_string_lossy(); + match key.strip_prefix('!') { + Some(removed) => keys.remove(removed), + None => keys.insert(key), + }; + } + } + keys.len() +} + /// The lines of `stack` below its name that its components give, in /// `ItemStack.addDetailsToTooltip` order. pub fn detail_lines( @@ -265,19 +518,9 @@ pub fn detail_lines( )) )); } - // BeehiveBlock: the block state components of the item. - if path == "beehive" { - lines.push( - text("container.beehive.bees") - .replacen("%s", "0", 1) - .replacen("%s", "3", 1), - ); - lines.push( - text("container.beehive.honey") - .replacen("%s", "0", 1) - .replacen("%s", "5", 1), - ); - } + let display = TooltipDisplay::for_stack(stack); + lines.extend(bees_line(stack, &display, translate)); + lines.extend(honey_line(stack, &display, translate)); let data = stack.data(); // PotDecorations.addToTooltip. if let Some(sherds) = data @@ -303,32 +546,13 @@ pub fn detail_lines( .into_iter() .map(|l| l.0), ); - let applied: Vec = stack - .potion() - .and_then(crate::brewing::potion) - .into_iter() - .flat_map(|p| p.effects.iter()) - .filter_map(|(effect, _, amplifier)| { - let (attribute, amount, operation) = effect_modifier(effect)?; - modifier_line( - translate, - attribute, - amount * (*amplifier + 1) as f64, - operation, - false, - ) - }) - .collect(); - if !applied.is_empty() { - lines.push(String::new()); - lines.push(text("potion.whenDrank")); - lines.extend(applied); - } - } - // JukeboxPlayable.addToTooltip: the song's description. - if path.starts_with("music_disc_") { - lines.push(text(&format!("item.minecraft.{path}.desc"))); + lines.extend( + potion_modifier_lines(stack, translate) + .into_iter() + .map(|l| l.0), + ); } + lines.extend(jukebox_line(stack, info, &display, translate)); // Trim.addToTooltip. if let Some((material, pattern)) = data.and_then(|d| d.trim.as_ref()) { let short = |s: &str| s.trim_start_matches("minecraft:").to_owned(); @@ -342,10 +566,7 @@ pub fn detail_lines( text(&format!("trim_material.minecraft.{}", short(material))) )); } - // DyedItemColor.addToTooltip (without advanced tooltips). - if data.is_some_and(|d| d.dyed_color.is_some()) { - lines.push(text("item.dyed")); - } + lines.extend(dyed_line(stack, &display, false, translate).map(|l| l.0)); if let Some(info) = info { lines.extend( attribute_lines(&info.modifiers, None, translate) diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index b4092a201..1749f8243 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -128,6 +128,8 @@ pub struct ItemInfo { pub block_transformer: Option, /// Has a default `bundle_contents` (the bundles, `BundleItem`). pub bundle: bool, + /// The ids of the item's default components (its prototype's keys). + pub components: Vec, } /// `Item.Properties.craftRemainder` (not a data component, so not in the /// default-component report): what an item leaves in the grid when crafted with. @@ -614,6 +616,10 @@ impl Items { cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned), block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned), bundle: c.get("minecraft:bundle_contents").is_some(), + components: c + .as_object() + .map(|c| c.keys().cloned().collect()) + .unwrap_or_default(), crafting_remainder: CRAFT_REMAINDERS .iter() .find(|(item, _)| *item == name) From 85db9a23a942a5110d099771535edc7818467753 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:57:54 +0100 Subject: [PATCH 216/475] Use the small explosion particle when an explosion keeps blocks --- crates/horizon-client/src/game/explosion.rs | 18 +++++++++--------- .../horizon-client/src/game/lan_particles.rs | 10 ++++++++-- crates/horizon-client/src/particles.rs | 9 +++++---- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 8800e29d8..5d6b4eb4f 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -573,16 +573,16 @@ impl Game<'_> { let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7; self.audio .play("minecraft:entity.generic.explode", Some(center), 4.0, pitch); - let block_count = if interaction == BlockInteraction::Keep { - 0 - } else { - targets.len() - }; + // ServerLevel.explode sends toBlow.size() whatever the interaction, and + // `ServerExplosion.isSmall` also covers an explosion that keeps blocks. + let block_count = targets.len(); + let small = radius < 2.0 || interaction == BlockInteraction::Keep; let (world, facts) = (&self.world, self.res.facts()); - self.particles.explosion(center, radius, block_count, &|b| { - facts.is_air(world.block(b[0], b[1], b[2])) - }); - self.lan_explode(center, radius, block_count); + self.particles + .explosion(center, small, block_count, radius, &|b| { + facts.is_air(world.block(b[0], b[1], b[2])) + }); + self.lan_explode(center, small, radius, block_count); Ok(()) } /// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse). diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index c41fdf1ad..0095da624 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -225,8 +225,14 @@ impl Game<'_> { /// `ServerLevel.explode`'s packet to each player within 64 blocks. The /// sound and the push reach them on their own (the level's sounds, the /// motion a hurt player is sent), so the packet carries neither. - pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) { - let emitter = if radius < 2.0 { + pub(super) fn lan_explode( + &self, + center: [f64; 3], + small: bool, + radius: f32, + block_count: usize, + ) { + let emitter = if small { "minecraft:explosion" } else { "minecraft:explosion_emitter" diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index fa9dc9c6e..2e28603c4 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -1381,16 +1381,17 @@ impl ParticleEngine { } } /// `ClientPacketListener.handleExplosion` + `ClientExplosionTracker`: the - /// explosion particle (emitter for radius >= 2) and up to 512 poof/smoke - /// particles thrown from air inside the radius. + /// explosion particle (the emitter unless `ServerExplosion.isSmall`) and up + /// to 512 poof/smoke particles thrown from air inside the radius. pub fn explosion( &mut self, center: [f64; 3], - radius: f32, + small: bool, block_count: usize, + radius: f32, is_air: &dyn Fn([i32; 3]) -> bool, ) { - let ty = if radius < 2.0 { + let ty = if small { ParticleType::Explosion } else { ParticleType::ExplosionEmitter From 4efa49ae820f957164aa5ad05eaaab18e8403f91 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:58:22 +0100 Subject: [PATCH 217/475] Raise sprint dust under running players A sprinting player kicks up dust of the block under their feet every tick (Entity.spawnSprintParticle). Horizon drew none, for the window's player or for the other players it sees. --- .../horizon-client/src/game/client_ambient.rs | 91 +++++++++++++++++++ crates/horizon-client/src/game/lan.rs | 1 + crates/horizon-client/src/game/remote.rs | 7 +- crates/horizon-client/src/mobs.rs | 2 + 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/client_ambient.rs b/crates/horizon-client/src/game/client_ambient.rs index a10c6da49..b655aff3f 100644 --- a/crates/horizon-client/src/game/client_ambient.rs +++ b/crates/horizon-client/src/game/client_ambient.rs @@ -4,8 +4,12 @@ //! on the game that has the window, in-frame or following a server, never on //! a server: nothing of them goes over the network. use super::Game; +use horizon_client::blocks::RenderShape; use horizon_client::mobs::{MobEvent, ambient}; +use horizon_client::motion::{LAVA, WATER}; use horizon_client::particles::{ParticleOptions, ParticleType}; +use horizon_client::pose::Pose; +use horizon_core::{geometry::Vec3, random::RandomSource}; impl Game<'_> { pub(super) fn tick_client_ambient(&mut self) { @@ -33,5 +37,92 @@ impl Game<'_> { } } self.eye_particles(); + self.sprint_dust(); + } + + /// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the + /// block under a sprinting player's feet streams back from them every + /// tick, for the window's own player and for the other players it sees. + fn sprint_dust(&mut self) { + // Each runner's feet, movement and supporting block. + let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new(); + // Entity.canSpawnSprintParticle. + if let Some(p) = self.player.as_ref() + && p.sprinting + && !p.spectator + && p.pose != Pose::Crouching + && !p.health.dead_or_dying() + && !p.fluids.was_touching_water + && !p.is_in_lava() + && p.fluid_height(WATER) <= 0.0 + && p.fluid_height(LAVA) <= 0.0 + { + let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]); + runners.push((p.position, p.velocity, support)); + } + let guests = self.lan.iter().flat_map(|host| host.models.values()); + for m in self.mobs.iter().chain(guests) { + // Another player's fluids aren't known here: none where they stand. + let feet = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + if m.extra.sprinting + && !m.extra.crouching + && m.alive() + && &*m.kind.id == "minecraft:player" + && self + .res + .facts() + .get(self.world.block(feet[0], feet[1], feet[2])) + .fluid_level + == 0 + { + runners.push((m.position, m.position.subtract(m.previous), None)); + } + } + for (position, movement, support) in runners { + // Entity.getOnPosLegacy: getOnPos(0.2). + let below = (position.y - f64::from(0.2f32)).floor() as i32; + let tall = |game: &Self, s: [i32; 3]| { + let name = game.block_name(s); + [ + "minecraft:fences", + "minecraft:walls", + "minecraft:fence_gates", + ] + .iter() + .any(|tag| game.block_in_tag(tag, &name)) + }; + let at = match support { + Some(s) if tall(self, s) => s, + Some(s) => [s[0], below, s[2]], + None => [position.x.floor() as i32, below, position.z.floor() as i32], + }; + let state = self.world.block(at[0], at[1], at[2]); + if self.res.facts().get(state).render_shape == RenderShape::Invisible { + continue; + } + let Some((sprite, tint)) = self.particle_sprite(state, at) else { + continue; + }; + let width = f64::from(0.6f32); + let r = self.particles.random(); + let mut x = position.x + (r.next_double() - 0.5) * width; + let mut z = position.z + (r.next_double() - 0.5) * width; + // Clamped into the block when the feet are in another column. + if position.x.floor() as i32 != at[0] { + x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0); + } + if position.z.floor() as i32 != at[2] { + z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0); + } + self.particles.add( + &ParticleOptions::block(ParticleType::Block, sprite, tint), + [x, position.y + 0.1, z], + [movement.x * -4.0, 1.5, movement.z * -4.0], + ); + } } } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..0ee6c961d 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1409,6 +1409,7 @@ impl Game<'_> { .each_ref() .map(|s| s.as_ref().map(|s| s.item.clone())); m.extra.crouching = g.sneaking; + m.extra.sprinting = g.sprinting; // Lying in its bed (LivingEntity.getBedOrientation). m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b)); // LivingEntity.updateSwingTime: the arm swing lan_swings started. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..23dc6c2f8 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1973,9 +1973,9 @@ impl Game<'_> { } } } - // Entity.DATA_SHARED_FLAGS_ID's crouching bit: another - // player's or a mannequin's crouch (their model and - // Entity.isDiscrete's name tag). + // Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits: + // another player's or a mannequin's crouch (their model and + // Entity.isDiscrete's name tag), and the dust a runner raises. if (mannequin || self .remote @@ -1986,6 +1986,7 @@ impl Game<'_> { for (index, _, value) in &p.values { if let (0, Value::Byte(flags)) = (index, value) { m.extra.crouching = flags & 2 != 0; + m.extra.sprinting = flags & 8 != 0; } } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 6b1e16f5c..ffeb145eb 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -636,6 +636,8 @@ pub struct Attrs { pub struct Extra { /// Players: crouching and riding (HumanoidModel's poses). pub crouching: bool, + /// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise. + pub sprinting: bool, pub passenger: bool, /// AgeableMob.age: negative while a baby (a tadpole counts up to 24000). pub age: i32, From d241c54e059bbb9274f8e2c1b2a1db6e3f62e815 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:58:45 +0100 Subject: [PATCH 218/475] Official mod: Recipes, a recipe viewer Every item sits in the item panel beside the inventory and container screens, with a search box (words in the name or id, @namespace, #tag). R or a click shows an item's recipes, U or a right click its uses, with a tab per station; Back returns through the pages. + beside a crafting recipe the player knows fills the grid as the recipe book does. In creative mode, with "Click gives items" on, a click gives one item and a right click a stack. The keys are rebindable in Controls. Written from vanilla 26.3's recipe book and screens and Horizon's design system only. On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 + Cargo.toml | 2 +- docs/mods.md | 5 +- mods/official/recipes/Cargo.toml | 16 ++ mods/official/recipes/horizon-mod.toml | 9 + mods/official/recipes/src/lib.rs | 364 +++++++++++++++++++++++++ 6 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 mods/official/recipes/Cargo.toml create mode 100644 mods/official/recipes/horizon-mod.toml create mode 100644 mods/official/recipes/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0e93bba0e..4d48c48cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2962,6 +2962,13 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "recipes" +version = "1.0.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "redox_syscall" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index e4628f962..f6ff38ad8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/recipes", "mods/official/zoom", "xtask"] resolver = "2" [workspace.package] diff --git a/docs/mods.md b/docs/mods.md index 706e48bd6..4c7ba012a 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks | | SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 | | Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands | -| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap | +| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; and Recipes, a recipe viewer | | Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model | | Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) | | In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods | @@ -521,7 +521,7 @@ says how). The formats: [api/mod-index.md](api/mod-index.md). ### Horizon's official mods -The Horizon extras that are mods now live in `mods/official//`, one crate each in the game's workspace, so +The Horizon extras that are mods now, and Horizon's own recipe viewer, live in `mods/official//`, one crate each in the game's workspace, so `cargo clippy --workspace` and pr_ready cover them. They use only the public API, like any other mod, with the same options on their settings page as the extra had and the same look. Each is published by a maintainer: build it on `main` with the toolchain CI uses (`cd mods/official/ && cargo horizon-mod build`), then open a pull request in @@ -535,6 +535,7 @@ so the same rustc and `Cargo.lock` give the same file. | Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` | | Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` | | Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) | +| Recipes | `recipes` | nothing (new) | `give` (a click on the item panel gives the item in creative mode: one, a stack with a right click): `false` (default), `true`. The keys are the game's: `key.recipes.recipe` (default `key.keyboard.r`) and `key.recipes.uses` (default `key.keyboard.u`) in `horizon-mods.txt`. Every item sits in the item panel (`browser`) with a search box (words in the name or id, `@namespace`, `#tag`); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (`recipes.place`). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book | ## Versions and compatibility diff --git a/mods/official/recipes/Cargo.toml b/mods/official/recipes/Cargo.toml new file mode 100644 index 000000000..5bf0f5d9c --- /dev/null +++ b/mods/official/recipes/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "recipes" +version = "1.0.0" +edition.workspace = true +license.workspace = true +publish = false +description = "Horizon's official recipe viewer: every item beside the inventory, its recipes and uses." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" } + +[lints] +workspace = true diff --git a/mods/official/recipes/horizon-mod.toml b/mods/official/recipes/horizon-mod.toml new file mode 100644 index 000000000..f4c04af0d --- /dev/null +++ b/mods/official/recipes/horizon-mod.toml @@ -0,0 +1,9 @@ +id = "recipes" +name = "Recipes" +version = "1.0.0" +description = "Every item beside your inventory, with a search box. R over an item shows its recipes, U its uses (both rebindable in Controls), and + fills the crafting grid with a recipe you know. In creative mode a click can give you the item (Mod settings). On servers that are not Horizon, it shows this game's own 26.3 recipes; what you know and what + can fill follow the server's recipe book." +authors = ["Horizon"] +api = "0.1" +side = "client" +capabilities = ["recipes", "ui", "input", "game-read", "chat", "storage"] +experimental = ["recipes", "ui", "input"] diff --git a/mods/official/recipes/src/lib.rs b/mods/official/recipes/src/lib.rs new file mode 100644 index 000000000..42f856016 --- /dev/null +++ b/mods/official/recipes/src/lib.rs @@ -0,0 +1,364 @@ +//! Horizon's official recipe viewer. Every item sits in the item panel the +//! game draws beside the inventory and container screens (`browser`), with +//! a search box. R over an item, the panel's or any slot's, shows its +//! recipes, U its uses (`input.register-key`, so both can be rebound in +//! Controls); a click does the same, left for recipes and right for uses. +//! A recipe page has a tab per station, and + beside a crafting recipe the +//! player knows fills the grid as the recipe book does (`recipes.place`). +//! In creative mode, with "Click gives items" on, a click on a panel item +//! gives one, a right click a stack (`recipes.give`). +//! +//! On a server that is not Horizon the recipes are the game's own 26.3 +//! ones; `known` and `place` follow the server's recipe book. +use horizon_mod_sdk::{ + Mod, Registration, browser, chat, game, input, recipes, + recipes::Kind, + storage, + ui::{self, ButtonWidget, Widget}, +}; + +const RECIPE_KEY: &str = "recipe"; +const USES_KEY: &str = "uses"; +const GIVE_BUTTON: u32 = 1; +/// What `set-recipes` takes at most. +const MAX_RECIPES: usize = 256; +const MAX_STACKS: usize = 64; +/// The stations, in the order of their tabs, with each one's icon. +const STATIONS: [&str; 8] = [ + "minecraft:crafting_table", + "minecraft:furnace", + "minecraft:blast_furnace", + "minecraft:smoker", + "minecraft:campfire", + "minecraft:stonecutter", + "minecraft:smithing_table", + "minecraft:brewing_stand", +]; + +fn station(kind: Kind) -> usize { + match kind { + Kind::CraftingShaped | Kind::CraftingShapeless => 0, + Kind::Smelting => 1, + Kind::Blasting => 2, + Kind::Smoking => 3, + Kind::Campfire => 4, + Kind::Stonecutting => 5, + Kind::Smithing => 6, + Kind::Brewing => 7, + } +} + +fn stack(item: &str) -> browser::Stack { + browser::Stack { + item: item.into(), + count: 1, + components: String::new(), + } +} + +/// A recipe page: an item's recipes or its uses, on one station's tab. +#[derive(Clone)] +struct View { + item: browser::Stack, + uses: bool, + tab: usize, +} + +#[derive(Default)] +struct Recipes { + items: Vec, + all: Vec, + /// The panel's items now: indexes into `items`, after the search. + listed: Vec, + search: String, + /// The page shown, then the ones Back returns to. + history: Vec, + /// The book id behind each recipe the page shows (-1: none). + shown: Vec, + give: bool, +} + +impl Recipes { + fn id(&self, s: &recipes::Stack) -> &str { + self.items + .get(s.item as usize) + .map_or("minecraft:air", |i| &i.id) + } + fn convert(&self, s: &recipes::Stack) -> browser::Stack { + browser::Stack { + item: self.id(s).into(), + count: s.count, + components: s.components.clone(), + } + } + /// The same item, and the same components when `want` names some. + fn matches(&self, s: &recipes::Stack, want: &browser::Stack) -> bool { + self.id(s) == want.item && (want.components.is_empty() || s.components == want.components) + } + fn name<'a>(&'a self, item: &'a str) -> &'a str { + self.items + .iter() + .find(|i| i.id == item) + .map_or(item, |i| &i.name) + } + + /// Loads the tables once a world is open, and drops them when it closes. + fn load(&mut self) { + let in_world = game::get_player().is_some(); + if in_world && self.items.is_empty() { + self.items = recipes::items(); + self.all = recipes::all(); + self.list(); + } else if !in_world && !self.items.is_empty() { + browser::set_recipes("", &[], 0, &[]); + browser::set_items(&[]); + *self = Recipes { + give: self.give, + ..Recipes::default() + }; + } + } + + /// Fills the panel with the items the search keeps: every word must be + /// in the name or the id; `@minecraft` keeps a namespace, `#logs` a tag. + fn list(&mut self) { + let words: Vec = self + .search + .to_lowercase() + .split_whitespace() + .map(String::from) + .collect(); + let keeps = |item: &recipes::Item| { + words.iter().all(|w| { + if let Some(ns) = w.strip_prefix('@') { + item.id.split(':').next().is_some_and(|n| n.starts_with(ns)) + } else if let Some(tag) = w.strip_prefix('#') { + item.tags.iter().any(|t| t.contains(tag)) + } else { + item.name.to_lowercase().contains(w.as_str()) || item.id.contains(w.as_str()) + } + }) + }; + self.listed = (self.items.iter().enumerate()) + .filter(|(_, i)| i.id != "minecraft:air" && keeps(i)) + .map(|(n, _)| n) + .collect(); + let stacks: Vec<_> = self + .listed + .iter() + .map(|&n| stack(&self.items[n].id)) + .collect(); + browser::set_items(&stacks); + } + + /// The recipes a view lists, all stations together. + fn found(&self, view: &View) -> Vec<&recipes::Recipe> { + (self.all.iter()) + .filter(|r| { + if view.uses { + r.slots + .iter() + .flatten() + .any(|s| self.matches(s, &view.item)) + } else { + r.results.iter().any(|s| self.matches(s, &view.item)) + } + }) + .collect() + } + + /// Opens a page, pushing it on the history; nothing when it is empty. + fn open(&mut self, item: browser::Stack, uses: bool) { + let view = View { item, uses, tab: 0 }; + if self.found(&view).is_empty() { + return; + } + self.history.push(view); + self.show(); + } + + /// Sends the page on top of the history, or the items when there is none. + fn show(&mut self) { + let Some(mut view) = self.history.last().cloned() else { + self.shown.clear(); + browser::set_recipes("", &[], 0, &[]); + return; + }; + let found = self.found(&view); + let stations: Vec = (0..STATIONS.len()) + .filter(|&s| found.iter().any(|r| station(r.kind) == s)) + .collect(); + view.tab = view.tab.min(stations.len().saturating_sub(1)); + let on: Vec<&recipes::Recipe> = (found.iter().copied()) + .filter(|r| stations.get(view.tab) == Some(&station(r.kind))) + .take(MAX_RECIPES) + .collect(); + let known = recipes::known(); + let views: Vec = on.iter().map(|r| self.view(r, &known)).collect(); + let mut title = format!( + "{}: {}", + if view.uses { "Uses" } else { "Recipes" }, + self.name(&view.item.item) + ); + let total = found + .iter() + .filter(|r| stations.get(view.tab) == Some(&station(r.kind))) + .count(); + if total > on.len() { + title.push_str(&format!(" ({} of {total})", on.len())); + } + let tabs: Vec<_> = stations.iter().map(|&s| stack(STATIONS[s])).collect(); + browser::set_recipes(&title, &tabs, view.tab as u32, &views); + self.shown = on.iter().map(|r| r.book_id).collect(); + if let Some(top) = self.history.last_mut() { + top.tab = view.tab; + } + } + + fn view(&self, r: &recipes::Recipe, known: &[i32]) -> browser::RecipeView { + let mut slots: Vec> = (r.slots.iter()) + .map(|s| s.iter().take(MAX_STACKS).map(|s| self.convert(s)).collect()) + .collect(); + let results = r + .results + .iter() + .take(MAX_STACKS) + .map(|s| self.convert(s)) + .collect(); + let (layout, width, height, note) = match r.kind { + Kind::CraftingShaped => (browser::Layout::Grid, r.width, r.height, String::new()), + Kind::CraftingShapeless => { + let n = slots.len() as u32; + let width = match n { + 0 | 1 => 1, + 2..=4 => 2, + _ => 3, + }; + let height = n.div_ceil(width).max(1); + slots.resize((width * height) as usize, Vec::new()); + (browser::Layout::Grid, width, height, "Shapeless".into()) + } + Kind::Smelting | Kind::Blasting | Kind::Smoking | Kind::Campfire => { + let seconds = r.cooking_time as f32 / 20.0; + let note = format!("{seconds} s, {} XP", (r.experience * 100.0).round() / 100.0); + (browser::Layout::Cooking, 1, 1, note) + } + Kind::Stonecutting => (browser::Layout::Single, 1, 1, String::new()), + Kind::Smithing => (browser::Layout::Smithing, 3, 1, String::new()), + Kind::Brewing => (browser::Layout::Brewing, 2, 1, String::new()), + }; + // + only for what the recipe book shows; greyed until the book has it. + let action = (r.book_id >= 0).then(|| known.contains(&r.book_id)); + browser::RecipeView { + layout, + width, + height, + slots, + results, + note, + action, + } + } + + fn handle(&mut self, event: browser::Event) { + match event { + browser::Event::Search(text) => { + self.search = text; + self.list(); + } + browser::Event::Clicked(c) => { + let Some(&n) = self.listed.get(c.index as usize) else { + return; + }; + let item = self.items[n].id.clone(); + if self.give { + let count = if c.right { 99 } else { 1 }; + let gift = recipes::Stack { + item: n as u32, + count, + components: String::new(), + }; + if recipes::give(&gift).is_ok() { + return; + } + } + self.open(stack(&item), c.right); + } + browser::Event::Key(k) if k.key == RECIPE_KEY || k.key == USES_KEY => { + let item = browser::Stack { + count: 1, + ..k.stack + }; + self.open(item, k.key == USES_KEY); + } + browser::Event::SlotClicked(c) => { + let item = browser::Stack { + count: 1, + ..c.stack + }; + self.open(item, c.right); + } + browser::Event::Tab(i) => { + if let Some(top) = self.history.last_mut() { + top.tab = i as usize; + } + self.show(); + } + browser::Event::Action(i) => { + let book = self.shown.get(i as usize).copied(); + if let Some(Err(e)) = book.map(|b| recipes::place(b, false)) { + chat::show(&e); + } + } + browser::Event::Back => { + self.history.pop(); + self.show(); + } + _ => {} + } + } + + fn page(&self) { + ui::set_settings(&[Widget::Button(ButtonWidget { + id: GIVE_BUTTON, + label: format!( + "Click gives items (creative): {}", + if self.give { "ON" } else { "OFF" } + ), + })]); + } +} + +impl Mod for Recipes { + fn init() -> Result<(Self, Registration), String> { + input::register_key(RECIPE_KEY, "Show recipes", "key.keyboard.r")?; + input::register_key(USES_KEY, "Show uses", "key.keyboard.u")?; + let give = storage::get("give").is_some_and(|v| v == b"true"); + let me = Recipes { + give, + ..Recipes::default() + }; + me.page(); + Ok((me, Registration::new().on_frame())) + } + fn frame(&mut self, _seconds: f32) { + self.load(); + // The keys matter only over an item, which `browser` reports. + let _ = input::poll(); + for event in browser::poll() { + self.handle(event); + } + } + fn ui_select(&mut self, id: u32, _row: u32) { + if id == GIVE_BUTTON { + self.give = !self.give; + let _ = storage::set("give", if self.give { b"true" } else { b"false" }); + self.page(); + } + } + fn ui_settings_opened(&mut self) { + self.page(); + } +} + +horizon_mod_sdk::export_mod!(Recipes); From 29eae3a1afc1f725a69a43cc8da3d44c878c43ff Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:58:51 +0100 Subject: [PATCH 219/475] Throw a burst of spit particles when a llama spits --- crates/horizon-client/src/game/creatures.rs | 5 +++++ crates/horizon-client/src/game/remote_projectiles.rs | 4 ++++ crates/horizon-client/src/particles.rs | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 86a7954e9..7343064e5 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1013,6 +1013,11 @@ impl Game<'_> { self.next_entity += 1; p.id = self.next_entity; let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]); + // LlamaSpit.recreateFromPacket: the spit's burst on the client. + if p.kind == ProjectileKind::LlamaSpit { + let v = p.velocity; + self.particles.llama_spit_burst(at, [v.x, v.y, v.z]); + } self.creatures.projectiles.push(p); self.projectile_event("minecraft:projectile_shoot", owner, at); } diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 897999c6b..64f09d72a 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -161,6 +161,10 @@ impl Game<'_> { let Some(k) = projectile_kind(kind) else { return false; }; + if k == ProjectileKind::LlamaSpit { + self.particles + .llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]); + } let mut p = Projectile::new(k, at, None); p.id = id; p.velocity = velocity; diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index fa9dc9c6e..fd05a1ac1 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -1398,6 +1398,18 @@ impl ParticleEngine { self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]); self.explosion_blocks(center, radius, block_count, is_air); } + /// `LlamaSpit.recreateFromPacket`: seven spit particles thrown ahead of a + /// new spit, each faster than the last along x and z. + pub fn llama_spit_burst(&mut self, at: [f64; 3], movement: [f64; 3]) { + for i in 0..7 { + let k = 0.4 + 0.1 * f64::from(i); + self.add( + &ParticleOptions::simple(ParticleType::Spit), + at, + [movement[0] * k, movement[1], movement[2] * k], + ); + } + } /// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`. pub fn explosion_blocks( &mut self, From 96b538d48f6e8e9da03faa04a8f9b8c85f1cf281 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:59:59 +0100 Subject: [PATCH 220/475] Throw up landing dust where a hard fall lands A landing that hurts sends the block's crumbs from the server (LivingEntity.checkFallDamage). Horizon built the event but dropped it, for the window's player and for every server player. --- crates/horizon-client/src/game.rs | 7 ++ crates/horizon-client/src/game/lan_actions.rs | 7 ++ .../horizon-client/src/game/lan_particles.rs | 79 +++++++++++++++++-- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a51bdcbcb..5b69799fc 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4578,6 +4578,13 @@ impl Game<'_> { }; self.audio.play(name, Some(feet), volume, pitch); } + // LivingEntity.checkFallDamage: the block's crumbs where the fall landed. + MotionEvent::Fall(horizon_client::landing::FallEvent::Particles { + state, + position, + count, + speed, + }) => self.landing_dust(None, state, position, count, speed), MotionEvent::Fall(event) => self.landing_block_updates(&event), MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity), MotionEvent::EnterBlock { state } => self.trigger_enter_block(state), diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 43bb573dc..31bb454cb 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2310,6 +2310,13 @@ impl Game<'_> { for event in events { match event { MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at), + // LivingEntity.checkFallDamage: the crumbs reach every player. + MotionEvent::Fall(horizon_client::landing::FallEvent::Particles { + state, + position, + count, + speed, + }) => self.landing_dust(Some(host), state, position, count, speed), MotionEvent::Fall(event) => self.landing_block_updates(&event), _ => {} } diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index c41fdf1ad..888264bcc 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -1,7 +1,8 @@ //! Particles a server sends its players and its clients draw: //! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which //! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`). -use super::Game; +use super::{Game, lan::LanHost}; +use horizon_client::lan::hub::Hub; use horizon_client::particles::{ParticleOptions, ParticleType}; use horizon_core::{geometry::Vec3, random::RandomSource}; use horizon_protocol::{ @@ -52,6 +53,66 @@ impl Game<'_> { } } + /// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the + /// landing block's crumbs: drawn in this window, when it has one, and + /// sent to each player within 32 blocks. `host` is the hub of a guest's + /// turn, which is out of `self.lan` meanwhile. + pub(super) fn landing_dust( + &mut self, + host: Option<&LanHost>, + state: BlockStateId, + at: Vec3, + count: i32, + speed: f64, + ) { + let at = [at.x, at.y, at.z]; + if self.serving.is_none() && self.options.dedicated.is_none() { + let block = [ + at[0].floor() as i32, + (at[1] - 0.2).floor() as i32, + at[2].floor() as i32, + ]; + if let Some((sprite, tint)) = self.particle_sprite(state, block) { + self.particles.send_particles( + &ParticleOptions::block(ParticleType::Block, sprite, tint), + at, + [0.0; 3], + speed, + count as u32, + ); + } + } + let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else { + return; + }; + let Some(particle) = Self::hub_particle( + hub, + &ParticleOptions::simple(ParticleType::Block), + Some(state), + ) else { + return; + }; + let packet = C::LevelParticles(play::LevelParticles { + particle, + override_limiter: false, + always_show: false, + pos: at, + dist: [0.0; 3], + max_speed: [speed as f32; 3], + count, + randomization: 0, + }); + for g in hub.guests.iter().filter(|g| hub.here(g)) { + // ServerLevel compares the player's block centre, strictly within 32. + let distance: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if distance < 32.0 * 32.0 { + g.send(&packet); + } + } + } + /// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's /// network id and the data it carries (`state` for the block particles). /// None while not serving, or for a particle whose data isn't sent from @@ -60,13 +121,19 @@ impl Game<'_> { &self, options: &ParticleOptions, state: Option, + ) -> Option { + Self::hub_particle(&self.lan.as_ref()?.hub, options, state) + } + + /// `network_particle` for a hub that is out of `self.lan` meanwhile (a + /// guest's turn). + fn hub_particle( + hub: &Hub, + options: &ParticleOptions, + state: Option, ) -> Option { let name = format!("minecraft:{}", options.ty.name()); - let id = self - .lan - .as_ref()? - .hub - .registry_id("minecraft:particle_type", &name)?; + let id = hub.registry_id("minecraft:particle_type", &name)?; use ParticleType as T; let c = options.color; let data = match options.ty { From a08f3eb2fa74ccff9d3565b8c0cab520a55276d8 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:02:36 +0100 Subject: [PATCH 221/475] Select a bundle's item with the mouse wheel --- crates/horizon-client/src/bundle.rs | 56 ++++++++-- crates/horizon-client/src/game.rs | 12 ++ crates/horizon-client/src/game/bundles.rs | 103 ++++++++++++++++++ crates/horizon-client/src/game/lan_actions.rs | 6 + crates/horizon-client/src/game/remote.rs | 4 +- crates/horizon-client/src/game/stations.rs | 22 +++- crates/horizon-client/src/item_data.rs | 17 +++ crates/horizon-client/src/item_render.rs | 1 + crates/horizon-client/src/lan/containers.rs | 36 ++++++ crates/horizon-client/src/lan/menu.rs | 5 +- crates/horizon-client/src/menu/bundle.rs | 7 +- crates/horizon-protocol/src/packets/play.rs | 6 + 12 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 crates/horizon-client/src/game/bundles.rs diff --git a/crates/horizon-client/src/bundle.rs b/crates/horizon-client/src/bundle.rs index 42473729f..948e2f4a9 100644 --- a/crates/horizon-client/src/bundle.rs +++ b/crates/horizon-client/src/bundle.rs @@ -1,6 +1,7 @@ //! `BundleContents.Mutable`: what a bundle takes in and gives back, by //! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click //! overrides on it. +use crate::item_data::BundleSelected; use crate::items::{ItemStack, Items}; use horizon_world::nbt::Tag; @@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool { pub struct Contents { items: Vec, weight: Weight, + /// `selectedItem`: an index into `items`, -1 for none. + selected: i32, } impl Contents { @@ -104,9 +107,11 @@ impl Contents { pub fn of(items: &Items, stack: &ItemStack) -> Option { let inside = contents_of(items, stack)?.to_vec(); let weight = contents_weight(items, &inside); + let selected = stack.data().map_or(-1, |d| d.bundle_selected.0); Some(Self { items: inside, weight, + selected, }) } @@ -154,18 +159,26 @@ impl Contents { amount } - /// `removeOne`: the first stack (Horizon keeps no selected item). + /// `removeOne`: the selected stack, else the first; nothing is selected after. pub fn remove_one(&mut self, items: &Items) -> Option { if self.items.is_empty() { return None; } - let stack = self.items.remove(0); + let at = + usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 }); + let stack = self.items.remove(at); self.weight = self .weight .sub(item_weight(items, &stack).times(stack.count)); + self.selected = -1; Some(stack) } + /// `getSelectedItemIndex`. + pub fn selected(&self) -> i32 { + self.selected + } + /// The stacks inside, in display order (the first is the next out). pub fn items(&self) -> &[ItemStack] { &self.items @@ -200,11 +213,28 @@ impl Contents { /// `toImmutable`, written back into the bundle's component. pub fn store(self, bundle: &mut ItemStack) { - bundle.data_mut().bundle_contents = self.items; + let data = bundle.data_mut(); + data.bundle_contents = self.items; + data.bundle_selected = BundleSelected(self.selected); bundle.tidy(); } } +/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the +/// selected one already or outside the contents. +pub fn toggle_selected(bundle: &mut ItemStack, index: i32) { + let Some(data) = bundle.extra.as_deref_mut() else { + return; + }; + let inside = (0..data.bundle_contents.len() as i32).contains(&index); + let selected = if data.bundle_selected.0 != index && inside { + index + } else { + -1 + }; + data.bundle_selected = BundleSelected(selected); +} + #[cfg(test)] mod tests { use super::Weight; @@ -232,8 +262,10 @@ pub struct TooltipLayout { /// The component's own width (96) and height, the text's excluded. pub width: f32, pub height: f32, - /// Each slot's top-left and the stack in it, in reading order. + /// Each slot's top-left and the stack in it, in drawing order. pub slots: Vec<([f32; 2], ItemStack)>, + /// The position in `slots` of the selected item, which is drawn highlighted. + pub selected: Option, /// The `+N` of the items past the grid: its top-left and the count. pub overflow: Option<([f32; 2], u32)>, /// The progress bar's top-left, its fill's width out of 94, and whether @@ -266,6 +298,7 @@ pub fn tooltip_layout( width: 96.0, height: description_height + 13.0 + 8.0, slots: Vec::new(), + selected: None, overflow: None, bar: [x + offset, y + description_height + 4.0], fill: 0.0, @@ -282,6 +315,7 @@ pub fn tooltip_layout( let x_start = x + offset + 96.0; let y_start = y + grid_height; let mut slots = Vec::with_capacity(shown.len()); + let mut selected = None; let mut overflow = None; // Rows fill bottom-up and columns right-to-left, as vanilla draws them. let mut slot = 0; @@ -290,8 +324,13 @@ pub fn tooltip_layout( let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0]; if hidden > 0 && row == 1 && column == 1 { overflow = Some((at, hidden)); - } else if let Some(stack) = shown.get(slot) { - slots.push((at, stack.clone())); + } else if slot < shown.len() { + // `extractSlot`: the cell of slot number n holds item `size - n`. + let index = shown.len() - 1 - slot; + if usize::try_from(contents.selected()) == Ok(index) { + selected = Some(slots.len()); + } + slots.push((at, shown[index].clone())); slot += 1; } } @@ -301,6 +340,7 @@ pub fn tooltip_layout( width: 96.0, height: grid_height + 13.0 + 8.0, slots, + selected, overflow, bar: [x + offset, y + grid_height + 4.0], // `Mth.mulAndTruncate(weight, 94)`, kept within the bar. @@ -349,8 +389,8 @@ mod tooltip_tests { assert_eq!(layout.slots.len(), 12); assert_eq!(layout.overflow, None); assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0); - // Rows fill bottom-up and columns right-to-left: the first item is in - // the bottom-right cell, the twelfth in the top-left. + // Rows fill bottom-up and columns right-to-left: the twelfth item is in + // the bottom-right cell, the first in the top-left. assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]); assert_eq!(&*layout.slots[0].1.item, "minecraft:stone"); assert_eq!(layout.slots[11].0, [0.0, 0.0]); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index de7eb448b..fc92a6c6f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -18,6 +18,7 @@ mod boats; mod books; mod bossbars; mod breezes; +mod bundles; mod campfire; mod campfire_douse; mod cartography; @@ -1254,6 +1255,8 @@ struct Game<'a> { inv_clicks: horizon_client::container_screen::Clicks, /// The Mouse Tweaks extra's drag and wheel (`tweaks`). tweaks: tweaks::Tweaks, + /// The wheel and hovered slot of `BundleMouseActions`. + bundle_mouse: bundles::BundleMouse, /// The last inventory click, for the double click that gathers a stack. last_inv_click: Option<(std::time::Instant, usize, bool)>, /// `lastQuickMoved`: what the last Shift click moved, which a Shift @@ -4653,6 +4656,7 @@ impl Game<'_> { self.shield_cooldown = self.shield_cooldown.saturating_sub(1); self.cooldowns.tick(); horizon_client::spans::time("tick_using", || self.tick_using()); + self.tick_bundle_hover(); horizon_client::spans::time("tick_hud", || self.tick_hud()); // The player's digging comes in with its packets, before the level // ticks; ServerLevel.tick then runs the scheduled and random block @@ -5847,6 +5851,12 @@ impl Game<'_> { /// Several clicks on one menu instance (a quick craft drag is three). fn menu_ops(&mut self, ops: &[(Option, i32, horizon_client::menu::Input)]) -> bool { use horizon_client::menu::Input; + // BundleMouseActions.onSlotClicked: a Shift click or a swap clears the bundle's selection. + for &(slot, _, input) in ops { + if let (Some(slot), Input::QuickMove | Input::Swap) = (slot, input) { + self.unselect_bundle(slot); + } + } if self.remote.is_some() && self.screen == Screen::Inventory { // On a server the clicks are the server's: predicted and sent. return self.remote_menu_ops(ops); @@ -11660,6 +11670,7 @@ impl ApplicationHandler for Game<'_> { } if self.screen == Screen::Inventory && d != 0.0 + && !self.bundle_scroll(scaled(raw_x), scaled(raw_y)) && !self.tweak_scroll(d, self.inventory_target().0) { self.station_scroll(d); @@ -12441,6 +12452,7 @@ impl<'a> Game<'a> { craft_table: None, inv_drag: None, tweaks: Default::default(), + bundle_mouse: Default::default(), inv_clicks: Default::default(), last_inv_click: None, last_quick_moved: None, diff --git a/crates/horizon-client/src/game/bundles.rs b/crates/horizon-client/src/game/bundles.rs new file mode 100644 index 000000000..198964803 --- /dev/null +++ b/crates/horizon-client/src/game/bundles.rs @@ -0,0 +1,103 @@ +//! `BundleMouseActions`: the wheel over a bundle in a container screen selects +//! one of the items it shows, which a click that takes one out of it then takes +//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the +//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`). +use super::{Game, Screen}; +use horizon_client::{ + bundle::{Contents, toggle_selected}, + mouse::ScrollWheelHandler, +}; +use horizon_protocol::packets::play::{self, Serverbound as S}; + +#[derive(Default)] +pub(super) struct BundleMouse { + wheel: ScrollWheelHandler, + /// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`). + hovered: Option, +} + +impl Game<'_> { + /// A slot a bundle can be selected in: the player's inventory, the off hand and a + /// container's own slots. A server only knows these in the inventory and chest-like menus. + fn bundle_slot(&mut self, slot: usize) -> bool { + (slot < 36 || slot == 504 || (300..500).contains(&slot)) + && (self.remote.is_none() || self.remote_storage_open()) + && (slot < 36 || slot == 504 || self.slots().contains(&slot)) + } + + /// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection + /// through the items its tooltip shows. True when the bundle took the wheel. + pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool { + let Some(slot) = self.inventory_target().0 else { + return false; + }; + if !self.bundle_slot(slot) { + return false; + } + let stack = self.slot_mut(slot).clone(); + let items = self.res.items(); + let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else { + return false; + }; + let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count()); + if shown == 0 { + return false; + } + let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y); + let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y }; + if wheel != 0 { + let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0); + let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32); + if next != selected { + self.select_bundle(slot, next); + } + } + true + } + + /// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there. + fn select_bundle(&mut self, slot: usize, index: i32) { + if let Some(stack) = self.slot_mut(slot) { + toggle_selected(stack, index); + } + if self.remote.is_some() { + let (_, menu) = self.remote_menu(); + if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) { + self.send(S::SelectBundleItem(play::SelectBundleItem { + slot: menu_slot as i32, + index, + })); + } + } + } + + /// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection. + pub(super) fn unselect_bundle(&mut self, slot: usize) { + if !self.bundle_slot(slot) { + return; + } + let selected = self + .slot_mut(slot) + .as_ref() + .and_then(|s| s.data()) + .is_some_and(|d| d.bundle_selected.0 >= 0); + if selected { + self.select_bundle(slot, -1); + } + } + + /// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the + /// screen, clears the bundle's selection. + pub(super) fn tick_bundle_hover(&mut self) { + let now = if self.screen == Screen::Inventory { + self.inventory_target().0 + } else { + None + }; + if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now) + && Some(left) != now + { + self.unselect_bundle(left); + } + } +} diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 43bb573dc..df651b102 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1216,6 +1216,12 @@ impl Game<'_> { { self.guest_carried_back(host, gi); } + S::SelectBundleItem(p) => { + if !spectator && let Ok(slot) = usize::try_from(p.slot) { + host.hub + .select_bundle_item(conn, slot, p.index, &mut self.world); + } + } S::ContainerClick(p) => { let rules = ItemRules(self.res.items()); let slots = host.hub.guests[gi] diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..32f5f6093 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1558,7 +1558,7 @@ impl Game<'_> { } } /// Find a game slot in the menu's complete, contiguous slot mapping. - fn menu_slot_index(menu: &str, internal: usize) -> Option { + pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option { (0..) .map_while(|slot| Self::menu_slot(menu, slot)) .position(|slot| slot == internal) @@ -4701,7 +4701,7 @@ impl Game<'_> { } /// The server's open menu: its window id and menu type (0, "inventory" /// when none is open). - fn remote_menu(&self) -> (i32, String) { + pub(super) fn remote_menu(&self) -> (i32, String) { match self.remote.as_ref().and_then(|r| r.menu.clone()) { Some((id, name)) => (id, name), None => (0, "inventory".to_owned()), diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 56b19e393..8efd6eed9 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -2104,12 +2104,18 @@ impl Game<'_> { batch: &mut HudBatch, layout: &horizon_client::bundle::TooltipLayout, ) { - for (at, stack) in &layout.slots { + for (i, (at, stack)) in layout.slots.iter().enumerate() { + let selected = layout.selected == Some(i); + let sprite = |name: &str| format!("gui/sprites/container/bundle/{name}"); // Each draw takes the atlas on its own: the item's needs `&mut self`. horizon_client::ui::nine_slice( batch, &self.gui.atlas, - "gui/sprites/container/bundle/slot_background", + &sprite(if selected { + "slot_highlight_back" + } else { + "slot_background" + }), at[0], at[1], 24.0, @@ -2119,6 +2125,18 @@ impl Game<'_> { let stack = stack.clone(); let [x, y] = *at; self.draw_item(batch, &stack, x + 4.0, y + 4.0); + if selected { + horizon_client::ui::nine_slice( + batch, + &self.gui.atlas, + &sprite("slot_highlight_front"), + x, + y, + 24.0, + 24.0, + u32::MAX, + ); + } } let atlas = &self.gui.atlas; if let Some((at, hidden)) = layout.overflow { diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index ac624e85b..253bc6cc5 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -52,6 +52,8 @@ pub struct StackData { pub pot_decorations: Option; 4]>>, /// `minecraft:bundle_contents`. pub bundle_contents: Vec, + /// `BundleContents.selectedItem`: runtime only, never saved or sent. + pub bundle_selected: BundleSelected, /// `minecraft:suspicious_stew_effects`: (effect id, duration). pub stew_effects: Vec<(Arc, i32)>, /// `minecraft:map_post_processing`: 0 lock, 1 scale. @@ -60,6 +62,20 @@ pub struct StackData { /// max stack size...), kept as their saved values in key order. pub other: Vec<(String, Tag)>, } +/// A bundle's selected item index (-1: none). Like vanilla's +/// `BundleContents.equals` it is left out when stacks are compared. +#[derive(Clone, Copy, Debug)] +pub struct BundleSelected(pub i32); +impl Default for BundleSelected { + fn default() -> Self { + Self(-1) + } +} +impl PartialEq for BundleSelected { + fn eq(&self, _: &Self) -> bool { + true + } +} /// The components `components_tag` writes from modelled fields. const MODELLED: &[&str] = &[ "minecraft:banner_patterns", @@ -791,6 +807,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option Value { /// The resolver's view of a client stack: its item, count and component patch. pub fn stack_view(stack: &crate::items::ItemStack) -> StackView { let mut view = StackView::new(&stack.item, stack.count as i32); + view.bundle_selected = stack.data().map_or(-1, |d| d.bundle_selected.0); if let Some(Tag::Compound(c)) = crate::item_data::components_tag(stack.damage, stack.data()) { for (k, v) in &c { let key = k.to_string_lossy(); diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 002f95cbb..c5fede5c6 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -421,6 +421,42 @@ impl Hub { drops } + /// `AbstractContainerMenu.setSelectedBundleItemIndex` on the guest's open + /// menu: a container's own slots, or the inventory's. A bundle's selection + /// lives on the server's stack until the click that takes from it. + pub fn select_bundle_item(&mut self, conn: u32, slot: usize, index: i32, world: &mut World) { + let Some(g) = self.guests.iter_mut().find(|g| g.conn == conn) else { + return; + }; + let open = g.menu.as_ref().map(|m| (m.size, m.slots)); + let mut items = match open { + Some((size, source)) if slot < size => source.read(world, g), + _ => None, + }; + let stack = match open { + Some((size, _)) if slot < size => items + .as_mut() + .and_then(|items| items.get_mut(slot)) + .and_then(Option::as_mut), + Some((size, _)) => g + .inventory + .get_mut(player_slot(slot - size)) + .and_then(Option::as_mut), + None => match slot { + 9..=35 => g.inventory.get_mut(slot).and_then(Option::as_mut), + 36..=44 => g.inventory.get_mut(slot - 36).and_then(Option::as_mut), + 45 => g.offhand.as_mut(), + _ => None, + }, + }; + if let Some(stack) = stack { + crate::bundle::toggle_selected(stack, index); + } + if let (Some((_, source)), Some(items)) = (open, items) { + source.write(world, g, items); + } + } + /// A click on the inventory menu (window 0) the game applied itself /// (the crafting grid): the client is corrected like after any click. pub fn inventory_clicked( diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 859bafa23..641e45b1b 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -5,7 +5,7 @@ //! menu are returned for the game to drop. use super::guest::Guest; use crate::{ - bundle::{Contents, can_be_in_bundle}, + bundle::{Contents, can_be_in_bundle, toggle_selected}, items::{ItemStack, Items}, world::World, }; @@ -514,7 +514,10 @@ fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bo } } (carried, _) => { + // toggleSelectedItem(-1), then the click goes on. view.guest.carried = carried; + toggle_selected(&mut bundle, -1); + view.set(i, Some(bundle)); return false; } } diff --git a/crates/horizon-client/src/menu/bundle.rs b/crates/horizon-client/src/menu/bundle.rs index fba4a488e..78a52655a 100644 --- a/crates/horizon-client/src/menu/bundle.rs +++ b/crates/horizon-client/src/menu/bundle.rs @@ -1,7 +1,7 @@ //! `BundleItem`'s click overrides in a PICKUP click //! (`tryItemClickBehaviourOverride`). use super::{Env, Menu, tidy}; -use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle}; +use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle, toggle_selected}; impl Menu { /// The carried stack's `overrideStackedOnOther`, then the clicked @@ -88,8 +88,11 @@ impl Menu { } } (carried, _) => { - // toggleSelectedItem(-1): Horizon keeps no selected item. + // toggleSelectedItem(-1), then the click goes on. self.carried = carried; + toggle_selected(&mut bundle, -1); + let slot = self.slots[s]; + self.containers[slot.container][slot.index] = Some(bundle); return false; } } diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 26bc09a84..7ab82720d 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -2249,6 +2249,11 @@ packet!( /// Input flags: forward 1, backward 2, left 4, right 8, jump 16, shift 32, sprint 64. PlayerInput { flags: u8 => UByte } ); +packet!( + /// ServerboundSelectBundleItemPacket: the menu slot of a bundle and the + /// index of its selected item (-1: none). + SelectBundleItem { slot: i32 => VarInt, index: i32 => VarInt } +); packet!(SetCarriedItem { slot: i16 => Short }); packet!(SetCreativeModeSlot { slot: i16 => Short, item: Option => Slot }); packet!(UseItem { hand: i32 => VarInt, sequence: i32 => VarInt, yaw: f32 => Float, pitch: f32 => Float }); @@ -2317,6 +2322,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound { ContainerSlotStateChanged(ContainerSlotStateChanged) = CONTAINER_SLOT_STATE_CHANGED, Punch(Empty) = PUNCH, ResourcePack(ResourcePackResponse) = RESOURCE_PACK, + SelectBundleItem(SelectBundleItem) = BUNDLE_ITEM_SELECTED, SelectTrade(SelectTrade) = SELECT_TRADE, SetBeacon(SetBeacon) = SET_BEACON, SetCarriedItem(SetCarriedItem) = SET_CARRIED_ITEM, From cf6b91a710eb52fa1b856d11da197514694eb243 Mon Sep 17 00:00:00 2001 From: masa-masa-0314 <348336850+masa-masa-0314@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:29:02 +0800 Subject: [PATCH 222/475] Items: draw the enchantment glint from enchanted_glint_item Horizon invented the glint: the GUI drew sine bands in screen space and the entity shader layered the same over held and dropped items. Vanilla 26.3 samples `misc/enchanted_glint_item` through `GLINT_TEXTURING` (`item.vsh`/`item.fsh`, `TextureTransform.setupGlintTexturing`), so items now carry the real texture: - `ItemQuad.foil_uv` keeps the model UV the glint samples; `Glint` turns it into the atlas UV with the 26.3 transform (translation(-o0, o1) * rotateZ(PI/18) * scale) and `WorldSink` gives foil quads overlay 7 and the per-corner glint UV. - The GUI glint is a separate additive HUD pass masked by the item's texture alpha (`item.fsh`'s ALPHA_CUTOUT 0.1 and GLINT path). - `entity.wgsl` samples the glint at overlay 7/8 like vanilla's `color.rgb += glintColor.rgb * glintColor.rgb`. - The Accessibility options Glint Speed and Glint Strength work: both sliders persist to `options.txt` (they were hidden placeholders), the GUI pass takes them per draw, and `Frame.glint_alpha` feeds the entity shader every frame instead of whatever the last HUD item set. - GUI items use `GLINT_TEXTURING`'s scale 8, world items `ENTITY_GLINT_TEXTURING`'s scale 0.5. Co-Authored-By: ZCode --- .../horizon-client/src/block_entity_render.rs | 7 ++ crates/horizon-client/src/breaking.rs | 2 + crates/horizon-client/src/clouds.rs | 2 + crates/horizon-client/src/entity.wgsl | 24 +++--- crates/horizon-client/src/entity_culling.rs | 2 + crates/horizon-client/src/fire_render.rs | 2 + crates/horizon-client/src/game.rs | 6 ++ .../horizon-client/src/game/armor_stands.rs | 1 + crates/horizon-client/src/game/creatures.rs | 4 + crates/horizon-client/src/game/dragon.rs | 6 ++ crates/horizon-client/src/game/end.rs | 4 + crates/horizon-client/src/game/fishing.rs | 2 + crates/horizon-client/src/game/frames.rs | 2 + crates/horizon-client/src/game/item_draw.rs | 19 +++++ crates/horizon-client/src/game/leads.rs | 2 + crates/horizon-client/src/game/maps.rs | 6 ++ crates/horizon-client/src/game/minecarts.rs | 1 + crates/horizon-client/src/game/mob_blocks.rs | 2 + crates/horizon-client/src/game/names.rs | 1 + crates/horizon-client/src/game/paintings.rs | 2 + crates/horizon-client/src/game/perspective.rs | 2 + .../horizon-client/src/game/text_displays.rs | 2 + crates/horizon-client/src/game/tnt.rs | 2 + crates/horizon-client/src/gfx.rs | 11 ++- crates/horizon-client/src/gfx/end_sky.rs | 2 + .../horizon-client/src/gfx/end_sky/tests.rs | 16 ++-- crates/horizon-client/src/gfx/entity_batch.rs | 2 + crates/horizon-client/src/gfx/lightning.rs | 2 + .../src/gfx/lightning/tests/mod.rs | 2 + crates/horizon-client/src/gfx/pack_weather.rs | 2 + crates/horizon-client/src/hud.rs | 38 ++++++++- crates/horizon-client/src/hud.wgsl | 14 ++-- crates/horizon-client/src/item_render.rs | 50 ++++++++++- crates/horizon-client/src/item_render/gui.rs | 83 ++++++++++--------- .../src/item_render/gui/depth.rs | 5 ++ .../horizon-client/src/item_render/special.rs | 1 + crates/horizon-client/src/mesher.rs | 2 + crates/horizon-client/src/mobs.rs | 3 +- crates/horizon-client/src/mobs/guardian.rs | 2 + crates/horizon-client/src/mobs/render.rs | 1 + crates/horizon-client/src/options_ui.rs | 12 +-- crates/horizon-client/src/particles.rs | 2 + crates/horizon-client/src/shadows.rs | 2 + 43 files changed, 280 insertions(+), 75 deletions(-) diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs index 28a876d80..19af406e1 100644 --- a/crates/horizon-client/src/block_entity_render.rs +++ b/crates/horizon-client/src/block_entity_render.rs @@ -149,6 +149,8 @@ pub fn draw_part( layer: 0, color, light, + + glint_uv: [0.0, 0.0], }); } } @@ -648,6 +650,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec) { layer: 0, color: c, light: call.light, + + glint_uv: [0.0, 0.0], }); } } @@ -1714,6 +1718,8 @@ impl BeamPart { layer: 6, color, light: self.wrap, + + glint_uv: [0.0, 0.0], }); } } @@ -1784,6 +1790,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec) layer, color: 0, light: 0, + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/breaking.rs b/crates/horizon-client/src/breaking.rs index 84eedae75..df6d0f33b 100644 --- a/crates/horizon-client/src/breaking.rs +++ b/crates/horizon-client/src/breaking.rs @@ -49,6 +49,8 @@ pub fn geometry( color: 0xffff_ffff, light: stage_light(stage), normal: [0; 4], + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/clouds.rs b/crates/horizon-client/src/clouds.rs index 70f29c132..5e9bb731a 100644 --- a/crates/horizon-client/src/clouds.rs +++ b/crates/horizon-client/src/clouds.rs @@ -179,6 +179,8 @@ impl Mesh { layer: 4, color: u32::MAX, light: shade, + + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 79e11719f..307f3fbba 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -44,6 +44,8 @@ struct Out { // entity.vsh's lightMapColor, multiplied in after the overlay. @location(9) light_color: vec4, @location(10) @interpolate(flat) weather_rect: vec4, + // The `GLINT_TEXTURING` sample for overlay 7 (item foil). + @location(11) glint_uv: vec2, } // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. fn entity_light(normal: vec3, nether: bool) -> f32 { @@ -103,12 +105,16 @@ fn entity_vertex(position: vec3, uv: vec2, out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u); out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u); out.overlay = overlay; + out.glint_uv = vec2(0.0); if overlay == 4u { out.spherical = length(position); } return out; } @vertex fn vs_entity(@location(0) position: vec3, @location(1) uv: vec2, - @location(2) overlay: u32, @location(3) color: vec4, @location(4) light: vec4, @location(5) normal: vec4) -> Out { - return entity_vertex(position, uv, overlay, color, light, normal); + @location(2) overlay: u32, @location(3) color: vec4, @location(4) light: vec4, + @location(5) normal: vec4, @location(6) glint_uv: vec2) -> Out { + var out = entity_vertex(position, uv, overlay, color, light, normal); + out.glint_uv = glint_uv; + return out; } // Retained bolt-local geometry, translated using the same f64 camera // subtraction as the former CPU path before adding each local f32 position. @@ -204,15 +210,11 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a); color *= in.light_color; if in.overlay == 7u || in.overlay == 8u { - // Enchantment glint (overlay 7, 8 with the hurt tint): the purple - // enchanted_glint sheen drifting diagonally over the item's shape. - let px = in.uv * vec2(textureDimensions(atlas)); - let t = globals.misc.y * 0.05; - let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5; - let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5; - let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0); - let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer); - color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a); + // Enchantment glint: item.fsh's GLINT path, sampled from + // enchanted_glint_item at the `GLINT_TEXTURING` UV. + let alpha = globals.fog_ends.w; + let glint = alpha * textureSample(atlas, atlas_sampler, in.glint_uv); + color = vec4(color.rgb + glint.rgb * glint.rgb, max(color.a, alpha)); } let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)); diff --git a/crates/horizon-client/src/entity_culling.rs b/crates/horizon-client/src/entity_culling.rs index 16de17465..0c309041a 100644 --- a/crates/horizon-client/src/entity_culling.rs +++ b/crates/horizon-client/src/entity_culling.rs @@ -186,6 +186,8 @@ mod tests { color: u32::MAX, light: 0, normal: [0; 4], + + glint_uv: [0.0, 0.0], }; let prior = vertex([0.0; 3]); let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])]; diff --git a/crates/horizon-client/src/fire_render.rs b/crates/horizon-client/src/fire_render.rs index c3a9f6a92..98eff6f9d 100644 --- a/crates/horizon-client/src/fire_render.rs +++ b/crates/horizon-client/src/fire_render.rs @@ -48,6 +48,8 @@ pub fn flames( color: 0xffff_ffff, light: 0xf000 | (u32::from(scene_light >> 4) * 16), normal: [0; 4], + + glint_uv: [0.0, 0.0], }); } remaining -= 0.45; diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 45fe3a8b1..91045ada5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7380,6 +7380,8 @@ impl Game<'_> { layer: 0, color, light: packed, + + glint_uv: [0.0, 0.0], }); } } @@ -7999,6 +8001,8 @@ impl Game<'_> { layer: 2, color, light: 0, + + glint_uv: [0.0, 0.0], }); } }; @@ -10801,6 +10805,7 @@ impl Game<'_> { debug_lines_on_top: &debug_lines_on_top, debug_crosshair: &debug_crosshair, hud: &hud, + glint_alpha: self.settings.file.double("glintStrength") as f32, clouds: (!title && self.dim == Dim::Overworld && horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3])) @@ -13478,6 +13483,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { debug_lines_on_top: &[], debug_crosshair: &[], hud, + glint_alpha: 0.75, nether_lighting: false, entities: &[], entity_spans: &[], diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index f175af401..92080b70c 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -872,6 +872,7 @@ impl Game<'_> { lights: Some(horizon_client::block_entity_render::diffuse_lights( self.dim == super::Dim::Nether, )), + glint: self.glint(), }; let profile = stack .extra diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 86a7954e9..366420557 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -145,6 +145,8 @@ fn flame_geometry( layer: 0, color: 0xFFFF_FFFF, light: packed, + + glint_uv: [0.0, 0.0], }); } h -= 0.45; @@ -3316,6 +3318,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: packed, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index fff03ef1c..9cea0f48a 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2482,6 +2482,8 @@ impl Game<'_> { layer: 2, color: c, light: 0, + + glint_uv: [0.0, 0.0], }); } } @@ -2547,6 +2549,8 @@ impl Game<'_> { layer: 6, color: c, light: wrap_light, + + glint_uv: [0.0, 0.0], }); } (last_sin, last_cos, last_u) = (sin, cos, uu); @@ -2592,6 +2596,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 32d5113b0..6d845c259 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -247,6 +247,8 @@ impl Game<'_> { layer: 2, color, light: 0, + + glint_uv: [0.0, 0.0], }); } } @@ -1240,6 +1242,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: full, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index 39f9e5ffe..55c1d46fd 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -643,6 +643,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: packed, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index d08c5f283..b9ba0b1cc 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -377,6 +377,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index b6b7e2fb7..580701ccd 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -296,8 +296,25 @@ impl Game<'_> { x, y, self.started.elapsed().as_secs_f32(), + self.settings.file.double("glintSpeed") as f32, + self.settings.file.double("glintStrength") as f32, ); } + /// The `GLINT_TEXTURING` inputs the atlas and options provide. + pub(super) fn glint(&self) -> Option { + self.gui + .atlas + .sprites + .get("misc/enchanted_glint_item") + .copied() + .map(|rect| item_render::Glint { + rect, + speed: self.settings.file.double("glintSpeed") as f32, + alpha: self.settings.file.double("glintStrength") as f32, + millis: self.started.elapsed().as_secs_f32() * 1000.0, + scale: 0.5, + }) + } /// Submit a resolved item under `pose` (camera-relative) at `light` /// (packed lightmap coordinates). pub(super) fn submit_item_world( @@ -311,6 +328,7 @@ impl Game<'_> { out, light, lights: Some(diffuse_lights(self.dim == Dim::Nether)), + glint: self.glint(), }; state.submit( &self.pack.item_renderer, @@ -332,6 +350,7 @@ impl Game<'_> { out, light, lights: None, + glint: self.glint(), }; state.submit( &self.pack.item_renderer, diff --git a/crates/horizon-client/src/game/leads.rs b/crates/horizon-client/src/game/leads.rs index 8867157ee..b14cc351a 100644 --- a/crates/horizon-client/src/game/leads.rs +++ b/crates/horizon-client/src/game/leads.rs @@ -381,6 +381,8 @@ impl Game<'_> { layer: 0, color, light: packed, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 6da65c592..97e7996d0 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -1133,6 +1133,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } }; @@ -1180,6 +1182,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } }; @@ -1251,6 +1255,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 072059e49..eeedbc9f5 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -1297,6 +1297,7 @@ impl Game<'_> { layer: 0, color, light, + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 083c96a66..5b26cd615 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -203,6 +203,8 @@ impl Game<'_> { layer: overlay, color, light, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index 9f7561e53..9bc3200ec 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -377,6 +377,7 @@ impl Game<'_> { layer, color, light, + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/game/paintings.rs b/crates/horizon-client/src/game/paintings.rs index 321e59f7d..1c0a74a47 100644 --- a/crates/horizon-client/src/game/paintings.rs +++ b/crates/horizon-client/src/game/paintings.rs @@ -308,6 +308,8 @@ impl Game<'_> { layer: 0, color, light: packed, + + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index caf49883b..a558dede2 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -710,6 +710,8 @@ mod preview_tests { color: u32::MAX, light: 0, normal: [0; 4], + + glint_uv: [0.0, 0.0], }) }; // A shield and arm can intersect: neither average depth orders both ends. diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index 45bd64fa7..226046fec 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -268,6 +268,8 @@ impl Game<'_> { light, layer: if through { 2 } else { 0 }, normal: [0; 4], + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index 6bd2ae956..2e87fa185 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -332,6 +332,8 @@ impl Game<'_> { layer: if lit { 5 } else { 0 }, color, light: packed, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 70d882a98..bd3c0b59c 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -172,7 +172,8 @@ struct Globals { inverse_view: [[f32; 4]; 4], sunrise: [f32; 4], sun: [f32; 4], - /// x: sky fog end, y: cloud fog end, z: dark disc (camera below the horizon), w: unused. + /// x: sky fog end, y: cloud fog end, z: dark disc (camera below the horizon), + /// w: the glint strength (`GlintAlpha`). fog_ends: [f32; 4], /// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png. end_portal_sprites: [[f32; 4]; 2], @@ -256,6 +257,9 @@ pub struct Frame<'a> { /// drawn after the hand at its projection, without the bob or fog. pub debug_crosshair: &'a [[f32; 8]], pub hud: &'a HudBatch, + /// `GlintAlpha` (accessibility Glint Strength); the entity shader uses it + /// for held/world items, independent of what the HUD batch last drew. + pub glint_alpha: f32, /// CardinalLighting.Type.NETHER, otherwise DEFAULT. pub nether_lighting: bool, /// Camera-relative entity geometry textured from the shared atlas. @@ -1724,7 +1728,7 @@ impl Renderer { usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4]; + let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; let entity_vertex = [Some(wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, @@ -3475,7 +3479,7 @@ impl Renderer { frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end), frame.fog.map_or(2048.0, |f| f.cloud_end), if frame.dark_disc { 1.0 } else { 0.0 }, - 0.0, + frame.glint_alpha, ], end_portal_sprites: frame.end_portal_sprites, crumbling_sprite: frame.crumbling_sprite, @@ -5222,6 +5226,7 @@ mod tests { color: 0xffff_ffff, light: id, normal: [0; 4], + glint_uv: [0.0, 0.0], }; 4] }; let entities: Vec<_> = [ diff --git a/crates/horizon-client/src/gfx/end_sky.rs b/crates/horizon-client/src/gfx/end_sky.rs index 15e9001fc..d74e053df 100644 --- a/crates/horizon-client/src/gfx/end_sky.rs +++ b/crates/horizon-client/src/gfx/end_sky.rs @@ -72,6 +72,8 @@ fn vertices(uv: [f32; 4]) -> Vec { layer: 2, color: 0xFF28_2828, light: 0, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/gfx/end_sky/tests.rs b/crates/horizon-client/src/gfx/end_sky/tests.rs index 61266003d..5cd2bcf7f 100644 --- a/crates/horizon-client/src/gfx/end_sky/tests.rs +++ b/crates/horizon-client/src/gfx/end_sky/tests.rs @@ -12,15 +12,15 @@ fn geometry_matches_pre_residency_vertex_snapshots() { for (uv, expected) in [ ( [0.0, 0.0, 1.0, 1.0], - "f7ee23457fc557b33ecf09c9afaf858f3d8907e108abf06055f627523f733536", + "82a9be3f3ed1f733017df2c81dc6dff163f67bf739c768e3fd0f90f00c7802f6", ), ( [0.125, 0.25, 0.375, 0.5], - "f74c4b8d23ec2b7c11b5752821d0733851bf11358f1b26f9448226ff62a6a318", + "05a058bd8e70cff900c8e1c81061892ed27866d86498ebe1e8ae5af4c3b770ef", ), ( [0.13, 0.27, 0.41, 0.59], - "f0aaa39c5b22168bdc7c6c329acb4d3598a2feb61ff7b6f2850b644682749b39", + "93064b9fd2e745da46b927b2625edc8a381bb8c6e20b0f0880e816c4c1745ec1", ), ] { let mut mesh = vertices(uv); @@ -31,10 +31,8 @@ fn geometry_matches_pre_residency_vertex_snapshots() { if n == 0.0 { 0.0 } else { n } }); } - assert_eq!( - format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))), - expected - ); + let hash = format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))); + assert_eq!(hash, expected); } } @@ -95,7 +93,7 @@ fn separate_draws_match_combined_celestial_pixels() { label: Some("End sky differential shader"), source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()), }); - let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4]; + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("End sky differential pipeline"), layout: None, @@ -305,6 +303,8 @@ fn separate_draws_match_combined_celestial_pixels() { color: level * 0x01010101, light: 0, normal: [0; 4], + + glint_uv: [0.0, 0.0], }) .to_vec() }; diff --git a/crates/horizon-client/src/gfx/entity_batch.rs b/crates/horizon-client/src/gfx/entity_batch.rs index 8153699fc..9d6c4bf65 100644 --- a/crates/horizon-client/src/gfx/entity_batch.rs +++ b/crates/horizon-client/src/gfx/entity_batch.rs @@ -130,6 +130,8 @@ mod tests { color: index as u32, light: (index * 3) as u32, normal: [1, 2, 3, 4], + + glint_uv: [0.0, 0.0], }) .collect() } diff --git a/crates/horizon-client/src/gfx/lightning.rs b/crates/horizon-client/src/gfx/lightning.rs index 251dbf00e..d979bb368 100644 --- a/crates/horizon-client/src/gfx/lightning.rs +++ b/crates/horizon-client/src/gfx/lightning.rs @@ -77,6 +77,8 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec { layer: 2, color, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/gfx/lightning/tests/mod.rs b/crates/horizon-client/src/gfx/lightning/tests/mod.rs index f89f402de..ccaf963a5 100644 --- a/crates/horizon-client/src/gfx/lightning/tests/mod.rs +++ b/crates/horizon-client/src/gfx/lightning/tests/mod.rs @@ -60,6 +60,8 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> Vec self.capacity { diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs index 2b6acda41..41eae1ec7 100644 --- a/crates/horizon-client/src/hud.rs +++ b/crates/horizon-client/src/hud.rs @@ -554,6 +554,8 @@ pub struct HudVertex { pub uv: [f32; 2], pub color: u32, pub depth: f32, + /// The glint sprite UV (`GLINT_TEXTURING`), unused unless `uv.x >= 2`. + pub glint_uv: [f32; 2], } /// The GUI Scale option (0 = auto). pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); @@ -569,6 +571,8 @@ pub struct HudBatch { pub blends: Vec<(std::ops::Range, Blend)>, /// The textures and time of the `Blend::EndPortal` runs. pub end_portal: EndPortalFill, + /// `options.glintStrength` for the glint pass (`GlintAlpha`). + pub glint_alpha: f32, /// Screen's first vertex: the preceding HUD is part of its blurred background. pub blur_at: u32, } @@ -620,6 +624,7 @@ impl HudBatch { patches: Vec::new(), blends: Vec::new(), end_portal: EndPortalFill::default(), + glint_alpha: 0.75, blur_at: 0, } } @@ -695,6 +700,26 @@ impl HudBatch { uv: uv[i], color, depth: 0.0, + glint_uv: [0.0, 0.0], + }); + } + } + /// A quad whose texture is the `GLINT_TEXTURING` sample at `glint_uv`; + /// `uv` is the item's atlas UV, which the shader masks by. + pub fn quad_glint( + &mut self, + p: [[f32; 2]; 4], + uv: [[f32; 2]; 4], + glint_uv: [[f32; 2]; 4], + color: u32, + ) { + for i in [0, 1, 2, 0, 2, 3] { + self.vertices.push(HudVertex { + pos: p[i], + uv: uv[i], + color, + depth: 0.0, + glint_uv: glint_uv[i], }); } } @@ -705,6 +730,7 @@ impl HudBatch { uv: uv[i], color, depth: p[i][2], + glint_uv: [0.0, 0.0], }); } } @@ -875,6 +901,7 @@ impl HudBatch { uv, color: c[i], depth: 0.0, + glint_uv: [0.0, 0.0], }); } } @@ -1122,7 +1149,8 @@ impl HudRenderer { bind_group_layouts: &[Some(&layout)], ..Default::default() }); - let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32]; + let attributes = + wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2]; let create = |label, blend, fragment_entry, depth: bool| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), @@ -1337,7 +1365,12 @@ impl HudRenderer { &self.uniform, 0, bytemuck::bytes_of(&HudUniform::new( - [size[0] as f32, size[1] as f32, batch.scale, 0.0], + [ + size[0] as f32, + size[1] as f32, + batch.scale, + batch.glint_alpha, + ], batch.end_portal, )), ); @@ -1787,6 +1820,7 @@ mod tests { patches: Vec::new(), blends: Vec::new(), end_portal: EndPortalFill::default(), + glint_alpha: 0.75, blur_at: 0, }; for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] { diff --git a/crates/horizon-client/src/hud.wgsl b/crates/horizon-client/src/hud.wgsl index ebb6dc2cb..1f6d5797e 100644 --- a/crates/horizon-client/src/hud.wgsl +++ b/crates/horizon-client/src/hud.wgsl @@ -13,22 +13,26 @@ struct Out { @location(1) color: vec4, // projection_from_position(gl_Position): the End portal's texProj0. @location(2) proj: vec4, + @location(3) glint_uv: vec2, } -@vertex fn vs_main(@location(0) pos: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) depth: f32) -> Out { +@vertex fn vs_main(@location(0) pos: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) depth: f32, @location(4) glint_uv: vec2) -> Out { let pixels = pos * hud.size.z; var out: Out; out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0); out.uv = uv; out.color = color; out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw); + out.glint_uv = glint_uv; return out; } @fragment fn fs_main(in: Out) -> @location(0) vec4 { - // u >= 2: flat vertex colour masked by the texture's alpha (enchantment glint). + // u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's + // texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path). if in.uv.x >= 2.0 { - let m = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a * in.color.a; - if m < 0.004 { discard; } - return vec4(in.color.rgb, m); + let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a; + if mask < 0.1 { discard; } + let glint = hud.size.w * textureSample(atlas, atlas_sampler, in.glint_uv); + return vec4(glint.rgb * glint.rgb, mask); } let c = textureSample(atlas, atlas_sampler, in.uv) * in.color; if c.a < 0.004 { discard; } diff --git a/crates/horizon-client/src/item_render.rs b/crates/horizon-client/src/item_render.rs index 4f981b874..0a72b2ecc 100644 --- a/crates/horizon-client/src/item_render.rs +++ b/crates/horizon-client/src/item_render.rs @@ -41,12 +41,48 @@ use std::{ pub struct ItemQuad { pub pos: [Vec3; 4], pub uv: [[f32; 2]; 4], + /// The model UVs `GLINT_TEXTURING` scrolls for the glint (vanilla's UV0, + /// or UV3 for a special model). + pub foil_uv: [[f32; 2]; 4], pub normal: Vec3, pub color: u32, pub foil: Foil, /// `MaterialInfo.lightEmission` (block light floor). pub light_emission: u8, } +/// `TextureTransform.setupGlintTexturing`'s inputs and the glint sprite. +#[derive(Clone, Copy, Debug)] +pub struct Glint { + pub rect: crate::hud::Rect, + /// `options.glintSpeed` (vanilla default 0.5). + pub speed: f32, + /// `options.glintStrength` / shader `GlintAlpha` (vanilla default 0.75). + pub alpha: f32, + /// `Util.getMillis()`. + pub millis: f32, + /// The transform's scale: `GLINT_TEXTURING`'s 8.0 for GUI items, + /// `ENTITY_GLINT_TEXTURING`'s 0.5 for items in the world. + pub scale: f32, +} +impl Glint { + /// `TextureTransform.setupGlintTexturing(scale)`: translation(-o0, o1) * + /// rotateZ(PI/18) * scale, wrapped and mapped into the glint sprite. + pub fn atlas_uv(&self, model: [f32; 2]) -> [f32; 2] { + let millis = (f64::from(self.millis) * f64::from(self.speed) * 8.0) as i64; + let offset0 = (millis % 110000) as f32 / 110000.0; + let offset1 = (millis % 30000) as f32 / 30000.0; + let angle = std::f32::consts::PI / 18.0; + let (sin, cos) = angle.sin_cos(); + let x = model[0] * self.scale * cos - model[1] * self.scale * sin - offset0; + let y = model[0] * self.scale * sin + model[1] * self.scale * cos + offset1; + let u = (x - x.floor()).clamp(0.5 / self.rect.w as f32, 1.0 - 0.5 / self.rect.w as f32); + let v = (y - y.floor()).clamp(0.5 / self.rect.h as f32, 1.0 - 0.5 / self.rect.h as f32); + [ + self.rect.u0 + u * (self.rect.u1 - self.rect.u0), + self.rect.v0 + v * (self.rect.v1 - self.rect.v0), + ] + } +} /// Which glint render type a quad uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Foil { @@ -242,6 +278,7 @@ fn submit_quads( sink.quad(ItemQuad { pos, uv, + foil_uv: q.uv, normal: transform_normal(m, direction_vector(q.facing)), color: argb(color), foil, @@ -679,6 +716,8 @@ pub struct WorldSink<'a> { pub light: u32, /// The level's diffuse lights; `None` leaves the colour unshaded. pub lights: Option<(Vec3, Vec3)>, + /// The `GLINT_TEXTURING` sample for foil quads (`None` leaves them unglinted). + pub glint: Option, } impl ItemSink for WorldSink<'_> { fn quad(&mut self, q: ItemQuad) { @@ -693,8 +732,14 @@ impl ItemSink for WorldSink<'_> { } else { self.light }; - let overlay = if q.foil == Foil::None { 0 } else { 7 }; - for (p, uv) in q.pos.iter().zip(q.uv) { + let glint = if q.foil == Foil::None { + None + } else { + self.glint + }; + let overlay = if glint.is_some() { 7 } else { 0 }; + for (i, (p, uv)) in q.pos.iter().zip(q.uv).enumerate() { + let glint_uv = glint.map_or([0.0, 0.0], |glint| glint.atlas_uv(q.foil_uv[i])); self.out.push(Vertex { normal: [0; 4], pos: p.to_array(), @@ -702,6 +747,7 @@ impl ItemSink for WorldSink<'_> { layer: overlay, color, light, + glint_uv, }); } } diff --git a/crates/horizon-client/src/item_render/gui.rs b/crates/horizon-client/src/item_render/gui.rs index ff25fff1d..87dfb354e 100644 --- a/crates/horizon-client/src/item_render/gui.rs +++ b/crates/horizon-client/src/item_render/gui.rs @@ -5,7 +5,7 @@ //! drawn far to near, and clipped to the item's 16x16 slot unless the model //! is oversized in the GUI (`GuiItemAtlas` draws each item into a scissored //! 16x16 slot; oversized items get a picture-in-picture of their bounds). -use super::{Foil, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost}; +use super::{Foil, Glint, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost}; use crate::hud::{HudBatch, Rect}; use glam::{EulerRot, Mat4, Vec2, Vec3}; use std::{collections::HashMap, f32::consts::PI, sync::Arc}; @@ -43,6 +43,7 @@ pub fn items_3d_lights() -> (Vec3, Vec3) { struct Point { p: Vec2, uv: [f32; 2], + glint_uv: [f32; 2], } fn lerp(a: Point, b: Point, t: f32) -> Point { Point { @@ -51,6 +52,10 @@ fn lerp(a: Point, b: Point, t: f32) -> Point { a.uv[0] + (b.uv[0] - a.uv[0]) * t, a.uv[1] + (b.uv[1] - a.uv[1]) * t, ], + glint_uv: [ + a.glint_uv[0] + (b.glint_uv[0] - a.glint_uv[0]) * t, + a.glint_uv[1] + (b.glint_uv[1] - a.glint_uv[1]) * t, + ], } } /// Sutherland-Hodgman against one half plane `dot(p, n) <= limit`. @@ -97,37 +102,25 @@ pub fn lit_color(q: &ItemQuad, lights: (Vec3, Vec3)) -> u32 { let c = |v: u8| (f32::from(v) * light) as u8; crate::hud::rgba(c(r), c(g), c(b), a) } -/// The glint's moving sheen at a GUI position (a stand-in for the -/// `GLINT_TEXTURING`-scrolled `enchanted_glint_item` texture added on top). -fn glint_color(p: Vec2, time: f32) -> u32 { - let d = ((p.x + p.y) / 32.0 - time * 0.35).rem_euclid(1.0); - let band = (1.0 - (d - 0.5).abs() * 6.0).max(0.0); - crate::hud::rgba( - (170.0 + 70.0 * band) as u8, - (90.0 + 110.0 * band) as u8, - 255, - (45.0 + 110.0 * band) as u8, - ) -} - -fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: bool) { - let shift = if glint { 2.0 } else { 0.0 }; +fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: Option<&Glint>) { for i in 1..fan.len().saturating_sub(1) { let (a, b, c) = (fan[0], fan[i], fan[i + 1]); let corners = [a, b, c, c]; - // `HudBatch.quad` draws (0, 1, 2) and (0, 2, 3): the second is empty. - batch.quad( - corners.map(|v| v.p.to_array()), - corners.map(|v| [v.uv[0] + shift, v.uv[1]]), - color(a.p), - ); - if glint { - // Per-vertex colours are flat per quad in the HUD batch: use the - // fan centre for the sheen. - let n = batch.vertices.len(); - for v in &mut batch.vertices[n - 6..] { - v.color = color(Vec2::from(v.pos)); - } + if let Some(glint) = glint { + let start = batch.vertices.len(); + batch.quad_glint( + corners.map(|v| v.p.to_array()), + corners.map(|v| [v.uv[0] + 2.0, v.uv[1]]), + corners.map(|v| glint.atlas_uv(v.glint_uv)), + u32::MAX, + ); + batch.blend_since(start, crate::hud::Blend::Additive); + } else { + batch.quad( + corners.map(|v| v.p.to_array()), + corners.map(|v| v.uv), + color(a.p), + ); } } } @@ -144,6 +137,8 @@ pub fn draw( x: f32, y: f32, time: f32, + glint_speed: f32, + glint_alpha: f32, ) { if state.is_empty() { return; @@ -256,7 +251,18 @@ fn draw_now( } else { [x, y, x + 16.0, y + 16.0] }; - draw_quads(&quads, lights, batch, rect, time); + let glint = sprites + .get("misc/enchanted_glint_item") + .copied() + .map(|rect| Glint { + rect, + speed: glint_speed, + alpha: glint_alpha, + millis: time * 1000.0, + scale: 8.0, + }); + batch.glint_alpha = glint_alpha; + draw_quads(&quads, lights, batch, rect, glint.as_ref()); } /// Lights, culls, sorts far to near and clips `quads` (GUI positions) to @@ -266,7 +272,7 @@ fn draw_quads( lights: (Vec3, Vec3), batch: &mut HudBatch, rect: [f32; 4], - time: f32, + glint: Option<&Glint>, ) { for face in depth::sort(quads) { let q = &quads[face.quad]; @@ -276,6 +282,7 @@ fn draw_quads( .map(|v| Point { p: v.pos.truncate(), uv: v.uv, + glint_uv: v.glint_uv, }) .collect(); let color = lit_color(q, lights); @@ -285,9 +292,9 @@ fn draw_quads( if fan.len() < 3 { continue; } - emit(batch, &fan, |_| color, false); + emit(batch, &fan, |_| color, None); if q.foil != Foil::None { - emit(batch, &fan, |p| glint_color(p, time), true); + emit(batch, &fan, |_| color, glint); } } } @@ -342,7 +349,7 @@ pub fn draw_banner_result( items_flat_lights(), batch, [x0, y0, x0 + 20.0, y0 + 40.0], - 0.0, + None, ); } @@ -354,6 +361,7 @@ mod tests { ItemQuad { pos: [(0.0, y0), (0.0, y1), (8.0, y1), (8.0, y0)].map(|(x, y)| Vec3::new(x, y, z(y))), uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + foil_uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], normal: Vec3::Z, color, foil: Foil::None, @@ -394,7 +402,7 @@ mod tests { (Vec3::Z, Vec3::Z), &mut batch, [0.0, 0.0, 16.0, 16.0], - 0.0, + None, ); assert_eq!(color_at(&batch, Vec2::new(4.0, 0.5)), Some(u32::MAX)); } @@ -410,7 +418,7 @@ mod tests { (Vec3::Z, Vec3::Z), &mut batch, [0.0, 0.0, 16.0, 16.0], - 0.0, + None, ); assert_eq!( color_at(&batch, Vec2::new(4.0, 2.0)), @@ -442,7 +450,7 @@ mod tests { (Vec3::Z, Vec3::Z), &mut batch, [0.0, 0.0, 16.0, 16.0], - 0.0, + None, ); for y in [0.5, 6.0] { assert_eq!( @@ -468,6 +476,7 @@ mod tests { let p = |x: f32, y: f32| Point { p: Vec2::new(x, y), uv: [x, y], + glint_uv: [0.0, 0.0], }; let fan = clip_triangle( [p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)], diff --git a/crates/horizon-client/src/item_render/gui/depth.rs b/crates/horizon-client/src/item_render/gui/depth.rs index 9fecfdee7..aaaa0c55d 100644 --- a/crates/horizon-client/src/item_render/gui/depth.rs +++ b/crates/horizon-client/src/item_render/gui/depth.rs @@ -10,6 +10,7 @@ use glam::{Vec3, Vec4}; pub(super) struct Corner { pub pos: Vec3, pub uv: [f32; 2], + pub glint_uv: [f32; 2], } pub(super) struct Face { @@ -38,6 +39,9 @@ fn split(face: Face, distances: &[f32]) -> (Face, Face) { let corner = Corner { pos: a.pos.lerp(b.pos, t), uv: std::array::from_fn(|k| a.uv[k] + (b.uv[k] - a.uv[k]) * t), + glint_uv: std::array::from_fn(|k| { + a.glint_uv[k] + (b.glint_uv[k] - a.glint_uv[k]) * t + }), }; back.push(corner); front.push(corner); @@ -73,6 +77,7 @@ pub(super) fn sort(quads: &[ItemQuad]) -> Vec { corners: std::array::from_fn::<_, 4, _>(|k| Corner { pos: q.pos[k], uv: q.uv[k], + glint_uv: q.foil_uv[k], }) .to_vec(), }) diff --git a/crates/horizon-client/src/item_render/special.rs b/crates/horizon-client/src/item_render/special.rs index d42378db1..6d8477ead 100644 --- a/crates/horizon-client/src/item_render/special.rs +++ b/crates/horizon-client/src/item_render/special.rs @@ -115,6 +115,7 @@ fn draw( sprite.v0 + (sprite.v1 - sprite.v0) * v[4], ] }), + foil_uv: poly.map(|v| [v[3], v[4]]), normal, color, foil, diff --git a/crates/horizon-client/src/mesher.rs b/crates/horizon-client/src/mesher.rs index 48d2f08bd..7594e7e31 100644 --- a/crates/horizon-client/src/mesher.rs +++ b/crates/horizon-client/src/mesher.rs @@ -38,6 +38,8 @@ pub struct Vertex { pub light: u32, /// RGBA8_SNORM face normal; w = -127 for two-sided lighting, 0 to keep baked/unlit colour. pub normal: [i8; 4], + /// The `GLINT_TEXTURING` atlas UV for a foil item quad; zero elsewhere. + pub glint_uv: [f32; 2], } /// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour, /// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 6b1e16f5c..61db8c909 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -2013,6 +2013,7 @@ pub fn render_model( layer: overlay, color: 0xFFFF_FFFF, light, + glint_uv: [0.0, 0.0], }); } } @@ -2147,7 +2148,7 @@ mod lighting_tests { #[test] fn packed_normal_uses_native_snorm_bytes_without_changing_vertex_offsets() { assert_eq!(std::mem::offset_of!(Vertex, normal), 32); - assert_eq!(std::mem::size_of::(), 36); + assert_eq!(std::mem::size_of::(), 44); let part = plane([0.0, 1.0, 0.0]); assert_eq!( part.polygons[0].vertex_normal(ModelNormals::new(Mat4::from_scale(Vec3::ZERO))), diff --git a/crates/horizon-client/src/mobs/guardian.rs b/crates/horizon-client/src/mobs/guardian.rs index 9704de044..e57e6ee9f 100644 --- a/crates/horizon-client/src/mobs/guardian.rs +++ b/crates/horizon-client/src/mobs/guardian.rs @@ -396,6 +396,8 @@ pub fn beam_geometry( layer: 0, color, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); }; // Sides: one quad per texture repeat. diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 077fd38a6..8620d88f5 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -968,6 +968,7 @@ fn draw_normals( layer: overlay, color, light, + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 61fda9878..cd26f2203 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -167,8 +167,6 @@ impl Opt { | Opt::NarratorHotkey | Opt::ChatDelay | Opt::DistortionEffects - | Opt::GlintSpeed - | Opt::GlintStrength | Opt::MonochromeLogo | Opt::RotateWithMinecarts | Opt::HighContrastBlockOutlines @@ -625,10 +623,12 @@ impl Opt { self.file_key().unwrap(), crate::options_file::V::Double(f64::from(v).powi(2)), ), - Opt::DamageTilt | Opt::PanoramaSpeed => s.file.set( - self.file_key().unwrap(), - crate::options_file::V::Double(f64::from(v)), - ), + Opt::DamageTilt | Opt::PanoramaSpeed | Opt::GlintSpeed | Opt::GlintStrength => { + s.file.set( + self.file_key().unwrap(), + crate::options_file::V::Double(f64::from(v)), + ) + } Opt::Fov => s.fov = step(30.0, 110.0), Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32, Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32, diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index fa9dc9c6e..23679cfb7 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -3946,6 +3946,8 @@ impl ParticleEngine { }, color, light, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/shadows.rs b/crates/horizon-client/src/shadows.rs index dece7f726..613eb0bb1 100644 --- a/crates/horizon-client/src/shadows.rs +++ b/crates/horizon-client/src/shadows.rs @@ -119,6 +119,8 @@ pub fn shadow( layer: crate::gfx::SHADOW_OVERLAY, color, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); } } From a1761dcb658b8ac5401251320e3d18237067e42d Mon Sep 17 00:00:00 2001 From: masa-masa-0314 <348336850+masa-masa-0314@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:57:16 +0800 Subject: [PATCH 223/475] Lightning: move the bolt offset to vertex attribute 7 The retained bolt offset and the entity vertex's new glint UV were both declared at attribute location 6, and the lightning pipeline builds its vertex buffers from the entity layout plus the bolt-offset instance buffer, so Device::create_render_pipeline rejected the location clash and the client crashed at startup. Co-Authored-By: ZCode --- crates/horizon-client/src/entity.wgsl | 3 ++- crates/horizon-client/src/gfx.rs | 3 ++- crates/horizon-client/src/gfx/lightning/tests/mod.rs | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 307f3fbba..2e7abda21 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -118,9 +118,10 @@ fn entity_vertex(position: vec3, uv: vec2, } // Retained bolt-local geometry, translated using the same f64 camera // subtraction as the former CPU path before adding each local f32 position. +// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV. @vertex fn vs_lightning(@location(0) position: vec3, @location(1) uv: vec2, @location(2) overlay: u32, @location(3) color: vec4, @location(4) light: vec4, @location(5) normal: vec4, - @location(6) offset: vec3) -> Out { + @location(7) offset: vec3) -> Out { return entity_vertex(offset + position, uv, overlay, color, light, normal); } struct CloudInfo { diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index bd3c0b59c..bd2b932f0 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -1869,7 +1869,8 @@ impl Renderer { alpha_blend, false, ); - let lightning_attributes = wgpu::vertex_attr_array![6=>Float32x3]; + // Attribute 7: 6 is the entity vertex buffer's glint UV. + let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3]; let lightning_vertex = [ entity_vertex[0].clone(), Some(wgpu::VertexBufferLayout { diff --git a/crates/horizon-client/src/gfx/lightning/tests/mod.rs b/crates/horizon-client/src/gfx/lightning/tests/mod.rs index ccaf963a5..66959ce54 100644 --- a/crates/horizon-client/src/gfx/lightning/tests/mod.rs +++ b/crates/horizon-client/src/gfx/lightning/tests/mod.rs @@ -227,8 +227,9 @@ fn retained_gpu_draw_matches_transient_additive_draw() { label: Some("Lightning differential shader"), source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()), }); - let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4]; - let offsets = wgpu::vertex_attr_array![6=>Float32x3]; + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; + // Attribute 7: 6 is the entity vertex buffer's glint UV. + let offsets = wgpu::vertex_attr_array![7=>Float32x3]; let vertices = [Some(wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, From 43d1f4b80185a11e405199188a6ba993c520bf42 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:06:26 +0100 Subject: [PATCH 224/475] Draw the swirls of potion effects around players and mobs A client draws the particles of an entity's visible effects itself, from the synched effect particle list (LivingEntity.tickEffects). Horizon drew none and sent no such list: now the window's own player, a server's mobs and players, and the mobs and guests of a game in the frame all swirl. --- .../horizon-client/src/game/client_ambient.rs | 119 +++++++++++++++++- crates/horizon-client/src/game/lan.rs | 11 ++ .../src/game/remote_mob_data.rs | 31 ++++- crates/horizon-client/src/lan/hub.rs | 62 ++++++++- crates/horizon-client/src/mobs.rs | 4 + 5 files changed, 223 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/client_ambient.rs b/crates/horizon-client/src/game/client_ambient.rs index a10c6da49..48bfc663c 100644 --- a/crates/horizon-client/src/game/client_ambient.rs +++ b/crates/horizon-client/src/game/client_ambient.rs @@ -5,7 +5,8 @@ //! a server: nothing of them goes over the network. use super::Game; use horizon_client::mobs::{MobEvent, ambient}; -use horizon_client::particles::{ParticleOptions, ParticleType}; +use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType}; +use horizon_core::{geometry::Vec3, random::RandomSource}; impl Game<'_> { pub(super) fn tick_client_ambient(&mut self) { @@ -33,5 +34,121 @@ impl Game<'_> { } } self.eye_particles(); + self.effect_swirls(); + } + + /// `LivingEntity.tickEffects` on a client: the swirls around an entity + /// with visible effects, from the particles its synched data lists. The + /// window's player has its own effects, a server's mobs and players the + /// list the server sent, and the mobs and guests of a game in the frame + /// theirs. + fn effect_swirls(&mut self) { + if let Some(p) = self.player.as_ref() { + let effects = &self.potions.effects.list; + let list: Vec<_> = effects + .iter() + .filter(|e| e.visible) + .filter_map(|e| effect_particle(e.id, e.ambient)) + .collect(); + let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient); + let invisible = effects.iter().any(|e| e.id == "invisibility"); + let size = ( + f64::from(p.dimensions.width), + f64::from(p.dimensions.height), + ); + swirl( + &mut self.particles, + p.position, + size, + &list, + ambient, + invisible, + ); + } + let guests = self.lan.iter().flat_map(|host| host.models.values()); + for m in self.mobs.iter().chain(guests) { + if !m.alive() { + continue; + } + let size = (f64::from(m.width()), f64::from(m.height())); + let invisible = m.invisible || m.has_effect("minecraft:invisibility"); + if m.extra.effect_particles.is_empty() { + // A mob of this game: its own effects, visible and not ambient. + if m.extra.effects.is_empty() { + continue; + } + let list: Vec<_> = m + .extra + .effects + .iter() + .filter_map(|e| effect_particle(e.0, false)) + .collect(); + swirl( + &mut self.particles, + m.position, + size, + &list, + false, + invisible, + ); + } else { + let list = &m.extra.effect_particles; + let ambient = m.extra.effect_ambient; + swirl( + &mut self.particles, + m.position, + size, + list, + ambient, + invisible, + ); + } + } } } + +/// `MobEffect.particleFactory`: the omens have particles of their own, any +/// other effect an entity effect in its colour, faint when ambient. +pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option { + Some(match effect.trim_start_matches("minecraft:") { + "trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen), + "raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen), + other => { + let color = horizon_client::brewing::effect(other)?.color; + let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0; + // MobEffect.AMBIENT_ALPHA is 38 of 255. + let alpha = if ambient { 38.0 / 255.0 } else { 1.0 }; + ParticleOptions::colored( + ParticleType::EntityEffect, + [channel(16), channel(8), channel(0), alpha], + ) + } + }) +} + +/// One tick of `LivingEntity.tickEffects`' client branch: a one in four +/// chance (one in fifteen while invisible, a fifth of either when all the +/// effects are ambient) of one of the particles at a random spot of the box. +fn swirl( + particles: &mut ParticleEngine, + feet: Vec3, + size: (f64, f64), + list: &[ParticleOptions], + ambient: bool, + invisible: bool, +) { + if list.is_empty() { + return; + } + let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 }; + let r = particles.random(); + let Ok(0) = r.next_int_bound(bound) else { + return; + }; + // Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ. + let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize; + let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5; + let y = feet.y + size.1 * r.next_double(); + let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5; + particles.add(&list[pick], [x, y, z], [1.0; 3]); +} diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..a48c30656 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -178,6 +178,11 @@ fn mob_meta( meta.push((9, serializer::FLOAT, Value::Float(health))); } defaults.push((9, serializer::FLOAT, Value::Float(1.0))); + // LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the + // swirls of its effects (the ones a mob has are visible, not ambient). + meta.extend(hub.effect_meta(m.extra.effects.iter().map(|e| (e.0, false)))); + defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new()))); + defaults.push((11, serializer::BOOLEAN, Value::Bool(false))); // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what // draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised // weapon). @@ -1409,6 +1414,12 @@ impl Game<'_> { .each_ref() .map(|s| s.as_ref().map(|s| s.item.clone())); m.extra.crouching = g.sneaking; + // LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects. + let visible = || g.effects.list.iter().filter(|e| e.visible); + m.extra.effect_particles = visible() + .filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient)) + .collect(); + m.extra.effect_ambient = visible().all(|e| e.ambient); // Lying in its bed (LivingEntity.getBedOrientation). m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b)); // LivingEntity.updateSwingTime: the arm swing lan_swings started. diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index c6702dfb6..3c7d6fb49 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -5,7 +5,8 @@ use super::Game; use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES}; use horizon_client::mobs::{Mob, Species, Who, bat, tame}; -use horizon_protocol::entity_data::Value; +use horizon_client::particles::{ParticleOptions, ParticleType}; +use horizon_protocol::entity_data::{Particle, ParticleData, Value}; use horizon_protocol::registry::Builtin; /// The values `apply` takes from one SetEntityData packet, by vanilla's entry @@ -34,6 +35,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti // LivingEntity.DATA_HEALTH_ID: the boss bars and the death // animation follow it. (9, Value::Float(health)) => m.health = *health, + // LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: + // the swirls of its effects (`tickEffects` draws them). + (10, Value::Particles(list)) => m.extra.effect_particles = swirls(list, builtin), + (11, Value::Bool(ambient)) => m.extra.effect_ambient = *ambient, // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4. // Mob.isAggressive draws the illagers' and skeletons' attack // poses and the vindicator's axe. @@ -100,6 +105,30 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti } } +/// The options of a synched particle list: an entity effect carries its ARGB +/// colour, the omens none. +fn swirls(list: &[Particle], builtin: &Builtin) -> Vec { + let channel = |argb: i32, shift: u32| ((argb >> shift) & 255) as f32 / 255.0; + list.iter() + .filter_map(|p| { + let name = builtin.get("minecraft:particle_type")?.name(p.id)?; + let ty = ParticleType::from_name(name)?; + Some(match p.data { + Some(ParticleData::Color(argb)) => ParticleOptions::colored( + ty, + [ + channel(argb, 16), + channel(argb, 8), + channel(argb, 0), + channel(argb, 24), + ], + ), + _ => ParticleOptions::simple(ty), + }) + }) + .collect() +} + /// VillagerData as the network sends it: the type and the profession by /// their built-in registry ids, then the level. fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) { diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1a8cbc62..c76404291 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -21,7 +21,7 @@ use crate::{ use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; use horizon_protocol::ids::play::clientbound as ids; use horizon_protocol::{ - entity_data::{Value, serializer}, + entity_data::{Particle, ParticleData, Value, serializer}, packets::play::{self, Clientbound as C, Serverbound as S}, text::{literal, translatable}, }; @@ -1500,6 +1500,49 @@ impl Hub { // ---- entities ------------------------------------------------------ + /// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES + /// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as + /// (effect, ambient). The entries are left out when there is nothing to + /// show, which is their default. + pub fn effect_meta<'a>( + &self, + effects: impl IntoIterator, + ) -> Vec<(u8, i32, Value)> { + let mut particles = Vec::new(); + let mut all_ambient = true; + for (effect, ambient) in effects { + let effect = effect.trim_start_matches("minecraft:"); + // MobEffect.particleFactory: the omens have their own particles, + // the others an entity effect in their colour (faint if ambient). + let (name, data) = match effect { + "trial_omen" | "raid_omen" => (effect, None), + _ => { + let Some(def) = crate::brewing::effect(effect) else { + continue; + }; + let alpha = if ambient { 38 } else { 255 }; + let argb = (alpha << 24) | def.color as i32; + ("entity_effect", Some(ParticleData::Color(argb))) + } + }; + let Some(id) = + self.registry_id("minecraft:particle_type", &format!("minecraft:{name}")) + else { + continue; + }; + particles.push(Particle { id, data }); + all_ambient &= ambient; + } + if particles.is_empty() { + return Vec::new(); + } + let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))]; + if all_ambient { + meta.push((11, serializer::BOOLEAN, Value::Bool(true))); + } + meta + } + fn player_state( &self, id: i32, @@ -1514,6 +1557,7 @@ impl Hub { health: f32, equipment: Vec<(u8, Option)>, attributes: Vec, + effects: &[EffectInstance], ) -> EntityState { let (sneaking, sprinting, gliding, sleeping) = flags; let shared = shared_flags(sneaking, sprinting, gliding); @@ -1547,6 +1591,14 @@ impl Hub { if skin != 0 { meta.push((16, serializer::BYTE, Value::Byte(skin as i8))); } + meta.extend( + self.effect_meta( + effects + .iter() + .filter(|e| e.visible) + .map(|e| (e.id, e.ambient)), + ), + ); EntityState { id, uuid, @@ -1559,7 +1611,11 @@ impl Hub { velocity: [0.0; 3], data: 0, meta, - defaults: Vec::new(), + // The effect swirls go back to none. + defaults: vec![ + (10, serializer::PARTICLES, Value::Particles(Vec::new())), + (11, serializer::BOOLEAN, Value::Bool(false)), + ], equipment, attributes, precise: false, @@ -1614,6 +1670,7 @@ impl Hub { h.health, equipment, h.attributes.clone(), + &[], )); } let placed: Vec = (0..self.guests.len()) @@ -1649,6 +1706,7 @@ impl Hub { .as_ref() .map(|m| super::attributes::syncable(&m.attributes)) .unwrap_or_default(), + &g.effects.list, ) }) .collect(); diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 6b1e16f5c..0bc37bcc7 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -715,6 +715,10 @@ pub struct Extra { pub vector: [f64; 3], /// Active status effects: (effect id, ticks left, amplifier). pub effects: Vec<(&'static str, i32, i32)>, + /// `LivingEntity.DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID` as a + /// server gave them: the swirls around it, and whether they are all faint. + pub effect_particles: Vec, + pub effect_ambient: bool, /// An invisible illusioner's mirror images. pub illusions: Option, /// A villager's claimed bed head and meeting bell. From efd6c60a272d20f69e498a48afe3e96a759cf164 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:07:36 +0100 Subject: [PATCH 225/475] Axolotl air: refill only where it rains locally, not in deserts or snowy biomes --- crates/horizon-client/src/mobs/ai.rs | 2 +- crates/horizon-client/src/mobs/world.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 403f63101..3499ab29d 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -458,7 +458,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { m.position.z.floor() as i32, ); let top = (m.position.y + f64::from(m.height())).floor() as i32; - let in_rain = w.raining && (w.can_see_sky(x, y, z) || w.can_see_sky(x, top, z)); + let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z); if m.fluids.in_water || in_rain { m.air = super::axolotl::MAX_AIR; } else { diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 7836a49ef..db2557e72 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -177,6 +177,25 @@ impl MobWorld<'_> { } self.world.light(x, y, z) >> 4 == 15 } + /// Level.isRainingAt: raining, open to the sky, nothing motion blocking + /// above, and the biome's precipitation at the position (its temperature + /// lowered above the snow level) is rain, not snow or none. + pub fn raining_at(&self, x: i32, y: i32, z: i32) -> bool { + if !self.raining || !self.can_see_sky(x, y, z) || self.motion_blocking_height(x, z) > y { + return false; + } + let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else { + return false; + }; + let biome = self.biomes.get(chunk.biome(x, y, z)); + let snow_level = self.sea_level + 17; + let temperature = if y > snow_level { + biome.temperature - (y - snow_level) as f32 * 0.05 / 40.0 + } else { + biome.temperature + }; + biome.precipitation && temperature >= 0.15 + } /// Level.getLightLevelDependentMagicValue: the dimension's brightness curve /// at the raw (sky darkened) light level. pub fn brightness(&self, x: i32, y: i32, z: i32) -> f32 { From 18f8a6868bb18d843562663835a5298bfdcae1a9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:09:23 +0100 Subject: [PATCH 226/475] Burst a breaking potion into item crumbs and coloured spell particles A thrown potion or experience bottle that breaks sends level events 2002 or 2007 with its colour and the splash sound (1053, 1054). Horizon spawned only eight terrain crumbs, on the server's own side, so a server player saw nothing. --- crates/horizon-client/src/game.rs | 17 ++++- .../src/game/level_particles.rs | 63 +++++++++++++++++++ crates/horizon-client/src/game/potions.rs | 61 +++++------------- 3 files changed, 93 insertions(+), 48 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8b82f7bed..54e017943 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2926,6 +2926,17 @@ impl Game<'_> { pos, data, } => self.smash_dust(pos, data), + // LevelEventHandler 2002 and 2007: a potion's splash. + LevelEvent::LevelEvent { + id: 2002, + pos, + data, + } => self.potion_splash_particles(pos, data, false), + LevelEvent::LevelEvent { + id: 2007, + pos, + data, + } => self.potion_splash_particles(pos, data, true), LevelEvent::LevelEvent { id: id @ 3002..=3005, pos, @@ -2958,6 +2969,8 @@ impl Game<'_> { 1044 => Some(("minecraft:block.smithing_table.use", 1.0)), // LevelEvent.SOUND_PAGE_TURN (a lectern's page). 1043 => Some(("minecraft:item.book.page_turn", 1.0)), + // LevelEvent.SOUND_SPLASH_POTION and its instant twin. + 1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)), _ => None, }; if let Some((name, volume)) = sound { @@ -2967,12 +2980,12 @@ impl Game<'_> { - self.world.random.next_float()) * 0.8 } - 1029 | 1030 | 1042 | 1043 | 1044 => { + 1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => { self.world.random.next_float() * 0.1 + 0.9 } _ => 1.0, }; - if matches!(id, 1029 | 1030) { + if matches!(id, 1029 | 1030 | 1053 | 1054) { // Clients play the anvil sound from its level event; // capturing it also sends a duplicate sound packet. self.audio.play_unshared(name, c, volume, pitch); diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index f12aedc5b..9fa53eb25 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -3,6 +3,7 @@ //! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax //! off (3004) and scraping (3005). use super::Game; +use horizon_client::items::ItemStack; use horizon_client::particles::{self, ParticleOptions, ParticleType}; use horizon_core::{position::BlockPos, random::RandomSource}; use horizon_world::level::LevelEvent; @@ -134,6 +135,68 @@ impl Game<'_> { // Each guest plays it from the global packet. self.audio.play_unshared(sound, at, volume, 1.0); } + /// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007 + /// with `InstantEffect`): eight splash potion crumbs, and a hundred + /// spell particles in the potion's colour `data`, flung out in a disc. + pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) { + let ty = if instant { + ParticleType::InstantEffect + } else { + ParticleType::Effect + }; + // Vec3.atBottomCenterOf. + let at = [ + f64::from(pos.x) + 0.5, + f64::from(pos.y), + f64::from(pos.z) + 0.5, + ]; + let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1)); + let r = self.particles.level_random(); + let crumbs: Vec<[f64; 3]> = (0..8) + .map(|_| { + [ + r.next_gaussian() * 0.15, + r.next_double() * 0.2, + r.next_gaussian() * 0.15, + ] + }) + .collect(); + for v in crumbs { + self.particles.add(&crumb, at, v); + } + let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0; + let color = [channel(16), channel(8), channel(0)]; + for _ in 0..100 { + let r = self.particles.level_random(); + let dist = r.next_double() * 4.0; + let angle = r.next_double() * std::f64::consts::PI * 2.0; + let velocity = [ + angle.cos() * dist, + 0.01 + r.next_double() * 0.5, + angle.sin() * dist, + ]; + let brightness = 0.75 + r.next_float() * 0.25; + let options = ParticleOptions { + color: [ + color[0] * brightness, + color[1] * brightness, + color[2] * brightness, + 1.0, + ], + scale: dist as f32, + ..ParticleOptions::simple(ty) + }; + self.particles.add( + &options, + [ + at[0] + velocity[0] * 0.1, + at[1] + 0.3, + at[2] + velocity[2] * 0.1, + ], + velocity, + ); + } + } /// Queue and play level event `id` at `pos` (`Level.levelEvent`). pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) { self.level_events.push(LevelEvent::LevelEvent { diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 13eae7d3e..0061fd5d3 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -674,62 +674,31 @@ impl Game<'_> { } /// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash). fn splash(&mut self, stack: &ItemStack, at: Vec3) { + let block = [at.x, at.y, at.z].map(|v| v.floor() as i32); if &*stack.item == "minecraft:experience_bottle" { // ThrownExperienceBottle.onHit: the water-coloured splash (level - // event 2002) and 3 to 11 experience. - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio.play( - "minecraft:entity.splash_potion.break", - Some([at.x, at.y, at.z]), - 1.0, - pitch, - ); - for _ in 0..8 { - let v = [ - f64::from(self.world.random.next_float() - 0.5) * 0.3, - 0.2, - f64::from(self.world.random.next_float() - 0.5) * 0.3, - ]; - let p = horizon_client::particles::Particle::terrain( - &mut self.world.random, - [at.x, at.y, at.z], - v, - "item/splash_potion".into(), - 0x38_5DC6, - ); - self.particles.push(p); - } + // event 2002, its sound 1053) and 3 to 11 experience. + self.level_event_at(2002, block, 0xFF38_5DC6u32 as i32); + self.level_event_at(1053, block, 0); let r = &mut self.world.random; let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0); self.spawn_orbs([at.x, at.y, at.z], value as u32); return; } let lingering = &*stack.item == "minecraft:lingering_potion"; - let event = if lingering { - "minecraft:entity.lingering_potion.break" - } else { - "minecraft:entity.splash_potion.break" - }; - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio.play(event, Some([at.x, at.y, at.z]), 1.0, pitch); + // AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a + // potion with instant effects, 2002 and 1053 otherwise. + let potion = stack.potion().and_then(brewing::potion); + let instant = potion.is_some_and(|d| { + d.effects + .iter() + .any(|(id, ..)| brewing::effect(id).is_some_and(|e| e.instant)) + }); let color = brewing::potion_color(stack.potion()); - for _ in 0..8 { - let v = [ - f64::from(self.world.random.next_float() - 0.5) * 0.3, - 0.2, - f64::from(self.world.random.next_float() - 0.5) * 0.3, - ]; - let p = horizon_client::particles::Particle::terrain( - &mut self.world.random, - [at.x, at.y, at.z], - v, - "item/splash_potion".into(), - color, - ); - self.particles.push(p); - } + self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32); + self.level_event_at(if instant { 1054 } else { 1053 }, block, 0); let scale = if lingering { 0.25 } else { 1.0 }; - let Some(def) = stack.potion().and_then(brewing::potion) else { + let Some(def) = potion else { return; }; // Every player in range (`ThrownSplashPotion.applySplash` over From 1c6c6c0b97ae6144bf476d76962d744c6d29a815 Mon Sep 17 00:00:00 2001 From: masa-masa-0314 <348336850+masa-masa-0314@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:45:23 +0800 Subject: [PATCH 227/475] Items: sample the glint from its own repeating texture Vanilla's GlintSampler repeats enchanted_glint_item per pixel, and the GLINT transform's scale 8 spans about 8 repeats across an item. Folding the UV per vertex into the shared atlas could only interpolate between the four corners, so the pattern rendered as one wide diagonal band instead of the texture's fine weave. The glint now binds as its own 128x128 texture with a repeat, linear, mip-free sampler (the texture's mcmeta: clamp false, blur true) in the HUD and entity pipelines, and `Glint` passes the unwrapped `GLINT_TEXTURING` UV through. Co-Authored-By: ZCode --- crates/horizon-client/src/entity.wgsl | 5 +- crates/horizon-client/src/game.rs | 7 ++ crates/horizon-client/src/game/item_draw.rs | 3 +- crates/horizon-client/src/gfx.rs | 71 +++++++++++++++++++- crates/horizon-client/src/hud.rs | 43 +++++++++++- crates/horizon-client/src/hud.wgsl | 5 +- crates/horizon-client/src/item_render.rs | 21 +++--- crates/horizon-client/src/item_render/gui.rs | 23 +++++-- 8 files changed, 154 insertions(+), 24 deletions(-) diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 2e7abda21..993f86947 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -27,6 +27,9 @@ struct Globals { @group(0) @binding(2) var atlas_sampler: sampler; @group(0) @binding(3) var lightmap: texture_2d; @group(0) @binding(4) var lightmap_sampler: sampler; +// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler). +@group(0) @binding(5) var glint_tex: texture_2d; +@group(0) @binding(6) var glint_sampler: sampler; struct Out { @builtin(position) clip: vec4, @location(0) uv: vec2, @@ -214,7 +217,7 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, // Enchantment glint: item.fsh's GLINT path, sampled from // enchanted_glint_item at the `GLINT_TEXTURING` UV. let alpha = globals.fog_ends.w; - let glint = alpha * textureSample(atlas, atlas_sampler, in.glint_uv); + let glint = alpha * textureSample(glint_tex, glint_sampler, in.glint_uv); color = vec4(color.rgb + glint.rgb * glint.rgb, max(color.a, alpha)); } let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 91045ada5..f4663c844 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -13385,6 +13385,7 @@ fn build_renderer( &assets.sprite_mips, &assets.animations, (&atlas.pixels, atlas.width, atlas.height), + (&atlas.glint.0, atlas.glint.1, atlas.glint.2), (&pack.forcefield.2, pack.forcefield.0), &gui.panorama, prepared, @@ -13411,6 +13412,7 @@ fn loading_renderer( ) -> Result { static FORCEFIELD: [u8; 4] = [0; 4]; static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255]; + static EMPTY_GLINT: [u8; 4] = [0; 4]; let empty_panorama = horizon_client::gfx::panorama::Faces { size: 1, pixels: [0u8; 4].repeat(6), @@ -13421,6 +13423,10 @@ fn loading_renderer( Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height), None => (EMPTY_HUD.as_slice(), 1, 1), }; + let (glint_pixels, glint_width, glint_height) = match atlas { + Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2), + None => (EMPTY_GLINT.as_slice(), 1, 1), + }; // A glyph rasterized into an earlier texture is not in these pixels; the // new texture gets it again when the character is drawn (see // `build_renderer`). @@ -13437,6 +13443,7 @@ fn loading_renderer( &[], &[], (pixels, width, height), + (glint_pixels, glint_width, glint_height), (&FORCEFIELD[..], 1), panorama, None, diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index 580701ccd..2dac91f77 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -307,8 +307,7 @@ impl Game<'_> { .sprites .get("misc/enchanted_glint_item") .copied() - .map(|rect| item_render::Glint { - rect, + .map(|_| item_render::Glint { speed: self.settings.file.double("glintSpeed") as f32, alpha: self.settings.file.double("glintStrength") as f32, millis: self.started.elapsed().as_secs_f32() * 1000.0, diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index bd2b932f0..339f7e7c2 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -1023,6 +1023,7 @@ impl Renderer { sprite_mips: &[crate::mipmap::Settings], animations: &[SpriteAnimation], hud_atlas: (&[u8], u32, u32), + glint: (&[u8], u32, u32), forcefield: (&[u8], u32), panorama_faces: &panorama::Faces, prepared: Option, @@ -1576,7 +1577,45 @@ impl Renderer { atlas_pixels, ); let atlas_view = atlas.create_view(&Default::default()); - let mut hud = HudRenderer::new(&device, config.format.remove_srgb_suffix(), &atlas_view)?; + // vanilla's `GlintSampler`: enchanted_glint_item as its own texture, + // repeated per pixel (`clamp: false`), linear without mipmaps + // (`blur: true`). + let (glint_pixels, glint_width, glint_height) = glint; + let glint_texture = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("Enchanted glint"), + size: wgpu::Extent3d { + width: glint_width, + height: glint_height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + glint_pixels, + ); + let glint_view = glint_texture.create_view(&Default::default()); + let glint_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Glint"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let mut hud = HudRenderer::new( + &device, + config.format.remove_srgb_suffix(), + &atlas_view, + &glint_view, + &glint_sampler, + )?; hud.atlas_texture = Some(atlas.clone()); let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Horizon entities"), @@ -1622,6 +1661,20 @@ impl Renderer { vf, wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), ), + entry( + 5, + vf, + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + ), + entry( + 6, + vf, + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + ), ], }); let weather = weather::Weather::new( @@ -1658,6 +1711,14 @@ impl Renderer { binding: 4, resource: wgpu::BindingResource::Sampler(&lightmap_sampler), }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&glint_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&glint_sampler), + }, ], }); let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -1684,6 +1745,14 @@ impl Renderer { binding: 4, resource: wgpu::BindingResource::Sampler(&lightmap_sampler), }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&glint_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&glint_sampler), + }, ], }); let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs index 41eae1ec7..7f0d31ab0 100644 --- a/crates/horizon-client/src/hud.rs +++ b/crates/horizon-client/src/hud.rs @@ -67,6 +67,9 @@ pub struct HudAtlas { pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>, /// The default font (bitmap sheets in this atlas, unifont rasterized on demand). pub font: crate::font::Font, + /// `misc/enchanted_glint_item` as its own image: vanilla's `GlintSampler` + /// repeats it per pixel, which one sprite inside this atlas cannot. + pub glint: (Vec, u32, u32), white: Rect, } /// Atlas rows reserved for glyphs rasterized at run time. @@ -526,6 +529,7 @@ impl HudAtlas { } } let white = sprites["horizon/white"]; + let glint = Self::glint_texture(root, defaults); let font = crate::font::Font::load( root, &sprites, @@ -542,10 +546,20 @@ impl HudAtlas { translucent, flags, fire_animations, + glint, font, white, }) } + /// `misc/enchanted_glint_item.png` on its own, or a transparent texel if + /// the resource pack lacks it. + fn glint_texture(root: &Path, defaults: &Path) -> (Vec, u32, u32) { + let mut path = root.join("assets/minecraft/textures/misc/enchanted_glint_item.png"); + if !path.exists() && defaults != root { + path = defaults.join("assets/minecraft/textures/misc/enchanted_glint_item.png"); + } + decode(&path).map_or((vec![0; 4], 1, 1), |(w, h, pixels)| (pixels, w, h)) + } } #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] @@ -554,7 +568,8 @@ pub struct HudVertex { pub uv: [f32; 2], pub color: u32, pub depth: f32, - /// The glint sprite UV (`GLINT_TEXTURING`), unused unless `uv.x >= 2`. + /// The `GLINT_TEXTURING` UV into the repeating glint texture, unused + /// unless `uv.x >= 2`. pub glint_uv: [f32; 2], } /// The GUI Scale option (0 = auto). @@ -1080,6 +1095,8 @@ impl HudRenderer { device: &wgpu::Device, format: wgpu::TextureFormat, view: &wgpu::TextureView, + glint_view: &wgpu::TextureView, + glint_sampler: &wgpu::Sampler, ) -> Result { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Horizon HUD"), @@ -1124,6 +1141,22 @@ impl HudRenderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }); let group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -1142,6 +1175,14 @@ impl HudRenderer { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(glint_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(glint_sampler), + }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { diff --git a/crates/horizon-client/src/hud.wgsl b/crates/horizon-client/src/hud.wgsl index 1f6d5797e..435f7f062 100644 --- a/crates/horizon-client/src/hud.wgsl +++ b/crates/horizon-client/src/hud.wgsl @@ -7,6 +7,9 @@ struct Hud { @group(0) @binding(0) var hud: Hud; @group(0) @binding(1) var atlas: texture_2d; @group(0) @binding(2) var atlas_sampler: sampler; +// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler). +@group(0) @binding(3) var glint_tex: texture_2d; +@group(0) @binding(4) var glint_sampler: sampler; struct Out { @builtin(position) clip: vec4, @location(0) uv: vec2, @@ -31,7 +34,7 @@ struct Out { if in.uv.x >= 2.0 { let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a; if mask < 0.1 { discard; } - let glint = hud.size.w * textureSample(atlas, atlas_sampler, in.glint_uv); + let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv); return vec4(glint.rgb * glint.rgb, mask); } let c = textureSample(atlas, atlas_sampler, in.uv) * in.color; diff --git a/crates/horizon-client/src/item_render.rs b/crates/horizon-client/src/item_render.rs index 0a72b2ecc..76b13dbca 100644 --- a/crates/horizon-client/src/item_render.rs +++ b/crates/horizon-client/src/item_render.rs @@ -50,10 +50,9 @@ pub struct ItemQuad { /// `MaterialInfo.lightEmission` (block light floor). pub light_emission: u8, } -/// `TextureTransform.setupGlintTexturing`'s inputs and the glint sprite. +/// `TextureTransform.setupGlintTexturing`'s inputs. #[derive(Clone, Copy, Debug)] pub struct Glint { - pub rect: crate::hud::Rect, /// `options.glintSpeed` (vanilla default 0.5). pub speed: f32, /// `options.glintStrength` / shader `GlintAlpha` (vanilla default 0.75). @@ -65,21 +64,19 @@ pub struct Glint { pub scale: f32, } impl Glint { - /// `TextureTransform.setupGlintTexturing(scale)`: translation(-o0, o1) * - /// rotateZ(PI/18) * scale, wrapped and mapped into the glint sprite. - pub fn atlas_uv(&self, model: [f32; 2]) -> [f32; 2] { + /// `TextureTransform.setupGlintTexturing(scale)` applied to the model UV: + /// translation(-o0, o1) * rotateZ(PI/18) * scale. The result repeats: + /// the shader samples the dedicated glint texture with vanilla's + /// repeating `GlintSampler`, so the UVs stay unwrapped. + pub fn uv(&self, model: [f32; 2]) -> [f32; 2] { let millis = (f64::from(self.millis) * f64::from(self.speed) * 8.0) as i64; let offset0 = (millis % 110000) as f32 / 110000.0; let offset1 = (millis % 30000) as f32 / 30000.0; let angle = std::f32::consts::PI / 18.0; let (sin, cos) = angle.sin_cos(); - let x = model[0] * self.scale * cos - model[1] * self.scale * sin - offset0; - let y = model[0] * self.scale * sin + model[1] * self.scale * cos + offset1; - let u = (x - x.floor()).clamp(0.5 / self.rect.w as f32, 1.0 - 0.5 / self.rect.w as f32); - let v = (y - y.floor()).clamp(0.5 / self.rect.h as f32, 1.0 - 0.5 / self.rect.h as f32); [ - self.rect.u0 + u * (self.rect.u1 - self.rect.u0), - self.rect.v0 + v * (self.rect.v1 - self.rect.v0), + model[0] * self.scale * cos - model[1] * self.scale * sin - offset0, + model[0] * self.scale * sin + model[1] * self.scale * cos + offset1, ] } } @@ -739,7 +736,7 @@ impl ItemSink for WorldSink<'_> { }; let overlay = if glint.is_some() { 7 } else { 0 }; for (i, (p, uv)) in q.pos.iter().zip(q.uv).enumerate() { - let glint_uv = glint.map_or([0.0, 0.0], |glint| glint.atlas_uv(q.foil_uv[i])); + let glint_uv = glint.map_or([0.0, 0.0], |glint| glint.uv(q.foil_uv[i])); self.out.push(Vertex { normal: [0; 4], pos: p.to_array(), diff --git a/crates/horizon-client/src/item_render/gui.rs b/crates/horizon-client/src/item_render/gui.rs index 87dfb354e..7c65ea86d 100644 --- a/crates/horizon-client/src/item_render/gui.rs +++ b/crates/horizon-client/src/item_render/gui.rs @@ -111,7 +111,7 @@ fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: batch.quad_glint( corners.map(|v| v.p.to_array()), corners.map(|v| [v.uv[0] + 2.0, v.uv[1]]), - corners.map(|v| glint.atlas_uv(v.glint_uv)), + corners.map(|v| glint.uv(v.glint_uv)), u32::MAX, ); batch.blend_since(start, crate::hud::Blend::Additive); @@ -157,7 +157,18 @@ pub fn draw( return; } let start = batch.vertices.len(); - draw_now(state, renderer, sprites, host, batch, x, y, time); + draw_now( + state, + renderer, + sprites, + host, + batch, + x, + y, + time, + glint_speed, + glint_alpha, + ); if let Some(key) = key && let Ok(mut drawn) = renderer.gui_drawn.lock() { @@ -231,6 +242,8 @@ fn draw_now( x: f32, y: f32, time: f32, + glint_speed: f32, + glint_alpha: f32, ) { let pose = Mat4::from_translation(Vec3::new(x + 8.0, y + 8.0, 0.0)) * Mat4::from_scale(Vec3::new(16.0, -16.0, 16.0)); @@ -252,10 +265,8 @@ fn draw_now( [x, y, x + 16.0, y + 16.0] }; let glint = sprites - .get("misc/enchanted_glint_item") - .copied() - .map(|rect| Glint { - rect, + .contains_key("misc/enchanted_glint_item") + .then_some(Glint { speed: glint_speed, alpha: glint_alpha, millis: time * 1000.0, From 303281ed09e4236062459aca955c3416a1224dca Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:12:28 +0100 Subject: [PATCH 228/475] Puff a poof where a dead mob's body goes A mob removes its body twenty ticks after dying and tells everyone with entity event 60, which draws twenty poofs about it. Horizon removed dead mobs silently. --- crates/horizon-client/src/game/creatures.rs | 29 +++++++++++++++++++++ crates/horizon-client/src/game/lan.rs | 10 +++++++ crates/horizon-client/src/game/remote.rs | 7 +++++ 3 files changed, 46 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 145717cb3..9f44a4e0b 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -525,6 +525,8 @@ impl Game<'_> { _ => Vec::new(), }; let mut guest_events = Vec::new(); + // The mobs whose bodies go this tick: (id, feet, width, height). + let mut gone = Vec::new(); let level_random; { // The level random, lent to the mobs (brain behaviours draw from @@ -597,6 +599,11 @@ impl Game<'_> { let from = events.len(); let kept = m.tick(&w, &mut scratch, &mut events); horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes); + // LivingEntity.tickDeath: after twenty ticks the body is removed. + if !kept && !m.removed && !m.alive() && m.death_time >= 20 { + let (width, height) = (f64::from(m.width()), f64::from(m.height())); + gone.push((m.id, m.position, width, height)); + } if let Some(conn) = seen_guest { // ShulkerBullet.finalTarget is the guest's entity: the // bullet homes on it while the projectiles tick, where @@ -655,6 +662,12 @@ impl Game<'_> { } self.mobs.append(&mut mobs); self.died = died; + // ... and entity event 60 goes out as it is: the poof. + for (id, at, width, height) in gone { + let at = [at.x, at.y, at.z]; + self.lan_entity_event(id as i32, 60, at); + self.death_poof(at, width, height); + } self.sync_riders(); self.tick_skeleton_traps(); self.tick_apparition(); @@ -681,6 +694,22 @@ impl Game<'_> { self.handle_mob_events(queued)?; Ok(()) } + /// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box. + pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) { + let options = horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::Poof, + ); + for _ in 0..20 { + let r = self.particles.level_random(); + let xa = r.next_gaussian() * 0.02; + let ya = r.next_gaussian() * 0.02; + let za = r.next_gaussian() * 0.02; + let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0; + let y = at[1] + height * r.next_double() - ya * 10.0; + let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0; + self.particles.add(&options, [x, y, z], [xa, ya, za]); + } + } /// LivingEntity.die / dropAllDeathLoot. fn mob_died(&mut self, m: &Mob) { let killer = self.last_hurt_by_player.remove(&m.id); diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..3478ed6ef 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -952,6 +952,16 @@ impl Game<'_> { host.hub.block_event(pos, b0, b1, state); } let entities = states.as_slice(); + // An entity that left the level since the last tick (a snowball + // that hit) is dropped by the tracker below: its event goes out + // first, as `Level.broadcastEntityEvent` precedes the discard. + let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events) + .into_iter() + .partition(|(id, ..)| !entities.iter().any(|e| e.id == *id)); + self.lan_entity_events = later; + for (id, event, at) in gone { + host.hub.entity_event(id, event, at); + } let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { let clock_rate = self.network_clock_rate(); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..64320bd37 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2130,6 +2130,13 @@ impl Game<'_> { { m.health = 0.0; } + // LivingEntity.handleEntityEvent 60: the poof as the dead body goes. + if p.event == 60 + && let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32) + { + let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height())); + self.death_poof([at.x, at.y, at.z], w, h); + } if p.event == super::totem::TOTEM_EVENT { self.remote_totem(p.id); } From d78238f7651843e1f056a0ecdcfdf4a09adcf8fd Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:14:32 +0100 Subject: [PATCH 229/475] Show the enchanted hit when a melee weapon's enchantments add damage Player.attack shows magic crit particles whenever the enchantments added damage. Horizon showed only the critical hit, for the window's player and for a guest's attack. --- crates/horizon-client/src/game.rs | 23 ++++++++++++++----- crates/horizon-client/src/game/lan.rs | 25 ++++++++++++--------- crates/horizon-client/src/game/lan_pvp.rs | 17 +++++++++----- crates/horizon-client/src/game/levels.rs | 2 +- crates/horizon-client/src/lan/containers.rs | 9 ++++---- 5 files changed, 50 insertions(+), 26 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 30e832eb0..e9add4a82 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1658,7 +1658,7 @@ struct Game<'a> { lan_block_events: Vec<([i32; 3], i32, i32, u32)>, lan_entity_events: Vec<(i32, i8, [f64; 3])>, /// Successful crits (attacking guest, hit entity), queued while the hub is taken. - lan_critical_hits: Vec<(Option, i32)>, + lan_critical_hits: Vec<(Option, i32, bool)>, /// Global level events for every guest (`ServerLevel.globalLevelEvent`). lan_global_events: Vec<(i32, [i32; 3], i32)>, /// `ContainerOpenersCounter`: how many players (the host and the guests) @@ -7602,7 +7602,7 @@ impl Game<'_> { /// strength and whether it is a critical hit): the weapon and effects /// scaled by the attack strength, a mace's smash, the critical hit, then /// enchantments. - pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) { + pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) { let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage); // Riptide's spin replaces the attack damage attribute. let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg); @@ -7630,8 +7630,9 @@ impl Game<'_> { amount *= 1.5; } // Enchantment damage (sharpness, smite, bane) scaled by the attack strength. - amount += self.enchant_damage_bonus(target_kind) * scale; - (amount, scale, crit) + let magic_boost = self.enchant_damage_bonus(target_kind) * scale; + amount += magic_boost; + (amount, scale, crit, magic_boost > 0.0) } /// `Player.getAttackStrengthScale(0.5)`. fn attack_strength_scale(&self) -> f32 { @@ -7818,7 +7819,7 @@ impl Game<'_> { pub(super) fn attack_mob(&mut self, index: usize) { let target_kind = self.mobs[index].kind.id.clone(); let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:")); - let (amount, scale, crit) = self.melee_damage(&target_kind); + let (amount, scale, crit, magic) = self.melee_damage(&target_kind); let sweep = self.sweep_attack_position(scale, crit); let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale); self.attack_ticks = 0; @@ -7881,6 +7882,18 @@ impl Game<'_> { feet, width, height, + false, + ); + } + // Player.attackVisualEffects: magicCrit when the enchantments added damage. + if magic { + self.critical_hit( + self.lan_acting, + self.mobs[index].id as i32, + feet, + width, + height, + true, ); } if actual > 2.0 { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..f1b14940a 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1224,8 +1224,9 @@ impl Game<'_> { self.lan_entity_events.push((id, event, at)); } } - /// `ServerPlayer.crit`: this window's emitter, and an animation for the - /// attacker's client and observers once the hub is back in place. + /// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's + /// emitter, and an animation for the attacker's client and observers once + /// the hub is back in place. pub(super) fn critical_hit( &mut self, attacker: Option, @@ -1233,16 +1234,18 @@ impl Game<'_> { feet: [f64; 3], width: f64, height: f64, + magic: bool, ) { use horizon_client::particles::{ParticleOptions, ParticleType}; - self.particles.tracking_emitter( - ParticleOptions::simple(ParticleType::Crit), - feet, - width, - height, - ); + let particle = if magic { + ParticleType::EnchantedHit + } else { + ParticleType::Crit + }; + self.particles + .tracking_emitter(ParticleOptions::simple(particle), feet, width, height); if self.audio.capture.is_some() { - self.lan_critical_hits.push((attacker, id)); + self.lan_critical_hits.push((attacker, id, magic)); } } /// Send the tick's level events, sounds and mob animations. @@ -1320,13 +1323,13 @@ impl Game<'_> { seen.insert(m.id, (m.hurt_time, dead, swing)); } host.mob_state.insert(self.dim, seen); - for (attacker, target) in critical_hits { + for (attacker, target, magic) in critical_hits { let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| { host.hub.guest(conn).map(|g| g.entity_id) }) else { continue; }; - host.hub.critical_hit(attacker, target); + host.hub.critical_hit(attacker, target, magic); } } diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 8e756b7be..65520221a 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -37,6 +37,8 @@ pub(super) struct Strike { amount: f32, scale: f32, crit: bool, + /// `Player.attack`'s `magicBoost > 0`: the enchantments added damage. + magic: bool, from: [f64; 3], sweep: Option<[f64; 3]>, /// `causeExtraKnockback`: its strength and direction (the attacker's @@ -109,7 +111,7 @@ impl Game<'_> { pub(super) fn pvp_strike(&mut self) -> Option { let p = self.player.as_ref()?; let from = [p.position.x, p.position.y, p.position.z]; - let (amount, scale, crit) = self.melee_damage("minecraft:player"); + let (amount, scale, crit, magic) = self.melee_damage("minecraft:player"); let sweep = self.sweep_attack_position(scale, crit); let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale); let (xd, zd) = self.extra_knockback_direction(); @@ -125,6 +127,7 @@ impl Game<'_> { amount, scale, crit, + magic, from, sweep, knockback: (strength, xd, zd), @@ -257,7 +260,7 @@ impl Game<'_> { } else { Default::default() }; - if hurt && s.crit { + if hurt && (s.crit || s.magic) { let target = match victim { Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)), Victim::Guest(conn) => host @@ -267,13 +270,17 @@ impl Game<'_> { }; if let Some((id, p)) = target { let d = p.dimensions; - self.critical_hit( - s.attacker, - id, + let (feet, width, height) = ( [p.position.x, p.position.y, p.position.z], f64::from(d.width), f64::from(d.height), ); + if s.crit { + self.critical_hit(s.attacker, id, feet, width, height, false); + } + if s.magic { + self.critical_hit(s.attacker, id, feet, width, height, true); + } } } // `Enchantment.doPostAttack`: thorns hurt the attacking player. diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 8d2e0a4a2..12a2184a4 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -75,7 +75,7 @@ pub(super) struct Parked { lan_events: Vec<(i32, [i32; 3], i32, Option)>, lan_block_events: Vec<([i32; 3], i32, i32, u32)>, lan_entity_events: Vec<(i32, i8, [f64; 3])>, - lan_critical_hits: Vec<(Option, i32)>, + lan_critical_hits: Vec<(Option, i32, bool)>, /// Only the per-level parts of these are used (`swap_level`). anims: BlockEntityAnims, raiding: Raiding, diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 002f95cbb..d0f8126b0 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -120,12 +120,13 @@ impl Hub { ); } - /// `ServerPlayer.crit`: send the victim's animation to the players tracking - /// the attacker and the attacker themselves (`sendToTrackingPlayersAndSelf`). - pub fn critical_hit(&self, attacker: i32, target: i32) { + /// `ServerPlayer.crit` (`magicCrit` when `magic`): send the victim's + /// animation to the players tracking the attacker and the attacker + /// themselves (`sendToTrackingPlayersAndSelf`). + pub fn critical_hit(&self, attacker: i32, target: i32, magic: bool) { let body = C::Animate(play::Animate { id: target, - action: 1, + action: if magic { 2 } else { 1 }, }) .encode(); for g in self.guests.iter().filter(|g| { From 591b7b74daab63df7e042a7d270c2490960f39a9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:15:10 +0100 Subject: [PATCH 230/475] Predict the sort and tweak clicks on a server like an ordinary click --- crates/horizon-client/src/game/remote.rs | 20 -------------------- crates/horizon-client/src/game/tweaks.rs | 3 +-- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e3..3c333c904 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4791,26 +4791,6 @@ impl Game<'_> { })); } } - /// A `ContainerClick` on game slot `internal` of the server's open menu - /// (`input` is the ContainerInput ordinal). False when the menu has no - /// such slot. - pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool { - let (container, menu) = self.remote_menu(); - let slot = Self::menu_slot_index(&menu, internal); - let Some(slot) = slot else { return false }; - let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s)); - let state = self.remote.as_ref().map_or(0, |r| r.state_id); - self.send(S::ContainerClick(play::ContainerClick { - container, - state, - slot: slot as i16, - button, - input, - changed: Vec::new(), - carried, - })); - true - } /// The server's open menu is the inventory or a chest-like container /// (chests, barrels, ender chests: `generic_9xN`; shulker boxes). pub(super) fn remote_storage_open(&self) -> bool { diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index cec83e248..648073874 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -70,8 +70,7 @@ impl Game<'_> { /// One ordinary click on a game slot. False when nothing took it. pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool { if self.remote.is_some() { - let ordinal = super::remote::input_ordinal(input); - return self.remote_slot_click(slot, button as i8, ordinal); + return self.remote_menu_ops(&[(Some(slot), button, input)]); } if !self.menu_click(Some(slot), button, input) { return false; From df045f1e9a417b72543e4288e4deabf932473164 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:22:13 +0100 Subject: [PATCH 231/475] CI parity: test the head merged into main A head tested alone keeps failing harnesses that main already fixed. Since 10-25 evening, 9 of 21 failed PR parity jobs failed only on such fixes: server_knockback (heads without #2114, 7 times), mob_ai's oracle heap (without #2270), save_roundtrip (without #2212). #2160 needed 4 parity runs this way. The job now merges main's current tip into the head and selects harnesses against that main commit, so only the pull request's own changes count. A branch that doesn't change the runner scripts or the selection gets main's, which replaces the two steps that copied main's vanilla-data.sh and parity.py in by hand. A head that conflicts with main is tested alone, with a warning. The job summary names the main commit, and the check is still `parity / parity` on the head. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/parity.yml | 88 ++++++++++++------------------------ docs/release-flow.md | 9 ++-- 2 files changed, 36 insertions(+), 61 deletions(-) diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 1fc31d8cb..9f678b43b 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -26,35 +26,46 @@ jobs: steps: - uses: actions/checkout@v4 with: - # The commit the event names: a fork's later pushes need another run-parity label. + # The commit the event names: a fork's later pushes need another run-parity label. The next step merges + # main into it. ref: ${{ inputs.head }} fetch-depth: 0 persist-credentials: false # Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs. clean: false + - name: The head merged into main (what merging it would land) + id: merge + env: + BASE_SHA: ${{ inputs.base }} + HEAD_SHA: ${{ inputs.head }} + run: | + # Harness fixes and game fixes that reached main since the branch was made then apply here too: a head + # tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before + # #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull + # request's own changes. A head that conflicts with main is tested alone. + set -euo pipefail + git merge --abort 2>/dev/null || true + git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main + main=$(git rev-parse origin/main) + if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then + echo "base=$main" >> "$GITHUB_OUTPUT" + echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY" + else + git merge --abort + echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes" + echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY" + fi - uses: dtolnay/rust-toolchain@stable - name: Tools from the runner run: bash tools/ci/runner-env.sh - name: Vanilla data, and resources prepared by this preparer - env: - BASE_SHA: ${{ inputs.base }} - run: | - # main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on - # the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own. - set -euo pipefail - if git rev-parse --verify -q origin/main >/dev/null \ - && git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then - git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh" - bash "$RUNNER_TEMP/vanilla-data.sh" - else - bash tools/ci/vanilla-data.sh - fi + run: bash tools/ci/vanilla-data.sh - name: The prepared output is the one the preparer's OUTPUT_VERSION says env: - BASE_SHA: ${{ inputs.base }} + BASE_SHA: ${{ steps.merge.outputs.base }} run: | - # The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither - # the tool nor the number, so there is nothing to check. + # A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number. if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then echo "This head's preparer has no OUTPUT_VERSION: nothing to check" exit 0 @@ -65,47 +76,8 @@ jobs: - name: The harnesses this change can affect env: PARITY_HOSTS: '' - BASE_SHA: ${{ inputs.base }} - run: | - # main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a - # branch made before a better selection landed gets it too; the head's own runner then runs those - # harnesses. A branch that changes the selection itself selects with its own code. - set -euo pipefail - own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner - fork_point=$(git merge-base "$BASE_SHA" HEAD) - if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then - own - exit - fi - had_reach=$(git ls-files tools/parity_reach.py) - git show origin/main:tools/parity.py > tools/parity.py - git show origin/main:tools/parity_reach.py > tools/parity_reach.py - status=0 - python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$? - git checkout -- tools/parity.py - if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi - cat .local/parity-selection.txt - if [ "$status" != 0 ]; then - echo "main's selection failed: the head's own selection runs" - own - exit - fi - names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true) - # The summary line says how many would run: a count that doesn't match the names read (a format change) - # must not let the job pass untested. - expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p') - count=$(printf '%s' "$names" | grep -c . || true) - if [ -z "$expected" ] || [ "$expected" != "$count" ]; then - echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs" - own - exit - fi - if [ "$count" = 0 ]; then - echo "Nothing to run." - exit 0 - fi - # shellcheck disable=SC2086 - python3 tools/parity.py $names --timeout 2400 + BASE_SHA: ${{ steps.merge.outputs.base }} + run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner - name: Logs of the failed harnesses if: failure() uses: actions/upload-artifact@v4 diff --git a/docs/release-flow.md b/docs/release-flow.md index ce7934824..b0d435c29 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -32,6 +32,12 @@ copy is: the Mac mini, his Linux desktop and playhorizon (below). Their runners between runs (`clean: false`), so the probes build incrementally into `.local/parity-target` and earlier passes stand. +The job tests the pull request's head merged into main's current tip, which is what merging it would land: harness +and game fixes that reached main after the branch was made apply to it too, and an old branch runs main's runner +scripts and selection unless it changes them. The base for the selection is that main commit, so only the pull +request's own changes count. The job summary names the main commit. A head that conflicts with main is tested +alone, with a warning. The check is still reported on the head. + 1. `tools/ci/runner-env.sh` puts the machine's Python 3.13 and the JDK `research-lock.json` pins on the PATH (Homebrew and `java_home` on the Mac; on Linux what `linux-runner.sh` pinned, else nixpkgs), and sccache when the machine has it. @@ -50,9 +56,6 @@ stand. modules, Java oracles and every Rust source their probes compile) changed since the merge base, plus any harness nothing is recorded about yet, and runs those that have no pass for their current inputs. A change to `Cargo.lock` or `research-lock.json` affects every harness. Each harness has 25 minutes, the job 45. - The selection runs with main's `tools/parity.py` and `tools/parity_reach.py` (`--changed --list`), so a - branch made before a better selection landed gets it too, and the head's own `parity.py` then runs the named - harnesses. A branch that changes either file selects with its own code. Failed harnesses' logs are uploaded as the `parity-logs` artifact. From 91f1f839ab9e5d637a15204bfc629a47d266bdeb Mon Sep 17 00:00:00 2001 From: kohad1ck <163426863+kohad1ck@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:28:19 +0800 Subject: [PATCH 232/475] Superflat: the Customize screen edits layers and presets Create New World greyed out Customize for a Superflat world because Horizon had no preset editor. Bring vanilla's CreateFlatWorldScreen and PresetFlatWorldScreen: - the layer list (top first), Remove Layer and the layer heights; - the preset chooser with the nine visible flat_world_preset entries, their display items and names; - the share box and Select, parsing layers;biome and validating block and biome ids against the prepared registries; - Done keeps the layers in WorldOptions, Cancel and Escape discard them. world_loading writes them with write_world_gen_settings_with, so a new world's world_gen_settings keeps the custom generator; Re-Create reads them back for the editor. Co-Authored-By: ZCode Agent --- crates/horizon-client/src/game/agent.rs | 16 + .../horizon-client/src/game/world_loading.rs | 7 +- crates/horizon-client/src/title.rs | 125 +++ .../horizon-client/src/title/create_world.rs | 25 +- crates/horizon-client/src/title/flat.rs | 882 ++++++++++++++++++ 5 files changed, 1051 insertions(+), 4 deletions(-) create mode 100644 crates/horizon-client/src/title/flat.rs diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index fa9dbf3c6..a069330f5 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -565,6 +565,22 @@ impl Game<'_> { "generate_structures": options.structures, "bonus_chest": options.bonus_chest(), }), + Mode::CreateFlat { + name, + seed, + options, + flat, + .. + } => json!({ + "mode": "customize-flat", + "fields": { "name": name, "seed": seed }, + "superflat": options.flat, + "biome": flat.settings().biome, + "layers": flat.settings().layers.iter().map(|layer| json!({ + "block": layer.block, + "height": layer.height, + })).collect::>(), + }), Mode::Edit { world, name, diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index ef47cc9c0..168a3eaf8 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -89,7 +89,12 @@ impl WorldRequest { level.hardcore = o.hardcore; level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into(); level.allow_commands = Some(o.commands(creative)); - horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?; + horizon_client::save::write_world_gen_settings_with( + &save_root, + level.seed, + o.world_gen(), + o.flat_settings(), + )?; } // Worlds made without the Create New World screen (--world, the // control socket) take its default: commands on for Creative only. diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index 87bd05fd0..e603fcf94 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -13,10 +13,12 @@ use std::{ }; mod create_world; +mod flat; #[cfg(test)] mod tests; pub mod world_management; pub use create_world::{DIFFICULTIES, WorldOptions}; +pub use flat::{FlatLayer, FlatSettings, State as FlatEditor}; /// SelectWorldScreen's `HeaderAndFooterLayout(8 + 9 + 8 + 20 + 4, 60)`. const LIST_TOP: f32 = 49.0; @@ -101,6 +103,16 @@ pub enum Mode { return_to: Box, }, Multiplayer(crate::multiplayer::Screen), + /// CreateFlatWorldScreen over Create New World: the fields that screen + /// keeps and the superflat editor on top of them. + CreateFlat { + name: String, + seed: String, + creative: bool, + focus: u8, + options: WorldOptions, + flat: flat::State, + }, } #[derive(Clone, Debug, PartialEq)] pub enum Action { @@ -128,6 +140,8 @@ pub struct Title { /// Direct Connection's address, synchronized with `options.txt`'s `lastServer`. pub last_server: String, pub saves: PathBuf, + /// The prepared resource root: the flat preset list and block registry. + resources: PathBuf, pub started: std::time::Instant, search: String, search_focused: bool, @@ -231,6 +245,7 @@ impl Title { mode, last_server: String::new(), saves, + resources: resources.to_owned(), started: now, search: String::new(), search_focused: false, @@ -316,6 +331,64 @@ impl Title { self.return_to_list(None); } } + /// A press on the superflat screens: their list, share box or buttons. + fn flat_click(&mut self, x: f32, y: f32, size: (f32, f32), hit: Option) { + match hit { + Some(70) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.remove_selected(); + } + } + Some(71) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.open_presets(); + } + } + Some(72) => self.close_flat(true), + Some(73) => self.close_flat(false), + Some(74) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.apply_share(); + } + } + Some(75) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.back_to_layers(); + } + } + None => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::click(flat, (x, y), size); + } + } + _ => {} + } + } + /// Return to Create New World, keeping (`apply`) or dropping the layers. + fn close_flat(&mut self, apply: bool) { + let mode = std::mem::replace(&mut self.mode, Mode::Main); + if let Mode::CreateFlat { + name, + seed, + creative, + focus, + mut options, + flat, + } = mode + { + if apply { + options.flat = true; + options.flat_settings = Some(flat.settings); + } + self.mode = Mode::Create { + name, + seed, + creative, + focus, + options, + }; + } + } fn matches_search(&self, world: &WorldEntry) -> bool { let filter = self.search.to_lowercase(); world.name.to_lowercase().contains(&filter) || world.folder.to_lowercase().contains(&filter) @@ -363,6 +436,10 @@ impl Title { if let Mode::Multiplayer(screen) = &mut self.mode { return screen.drag(cursor.1, size); } + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::drag(flat, cursor, size); + return; + } if let Mode::Select { worlds, .. } = &self.mode { let (top, bottom, _, content) = self.world_list(worlds, size); let mut scroll = self.scroll.get(); @@ -375,6 +452,9 @@ impl Title { if let Mode::Multiplayer(screen) = &mut self.mode { screen.release(); } + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::release(flat); + } self.scroll.get_mut().release(); } /// Mouse wheel over the classic world and server lists. @@ -383,6 +463,10 @@ impl Title { screen.wheel(delta, cursor, size); return; } + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::wheel(flat, delta, cursor, size); + return; + } let Mode::Select { worlds, .. } = &self.mode else { return; }; @@ -703,6 +787,7 @@ impl Title { Mode::Create { creative, options, .. } => create_world::buttons(w, h, *creative, options), + Mode::CreateFlat { flat, .. } => flat::buttons(w, h, flat), Mode::Edit { world, name, .. } => { let x = (w / 2.0).floor() - 100.0; let top = edit_top(h); @@ -1022,6 +1107,7 @@ impl Title { options, .. } => self.draw_create(atlas, batch, cursor, name, seed, *focus, options), + Mode::CreateFlat { flat, .. } => flat::draw(self, atlas, batch, cursor, flat), Mode::Edit { name, focused, .. } => { crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false); centered( @@ -1224,6 +1310,10 @@ impl Title { .into_iter() .find(|b| b.enabled && x >= b.x && x < b.x + b.w && y >= b.y && y < b.y + 20.0) .map(|b| b.id); + if matches!(self.mode, Mode::CreateFlat { .. }) { + self.flat_click(x, y, (w, h), hit); + return (Action::None, hit.is_some()); + } let action = self.click_action(x, y, (w, h), hit); // World list selection is silent; launching its selected entry sounds. let sound = hit.is_some() || matches!(action, Action::Play { .. }); @@ -1387,6 +1477,27 @@ impl Title { self.mode = *return_to.clone(); Action::None } + ( + Mode::Create { + name, + seed, + creative, + focus, + options, + }, + Some(26), + ) => { + let flat = flat::State::new(&self.resources, options.flat_settings.clone()); + self.mode = Mode::CreateFlat { + name: name.clone(), + seed: seed.clone(), + creative: *creative, + focus: *focus, + options: options.clone(), + flat, + }; + Action::None + } ( Mode::Create { creative, options, .. @@ -1590,6 +1701,16 @@ impl Title { self.return_to_list(Some(&folder)); } Mode::Error { return_to, .. } => self.mode = *return_to.clone(), + Mode::CreateFlat { .. } => { + if matches!(&self.mode, Mode::CreateFlat { flat, .. } if flat.page == flat::Page::Presets) + { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.back_to_layers(); + } + } else { + self.close_flat(false); + } + } Mode::Create { .. } => self.cancel_create(), _ => self.mode = Mode::Main, } @@ -1618,6 +1739,7 @@ const SEARCH: crate::clipboard::FieldId = ("world.search", 0); const EDIT_NAME: crate::clipboard::FieldId = ("world.edit-name", 0); const WORLD_NAME: crate::clipboard::FieldId = ("world.name", 0); const WORLD_SEED: crate::clipboard::FieldId = ("world.seed", 0); +const FLAT_SHARE: crate::clipboard::FieldId = ("world.flat-share", 0); /// The focused field of the world screens and the server screens: the list's /// search, a world name or seed, or Add Server, Direct Connection and Join a /// Friend. @@ -1641,6 +1763,9 @@ fn caret_field<'a>( _ => None, }, Mode::Multiplayer(screen) => screen.focused_text(), + Mode::CreateFlat { flat, .. } if flat.page == flat::Page::Presets && flat.share_focused => { + Some((FLAT_SHARE, &mut flat.share, 1230)) + } _ => None, } } diff --git a/crates/horizon-client/src/title/create_world.rs b/crates/horizon-client/src/title/create_world.rs index a2fa6b8ce..04b92ebb2 100644 --- a/crates/horizon-client/src/title/create_world.rs +++ b/crates/horizon-client/src/title/create_world.rs @@ -32,6 +32,8 @@ pub struct WorldOptions { pub commands: Option, /// The Superflat world preset instead of Default. pub flat: bool, + /// CreateFlatWorldScreen's layers and biome, kept with the world type. + pub flat_settings: Option, pub structures: bool, pub bonus_chest: bool, } @@ -43,6 +45,7 @@ impl Default for WorldOptions { difficulty: 2, commands: None, flat: false, + flat_settings: None, structures: true, bonus_chest: false, } @@ -64,6 +67,10 @@ impl WorldOptions { .unwrap_or(2), commands: Some(commands), flat: generation.flat, + flat_settings: generation + .flat + .then(|| super::flat::FlatSettings::from_world(dir)) + .flatten(), structures: generation.structures, bonus_chest: generation.bonus_chest, ..Self::default() @@ -88,6 +95,12 @@ impl WorldOptions { bonus_chest: self.bonus_chest(), } } + /// The custom layers, as `write_world_gen_settings_with` takes them. + pub fn flat_settings(&self) -> Option { + self.flat_settings + .as_ref() + .map(super::flat::FlatSettings::to_tag) + } /// A press on one of the open tab's cycle buttons or switches. pub(super) fn press(&mut self, id: u8, creative: &mut bool) { match id { @@ -101,7 +114,13 @@ impl WorldOptions { } 23 => self.difficulty = (self.difficulty + 1) % DIFFICULTIES.len(), 24 => self.commands = Some(!self.commands(*creative)), - 25 => self.flat = !self.flat, + 25 => { + self.flat = !self.flat; + if self.flat { + // setWorldType starts the generator from the flat preset. + self.flat_settings = Some(super::flat::FlatSettings::default()); + } + } 27 => self.structures = !self.structures, 28 => self.bonus_chest = !self.bonus_chest, _ => {} @@ -243,13 +262,13 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec, + pub structure_overrides: Vec, +} +impl Default for FlatSettings { + fn default() -> Self { + // worldgen/world_preset/flat.json's overworld. + Self { + biome: "minecraft:plains".into(), + features: false, + lakes: false, + layers: vec![ + FlatLayer { + block: "minecraft:bedrock".into(), + height: 1, + }, + FlatLayer { + block: "minecraft:dirt".into(), + height: 2, + }, + FlatLayer { + block: "minecraft:grass_block".into(), + height: 1, + }, + ], + structure_overrides: vec!["minecraft:strongholds".into(), "minecraft:villages".into()], + } + } +} +impl FlatSettings { + /// A saved world's flat generator settings, for Re-Create. + pub fn from_world(dir: &Path) -> Option { + let settings = crate::save::world_gen_settings(dir)?; + let generator = settings + .get("dimensions")? + .get("minecraft:overworld")? + .get("generator")?; + if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "flat" { + return None; + } + Self::from_tag(generator.get("settings")?) + } + pub fn from_tag(tag: &Tag) -> Option { + let Some(Tag::List(list)) = tag.get("layers") else { + return None; + }; + let layers: Vec = list + .iter() + .filter_map(|layer| { + Some(FlatLayer { + block: crate::save::text(layer.get("block"))?, + height: crate::save::number(layer.get("height"))?.clamp(0, Y_SIZE as i64) + as i32, + }) + }) + .collect(); + if layers.is_empty() { + return None; + } + let flag = |key: &str, default: bool| { + crate::save::number(tag.get(key)).map_or(default, |value| value != 0) + }; + let structure_overrides = match tag.get("structure_overrides") { + Some(Tag::String(text)) => vec![text.to_string_lossy()], + Some(Tag::List(list)) => list + .iter() + .filter_map(|value| crate::save::text(Some(value))) + .collect(), + _ => Vec::new(), + }; + Some(Self { + biome: crate::save::text(tag.get("biome")).unwrap_or_else(|| "minecraft:plains".into()), + features: flag("features", false), + lakes: flag("lakes", false), + layers, + structure_overrides, + }) + } + /// FlatLevelGeneratorSettings.CODEC, as world_gen_settings stores it. + pub fn to_tag(&self) -> Tag { + let string = |text: &str| Tag::String(text.into()); + Tag::compound([ + ("biome", string(&self.biome)), + ("features", Tag::Byte(i8::from(self.features))), + ("lakes", Tag::Byte(i8::from(self.lakes))), + ( + "layers", + Tag::List( + self.layers + .iter() + .map(|layer| { + Tag::compound([ + ("block", string(&layer.block)), + ("height", Tag::Int(layer.height)), + ]) + }) + .collect(), + ), + ), + ( + "structure_overrides", + Tag::List( + self.structure_overrides + .iter() + .map(|id| string(id)) + .collect(), + ), + ), + ]) + } + /// `CreateFlatWorldScreen.save`: the text the preset screen shows. + pub fn to_share(&self) -> String { + let layers = self + .layers + .iter() + .map(|layer| { + if layer.height == 1 { + layer.block.clone() + } else { + format!("{}*{}", layer.height, layer.block) + } + }) + .collect::>() + .join(","); + format!("{layers};{}", self.biome) + } + /// `PresetFlatWorldScreen.fromString`: `layers;biome`, keeping the + /// decorations the screen was opened with. `None` for a malformed list, + /// as vanilla falls back to the preset's own settings. + pub fn parse( + text: &str, + blocks: &BTreeSet, + biomes: &BTreeSet, + base: &Self, + ) -> Option { + let (layer_text, biome_text) = text.split_once(';').unwrap_or((text, "")); + let mut layers = Vec::new(); + let mut total = 0; + for part in layer_text.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (height, block) = match part.split_once('*') { + Some((height, block)) => (height.trim().parse::().ok()?, block.trim()), + None => (1, part), + }; + let block = namespaced(block); + if !blocks.is_empty() && !blocks.contains(&block) { + return None; + } + let height = height.max(0).min(Y_SIZE - total); + if height == 0 { + continue; + } + total += height; + layers.push(FlatLayer { block, height }); + } + if layers.is_empty() { + return None; + } + let mut biome = namespaced(biome_text.trim()); + if biome == "minecraft:" || biome.is_empty() { + biome = "minecraft:plains".into(); + } + if !biomes.is_empty() && !biomes.contains(&biome) { + biome = "minecraft:plains".into(); + } + Some(Self { + biome, + layers, + ..base.clone() + }) + } +} +/// `Identifier.parse`: bare names get the default namespace. +fn namespaced(id: &str) -> String { + if id.is_empty() || id.contains(':') { + id.to_owned() + } else { + format!("minecraft:{id}") + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Page { + Layers, + Presets, +} + +#[derive(Clone, Debug)] +pub struct State { + pub(super) page: Page, + pub(super) settings: FlatSettings, + /// The preset screen's share box (`createWorld.customize.presets.share`). + pub(super) share: String, + pub(super) share_focused: bool, + /// The selected layer, in the list's top-first order. + pub(super) selected: Option, + pub(super) scroll: ui::Scroll, + preset_selected: Option, + preset_scroll: ui::Scroll, + presets: Vec, + blocks: BTreeSet, + biomes: BTreeSet, +} +#[derive(Clone, Debug)] +struct Preset { + id: String, + display: String, + settings: FlatSettings, +} +impl State { + /// The layers the editor holds, for the control socket's state. + pub fn settings(&self) -> &FlatSettings { + &self.settings + } + pub(super) fn new(root: &Path, current: Option) -> Self { + let settings = current.unwrap_or_default(); + let (presets, blocks, biomes) = load(root); + Self { + page: Page::Layers, + share: settings.to_share(), + settings, + share_focused: false, + selected: None, + scroll: ui::Scroll::default(), + preset_selected: None, + preset_scroll: ui::Scroll::default(), + presets, + blocks, + biomes, + } + } + pub(super) fn open_presets(&mut self) { + self.page = Page::Presets; + self.preset_selected = None; + self.preset_scroll = ui::Scroll::default(); + self.share = self.settings.to_share(); + self.share_focused = false; + } + pub(super) fn back_to_layers(&mut self) { + self.page = Page::Layers; + self.share_focused = false; + } + pub(super) fn remove_selected(&mut self) { + let Some(row) = self.selected else { + return; + }; + let index = self.settings.layers.len().wrapping_sub(1 + row); + if index < self.settings.layers.len() { + self.settings.layers.remove(index); + } + self.selected = None; + } + /// PresetFlatWorldScreen's Select: parse the share text into the settings + /// the Create New World screen keeps. + pub(super) fn apply_share(&mut self) { + // PresetFlatWorldScreen keeps the selected preset's decorations while + // the share text supplies its layers and biome. + let base = self + .preset_selected + .and_then(|index| self.presets.get(index)) + .map(|preset| preset.settings.clone()) + .unwrap_or_else(|| self.settings.clone()); + // A malformed list falls back to FlatLevelGeneratorSettings.getDefault. + let settings = + FlatSettings::parse(&self.share, &self.blocks, &self.biomes, &base).unwrap_or_default(); + self.settings = settings; + self.share = self.settings.to_share(); + self.page = Page::Layers; + self.share_focused = false; + self.selected = None; + } + fn select_preset(&mut self, index: usize) { + let Some(preset) = self.presets.get(index) else { + return; + }; + self.preset_selected = Some(index); + self.share = preset.settings.to_share(); + self.share_focused = false; + } + fn selecting(&self) -> Option { + self.preset_selected + } +} + +/// `/data//worldgen/flat_level_generator_preset/.json`, +/// in the order of the `visible` tag, plus the block and biome ids the share +/// text is checked against. +fn load(root: &Path) -> (Vec, BTreeSet, BTreeSet) { + let mut presets = Vec::new(); + let tag_path = + root.join("data/minecraft/tags/worldgen/flat_level_generator_preset/visible.json"); + if let Ok(text) = std::fs::read_to_string(tag_path) + && let Ok(Value::Object(tag)) = serde_json::from_str(&text) + && let Some(Value::Array(ids)) = tag.get("values") + { + for id in ids.iter().filter_map(Value::as_str) { + let Some((namespace, path)) = id.split_once(':') else { + continue; + }; + let path = root.join(format!( + "data/{namespace}/worldgen/flat_level_generator_preset/{path}.json" + )); + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + let Ok(value) = serde_json::from_str::(&text) else { + continue; + }; + let Some(settings) = preset_settings(&value) else { + continue; + }; + presets.push(Preset { + id: id.to_owned(), + display: value["display"] + .as_str() + .unwrap_or("minecraft:grass_block") + .to_owned(), + settings, + }); + } + } + let blocks = registry_ids(root, "minecraft:block"); + let biomes = json_keys(&root.join("worldgen/biomes.json")); + (presets, blocks, biomes) +} +fn preset_settings(value: &Value) -> Option { + let settings = value.get("settings")?; + let layers = settings + .get("layers")? + .as_array()? + .iter() + .filter_map(|layer| { + Some(FlatLayer { + block: layer.get("block")?.as_str()?.to_owned(), + height: layer.get("height")?.as_i64()? as i32, + }) + }) + .collect::>(); + if layers.is_empty() { + return None; + } + let structure_overrides = match settings.get("structure_overrides") { + Some(Value::String(id)) => vec![id.clone()], + Some(Value::Array(ids)) => ids + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + _ => Vec::new(), + }; + Some(FlatSettings { + biome: settings + .get("biome") + .and_then(Value::as_str) + .unwrap_or("minecraft:plains") + .to_owned(), + features: settings + .get("features") + .and_then(Value::as_bool) + .unwrap_or(false), + lakes: settings + .get("lakes") + .and_then(Value::as_bool) + .unwrap_or(false), + layers, + structure_overrides, + }) +} +/// The ids of a registry in the prepared `registries.json`. +fn registry_ids(root: &Path, registry: &str) -> BTreeSet { + let Ok(text) = std::fs::read_to_string(root.join("registries.json")) else { + return BTreeSet::new(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return BTreeSet::new(); + }; + value + .get(registry) + .and_then(|registry| registry.get("entries")) + .and_then(Value::as_object) + .map(|entries| entries.keys().cloned().collect()) + .unwrap_or_default() +} +fn json_keys(path: &Path) -> BTreeSet { + let Ok(text) = std::fs::read_to_string(path) else { + return BTreeSet::new(); + }; + let Ok(value) = serde_json::from_str::(&text) else { + return BTreeSet::new(); + }; + value + .as_object() + .map(|entries| entries.keys().cloned().collect()) + .unwrap_or_default() +} + +/// CreateFlatWorldScreen's list and the preset screen's list. +fn list_area(state: &State, size: (f32, f32)) -> (f32, f32, f32, f32) { + let (w, h) = (size.0.ceil(), size.1.ceil()); + let (top, bottom) = match state.page { + Page::Layers => (43.0, h - 60.0), + Page::Presets => (80.0, h - 37.0), + }; + let left = ((w - ROW_WIDTH) * 0.5).floor(); + let rows = match state.page { + Page::Layers => HEADER + state.settings.layers.len() as f32 * LAYER_ROW + 4.0, + Page::Presets => state.presets.len() as f32 * PRESET_ROW + 4.0, + }; + (top, bottom, left, rows) +} + +pub(super) fn buttons(w: f32, h: f32, state: &State) -> Vec