From a7ff22eea05de72275341d7346252fc5d2ecdbb6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 23:05:12 +0100 Subject: [PATCH 01/44] 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 02/44] 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 9e3956282abf29f49ba9e0e1f0f04b3622a9b900 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 12:18:36 +0100 Subject: [PATCH 03/44] 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 04/44] 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 05/44] 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 06/44] 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 5c0f7142f36cad8e1a1e7adffe3c6c5902328edb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:03:44 +0100 Subject: [PATCH 07/44] 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 6bc8a8b8899fc5be2e6c03485db8b6b9b47ff05d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:09:45 +0100 Subject: [PATCH 08/44] 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 09/44] 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 e2781f7939dfb4451f356a60d6513e25a4a271fa Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 13:20:35 +0100 Subject: [PATCH 10/44] 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 11/44] 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 12/44] 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 13/44] 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 d136dda0928f3150fead27675fa812eef83d8d66 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:08:48 +0100 Subject: [PATCH 14/44] 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 15/44] 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 16/44] 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 3615c65d18e24888cea2e6b3a7dda86c7ba5c7a6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:11:01 +0100 Subject: [PATCH 17/44] 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 18/44] 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 19/44] 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 20/44] 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 a5fa40d2eea6609a3c567601059cdcf29415c4a3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:12:48 +0100 Subject: [PATCH 21/44] 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 22/44] 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 23/44] 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 e2a399aff0ee5463e9059a9a3dd49cb67e5167db Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:19 +0100 Subject: [PATCH 24/44] 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 84aba7b3d494f6d1315214c41a27464e14e8be87 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:27 +0100 Subject: [PATCH 25/44] 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 79bfb8fcb81860893bd4664d4f3e86effd015eef Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:26 +0100 Subject: [PATCH 26/44] 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 4c72a3a44f41278aa0481f95acf0d184bf540421 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:14:42 +0100 Subject: [PATCH 27/44] 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 28/44] 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 29/44] 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 30/44] 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 31/44] 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 32/44] 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 728bf14aa7db38211cd50d751f38486194d28de3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:06 +0100 Subject: [PATCH 33/44] 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 601e7918a844884574222a26ec75d05169499386 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:16:43 +0100 Subject: [PATCH 34/44] 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 13144a82a15291da42e1449741ae15d63c33d4a9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:13:28 +0100 Subject: [PATCH 35/44] 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 8c8f857313bd5fbe82cb47af8d339640460ac174 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:17:42 +0100 Subject: [PATCH 36/44] 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 26626e8c2759f6cd7fef2535dd7ae915168af805 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 15:45:51 +0100 Subject: [PATCH 37/44] 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 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 38/44] 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 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 39/44] 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 da736d56d64aade0b9b95978358515a8c9b40b36 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 21:30:42 +0100 Subject: [PATCH 40/44] Lingering clouds skip mobs none of their effects can affect --- crates/horizon-client/src/game/potions.rs | 64 ++++++++++++++++++----- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index bfffff5ee..6a3f08ac5 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -79,6 +79,39 @@ fn mob_effect_id(id: &str) -> Option<&'static str> { .get(id) .copied() } +/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and +/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison, +/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags +/// decide first, then the tag of mobs that ignore poison and regeneration. +fn mob_can_be_affected( + tags: &std::collections::HashMap>, + kind: &str, + effect: &str, +) -> bool { + let effect = effect.trim_start_matches("minecraft:"); + let kind = kind.trim_start_matches("minecraft:"); + let overridden = match kind { + "wither" | "wither_skeleton" => effect == "wither", + "spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison", + "parched" => effect == "weakness", + _ => false, + }; + if overridden { + return false; + } + let in_tag = |tag: &str| { + tags.get(tag) + .is_some_and(|t| t.contains(&format!("minecraft:{kind}"))) + }; + if in_tag("minecraft:immune_to_infested") { + effect != "infested" + } else if in_tag("minecraft:immune_to_oozing") { + effect != "oozing" + } else { + !in_tag("minecraft:ignores_poison_and_regen") + || (effect != "regeneration" && effect != "poison") + } +} impl Game<'_> { /// A splash potion, lingering potion or experience bottle in flight (the /// player's throws and dispensers both end up here). @@ -632,14 +665,20 @@ impl Game<'_> { 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)) - { + let effects = potion.effects; + let tags = &self.res.stations().enchantments.entity_tags; + // Only mobs that at least one of the cloud's effects can affect are + // taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so + // the others neither become victims nor use up its radius. + for m in self.mobs.iter().filter(|m| { + m.alive() + && m.bounds().intersects(area) + && effects + .iter() + .any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id)) + }) { 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) @@ -687,11 +726,8 @@ impl Game<'_> { return; }; // Undead invert healing and harming. - let inverted = self - .res - .stations() - .enchantments - .entity_tags + let tags = &self.res.stations().enchantments.entity_tags; + let inverted = tags .get("minecraft:inverted_healing_and_harm") .is_some_and(|t| t.contains(&self.mobs[i].kind.id)); for (id, duration, amp) in effects { @@ -707,7 +743,11 @@ impl Game<'_> { } } _ => { - if let Some(name) = mob_effect_id(id) { + // `LivingEntity.addEffect` refuses what the mob + // cannot be affected by. + if mob_can_be_affected(tags, &m.kind.id, id) + && let Some(name) = mob_effect_id(id) + { m.add_effect(name, scaled(*duration), *amp); } } From b463f1c80a75471f1e5171f989dd3036d90d654e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:50:22 +0100 Subject: [PATCH 41/44] Waxed hanging signs play their own failed-interaction sound HangingSignBlockEntity.getSignInteractionFailedSoundEvent is block.hanging_sign.waxed_interact_fail, not the standing sign's. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/sign_edit.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index bdd2008f4..3d946ce7b 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -87,12 +87,14 @@ impl Game<'_> { 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, - ); + // `getSignInteractionFailedSoundEvent`: a hanging sign has its own. + let sound = if sign.hanging { + "minecraft:block.hanging_sign.waxed_interact_fail" + } else { + "minecraft:block.sign.waxed_interact_fail" + }; + self.audio + .play(sound, 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); From e42a82e9b99335f58aae3011f153db96ee908f3e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 07:03:08 +0100 Subject: [PATCH 42/44] Name the difficulty id field as #2239 does #2239 adds the same LocalDifficulty field as `id`; with the same lines here the two branches merge without a conflict. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/bee.rs | 2 +- crates/horizon-client/src/mobs/spawning.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 741a78df6..92c11544c 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -324,7 +324,7 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) { Who::Player => { cx.events.push(MobEvent::AttackPlayer(amount, from)); // Poison only on normal (10 s) and hard (18 s). - let seconds = match cx.w.difficulty.base { + let seconds = match cx.w.difficulty.id { 2 => 10, 3 => 18, _ => 0, diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 57ecb1692..bbb8e2e46 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -37,8 +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, + /// `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]; @@ -51,7 +51,7 @@ impl LocalDifficulty { return Self { effective: 0.0, hard, - base, + id: base, }; } let global = ((total as f32 - 72000.0) / 1_440_000.0).clamp(0.0, 1.0) * 0.25; @@ -64,7 +64,7 @@ impl LocalDifficulty { Self { effective: f32::from(base) * (0.75 + global + local), hard, - base, + id: base, } } /// `getSpecialMultiplier`. From 523280e781e35f4d86f68852a3f125baf8f30c8f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:07:42 +0100 Subject: [PATCH 43/44] Draw the new level-event sound pitches from the client level's random LevelEventHandler plays 1501, 1502, 1046 and 1047 with ClientLevel.getRandom(), not the server level's random: drawing them from the world's random moved the level random in use_item_on's respawn anchor cases (12 of 4000 differed). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 23e28eab7..ac567483a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3112,12 +3112,19 @@ impl Game<'_> { }; if let Some((name, volume)) = sound { let pitch = match id { - 1009 | 1501 | 1502 => { + 1009 => { 2.6 + (self.world.random.next_float() - self.world.random.next_float()) * 0.8 } - 1029 | 1030 | 1042 | 1043 | 1044 | 1046 | 1047 | 1053 | 1054 => { + // LevelEventHandler draws these from the client + // level's random, before 1501's and 1502's smoke. + 1501 | 1502 => { + let r = self.particles.level_random(); + 2.6 + (r.next_float() - r.next_float()) * 0.8 + } + 1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9, + 1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => { self.world.random.next_float() * 0.1 + 0.9 } 1019 | 1021 => { From 07e935d0bfee8d652520e8ce8a931d1512b1d280 Mon Sep 17 00:00:00 2001 From: FusselTV Date: Tue, 27 Oct 2026 10:29:53 +0100 Subject: [PATCH 44/44] Mobs: drop the unreachable second rabbit arm in mob_to_nbt #2585 and 0d496fdc8 both added a "minecraft:rabbit" arm to mob_to_nbt; the first writes RabbitType and MoreCarrotTicks, so the second is never reached and clippy -D warnings fails on main (unreachable_patterns). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity_nbt.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index c65f35f5b..b2d1401da 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -498,10 +498,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { Tag::Int(i32::from(m.extra.variant.min(4))), ); } - // Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny). - "minecraft:rabbit" => { - map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant))); - } "minecraft:parrot" => { map.insert( "Variant".into(),