From 372f918e44a14f984e1359001d4285a5ed3958ef Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 01:18:53 +0200 Subject: [PATCH 01/57] Multiplayer: keep firework flight duration, boost gliding players Two halves of FireworkRocketEntity on a server were missing: - SetCreativeModeSlot encoded only damage, so a creative-picked rocket lost its minecraft:fireworks component (flight_duration and explosions gone); the wire codec also had no decoder for it or firework_explosion, so a stack carrying them failed to decode at all. The components now follow Fireworks.STREAM_CODEC/FireworkExplosion.STREAM_CODEC both ways (component ids 71/70). - A remote rocket never read DATA_ATTACHED_TO_TARGET, DATA_ID_FIREWORKS_ITEM or DATA_SHOT_AT_ANGLE, so the server-attached boost rocket was an inert marker: attached_to now comes from EntityDataSerializers.OPTIONAL_UNSIGNED_INT (the id plus one), the item feeds the explosion list, and FireworkRocketEntity.tick's isFallFlying boost applies to the local player. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game/fireworks.rs | 9 +- crates/horizon-client/src/game/remote.rs | 48 +++++++++ .../src/game/remote_projectiles.rs | 54 +++++++++- crates/horizon-protocol/src/item.rs | 99 +++++++++++++++++++ 4 files changed, 207 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 85fccc2de..a0aa276b7 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -25,9 +25,12 @@ pub(super) struct Rocket { pub(super) velocity: Vec3, life: i32, lifetime: i32, - explosions: Vec, + pub(super) explosions: Vec, /// Boosting the player (attachedToEntity). pub(super) attached: bool, + /// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows; + /// boosting happens when it is the local player. + pub(super) attached_to: Option, /// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on /// the first block it hits. pub(super) angled: bool, @@ -48,6 +51,7 @@ impl Rocket { lifetime: i32::MAX, explosions, attached: false, + attached_to: None, angled: false, item, } @@ -63,7 +67,7 @@ impl Rocket { (self.position, self.velocity) } } -fn fireworks_of(stack: &ItemStack) -> (u8, Vec) { +pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec) { // The item's default component: flight 1, no explosions. stack .data() @@ -89,6 +93,7 @@ impl Game<'_> { lifetime, explosions, attached, + attached_to: None, angled: false, item: stack.clone(), } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 45ba4bebe..29a7ab46f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -596,6 +596,28 @@ impl Game<'_> { out.tidy(); Some(out) } + /// A wire `FireworkExplosion` as vanilla's NBT codec writes it + /// (`FireworkExplosion.Shape`'s BY_ID order). + fn fireworks_tag(e: &horizon_protocol::item::FireworkExplosion) -> Tag { + const SHAPES: [&str; 5] = ["small_ball", "large_ball", "star", "creeper", "burst"]; + let ints = |c: &[i32]| Tag::List(c.iter().map(|c| Tag::Int(*c)).collect()); + Tag::compound([ + ( + "shape", + Tag::String( + SHAPES + .get(e.shape.max(0) as usize) + .copied() + .unwrap_or("small_ball") + .into(), + ), + ), + ("colors", ints(&e.colors)), + ("fade_colors", ints(&e.fade)), + ("has_trail", Tag::Byte(i8::from(e.trail))), + ("has_twinkle", Tag::Byte(i8::from(e.twinkle))), + ]) + } /// The item's patched components as vanilla's `components` compound. fn protocol_components(&self, s: &Stack) -> Option { use horizon_protocol::item::{Component, component}; @@ -633,6 +655,14 @@ impl Game<'_> { ("pages", Tag::List(b.pages.clone())), ("resolved", Tag::Byte(i8::from(b.resolved))), ]), + Component::Fireworks { flight, explosions } => Tag::compound([ + ("flight_duration", Tag::Byte(*flight as i8)), + ( + "explosions", + Tag::List(explosions.iter().map(Self::fireworks_tag).collect()), + ), + ]), + Component::FireworkExplosion(e) => Self::fireworks_tag(e), Component::Enchantments(list) => { // Enchantments are a synced (data-driven) registry: the // ids index the server's RegistryData, not the builtin @@ -719,6 +749,24 @@ impl Game<'_> { horizon_protocol::item::Component::Int(s.damage as i32), )); } + if let Some((flight, explosions)) = s.data().and_then(|d| d.fireworks.clone()) { + stack.added.push(( + horizon_protocol::item::component::FIREWORKS, + horizon_protocol::item::Component::Fireworks { + flight: i32::from(flight), + explosions: explosions + .iter() + .map(|e| horizon_protocol::item::FireworkExplosion { + shape: e.shape as i32, + colors: e.colors.iter().map(|c| *c as i32).collect(), + fade: e.fade.iter().map(|c| *c as i32).collect(), + trail: e.trail, + twinkle: e.twinkle, + }) + .collect(), + }, + )); + } Some(stack) } /// A line in the connection's chat, with its styles, click and hover diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 58f82e3a9..dbf7f3ccf 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -2,7 +2,7 @@ //! end crystals on a server's client (`ClientPacketListener.handleAddEntity` //! and `handleSetEntityData`): the server simulates them, the client draws //! them where it says and interpolates their moves. -use super::{FallingBlock, Game}; +use super::{FallingBlock, Game, fireworks::fireworks_of, view_vector}; use horizon_client::projectiles::{Projectile, ProjectileKind}; use horizon_core::{geometry::Vec3, position::BlockPos}; use horizon_protocol::entity_data::Value; @@ -117,6 +117,37 @@ impl Game<'_> { pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) { let id = id as u32; self.remote_hook_data(id, values); + if self.rockets.iter().any(|r| r.id == id) { + for (index, _, value) in values { + match (index, value) { + // FireworkRocketEntity.DATA_ID_FIREWORKS_ITEM: its + // explosions for `createFireworks`. + (8, Value::Item(stack)) => { + if let Some(stack) = self.to_stack(stack) + && let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) + { + let (_, explosions) = fireworks_of(&stack); + r.explosions = explosions; + r.item = stack; + } + } + // DATA_ATTACHED_TO_TARGET (OptionalInt: the id plus 1). + (9, Value::Id(v)) => { + let v = (*v > 0).then(|| (*v - 1) as u32); + if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) { + r.attached_to = v; + } + } + // DATA_SHOT_AT_ANGLE. + (10, Value::Bool(a)) => { + if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) { + r.angled = *a; + } + } + _ => {} + } + } + } if self.potions.has_thrown(id) { // ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is. for (index, _, value) in values { @@ -199,9 +230,30 @@ impl Game<'_> { let id = t.id; t.remote_step(|at| step(id, at)); } + // FireworkRocketEntity.tick: a rocket attached to the gliding local + // player boosts it client-side too (attachedToEntity.isFallFlying). + let me = self + .remote + .as_ref() + .and_then(|r| u32::try_from(r.entity_id).ok()); for r in &mut self.rockets { let id = r.id; r.remote_step(|at| step(id, at)); + if me.is_some() && r.attached_to == me { + if let Some(p) = self.player.as_mut() + && p.fall_flying + { + let look = view_vector(self.yaw, self.pitch); + let m = p.velocity; + p.velocity = Vec3::new( + m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, + m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, + m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, + ); + r.position = p.position; + r.velocity = p.velocity; + } + } } for c in &mut self.crystals { c.time += 1; diff --git a/crates/horizon-protocol/src/item.rs b/crates/horizon-protocol/src/item.rs index 650fa4baa..753fc7349 100644 --- a/crates/horizon-protocol/src/item.rs +++ b/crates/horizon-protocol/src/item.rs @@ -35,6 +35,8 @@ pub mod component { pub const CHARGED_PROJECTILES: i32 = 51; pub const WRITABLE_BOOK_CONTENT: i32 = 56; pub const WRITTEN_BOOK_CONTENT: i32 = 57; + pub const FIREWORK_EXPLOSION: i32 = 70; + pub const FIREWORKS: i32 = 71; } /// A written book's content (`WrittenBookContent.STREAM_CODEC`): the @@ -78,6 +80,48 @@ pub enum Component { WrittenBook(WrittenBook), /// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates. Stacks(Vec), + /// `Fireworks`: flight duration and its explosions. + Fireworks { + flight: i32, + explosions: Vec, + }, + /// `FireworkExplosion` (a firework star). + FireworkExplosion(FireworkExplosion), +} + +/// `FireworkExplosion.STREAM_CODEC`: shape id, colors, fade colors, trail +/// and twinkle (FireworkExplosion.Shape's BY_ID order). +#[derive(Clone, Debug, PartialEq)] +pub struct FireworkExplosion { + pub shape: i32, + pub colors: Vec, + pub fade: Vec, + pub trail: bool, + pub twinkle: bool, +} +impl FireworkExplosion { + fn read(r: &mut Reader) -> Result { + let ints = |r: &mut Reader| -> Result> { + let n = r.count(1024)?; + (0..n).map(|_| r.i32()).collect() + }; + Ok(Self { + shape: r.varint()?, + colors: ints(r)?, + fade: ints(r)?, + trail: r.bool()?, + twinkle: r.bool()?, + }) + } + fn write(&self, w: &mut Writer) { + w.varint(self.shape); + w.varint(self.colors.len() as i32); + self.colors.iter().for_each(|c| w.i32(*c)); + w.varint(self.fade.len() as i32); + self.fade.iter().for_each(|c| w.i32(*c)); + w.bool(self.trail); + w.bool(self.twinkle); + } } /// `Filterable.streamCodec`: the raw value, then an optional filtered one @@ -224,6 +268,17 @@ impl Component { hidden: (0..n).map(|_| r.varint()).collect::>()?, } } + FIREWORKS => { + let flight = r.varint()?; + let n = r.count(256)?; + Self::Fireworks { + flight, + explosions: (0..n) + .map(|_| FireworkExplosion::read(r)) + .collect::>()?, + } + } + FIREWORK_EXPLOSION => Self::FireworkExplosion(FireworkExplosion::read(r)?), other => return Err(Error::new(format!("unsupported item component {other}"))), }) } @@ -294,6 +349,12 @@ impl Component { w.varint(stacks.len() as i32); stacks.iter().for_each(|s| Template::write(s, w)); } + Self::Fireworks { flight, explosions } => { + w.varint(*flight); + w.varint(explosions.len() as i32); + explosions.iter().for_each(|e| e.write(w)); + } + Self::FireworkExplosion(e) => e.write(w), Self::WritableBook(pages) => { w.varint(pages.len() as i32); for page in pages { @@ -544,6 +605,44 @@ mod tests { round::, HashedSlot>(None); } + #[test] + fn fireworks_components_round_trip() { + let mut rocket = Stack::new(42, 1); + rocket.added.push(( + component::FIREWORKS, + Component::Fireworks { + flight: 3, + explosions: vec![ + FireworkExplosion { + shape: 4, + colors: vec![0xff0000, 0x00ff00], + fade: vec![0x0000ff], + trail: true, + twinkle: false, + }, + FireworkExplosion { + shape: 0, + colors: vec![], + fade: vec![], + trail: false, + twinkle: true, + }, + ], + }, + )); + rocket.added.push(( + component::FIREWORK_EXPLOSION, + Component::FireworkExplosion(FireworkExplosion { + shape: 2, + colors: vec![7], + fade: vec![], + trail: false, + twinkle: false, + }), + )); + round::, Slot>(Some(rocket)); + } + #[test] fn charged_projectiles_are_item_stack_templates() { // A crossbow (id 7) loaded with one arrow (id 9): ChargedProjectiles From da150409ea6b63f8a70450d5d7a62ac00a8c2ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kaic=20Salom=C3=A3o?= Date: Sun, 25 Oct 2026 02:49:22 -0300 Subject: [PATCH 02/57] Complete and color mod commands in the chat on the server thread --- crates/horizon-client/src/game/chat.rs | 2 +- crates/horizon-client/src/game/mods.rs | 25 ++++++++++++++++++++++++ crates/horizon-client/src/game/remote.rs | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 1bc30513b..e879698c7 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -18,7 +18,7 @@ fn chat_tree<'a>( mods: &'a super::mods::ModsState, ) -> &'a horizon_client::command::Dispatcher { match remote.as_ref().and_then(|r| r.commands.as_ref()) { - Some(tree) => tree, + Some(tree) => mods.remote_dispatcher().unwrap_or(tree), None => mods.dispatcher(), } } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 572f9fe2b..982c7f097 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -39,6 +39,9 @@ pub(super) struct ModsState { /// The chat's command tree while a running mod owns commands. #[cfg(feature = "mods")] tree: Option, + /// The server's command tree with the running mods' commands added. + #[cfg(feature = "mods")] + remote_tree: Option, } impl ModsState { /// The chat's command tree: vanilla's, plus the running mods' commands. @@ -49,6 +52,14 @@ impl ModsState { } vanilla::game_dispatcher() } + /// The server's tree plus the mods' commands, when a mod owns any. + pub(super) fn remote_dispatcher(&self) -> Option<&Dispatcher> { + #[cfg(feature = "mods")] + if let Some(tree) = &self.remote_tree { + return Some(tree); + } + None + } /// Whether a mod runs: the chat keeps its lines for them. pub(super) fn running(&self) -> bool { #[cfg(feature = "mods")] @@ -158,6 +169,7 @@ impl Game<'_> { ); } } + pub(super) fn merge_remote_tree(&mut self) {} pub(super) fn sync_mods(&mut self) {} pub(super) fn tick_mods(&mut self) {} pub(super) fn mods_frame(&mut self, _elapsed: Duration) {} @@ -238,7 +250,20 @@ impl Game<'_> { .peek() .is_some() .then(|| vanilla::game_dispatcher().with_mod_commands(names)); + self.merge_remote_tree(); } + + /// The server's command tree with the mods' commands, for the chat. + pub(super) fn merge_remote_tree(&mut self) { + let names: Vec = self.mods.mods.commands().map(str::to_owned).collect(); + self.mods.remote_tree = self + .remote + .as_ref() + .and_then(|r| r.commands.as_ref()) + .filter(|_| !names.is_empty()) + .map(|t| t.with_mod_commands(names.iter().map(String::as_str))); + } + /// After the options changed: restart the mods if the list of the ones /// turned on did. pub(super) fn sync_mods(&mut self) { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 23c6677c2..9da81e551 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2423,6 +2423,7 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.commands = Some(tree); } + self.merge_remote_tree(); } C::SystemChat(p) => { // The component as sent, translated in the client's own From 90ae953747a746cc32b20bc1cf4cf73cd21ce8c0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:21:48 +0100 Subject: [PATCH 03/57] Rockets: collapse the boost's nested if (clippy::collapsible_if) Verify failed on it with warnings denied. Co-Authored-By: Claude Opus 5.5 --- .../src/game/remote_projectiles.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index dbf7f3ccf..056efa7c3 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -239,20 +239,20 @@ impl Game<'_> { for r in &mut self.rockets { let id = r.id; r.remote_step(|at| step(id, at)); - if me.is_some() && r.attached_to == me { - if let Some(p) = self.player.as_mut() - && p.fall_flying - { - let look = view_vector(self.yaw, self.pitch); - let m = p.velocity; - p.velocity = Vec3::new( - m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, - m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, - m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, - ); - r.position = p.position; - r.velocity = p.velocity; - } + if me.is_some() + && r.attached_to == me + && let Some(p) = self.player.as_mut() + && p.fall_flying + { + let look = view_vector(self.yaw, self.pitch); + let m = p.velocity; + p.velocity = Vec3::new( + m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, + m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, + m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, + ); + r.position = p.position; + r.velocity = p.velocity; } } for c in &mut self.crystals { From 11035336a0115e7fbf4444934500c06ae93c66a4 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:24:29 +0100 Subject: [PATCH 04/57] Server: a gliding player's rocket boosts them FireworkRocketItem.use attaches a rocket only to a player who is fall flying. The server tracks a guest's glide (START_FALL_FLYING) but the body it swaps in for their item use left fall_flying unset, so on the server thread, LAN and dedicated servers the rocket did nothing. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index d0f16a0d6..e140f31eb 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -264,6 +264,8 @@ impl Game<'_> { motion.sprinting = g.sprinting; motion.may_fly = g.creative() || g.game_mode == SPECTATOR; motion.flying = g.flying; + // FireworkRocketItem.use boosts only a gliding player. + motion.fall_flying = g.fall_flying; motion.invulnerable = motion.may_fly; let _ = motion.set_health(g.health, false); motion.health.food.food = g.food; From e7ca664e01471d6c8e3581c411138d336c1ffff1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:32:23 +0100 Subject: [PATCH 05/57] Server: a guest's boost rocket is theirs The server attached every boosting rocket to its own player: it followed the host, and DATA_ATTACHED_TO_TARGET named the host (or nothing on the server thread, which has none), so the guest's client never saw a rocket attached to them and did not boost. The rocket now keeps the guest it was fired by, follows them and names their entity. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/fireworks.rs | 23 ++++++++++++++++--- .../horizon-client/src/game/lan_entities.rs | 12 ++++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index a0aa276b7..623e0e147 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -28,6 +28,9 @@ pub(super) struct Rocket { pub(super) explosions: Vec, /// Boosting the player (attachedToEntity). pub(super) attached: bool, + /// On a server, the player it boosts when not this game's own: a guest's + /// connection. + pub(super) guest: Option, /// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows; /// boosting happens when it is the local player. pub(super) attached_to: Option, @@ -51,6 +54,7 @@ impl Rocket { lifetime: i32::MAX, explosions, attached: false, + guest: None, attached_to: None, angled: false, item, @@ -93,6 +97,7 @@ impl Game<'_> { lifetime, explosions, attached, + guest: None, attached_to: None, angled: false, item: stack.clone(), @@ -132,7 +137,8 @@ impl Game<'_> { return false; } let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position); - let rocket = self.new_rocket(at, stack, true); + let mut rocket = self.new_rocket(at, stack, true); + rocket.guest = self.lan_acting; self.rockets.push(rocket); if !self.creative { self.consume_held(); @@ -145,7 +151,18 @@ impl Game<'_> { while i < self.rockets.len() { let mut r = self.rockets.remove(i); r.previous = r.position; - if r.attached { + if r.attached + && let Some(conn) = r.guest + { + // A guest's rocket follows them; their own client boosts them. + let guest = self + .lan + .as_ref() + .and_then(|host| host.hub.guests.iter().find(|g| g.conn == conn)); + if let Some(g) = guest { + r.position = Vec3::new(g.position[0], g.position[1], g.position[2]); + } + } else if r.attached { if let Some(p) = self.player.as_mut() { if p.fall_flying { let look = view_vector(self.yaw, self.pitch); @@ -234,7 +251,7 @@ impl Game<'_> { let at = [r.position.x, r.position.y, r.position.z]; if !r.explosions.is_empty() { let damage = 5.0 + r.explosions.len() as f32 * 2.0; - if r.attached { + if r.attached && r.guest.is_none() { self.hurt_player_by("minecraft:fireworks", damage); } let from = r.position; diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index db0c3e8b2..c525c902e 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -417,11 +417,19 @@ impl Game<'_> { Value::Item(hub.stack(&Some(r.item.clone()))), )); } + let boosted = match r.guest { + Some(conn) => hub + .guests + .iter() + .find(|g| g.conn == conn) + .map(|g| g.entity_id), + None => host_entity, + }; if r.attached - && let Some(host) = host_entity + && let Some(player) = boosted { e.meta - .push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(host + 1))); + .push((9, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(player + 1))); } if r.angled { e.meta.push((10, serializer::BOOLEAN, Value::Bool(true))); From 41b799042acb262601e6a3e2695a5a3e68b4e618 Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 14:26:55 +0100 Subject: [PATCH 06/57] Sync mobs' aggressive and item-use flags so clients pose them right Mob.DATA_MOB_FLAGS_ID's aggressive bit and LivingEntity's DATA_LIVING_ENTITY_FLAGS never reached a client for mobs: on the server thread and for LAN guests a skeleton kept its arms down while its arrows flew, and a drawn bow never pulled. Send both entries (with their defaults so a flag going back to 0 is sent too), decode them for every mob, and let a remote mob's item-use ticks count up as LivingEntity.updatingUsingItem does. The same entries drive the zombie arms, the vindicator's axe, the drowned's trident and the spellcasters' poses, so the pillager's charging and evoker's/illusioner's casting entries go with them. A left-handed mob now mirrors the pose and the held item (Mob.getMainArm), and a bow in the off hand counts for LivingEntity.isHolding. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game/lan.rs | 28 +++++++- crates/horizon-client/src/game/mob_blocks.rs | 4 +- crates/horizon-client/src/game/remote.rs | 67 +++++++------------ .../src/game/remote_mob_data.rs | 63 +++++++++++++++++ crates/horizon-client/src/mobs.rs | 5 ++ crates/horizon-client/src/mobs/anim.rs | 4 +- crates/horizon-client/src/mobs/undead.rs | 5 ++ 7 files changed, 127 insertions(+), 49 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 8460f54e6..522c6f1d3 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -162,17 +162,26 @@ fn mob_meta( meta.push((3, serializer::BOOLEAN, Value::Bool(true))); } } + // LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the off + // hand 2 (a skeleton draws its bow, a witch raises her potion). + let using = i8::from(m.extra.using_item > 0) | i8::from(m.extra.using_off_hand) << 1; + if using != 0 { + meta.push((8, serializer::BYTE, Value::Byte(using))); + } + defaults.push((8, serializer::BYTE, Value::Byte(0))); // LivingEntity.DATA_HEALTH_ID: defined as 1, then set to the max health. let health = m.health.max(0.0); if health != 1.0 { meta.push((9, serializer::FLOAT, Value::Float(health))); } defaults.push((9, serializer::FLOAT, Value::Float(1.0))); - // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2. - let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1; + // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4. + let flags = + i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1 | i8::from(m.ai.aggressive) << 2; if flags != 0 { meta.push((15, serializer::BYTE, Value::Byte(flags))); } + defaults.push((15, serializer::BYTE, Value::Byte(0))); // AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's). if let Some(index) = baby_index(kind) { if m.is_baby() { @@ -289,6 +298,21 @@ fn mob_meta( meta.push((18, serializer::BOOLEAN, Value::Bool(true))); } } + // Pillager.IS_CHARGING_CROSSBOW: the charge pose and the held + // item's loading animation. + "minecraft:pillager" => { + if e.flag { + meta.push((17, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((17, serializer::BOOLEAN, Value::Bool(false))); + } + // SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose. + "minecraft:evoker" | "minecraft:illusioner" => { + if e.variant2 != 0 { + meta.push((17, serializer::BYTE, Value::Byte(e.variant2 as i8))); + } + defaults.push((17, serializer::BYTE, Value::Byte(0))); + } // Enderman.DATA_CARRY_STATE. "minecraft:enderman" => { if let Some(state) = e.carried { diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index a9e854bfb..083c96a66 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -259,7 +259,7 @@ impl Game<'_> { // ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint. for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] { let Some(item) = item else { continue }; - let left = off_hand != mob.extra.main_hand_left; + let left = off_hand != mob.main_arm_left(); let Some(t) = render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left) else { @@ -305,7 +305,7 @@ impl Game<'_> { .player_model .as_ref() .is_some_and(|p| std::ptr::eq(p, mob)); - owner.main_arm_left = mob.extra.main_hand_left; + owner.main_arm_left = mob.main_arm_left(); if off_hand == mob.extra.using_off_hand && mob.extra.using_item > 0 { owner.using_this_item = true; owner.use_duration = 72_000; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index dc3ce5de9..ae5d25a44 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1994,15 +1994,6 @@ impl Game<'_> { } } } - if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) - && m.species() == horizon_client::mobs::Species::Rabbit - { - for (index, _, value) in &p.values { - if let (15, Value::Byte(flags)) = (index, value) { - m.no_ai = flags & 1 != 0; - } - } - } self.remote_mob_data(p.id, &p.values); if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) && m.species() == horizon_client::mobs::Species::Creeper @@ -2017,25 +2008,12 @@ impl Game<'_> { } } } - // LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS): - // another player starts using the item in a hand (eating, a - // bow drawn, a shield up), or stops. + // LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player + // too, remote_mob_data's apply reads index 8 above. let other = self .remote .as_ref() .is_some_and(|r| r.level_players.contains_key(&p.id) && r.entity_id != p.id); - if other && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { - for (index, _, value) in &p.values { - if let (8, Value::Byte(flags)) = (index, value) { - m.extra.using_off_hand = flags & 2 != 0; - if flags & 1 == 0 { - m.extra.using_item = 0; - } else if m.extra.using_item == 0 { - m.extra.using_item = 1; - } - } - } - } // LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): another // player lies down in their bed (drawn lying in it), or gets up. if other { @@ -3520,25 +3498,28 @@ impl Game<'_> { m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32; *steps -= 1; } - // LivingEntity.updatingUsingItem for another player: the ticks the - // item has been used before this one. - if player && m.extra.using_item > 0 { - let item = if m.extra.using_off_hand { - m.extra.off_hand.clone() - } else { - m.extra.main_hand.clone() - }; - if let Some(item) = item { - let eye = if m.extra.crouching { 1.27 } else { 1.62 }; - let at = [m.position.x, m.position.y, m.position.z]; - using.push(( - item, - m.extra.using_item as u32 - 1, - at, - eye, - m.head_pitch, - m.yaw, - )); + // LivingEntity.updatingUsingItem: the ticks the item has been + // used before this one. Another player's use particles; a mob's + // drawn bow only needs the count (its pull follows it). + if m.extra.using_item > 0 { + if player { + let item = if m.extra.using_off_hand { + m.extra.off_hand.clone() + } else { + m.extra.main_hand.clone() + }; + if let Some(item) = item { + let eye = if m.extra.crouching { 1.27 } else { 1.62 }; + let at = [m.position.x, m.position.y, m.position.z]; + using.push(( + item, + m.extra.using_item as u32 - 1, + at, + eye, + m.head_pitch, + m.yaw, + )); + } } m.extra.using_item += 1; } diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 5573d0f87..5d024cd84 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -20,9 +20,27 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) { // client, which draws the flames. A client doesn't count the // fire down: the flag goes off when the server's fire is out. (0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1), + // LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the + // off hand 2. The flag starts and stops the use; the client ticks + // the count itself (a bow's pull follows it). + (8, Value::Byte(flags)) => { + m.extra.using_off_hand = flags & 2 != 0; + if flags & 1 == 0 { + m.extra.using_item = 0; + } else if m.extra.using_item == 0 { + m.extra.using_item = 1; + } + } // LivingEntity.DATA_HEALTH_ID: the boss bars and the death // animation follow it. (9, Value::Float(health)) => m.health = *health, + // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4. + // A skeleton raises its bow and a zombie its arms on the last bit. + (15, Value::Byte(flags)) => { + m.no_ai = flags & 1 != 0; + m.extra.left_handed = flags & 2 != 0; + m.ai.aggressive = flags & 4 != 0; + } (i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 }, _ => {} } @@ -45,6 +63,13 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) { (Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => { m.extra.size = (*size).clamp(1, 127); } + // Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's + // IS_CELEBRATING takes 16). + (Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging, + // SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose. + (Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => { + m.extra.variant2 = (*spell).max(0) as u8; + } // Villager.DATA_VILLAGER_DATA and ZombieVillager's (after // Zombie's three): what VillagerProfessionLayer draws. (Species::Villager, 19, Value::Ids(data)) @@ -218,4 +243,42 @@ mod tests { ); assert_eq!(name(&z), ("desert", "farmer", 3)); } + + #[test] + fn the_mob_flags_drive_the_aggressive_poses() { + let Some(mut m) = mob("minecraft:skeleton") else { + return; + }; + apply(&mut m, &[(15, s::BYTE, Value::Byte(2 | 4))]); + assert!(m.ai.aggressive && m.extra.left_handed && !m.no_ai); + apply(&mut m, &[(15, s::BYTE, Value::Byte(0))]); + assert!(!m.ai.aggressive && !m.extra.left_handed); + } + + #[test] + fn the_living_flags_start_and_stop_a_mob_using_an_item() { + let Some(mut m) = mob("minecraft:skeleton") else { + return; + }; + apply(&mut m, &[(8, s::BYTE, Value::Byte(1))]); + assert_eq!(m.extra.using_item, 1); + assert!(!m.extra.using_off_hand); + // Using in the off hand. + apply(&mut m, &[(8, s::BYTE, Value::Byte(3))]); + assert!(m.extra.using_off_hand); + apply(&mut m, &[(8, s::BYTE, Value::Byte(0))]); + assert_eq!(m.extra.using_item, 0); + } + + #[test] + fn a_pillager_charges_and_a_spellcaster_casts() { + let Some(mut p) = mob("minecraft:pillager") else { + return; + }; + apply(&mut p, &[(17, s::BOOLEAN, Value::Bool(true))]); + assert!(p.extra.flag); + let mut e = mob("minecraft:evoker").unwrap(); + apply(&mut e, &[(17, s::BYTE, Value::Byte(2))]); + assert_eq!(e.extra.variant2, 2); + } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 4e3879fe5..1a124b6de 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1646,6 +1646,11 @@ impl Mob { pub fn species(&self) -> Species { self.kind.species } + /// `Mob.getMainArm`: the left arm when the mob is left-handed, a player's + /// `mainHand` option for a player mob. + pub fn main_arm_left(&self) -> bool { + self.extra.main_hand_left || self.extra.left_handed + } /// The player hit this mob (melee): LivingEntity.hurt with knockback. pub fn hurt( &mut self, diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index aa0d8fb2a..b2158132b 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -642,7 +642,7 @@ pub fn setup( } else { ArmPose::Empty }; - let mut h = if m.extra.main_hand_left { + let mut h = if m.main_arm_left() { Hum::new(ArmPose::Empty, main) } else { Hum::new(main, ArmPose::Empty) @@ -693,7 +693,7 @@ pub fn setup( } else { ArmPose::Empty }; - let mut h = if m.extra.main_hand_left { + let mut h = if m.main_arm_left() { Hum::new(off, pose) } else { Hum::new(pose, off) diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index 197321dea..e77a37641 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -621,8 +621,10 @@ impl BowAttack { } } } +/// `LivingEntity.isHolding(Items.BOW)`: the bow can sit in either hand. fn holding_bow(m: &Mob) -> bool { m.extra.main_hand.as_deref() == Some("minecraft:bow") + || m.extra.off_hand.as_deref() == Some("minecraft:bow") } impl Goal for BowAttack { goal_clone!(); @@ -715,7 +717,10 @@ impl Goal for BowAttack { } else { self.attack_time -= 1; if self.attack_time <= 0 && self.see_time >= -60 { + // mob.startUsingItem(ProjectileUtil.getWeaponHoldingHand): + // the off hand when the main one doesn't hold the bow. m.extra.using_item = 1; + m.extra.using_off_hand = m.extra.main_hand.as_deref() != Some("minecraft:bow"); } } } From b457f6382a76b2bfa64077fee92a5bbaa9feca61 Mon Sep 17 00:00:00 2001 From: JasonGu <1456970218@qq.com> Date: Sun, 25 Oct 2026 21:41:56 +0800 Subject: [PATCH 07/57] Localization: names, container titles, death text and search follow the language - item and block names in tooltips, held labels, command feedback and the creative tabs read the selected language; the pinned English map stays for logic and search - Game::translate resolves through lang::get_or, so station titles follow the language screen - container titles and Inventory labels for chests, hoppers, furnaces, smokers, blast furnaces, crafters, shulkers and mounts are translated - server-resolved components (menu titles, death messages, command suggestion tooltips) load the selected language over en_us, and follow a language change while a world is open - death messages always reach the death screen translated; the chat broadcast still obeys showDeathMessages - creative search matches the localized display name - sign editors use sign.edit and hanging_sign.edit Co-Authored-By: OpenCode DeepSeek V4.1 Flash --- crates/horizon-client/src/creative.rs | 86 ++++++++++++++++++- crates/horizon-client/src/game.rs | 57 ++++++++---- crates/horizon-client/src/game/commands.rs | 2 +- crates/horizon-client/src/game/lan_actions.rs | 50 +++++------ crates/horizon-client/src/game/machines.rs | 50 +++++++---- .../src/game/machines/crafter.rs | 3 +- crates/horizon-client/src/game/mount_menu.rs | 3 +- crates/horizon-client/src/game/options.rs | 1 + crates/horizon-client/src/game/packs.rs | 1 + crates/horizon-client/src/game/potions.rs | 2 +- crates/horizon-client/src/game/recipe_book.rs | 2 +- crates/horizon-client/src/game/remote.rs | 9 ++ crates/horizon-client/src/game/sign_edit.rs | 11 +-- crates/horizon-client/src/game/stations.rs | 33 ++++--- crates/horizon-client/src/game/storage.rs | 6 +- crates/horizon-client/src/game/titles.rs | 2 +- crates/horizon-client/src/game/tweaks.rs | 2 +- crates/horizon-client/src/lang.rs | 5 ++ crates/horizon-client/src/menu.rs | 2 +- crates/horizon-client/src/net.rs | 12 +-- 20 files changed, 236 insertions(+), 103 deletions(-) diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index e1d1c262e..593aad61d 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -71,10 +71,52 @@ impl Names { .cloned() .unwrap_or_else(|| path.replace('_', " ")) } + /// An item's display name in the selected language: `item.*` then + /// `block.*`, then the pinned English map (`Names::item`). + pub fn display_item(&self, id: &str) -> String { + let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id)); + if let Some(lang) = crate::lang::current() { + for prefix in ["item", "block"] { + if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) { + return name.to_owned(); + } + } + } + self.item(id) + } + /// A translation key's text in the selected language, else the pinned + /// English map (`HoverName` and tooltip keys such as potion names). + pub fn display_get(&self, key: &str) -> Option { + crate::lang::current() + .and_then(|lang| lang.get(key).map(str::to_owned)) + .or_else(|| self.get(key).map(str::to_owned)) + } pub fn get(&self, key: &str) -> Option<&str> { self.map.get(key).map(String::as_str) } } + +/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the +/// export's `title` is the English fallback. +pub fn item_group_key(id: &str) -> Option<&'static str> { + Some(match id.trim_start_matches("minecraft:") { + "building_blocks" => "itemGroup.buildingBlocks", + "colored_blocks" => "itemGroup.coloredBlocks", + "natural_blocks" => "itemGroup.natural", + "functional_blocks" => "itemGroup.functional", + "redstone_blocks" => "itemGroup.redstone", + "hotbar" => "itemGroup.hotbar", + "search" => "itemGroup.search", + "tools_and_utilities" => "itemGroup.tools", + "combat" => "itemGroup.combat", + "food_and_drinks" => "itemGroup.foodAndDrink", + "ingredients" => "itemGroup.ingredients", + "spawn_eggs" => "itemGroup.spawnEggs", + "op_blocks" => "itemGroup.op", + "inventory" => "itemGroup.inventory", + _ => return None, + }) +} impl Creative { pub const WIDTH: f32 = 195.0; pub const HEIGHT: f32 = 136.0; @@ -303,7 +345,11 @@ impl Creative { .collect(); } // FullTextSearchTree: tooltip lines, and the item id after a colon. + // The export's lines are English (they come from the vanilla jars); + // the display name in the selected language is matched too, so a + // localized search finds the item. let text = java_lower(term); + let localized = |i: usize| java_lower(&names.display_item(&self.search_items[i].item)); all.filter(|&i| { let e = &self.search_index[i]; let fallback; @@ -314,11 +360,15 @@ impl Creative { &e.lines }; match text.split_once(':') { - None => any(lines.iter().map(String::as_str), &text), + None => { + any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text) + } Some((ns, path)) => { let (ns, path) = (java_trim(ns), java_trim(path)); e.namespace.contains(ns) - && (e.path.contains(path) || any(lines.iter().map(String::as_str), path)) + && (e.path.contains(path) + || any(lines.iter().map(String::as_str), path) + || localized(i).contains(path)) } } }) @@ -759,6 +809,24 @@ mod tests { assert_eq!(q("#craft"), Vec::::new()); } + #[test] + fn search_matches_the_display_name_in_the_selected_language() { + let c = creative(0); + let names = Names { + map: HashMap::from([ + ("item.minecraft.anvil".to_owned(), "铁砧".to_owned()), + ("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()), + ]), + }; + let q = |s: &str| c.search_results(s, &names); + assert_eq!(q("铁砧"), [0]); + assert_eq!(q("木锄"), [2]); + // The English tooltip lines the export indexed still match. + assert_eq!(q("anvil"), [0]); + // A colon's path searches the display name too. + assert_eq!(q("minecraft:铁砧"), [0]); + } + #[test] fn scrollbar_drag_follows_the_mouse_while_held() { let mut c = creative(90); @@ -794,6 +862,20 @@ mod tests { assert!(!c.scrolling); } + #[test] + fn creative_tab_titles_have_vanilla_keys() { + assert_eq!( + item_group_key("minecraft:building_blocks"), + Some("itemGroup.buildingBlocks") + ); + assert_eq!( + item_group_key("minecraft:food_and_drinks"), + Some("itemGroup.foodAndDrink") + ); + assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar")); + assert_eq!(item_group_key("minecraft:nonsense"), None); + } + #[test] fn wheel_moves_one_row_per_notch() { let mut c = creative(90); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 653a1d038..6bd8f88cb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5296,22 +5296,32 @@ impl Game<'_> { Some(BlockEntity::Chest(_)) if self.draw_machine_screen(batch, ox, oy) => {} Some(BlockEntity::Chest(_)) => self.draw_chest_screen(batch, ox, oy), Some(BlockEntity::Furnace(f)) => { - let (tex, title) = match f.kind { + let (tex, key, fallback) = match f.kind { horizon_client::containers::FurnaceKind::Furnace => { - ("gui/container/furnace", "Furnace") - } - horizon_client::containers::FurnaceKind::BlastFurnace => { - ("gui/container/blast_furnace", "Blast Furnace") + ("gui/container/furnace", "container.furnace", "Furnace") } + horizon_client::containers::FurnaceKind::BlastFurnace => ( + "gui/container/blast_furnace", + "container.blast_furnace", + "Blast Furnace", + ), horizon_client::containers::FurnaceKind::Smoker => { - ("gui/container/smoker", "Smoker") + ("gui/container/smoker", "container.smoker", "Smoker") } }; part(batch, tex, 0.0, 0.0, 176.0, 166.0, ox, oy); - let title = &self.container_title(title.to_owned()); - let tw = HudBatch::text_width(atlas, title); - batch.text(atlas, title, ox + (176.0 - tw) / 2.0, oy + 6.0, grey, false); - batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false); + let title = self.container_title(self.translate(key, fallback)); + let tw = HudBatch::text_width(atlas, &title); + batch.text( + atlas, + &title, + ox + (176.0 - tw) / 2.0, + oy + 6.0, + grey, + false, + ); + let inventory = self.translate("container.inventory", "Inventory"); + batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false); if f.lit_remaining > 0 { // AbstractFurnaceScreen: flame height ceil(lit * 13) + 1. let total = if f.lit_total == 0 { 200 } else { f.lit_total }; @@ -5365,14 +5375,12 @@ impl Game<'_> { "gui/container/inventory" }; part(batch, texture, 0.0, 0.0, 176.0, 166.0, ox, oy); - let (title, tx) = if self.craft_size == 3 { - ("Crafting", 29.0) - } else { - ("Crafting", 97.0) - }; - batch.text(atlas, title, ox + tx, oy + 6.0, grey, false); + let title = self.translate("container.crafting", "Crafting"); + let tx = if self.craft_size == 3 { 29.0 } else { 97.0 }; + batch.text(atlas, &title, ox + tx, oy + 6.0, grey, false); if self.craft_size == 3 { - batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false); + let inventory = self.translate("container.inventory", "Inventory"); + batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false); } } } @@ -9402,6 +9410,11 @@ impl Game<'_> { fn creative_origin(batch: &HudBatch) -> (f32, f32) { batch.centered_origin(Creative::WIDTH, Creative::HEIGHT) } + /// A creative tab's title: `itemGroup.*`, else the export's English title. + fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String { + horizon_client::creative::item_group_key(&tab.id) + .map_or_else(|| tab.title.clone(), |key| self.translate(key, &tab.title)) + } /// Vanilla tooltip: dark box with a purple gradient border. fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) { let atlas = &self.gui.atlas; @@ -9471,9 +9484,10 @@ impl Game<'_> { } let atlas = &self.gui.atlas; if !tab.inventory { + let title = self.creative_tab_title(&tab); batch.text( atlas, - &tab.title, + &title, ox + 8.0, oy + 6.0, rgba(64, 64, 64, 255), @@ -9584,7 +9598,12 @@ impl Game<'_> { && self.cursor.1 >= oy + ty && self.cursor.1 < oy + ty + 32.0 { - self.tooltip(batch, &t.title, self.cursor.0, self.cursor.1); + self.tooltip( + batch, + &self.creative_tab_title(t), + self.cursor.0, + self.cursor.1, + ); } } self.draw_carried(batch); diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 2e551f440..1bb468a7f 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -439,7 +439,7 @@ impl Game<'_> { .find(|i| i.id == id) .map(|i| i.stack.item.to_string()) .unwrap_or_default(); - Text::literal(self.pack.names.item(&item)) + Text::literal(self.pack.names.display_item(&item)) } Target::Mob(id) if self diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..03bb15b65 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1791,33 +1791,33 @@ impl Game<'_> { // ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3. let drops = (g.game_mode != SPECTATOR) .then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]); + // ServerPlayer.die: the death screen always shows the message; + // chat only when the rule allows and the team does not hide it. let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take()); - let message = match cause { - // ServerPlayer.die: the death message, to everyone unless - // the player's team hides it. - Some(key) if self.ui.rules.bool("show_death_messages") => { - let text = horizon_client::text::Text::translate( - &key, - &[self.player_text(&name, uuid)], - ); - let message = horizon_client::lan::data::component_nbt(&text.component()); - let team = self.score.board.players_team(&name); - if horizon_client::scoreboard::own_death_message_shown(team) { - host.hub.system_message(message.clone()); - if self.options.dedicated.is_some() { - super::dedicated::log( - "INFO", - &format!("System chat: {}", text.plain()), - ); - } else if self.player.is_some() { - self.system_message(text); - } - } - message + let key = cause.unwrap_or_else(|| { + if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) { + "death.attack.outOfWorld".into() + } else { + "death.attack.generic".into() } - Some(_) => literal(""), - None => literal(&format!("{name} died")), - }; + }); + let text = + horizon_client::text::Text::translate(&key, &[self.player_text(&name, uuid)]); + let message = horizon_client::lan::data::component_nbt(&text.component()); + if self.ui.rules.bool("show_death_messages") { + let team = self.score.board.players_team(&name); + if horizon_client::scoreboard::own_death_message_shown(team) { + host.hub.system_message(message.clone()); + if self.options.dedicated.is_some() { + super::dedicated::log( + "INFO", + &format!("System chat: {}", text.plain()), + ); + } else if self.player.is_some() { + self.system_message(text); + } + } + } host.hub.send_to( conn, &C::PlayerCombatKill(play::PlayerCombatKill { id, message }), diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 8ce1940c9..6866552c1 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -889,13 +889,31 @@ impl Game<'_> { let key_is_cart = self .container .is_some_and(|k| k.1 == super::minecarts::CART_KEY_Y); - let (texture, height, title) = match screen { - Screen::Hopper if key_is_cart => { - ("gui/container/hopper", 133.0, "Minecart with Hopper") - } - Screen::Hopper => ("gui/container/hopper", 133.0, "Item Hopper"), - Screen::Dispenser => ("gui/container/dispenser", 166.0, "Dispenser"), - Screen::Dropper => ("gui/container/dispenser", 166.0, "Dropper"), + let (texture, height, key, fallback) = match screen { + Screen::Hopper if key_is_cart => ( + "gui/container/hopper", + 133.0, + "entity.minecraft.hopper_minecart", + "Minecart with Hopper", + ), + Screen::Hopper => ( + "gui/container/hopper", + 133.0, + "container.hopper", + "Item Hopper", + ), + Screen::Dispenser => ( + "gui/container/dispenser", + 166.0, + "container.dispenser", + "Dispenser", + ), + Screen::Dropper => ( + "gui/container/dispenser", + 166.0, + "container.dropper", + "Dropper", + ), }; if let Some(mut r) = atlas.sprites.get(texture).copied() { let dv = (r.v1 - r.v0) / r.h as f32; @@ -905,23 +923,17 @@ impl Game<'_> { batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX); } let grey = horizon_client::hud::rgba(64, 64, 64, 255); - let title = &self + let title = self .remote_menu_title() - .unwrap_or_else(|| self.container_title(title.to_owned())); + .unwrap_or_else(|| self.container_title(self.translate(key, fallback))); let title_x = if screen == Screen::Hopper { 8.0 } else { - (176.0 - horizon_client::hud::HudBatch::text_width(atlas, title)) / 2.0 + (176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0 }; - batch.text(atlas, title, ox + title_x, oy + 6.0, grey, false); - batch.text( - atlas, - "Inventory", - ox + 8.0, - oy + height - 94.0, - grey, - false, - ); + batch.text(atlas, &title, ox + title_x, oy + 6.0, grey, false); + let inventory = self.translate("container.inventory", "Inventory"); + batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false); true } } diff --git a/crates/horizon-client/src/game/machines/crafter.rs b/crates/horizon-client/src/game/machines/crafter.rs index 0fe66f7c8..b76bafd64 100644 --- a/crates/horizon-client/src/game/machines/crafter.rs +++ b/crates/horizon-client/src/game/machines/crafter.rs @@ -256,7 +256,8 @@ impl Game<'_> { grey, false, ); - batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false); + let inventory = self.translate("container.inventory", "Inventory"); + batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false); self.paint_screen(batch, ox, oy, rest, true); } /// `gui.togglable_slot` over an empty, enabled grid slot. diff --git a/crates/horizon-client/src/game/mount_menu.rs b/crates/horizon-client/src/game/mount_menu.rs index e9e1e380b..c80c900f9 100644 --- a/crates/horizon-client/src/game/mount_menu.rs +++ b/crates/horizon-client/src/game/mount_menu.rs @@ -470,7 +470,8 @@ impl Game<'_> { None => String::new(), }; batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false); - batch.text(atlas, "Inventory", ox + 8.0, oy + 72.0, grey, false); + let inventory = self.translate("container.inventory", "Inventory"); + batch.text(atlas, &inventory, ox + 8.0, oy + 72.0, grey, false); if let Some(m) = self.mobs.iter().find(|m| m.id == open.mob) { self.draw_mob_preview( batch, diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index d947f91d0..bb78b8bf8 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -246,6 +246,7 @@ impl Game<'_> { pub(super) fn load_language(&mut self, code: &str) { let root = self.options.resources.clone(); lang::set(lang::Language::load(&[&root], code)); + self.refresh_remote_language(); self.ui.chat.rescale(&self.gui.atlas); } /// Settings that Horizon applies beyond render distance, volume and GUI scale. diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 7629a5d25..733b19649 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -203,6 +203,7 @@ impl Game<'_> { 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())); + self.refresh_remote_language(); self.audio.reload(root); self.clear_lod(); self.streaming.reload_resources(&mut self.world, context); diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b17316e08..49e171ce1 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -755,7 +755,7 @@ impl Game<'_> { image_width, &self.potions.effects.list, self.clock.rate(), - &|key| self.pack.names.get(key).map(str::to_owned), + &|key| Some(horizon_client::lang::get(key)), &|text| self.gui.atlas.font.width(text).ceil() as i32, ); if let Some(lines) = inventory_effects::paint( diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index d3c14ffc7..f68460b7d 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -502,7 +502,7 @@ impl Game<'_> { horizon_client::item_tooltip::lines( stack, self.res.items().get(&stack.item), - &|k| self.pack.names.get(k).map(str::to_owned), + &|k| Some(horizon_client::lang::get(k)), ) }) { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5a71b13a8..e7be063e6 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4990,6 +4990,15 @@ impl Game<'_> { pub(super) fn remote_menu_title(&self) -> Option { self.remote.as_ref()?.menu_title.clone() } + /// A language change reaches the server-sent components too: their titles + /// are resolved when the packet arrives, with the session's language. + pub(super) fn refresh_remote_language(&mut self) { + if let Some(lang) = horizon_client::lang::current() + && let Some(r) = self.remote.as_mut() + { + r.lang = Arc::new(lang.map().clone()); + } + } /// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title. pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> { let r = self.remote.as_ref()?; diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 5a26e3e69..e8ee0a94d 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -248,13 +248,14 @@ impl Game<'_> { let atlas = &self.gui.atlas; let (w, h) = (batch.width, batch.height); batch.fill(atlas, 0.0, 0.0, w, h, rgba(16, 16, 16, 160)); - let title = if sign.hanging { - "Edit Hanging Sign Message" + let (key, fallback) = if sign.hanging { + ("hanging_sign.edit", "Edit Hanging Sign Message") } else { - "Edit Sign Message" + ("sign.edit", "Edit Sign Message") }; - let tw = HudBatch::text_width(atlas, title); - batch.text(atlas, title, (w - tw) / 2.0, 40.0, u32::MAX, true); + let title = self.translate(key, fallback); + let tw = HudBatch::text_width(atlas, &title); + batch.text(atlas, &title, (w - tw) / 2.0, 40.0, u32::MAX, true); let name = self .res .facts() diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index ffe56d699..2501e892d 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -277,15 +277,10 @@ fn argb(c: i32) -> u32 { } impl Game<'_> { - fn data(&self) -> &stations::StationData { - self.res.stations() - } + /// `Component.translatable`: the selected language (which `lang` layers + /// over `en_us`), the caller's English text last. pub(super) fn translate(&self, key: &str, fallback: &str) -> String { - self.data() - .enchantments - .translate(key) - .unwrap_or(fallback) - .to_owned() + horizon_client::lang::get_or(key, fallback) } fn brewing_open(&self) -> bool { matches!(self.container_kind(), Some(BlockEntity::Brewing(_))) @@ -421,7 +416,8 @@ impl Game<'_> { pub(super) fn anvil_item_name(&self, s: &Station) -> String { match &s.inputs[0] { Some(input) - if input.custom_name().is_none() && s.name == self.pack.names.item(&input.item) => + if input.custom_name().is_none() + && s.name == self.pack.names.display_item(&input.item) => { String::new() } @@ -462,7 +458,7 @@ impl Game<'_> { let name = self.anvil_item_name(&s); let hover = s.inputs[0] .as_ref() - .map(|i| stations::hover_name(&|id| self.pack.names.item(id), i)) + .map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i)) .unwrap_or_default(); s.anvil = stations::anvil_result( &data.enchantments, @@ -895,7 +891,7 @@ impl Game<'_> { s.anvil_last = s.inputs[0].clone(); s.name = s.inputs[0] .as_ref() - .map(|i| stations::hover_name(&|id| self.pack.names.item(id), i)) + .map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i)) .unwrap_or_default(); } self.station_changed(); @@ -1648,28 +1644,29 @@ impl Game<'_> { let name = if let Some(n) = stack.custom_name() { n.to_owned() } else if stack.data().is_some_and(|d| d.ominous_banner) { - e.translate("block.minecraft.ominous_banner") - .map_or_else(|| "Ominous Banner".to_owned(), str::to_owned) + self.translate("block.minecraft.ominous_banner", "Ominous Banner") } else if stack.data().and_then(|d| d.potion.as_ref()).is_some() || stack.item.ends_with("potion") || &*stack.item == "minecraft:tipped_arrow" { let key = brewing::potion_name_key(&stack.item, stack.potion()); - e.translate(&key) - .map_or_else(|| self.pack.names.item(&stack.item), str::to_owned) + self.pack + .names + .display_get(&key) + .unwrap_or_else(|| self.pack.names.display_item(&stack.item)) } else { - self.pack.names.item(&stack.item) + self.pack.names.display_item(&stack.item) }; let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack); let mut lines = Vec::new(); if display.name_visible(self.screen == Screen::Creative) { lines.push((name, color)); } - let translate = |k: &str| e.translate(k).map(str::to_owned); + let translate = |k: &str| self.pack.names.display_get(k); // ItemContainerContents.addToTooltip. let hover_name = |s: &ItemStack| { s.custom_name() - .map_or_else(|| self.pack.names.item(&s.item), str::to_owned) + .map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned) }; if display.shows("minecraft:container") { for line in diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 9b28ce26b..bc05b546f 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -227,7 +227,8 @@ impl Game<'_> { ); let title = self.translate("container.shulkerBox", "Shulker Box"); batch.text(atlas, &title, ox + 8.0, oy + 6.0, grey, false); - batch.text(atlas, "Inventory", ox + 8.0, oy + 73.0, grey, false); + let inventory = self.translate("container.inventory", "Inventory"); + batch.text(atlas, &inventory, ox + 8.0, oy + 73.0, grey, false); return; } let rows = self.chest_rows().unwrap_or(3); @@ -255,9 +256,10 @@ impl Game<'_> { grey, false, ); + let inventory = self.translate("container.inventory", "Inventory"); batch.text( atlas, - "Inventory", + &inventory, ox + 8.0, oy + 114.0 + rows as f32 * 18.0 - 94.0, grey, diff --git a/crates/horizon-client/src/game/titles.rs b/crates/horizon-client/src/game/titles.rs index 70ade5595..cbae1f6b4 100644 --- a/crates/horizon-client/src/game/titles.rs +++ b/crates/horizon-client/src/game/titles.rs @@ -30,7 +30,7 @@ impl Game<'_> { Some(HeldName { item: stack.item.to_string(), hover_name: Text::literal(item_tooltip::name_line(stack, &|key| { - self.pack.names.get(key).map(str::to_owned) + self.pack.names.display_get(key) })), rarity_color: match rarity { "uncommon" => text::YELLOW, diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index 681368cab..cec83e248 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -399,7 +399,7 @@ impl Game<'_> { } let views: Vec> = slots.iter().map(|&s| self.slot_view(s)).collect(); let clicks = { - let names = |id: &str| self.pack.names.item(id); + let names = |id: &str| self.pack.names.display_item(id); let rank = |s: &ItemStack| { let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX); (order, hover_name(&names, s)) diff --git a/crates/horizon-client/src/lang.rs b/crates/horizon-client/src/lang.rs index 15f47285a..79a06229d 100644 --- a/crates/horizon-client/src/lang.rs +++ b/crates/horizon-client/src/lang.rs @@ -117,6 +117,11 @@ impl Language { pub fn get(&self, key: &str) -> Option<&str> { self.map.get(key).map(String::as_str) } + /// The resolved map, for `horizon_protocol::text::plain`: a server's + /// components carry keys, and the network layer resolves them with this. + pub fn map(&self) -> &HashMap { + &self.map + } pub fn len(&self) -> usize { self.map.len() } diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index 07e4ba2b9..912d4498c 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -995,7 +995,7 @@ impl Menu { fn anvil_result(&mut self, env: &Env) { let hover = self.containers[MAIN][0] .as_ref() - .map(|i| crate::stations::hover_name(&|id| env.names.item(id), i)) + .map(|i| crate::stations::hover_name(&|id| env.names.display_item(id), i)) .unwrap_or_default(); let out = crate::stations::anvil_result( env.enchantments, diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index 9cce9e1c6..2b314f2a8 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -597,12 +597,14 @@ pub fn json_text(v: &serde_json::Value, lang: &Language) -> String { } } -/// en_us.json from the prepared resources. +/// The language a session resolves server-sent components with: the client's +/// selected language, layered over `en_us` (`ClientLanguage.loadFrom`), so a +/// menu title the server sends as a key follows the language screen. pub fn load_language(resources: &std::path::Path) -> Language { - std::fs::read(resources.join("assets/minecraft/lang/en_us.json")) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()) - .unwrap_or_default() + let root = resources.to_path_buf(); + crate::lang::Language::load(&[&root], &crate::lang::code()) + .map() + .clone() } /// Ask a server for its status (ServerStatusPinger): MOTD, players and the From e9d2c1041774e9e291c2b8e360bf5c7e2485ffb7 Mon Sep 17 00:00:00 2001 From: vlesmentie <129341611+vlesmentie@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:32:36 -0300 Subject: [PATCH 08/57] Peaceful zombie horses spawn without their zombie rider ZombieHorse.finalizeSpawn gets its rider from EntityType.create, which returns null for a notInPeaceful type (the zombie) in peaceful, so vanilla spawns the zombie horse alone there. spawn_jockeys created the rider regardless. mobs/finalize.rs already skips it through level.create. Fixes #1761. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/passengers.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/passengers.rs b/crates/horizon-client/src/game/passengers.rs index b20c1560b..8f205c484 100644 --- a/crates/horizon-client/src/game/passengers.rs +++ b/crates/horizon-client/src/game/passengers.rs @@ -147,8 +147,16 @@ impl Game<'_> { } } Species::Strider if !baby => self.spawn_strider_jockey(id, at), - // ZombieHorse.finalizeSpawn: a zombie with an iron spear. - Species::ZombieHorse if natural => { + // ZombieHorse.finalizeSpawn: a zombie with an iron spear. The rider + // comes from EntityType.create, which makes no zombie in peaceful + // (canSpawn), so a peaceful zombie horse spawns without one. + Species::ZombieHorse + if natural + && (self.ui.difficulty != 0 + || horizon_client::mobs::spawn_rules::allowed_in_peaceful( + Species::Zombie, + )) => + { if let Some(zombie) = self.spawn_mob("minecraft:zombie", at, false) { if let Some(z) = self.mobs.iter_mut().find(|m| m.id == zombie) { z.extra.main_hand = Some("minecraft:iron_spear".into()); From 4b5fa2ea6b86a7766c9503f01524681602732656 Mon Sep 17 00:00:00 2001 From: Fo <26b95bff27431f3eaab8@wajo.ai> Date: Sun, 25 Oct 2026 14:28:55 +0000 Subject: [PATCH 09/57] build: make horizon-dualsense compile on iOS hidapi 2.6.7 has no iOS backend, so the crate failed to build for aarch64-apple-ios (hidapi's build script looked for hid.h). Gate the hidapi and cpal dependencies plus all HID device/haptics code behind cfg(not(target_os = "ios")) and provide an inert stub on iOS (Link::start() is a no-op), mirroring how horizon-gamecontroller already compiles empty on iOS. Verified with cargo check -p horizon-dualsense --target aarch64-apple-ios: clean, no warnings. Host clippy/tests are left to CI (this sandbox has no C toolchain for the host target). Co-Authored-By: Fo <26b95bff27431f3eaab8@wajo.ai> --- crates/horizon-dualsense/Cargo.toml | 8 +++- crates/horizon-dualsense/src/device.rs | 47 ++++++++++++++++---- crates/horizon-dualsense/src/haptics_stub.rs | 21 +++++++++ crates/horizon-dualsense/src/lib.rs | 6 +++ 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 crates/horizon-dualsense/src/haptics_stub.rs diff --git a/crates/horizon-dualsense/Cargo.toml b/crates/horizon-dualsense/Cargo.toml index fe05b4f59..1a41e5703 100644 --- a/crates/horizon-dualsense/Cargo.toml +++ b/crates/horizon-dualsense/Cargo.toml @@ -7,10 +7,16 @@ rust-version = "1.85" description = "The DualSense's output over HID: rumble, adaptive triggers, lightbar and player LEDs" [dependencies] +log = "0.4" + +# hidapi has no iOS backend and its build script stops there, so the output's two +# dependencies go only to the targets that have one. On iOS the crate builds empty: pads +# are read through GameController (horizon-gamecontroller), which only reads, and the +# DualSense's own output waits for a desktop. +[target.'cfg(not(target_os = "ios"))'.dependencies] # MIT; bundles the C hidapi under its BSD licence option. hidraw on Linux (libudev, as gilrs), # IOKit on macOS (opened shared, so gilrs keeps reading the pad), the HID API on Windows. hidapi = { version = "2.6.7", default-features = false, features = ["linux-static-hidraw", "macos-shared-device"] } -log = "0.4" # HD haptics through the pad's own audio device (USB). cpal = "0.16" diff --git a/crates/horizon-dualsense/src/device.rs b/crates/horizon-dualsense/src/device.rs index 97c6ec72b..0b11ac415 100644 --- a/crates/horizon-dualsense/src/device.rs +++ b/crates/horizon-dualsense/src/device.rs @@ -1,14 +1,21 @@ //! The pad over hidapi, and the thread that writes to it: writes block (a Bluetooth report can //! take milliseconds), so the game only hands over what the pad should do. -use crate::{ - DEFAULT_LIGHTBAR, Output, PLAYER_1, TRIGGER_OFF, TriggerEffect, Vibration, is_dualsense, -}; +#[cfg(not(target_os = "ios"))] +use crate::{DEFAULT_LIGHTBAR, Output, PLAYER_1, Vibration, is_dualsense}; +use crate::{TRIGGER_OFF, TriggerEffect}; +#[cfg(not(target_os = "ios"))] use hidapi::{BusType, HidApi, HidDevice, HidResult}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; -use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +#[cfg(not(target_os = "ios"))] +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::mpsc::Sender; +#[cfg(not(target_os = "ios"))] +use std::sync::mpsc::{self, RecvTimeoutError}; use std::thread::JoinHandle; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(target_os = "ios"))] +use std::time::Instant; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Transport { @@ -17,6 +24,7 @@ pub enum Transport { } /// An open DualSense. +#[cfg(not(target_os = "ios"))] pub struct DualSense { device: HidDevice, transport: Transport, @@ -34,6 +42,7 @@ pub fn bluetooth_allowed() -> bool { cfg!(target_os = "linux") || std::env::var_os("HORIZON_DUALSENSE_BT").is_some_and(|v| v == "1") } +#[cfg(not(target_os = "ios"))] impl DualSense { /// The first DualSense hidapi lists, USB before Bluetooth (as SDL); `None` when there is /// none, an error when it can't be opened (Linux without access to its hidraw node). @@ -119,13 +128,17 @@ impl Default for State { /// A vibration: the motors' levels (left, right), each for a while, in order. pub type Rumble = Vec<([u8; 2], Duration)>; +// On iOS no thread reads the queue (the Link never opens); the variants stay so the +// Link's API is the same everywhere. +#[cfg_attr(target_os = "ios", allow(dead_code))] enum Message { State(State), Rumble(Rumble), Scan, } -/// The writer thread. Dropping it frees the triggers and stops the motors before it ends. +/// The output thread. Dropping it frees the triggers and stops the motors before it ends; on +/// iOS there is none (no hidapi backend) and the Link stays closed. pub struct Link { tx: Option>, /// 0: no pad open, else 1 + the transport. @@ -135,6 +148,7 @@ pub struct Link { impl Link { /// Start the thread and look for a DualSense. + #[cfg(not(target_os = "ios"))] pub fn start() -> Link { let (tx, rx) = mpsc::channel(); let open = Arc::new(AtomicU8::new(0)); @@ -151,6 +165,17 @@ impl Link { } } + /// No output on iOS: hidapi has no backend there, and iOS reads pads through + /// GameController (horizon-gamecontroller), which only reads. The Link never opens. + #[cfg(target_os = "ios")] + pub fn start() -> Link { + Link { + tx: None, + open: Arc::new(AtomicU8::new(0)), + thread: None, + } + } + /// The pad's transport while one is open and taking reports. pub fn open(&self) -> Option { match self.open.load(Ordering::Relaxed) { @@ -194,8 +219,10 @@ impl Drop for Link { /// Looks for the pad this many more times, a second apart, after a scan finds nothing (its /// hidraw node may appear after the controller does). +#[cfg(not(target_os = "ios"))] const SCAN_TRIES: u32 = 5; +#[cfg(not(target_os = "ios"))] struct Writer { api: Option, pad: Option, @@ -213,6 +240,7 @@ struct Writer { next_scan: Instant, } +#[cfg(not(target_os = "ios"))] #[derive(Clone, Copy, Debug, PartialEq)] enum Lights { /// Over Bluetooth, until the pad's clock says its animation is over (or this long after @@ -225,11 +253,13 @@ enum Lights { /// The pad's sensor clock once its Bluetooth connection animation is over (SDL_hidapi_ps5.c's /// `HIDAPI_DriverPS5_CheckPendingLEDReset`). +#[cfg(not(target_os = "ios"))] const ANIMATION_OVER: u32 = 10_200_000; /// The sensor clock of a Bluetooth input report 0x31: its state block starts at byte 2 and /// holds the clock at 27..31, little-endian (SDL's `PS5StatePacketCommon_t`, the kernel's /// `dualsense_input_report`). +#[cfg(not(target_os = "ios"))] fn bluetooth_clock(report: &[u8]) -> Option { match report { [0x31, _, state @ ..] if state.len() >= 31 => { @@ -239,6 +269,7 @@ fn bluetooth_clock(report: &[u8]) -> Option { } } +#[cfg(not(target_os = "ios"))] impl Writer { fn new(open: Arc) -> Self { Writer { @@ -451,7 +482,7 @@ impl Writer { } } -#[cfg(test)] +#[cfg(all(test, not(target_os = "ios")))] mod tests { use super::*; diff --git a/crates/horizon-dualsense/src/haptics_stub.rs b/crates/horizon-dualsense/src/haptics_stub.rs new file mode 100644 index 000000000..7afc944ca --- /dev/null +++ b/crates/horizon-dualsense/src/haptics_stub.rs @@ -0,0 +1,21 @@ +//! No HD haptics on iOS: the trick plays vibrations through the pad's own four-channel audio +//! device over USB, and a phone has neither that device nor the audio stack the real module +//! needs. The motors' rumble through [`crate::Link`] covers vibration instead. + +/// The pad's audio output, playing the queued samples (left, right actuator). +pub struct Haptics; + +impl Haptics { + /// The DualSense's audio device, if one is there with its four channels (USB). A phone + /// has none. + pub fn open() -> Option { + None + } + + /// Play a vibration (heavy and light levels, 0..1, each for a while), replacing the one + /// playing. Nothing plays on iOS. + pub fn play(&self, _steps: &[(f32, f32, std::time::Duration)]) {} + + /// Stop at once. Nothing plays on iOS. + pub fn stop(&self) {} +} diff --git a/crates/horizon-dualsense/src/lib.rs b/crates/horizon-dualsense/src/lib.rs index 438d8f6fe..80831ee00 100644 --- a/crates/horizon-dualsense/src/lib.rs +++ b/crates/horizon-dualsense/src/lib.rs @@ -1,12 +1,18 @@ //! The DualSense's output report, built by hand (docs/controllers.md "DualSense output"): the //! compatible-vibration motors, the adaptive triggers, the lightbar and the player LEDs. Gilrs //! keeps reading the pad; this crate only writes to it, over hidapi ([`device`]). +//! On iOS the crate builds without hidapi, which has no backend there: [`Link`] never opens, +//! and the pad is read through GameController, which only reads. //! //! The byte layout is the one documented by the Linux kernel's `hid-playstation.c` and SDL's //! `SDL_hidapi_ps5.c`: a 47-byte block shared by both transports, sent as report 0x02 over USB //! and as report 0x31 over Bluetooth, where a sequence tag leads it and a CRC-32 (seeded with //! the byte 0xA2) closes it. pub mod device; +#[cfg(not(target_os = "ios"))] +pub mod haptics; +#[cfg(target_os = "ios")] +#[path = "haptics_stub.rs"] pub mod haptics; pub mod trigger; From 5fb0cad6e7a520365e78b2635884af970493e11d Mon Sep 17 00:00:00 2001 From: vlesmentie <129341611+vlesmentie@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:31:02 -0300 Subject: [PATCH 10/57] Wither roses give the player Wither WitherRoseBlock.entityInside adds getBeeInteractionEffect (Wither, 40 ticks) to a living entity inside it on a server level that isn't peaceful, unless it is invulnerable to wither damage. The player's inside-block dispatch had no wither rose case, so neither singleplayer mode applied it. The sweep now asks for it, the game adds it to its own player, and the server-thread path adds it to a server's player. Fixes #1319. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 1 + crates/horizon-client/src/game/creatures.rs | 10 +++ crates/horizon-client/src/game/lan_actions.rs | 6 ++ crates/horizon-client/src/motion.rs | 79 ++++++++++++++++++- 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 699468651..d6a587e53 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4289,6 +4289,7 @@ impl Game<'_> { MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity), MotionEvent::EnterBlock { state } => self.trigger_enter_block(state), MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos), + MotionEvent::InsideWitherRose => self.wither_rose_contact(), MotionEvent::SmashLanding { state, position, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 0bb7bbe85..a0419f23b 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1558,6 +1558,16 @@ impl Game<'_> { id, ticks, amplifier, )); } + /// `WitherRoseBlock.entityInside` for the player: on a server level that + /// isn't peaceful, `getBeeInteractionEffect` (Wither, 40 ticks) unless the + /// player is invulnerable to wither damage (creative, spectator). A + /// server's player gets it from the server. + pub(super) fn wither_rose_contact(&mut self) { + if self.remote.is_some() || self.ui.difficulty == 0 { + return; + } + self.give_player_effect("minecraft:wither", 40, 0); + } /// Burning applied to the player by mobs. fn tick_player_effects(&mut self) { self.creatures.wind_cooldown = self.creatures.wind_cooldown.saturating_sub(1); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..882a66244 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2010,6 +2010,12 @@ impl Game<'_> { let _ = w.apply_fire_contact(); let _ = w.server_hazards_tick(in_wall); let mut events = std::mem::take(&mut w.player.events); + // WitherRoseBlock.entityInside along those moves. + for event in &events { + if matches!(event, MotionEvent::InsideWitherRose) { + game.wither_rose_contact(); + } + } // ServerGamePacketListenerImpl.handleMovePlayer: // Player.checkMovementStatistics for each move. for d in travelled { diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 560cadc80..ecf7b76d1 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -167,6 +167,9 @@ pub enum MotionEvent { InsidePortal { pos: BlockPos, }, + /// `WitherRoseBlock.entityInside`: the player's box swept through a + /// wither rose; the server adds its Wither unless peaceful. + InsideWitherRose, /// `ServerPlayer.checkFallDamage` after a mace smash: block particles /// from the landing block, more the further the fall. SmashLanding { @@ -1294,9 +1297,13 @@ impl MotionWorld<'_, '_, '_> { } }; if inside && visited.insert(p.pack()) { - // CactusBlock and CampfireBlock.entityInside (`entity.hurt`). + // CactusBlock and CampfireBlock.entityInside (`entity.hurt`), + // and WitherRoseBlock's server-side effect. match name { "minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?, + "minecraft:wither_rose" => { + self.player.events.push(MotionEvent::InsideWitherRose); + } "minecraft:campfire" | "minecraft:soul_campfire" => { let lit = self .assets @@ -2368,6 +2375,76 @@ mod tests { assert_eq!(player.position.y, 4.0); } + #[test] + fn standing_in_a_wither_rose_asks_for_its_effect() { + let root = std::env::var_os("HORIZON_TEST_RESOURCES") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION) + }); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 1).unwrap(); + let rose = assets + .registry + .default_state("minecraft:wither_rose") + .unwrap(); + scene.set(20, 4, 20, rose).unwrap(); + let roses = |player: &PlayerMotion| { + player + .events + .iter() + .filter(|e| matches!(e, MotionEvent::InsideWitherRose)) + .count() + }; + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap(); + MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + } + .controlled_simulation_tick(Default::default()) + .unwrap(); + assert_eq!(roses(&player), 1); + // A server's player: its client moved it, the server applies the effect. + player.events.clear(); + let from = player.position; + MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + } + .server_block_damage(from) + .unwrap(); + assert_eq!(roses(&player), 1); + // Two blocks away, nothing. + let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap(); + MotionWorld { + entities: &[], + player: &mut away, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + } + .controlled_simulation_tick(Default::default()) + .unwrap(); + assert_eq!(roses(&away), 0); + } + #[test] fn server_travel_moves_only_the_velocity() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) From 4bfa496173109366e9e37bf1d4194f7844fb389c Mon Sep 17 00:00:00 2001 From: vlesmentie <129341611+vlesmentie@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:47:21 -0300 Subject: [PATCH 11/57] Lava cauldrons burn the player inside them The player's inside-block sweep skipped lava cauldrons, so standing in one did nothing. It now tests the player against LavaCauldronBlock's entity-inside shape (the cauldron's shape or its lava, Block.column(12, 4, 15)) the way Entity.checkInsideBlocks does, and applies LavaCauldronBlock.entityInside: CLEAR_FREEZE, LAVA_IGNITE (15 seconds) and Entity.lavaHurt (lava, 4), for the game's own player and a server's. Fixes #1320. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/motion.rs | 94 ++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 560cadc80..8637ead1f 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1259,21 +1259,22 @@ impl MotionWorld<'_, '_, '_> { continue; } let powder_snow = name == "minecraft:powder_snow"; + let lava_cauldron = name == "minecraft:lava_cauldron"; // getEntityInsideCollisionShape is the whole block but for powder // snow (its collision shape, the whole block when it is empty), // filled cauldrons, end portals and tripwires; Horizon doesn't - // follow the player into those last three. + // follow the player into the water and powder snow cauldrons, end + // portals or tripwires yet. if matches!( name, "minecraft:water_cauldron" - | "minecraft:lava_cauldron" | "minecraft:powder_snow_cauldron" | "minecraft:end_portal" | "minecraft:tripwire" ) { continue; } - let inside = !powder_snow || { + let inside = !(powder_snow || lava_cauldron) || { let context = self.context(); let binding = self.collision_binding; let shape = binding.collision_shape_with_context( @@ -1282,21 +1283,30 @@ impl MotionWorld<'_, '_, '_> { p, &context, )?; - shape.is_empty() || shape.boxes() == VoxelShape::block().boxes() || { - let boxes: Vec<_> = shape - .moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z))) - .boxes() - .to_vec(); - self.player - .dimensions - .bounds(from) - .collided_along_vector(to.subtract(from), &boxes) - } + powder_snow && (shape.is_empty() || shape.boxes() == VoxelShape::block().boxes()) + || { + let offset = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)); + let mut boxes: Vec<_> = shape.moved(offset).boxes().to_vec(); + if lava_cauldron { + // LavaCauldronBlock.FILLED_SHAPE: the cauldron's + // shape or its lava, Block.column(12, 4, 15). + boxes.push(Aabb::new( + Vec3::new(2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0).add_vector(offset), + Vec3::new(14.0 / 16.0, 15.0 / 16.0, 14.0 / 16.0).add_vector(offset), + )); + } + self.player + .dimensions + .bounds(from) + .collided_along_vector(to.subtract(from), &boxes) + } }; if inside && visited.insert(p.pack()) { - // CactusBlock and CampfireBlock.entityInside (`entity.hurt`). + // CactusBlock, CampfireBlock and LavaCauldronBlock.entityInside + // (`entity.hurt`). match name { "minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?, + "minecraft:lava_cauldron" => self.lava_cauldron_inside()?, "minecraft:campfire" | "minecraft:soul_campfire" => { let lit = self .assets @@ -1354,6 +1364,14 @@ impl MotionWorld<'_, '_, '_> { } self.player.impulse.try_reset(); } + /// `LavaCauldronBlock.entityInside`: CLEAR_FREEZE, LAVA_IGNITE + /// (`Entity.lavaIgnite`, 15 seconds) and then `Entity.lavaHurt` (lava, + /// 4), as ordinary lava does to the player. + fn lava_cauldron_inside(&mut self) -> Result<()> { + self.player.ticks_frozen = 0; + self.player.ignite_for_ticks(300)?; + self.environment_hurt("minecraft:lava", 4.0) + } /// `WebBlock.entityInside`: stuck at a quarter of the speed, half with /// the Weaving effect. fn web_inside(&mut self) { @@ -2368,6 +2386,54 @@ mod tests { assert_eq!(player.position.y, 4.0); } + #[test] + fn a_lava_cauldron_burns_the_player_inside_it() { + let root = std::env::var_os("HORIZON_TEST_RESOURCES") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION) + }); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 1).unwrap(); + let cauldron = assets + .registry + .default_state("minecraft:lava_cauldron") + .unwrap(); + scene.set(20, 4, 20, cauldron).unwrap(); + let mut tick = |player: &mut PlayerMotion| { + MotionWorld { + entities: &[], + player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + } + .controlled_simulation_tick(Default::default()) + .unwrap(); + }; + // Standing on the cauldron's floor, in its lava. + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.25, 20.5)).unwrap(); + player.ticks_frozen = 40; + tick(&mut player); + assert!(player.health.health() < 20.0, "the lava must hurt"); + assert!(player.fire_ticks > 0, "the lava must ignite"); + assert_eq!(player.ticks_frozen, 0); + // Beside it, nothing. + let mut away = PlayerMotion::new(&resources, Vec3::new(22.5, 4.0, 20.5)).unwrap(); + tick(&mut away); + assert_eq!(away.health.health(), 20.0); + assert!(away.fire_ticks <= 0); + } + #[test] fn server_travel_moves_only_the_velocity() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) From 3843a9a4aae425fa4ec4f759dc9e4e37a5c44abc Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:27:51 +0100 Subject: [PATCH 12/57] Use the team-formatted player name in chat Chat, /say, /me and /tell now name the sender the way Player.getDisplayName does: the team prefix, the name and the suffix in the team colour. On a server the hub gets the scoreboard's teams each tick; in-frame the existing player_display_name is used. --- crates/horizon-client/src/game/chat.rs | 2 +- crates/horizon-client/src/game/commands.rs | 8 +++---- crates/horizon-client/src/game/scoreboard.rs | 1 + crates/horizon-client/src/lan/hub.rs | 22 ++++++++++++++++++++ crates/horizon-client/src/lan/hub/chat.rs | 12 +++++------ 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index ec85732ae..9fd3157c4 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -586,7 +586,7 @@ impl Game<'_> { } else { let line = Text::translate( "chat.type.text", - &[Text::literal(PLAYER_NAME), Text::literal(message)], + &[self.player_display_name(), Text::literal(message)], ); let focused = self.screen == Screen::Chat; let heard = self.mods.running().then(|| line.plain()); diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 19a16e261..b84f13e4a 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -214,7 +214,7 @@ impl Game<'_> { } /// Player.getDisplayName: the name (team-formatted), suggesting a /// `/tell`, showing the player on hover, inserting the name. - fn player_display_name(&self) -> Text { + pub(super) fn player_display_name(&self) -> Text { let name = self.acting_player_name().to_owned(); let Some(uuid) = self.lan_acting_uuid else { return self.target_name(Target::Player); @@ -2809,7 +2809,7 @@ impl Game<'_> { let name = match self.command_source.map(|s| s.entity) { Some(Some(t)) => self.target_name(t), Some(None) => Text::literal("Server"), - None => Text::literal(self.acting_player_name()), + None => self.player_display_name(), }; let line = match id { "say" => Text::translate("chat.type.announcement", &[name, message]), @@ -2819,14 +2819,14 @@ impl Game<'_> { // Whispering to yourself: the incoming and outgoing lines. let out = Text::translate( "commands.message.display.outgoing", - &[Text::literal(self.acting_player_name()), message.clone()], + &[self.player_display_name(), message.clone()], ) .with_color(text::GRAY) .italic(); self.system_message(out); Text::translate( "commands.message.display.incoming", - &[Text::literal(self.acting_player_name()), message], + &[self.player_display_name(), message], ) .with_color(text::GRAY) .italic() diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 227a09a6c..81c03e288 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -332,6 +332,7 @@ impl Game<'_> { return; } self.load_scoreboard(); + host.hub.teams.clone_from(&self.score.board.teams); let view = horizon_client::lan::scoreboard::View::of(&self.score.board); for g in host.hub.guests.iter_mut().filter(|g| g.placed) { if g.scoreboard != view { diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index c2a395899..fd2381596 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -149,6 +149,9 @@ pub struct Hub { pub singleplayer_owner: Option, /// Who rides what, set by the game before each tick. pub riding: super::entity::Riding, + /// The scoreboard's teams, set by the game each tick, for players' + /// display names. + pub teams: Vec, last_clock: i64, last_rate: f32, ticking: (f32, bool), @@ -206,6 +209,24 @@ pub fn display_name(uuid: u128, name: &str) -> Tag { ]) } +impl Hub { + /// Player.getDisplayName: the name formatted for the player's team + /// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the + /// player on hover and inserting the name. + pub fn player_display_name(&self, uuid: u128, name: &str) -> Tag { + let Some(team) = self.teams.iter().find(|t| t.players.contains(name)) else { + return display_name(uuid, name); + }; + let mut c = team.formatted_name_comp(&crate::scoreboard::literal(name)); + c["click_event"] = + serde_json::json!({"action": "suggest_command", "command": format!("/tell {name} ")}); + c["hover_event"] = serde_json::json!({"action": "show_entity", "id": "minecraft:player", + "uuid": horizon_protocol::crypto::uuid_string(uuid), "name": name}); + c["insertion"] = name.into(); + crate::scoreboard::component_tag(&c) + } +} + /// A yellow translation of player names (`multiplayer.player.joined`). fn yellow_names(key: &str, names: Vec) -> Tag { Tag::compound([ @@ -315,6 +336,7 @@ impl Hub { entity_types: Default::default(), entities: Default::default(), entity_out: Vec::new(), + teams: Vec::new(), } } diff --git a/crates/horizon-client/src/lan/hub/chat.rs b/crates/horizon-client/src/lan/hub/chat.rs index 7e75c84c5..bdc7c3fb2 100644 --- a/crates/horizon-client/src/lan/hub/chat.rs +++ b/crates/horizon-client/src/lan/hub/chat.rs @@ -2,7 +2,7 @@ //! handle it: chat sessions validated against the services' keys, signed //! messages checked link by link, the acknowledged window, and messages //! relayed with each connection's numbering and signature cache. -use super::{HostEvent, Hub, display_name}; +use super::{HostEvent, Hub}; use horizon_protocol::{ packets::play::{self, Clientbound as C}, secure_chat::{self, Body, Chain, ChatSession, DecodeError, ProfileKey, Signature, Unpacked}, @@ -214,7 +214,7 @@ impl Hub { } let bound = play::ChatTypeBound { chat_type: self.chat_type, - name: display_name(uuid, &name), + name: self.player_display_name(uuid, &name), target: None, }; let line = format!("<{name}> {}", message.content); @@ -427,7 +427,7 @@ impl Hub { let (uuid, name) = (self.guests[i].uuid, self.guests[i].name.clone()); let bound = play::ChatTypeBound { chat_type: self.chat_type_id(chat_type), - name: display_name(uuid, &name), + name: self.player_display_name(uuid, &name), target: None, }; let line = match chat_type { @@ -467,15 +467,15 @@ impl Hub { self.chat_type_id("msg_command_incoming"), ); for &t in targets { - let target = display_name(self.guests[t].uuid, &self.guests[t].name); + let target = self.player_display_name(self.guests[t].uuid, &self.guests[t].name); let out = play::ChatTypeBound { chat_type: outgoing, - name: display_name(uuid, &name), + name: self.player_display_name(uuid, &name), target: Some(target), }; let inc = play::ChatTypeBound { chat_type: incoming, - name: display_name(uuid, &name), + name: self.player_display_name(uuid, &name), target: None, }; match &message { From 13e83e4e63e13752978d552980b997573013fea4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:36:01 +0100 Subject: [PATCH 13/57] Read saved attribute bases for every mob LivingEntity.readAdditionalSaveData applies the attributes list for any living entity, then sets the health (the max health when none is saved). Only horses read max_health and movement_speed, so a summoned cow kept its type defaults. Read max_health, movement_speed and attack_damage for all mobs before the health; horses still read jump_strength. --- crates/horizon-client/src/entity_nbt.rs | 26 +++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index bf0764a6b..00576ea02 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -617,9 +617,23 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - m.head_pitch = pitch; m.on_ground = flag(tag, "OnGround"); m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0); - if let Some(h) = float(tag.get("Health")) { - m.health = h as f32; + // LivingEntity.readAdditionalSaveData: the saved attribute bases, then + // the health, the max health when there is none. + if let Some(Tag::List(attrs)) = tag.get("attributes") { + for a in attrs { + let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else { + continue; + }; + let v = f64::from_bits(*bits); + match id.as_str() { + "minecraft:max_health" => m.attrs.max_health = v as f32, + "minecraft:movement_speed" => m.attrs.speed = v, + "minecraft:attack_damage" => m.attrs.attack_damage = v as f32, + _ => {} + } + } } + m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32); m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32; // What it holds (equipment.mainhand / offhand), when saved. if let Some(eq) = tag.get("equipment") { @@ -735,12 +749,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - let (Some(id), Some(Tag::Double(bits))) = (text(a.get("id")), a.get("base")) else { continue; }; - let v = f64::from_bits(*bits); - match id.as_str() { - "minecraft:max_health" => m.attrs.max_health = v as f32, - "minecraft:movement_speed" => m.attrs.speed = v, - "minecraft:jump_strength" => m.extra.vector[0] = v, - _ => {} + if id == "minecraft:jump_strength" { + m.extra.vector[0] = f64::from_bits(*bits); } } } From 8ffd595575358067bf29cf02a7166bffef56af9b Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:58:42 +0000 Subject: [PATCH 14/57] fix: suppress secondary explosions from destroyed End crystals Pass the crystal damage cause through destruction so explosion damage removes crystals without another blast, including during dragon respawn. Keep fight callbacks and direct-hit blasts. Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/game/dragon.rs | 40 +++++++++++++------ tools/lan_e2e.py | 50 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 54b260c67..701c09e64 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -336,6 +336,12 @@ enum StrafeRequest { /// Anything else: the player nearest the crystal. Nearest(Vec3), } +/// Whether `EndCrystal.hurtServer` creates a blast before notifying the fight. +#[derive(Clone, Copy, PartialEq)] +enum CrystalDamage { + Direct, + Explosion, +} /// What `EnderDragon.hurt` made of a hit. #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum Hurt { @@ -867,8 +873,9 @@ impl Game<'_> { } } } - /// A crystal took damage: `EndCrystal.hurtServer` blows it up (power 6). - fn destroy_crystal(&mut self, index: usize, by: Option) { + /// `EndCrystal.hurtServer`: remove it, blast only for non-explosion damage, + /// then notify the dragon fight even when no blast was created. + fn destroy_crystal(&mut self, index: usize, by: Option, damage: CrystalDamage) { if index >= self.crystals.len() || self.crystals[index].invulnerable { return; } @@ -880,14 +887,23 @@ impl Game<'_> { .iter() .any(|p| p.distance_squared(c.position) < 0.01) { - let at = [c.position.x, c.position.y, c.position.z]; - let _ = - self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false); + if damage == CrystalDamage::Direct { + let at = [c.position.x, c.position.y, c.position.z]; + let _ = self.server_explode( + at, + 6.0, + super::explosion::BlockInteraction::Destroy, + false, + ); + } self.abort_respawn(); return; } - let at = [c.position.x, c.position.y, c.position.z]; - let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false); + if damage == CrystalDamage::Direct { + let at = [c.position.x, c.position.y, c.position.z]; + let _ = + self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false); + } // EnderDragonFight.onCrystalDestroyed. self.count_crystals(); if let Some(d) = self.dragon.as_mut() { @@ -962,7 +978,7 @@ impl Game<'_> { return Hurt::Refused; } let by = by_player.then(|| self.acting_player()); - self.destroy_crystal(index, by); + self.destroy_crystal(index, by, CrystalDamage::Direct); Hurt::Landed { full: true } } else if id >= SPECIAL { self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow) @@ -2634,8 +2650,8 @@ impl Crystal { } } impl Game<'_> { - /// `ServerExplosion.hurtEntities` on the end crystals (each in reach - /// blows up in turn) and the dragon (explosions always hurt dragons). + /// `ServerExplosion.hurtEntities`: crystals in reach are removed without + /// another blast; explosions always hurt dragons. pub(super) fn explosion_hits_special(&mut self, center: [f64; 3], radius: f32) { let diameter = f64::from(radius * 2.0); let in_reach = |p: Vec3| { @@ -2651,9 +2667,9 @@ impl Game<'_> { .map(|c| c.position) .collect(); for at in crystals { - // One blowing up may already have taken the next. + // Look up the current index after each removal. if let Some(i) = self.crystals.iter().position(|c| c.position == at) { - self.destroy_crystal(i, None); + self.destroy_crystal(i, None, CrystalDamage::Explosion); } } let Some(d) = self.dragon.as_ref() else { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 320a9c56a..9aa1e1278 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -6252,5 +6252,55 @@ CHECKS += ['crystals'] CHECK_FNS.update(crystals=check_crystals) +def check_crystal_blast(host, guest, out): + """#1533: TNT removes a crystal without a blast; a punched crystal blasts once. + Both clients must see the surviving distant and invulnerable crystals.""" + spots = apart(host, guest) + seen = {} + for label, player, other in (('host', host, guest), ('guest', guest, host)): + x, y, z = spots[label] + y += 32 + player('command', command='gamemode creative') + for offset, cause in enumerate(('tnt', 'melee')): + # Leave survivors in separate arenas; the scratch world is discarded. + z = spots[label][2] + (0 if label == 'host' else 80) + offset * 40 + case = f'{label}-{cause}' + host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 25} {y - 1} {z + 5} minecraft:bedrock') + host('command', command=f'fill {x - 3} {y} {z - 3} {x + 25} {y + 4} {z + 5} minecraft:air') + positions = [[x + dx, y, z + dz] for dx, dz in ((2.5, 0.5), (12.5, 0.5), (22.5, 0.5), (4.5, 3.5))] + + def remaining(game): + crystals = [p for p in game('status')['projectiles'] if p['kind'] == 'minecraft:end_crystal'] + return [i for i, at in enumerate(positions) + if any(math.dist(p['position'], at) < 0.01 for p in crystals)] + + for i, at in enumerate(positions): + host('command', command=f'summon minecraft:end_crystal {at[0]} {at[1]} {at[2]} {{Invulnerable:{1 if i == 3 else 0}b}}') + player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}') + other('command', command='gamemode creative') + other('command', command=f'tp @s {x + 0.5} {y} {z + 4.5}') + player('command', command='clear @s') + player.wait(20) + for game in (host, guest): + assert remaining(game) == [0, 1, 2, 3], f'{case}: crystals not tracked on {game.name}' + if cause == 'tnt': + player('command', command=f'summon minecraft:tnt {x + 0.5} {y} {z + 0.5} {{fuse:10s}}') + else: + player.raw('look-at', positions[0][0], y + 1, positions[0][2]) + player.raw('mouse', 'left', 'click') + player.wait(80) + result = {game.name: remaining(game) for game in (host, guest)} + seen[case] = result + # TNT reaches the first crystal only. A direct crystal blast reaches + # the second too, but that crystal must not detonate the third. + expected = [1, 2, 3] if cause == 'tnt' else [2, 3] + assert all(ids == expected for ids in result.values()), f'{case}: expected {expected}, got {result}' + return seen + + +CHECKS += ['crystal_blast'] +CHECK_FNS.update(crystal_blast=check_crystal_blast) + + if __name__ == '__main__': main() From 0481dbdb81e49134f42dccafb58474504466a440 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:10:56 +0100 Subject: [PATCH 15/57] Make hardcore players spectators when they respawn A hardcore death offered another survival life. Vanilla's death screen says Spectate in hardcore, and the server makes the respawned player a spectator (ServerGamePacketListenerImpl.handleClientCommand). Do the same on the server and in the in-frame world, and add a harness that compares a hardcore respawn on a dedicated server with vanilla's. --- crates/horizon-client/src/game/agent.rs | 8 +- crates/horizon-client/src/game/lan_actions.rs | 5 + crates/horizon-client/src/game/screens.rs | 27 +++- .../horizonlab/parity/LocatorBarClient.java | 6 +- tools/test_hardcore_respawn_parity.py | 139 ++++++++++++++++++ 5 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 tools/test_hardcore_respawn_parity.py diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index f6b68aa74..b85710146 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -1598,8 +1598,12 @@ impl Game<'_> { if self.screen != Screen::Dead { return Err("respawn needs the death screen".into()); } - let label = - horizon_client::text::Text::translate("deathScreen.respawn", &[]).plain(); + let key = if self.level.hardcore { + "deathScreen.spectate" + } else { + "deathScreen.respawn" + }; + let label = horizon_client::text::Text::translate(key, &[]).plain(); let w = widgets::find(&widgets::last(), &label)?; if !w.enabled { // DeathScreen enables its buttons after 20 ticks. diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..2942bea7d 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1649,6 +1649,11 @@ impl Game<'_> { border: &self.border, }; host.hub.respawn(i, spawn, &ctx); + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if self.level.hardcore { + host.hub.set_game_mode(conn, SPECTATOR); + } if let Some(config) = elsewhere { self.respawn_trip(conn, config); } diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index cf1f3269f..5fa040665 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -2,6 +2,7 @@ use super::{Game, Screen}; use horizon_client::{ Result, + command::args::GameMode, hud::{HudBatch, rgba}, text::{self, Text}, ui, @@ -81,9 +82,15 @@ fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] { (x0 + 72.0, y, "gui/sprites/pause_menu/player_reporting"), ] } -fn death_buttons(w: f32, h: f32) -> [(f32, f32, &'static str, u8); 2] { +/// DeathScreen.init: a hardcore player can only spectate. +fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] { + let respawn = if hardcore { + "deathScreen.spectate" + } else { + "deathScreen.respawn" + }; [ - (w / 2.0 - 100.0, h / 4.0 + 72.0, "deathScreen.respawn", 0), + (w / 2.0 - 100.0, h / 4.0 + 72.0, respawn, 0), ( w / 2.0 - 100.0, h / 4.0 + 96.0, @@ -223,7 +230,12 @@ impl Game<'_> { ), ); } - let title = Text::translate("deathScreen.title", &[]); + let title = if self.level.hardcore { + "deathScreen.title.hardcore" + } else { + "deathScreen.title" + }; + let title = Text::translate(title, &[]); let tw = atlas.font.width_text(&title); ui::text_scaled( batch, @@ -258,7 +270,7 @@ impl Game<'_> { .ui .died_at .is_none_or(|t| t.elapsed().as_millis() >= 1000); - for (x, y, key, _) in death_buttons(w, h) { + for (x, y, key, _) in death_buttons(w, h, self.level.hardcore) { let hover = active && ui::hovered(self.cursor, x, y, 200.0, 20.0); ui::button( batch, @@ -286,7 +298,7 @@ impl Game<'_> { .as_ref() .map_or([1, 1], |r| [r.config.width, r.config.height]); let b = HudBatch::new(size); - let hit = death_buttons(b.width, b.height) + let hit = death_buttons(b.width, b.height, self.level.hardcore) .into_iter() .find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0)) .map(|d| d.3); @@ -330,6 +342,11 @@ impl Game<'_> { Some(1) => self.close_world()?, _ => {} } + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if hit == Some(0) && self.remote.is_none() && self.level.hardcore { + self.set_mode(GameMode::Spectator); + } if hit.is_some() { self.audio.play_button_click(); } diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index 76a951894..3bed808f6 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -84,7 +84,9 @@ import net.minecraft.world.phys.Vec3; * still), {@code sync NAME} (runs {@code time query gametime}, whose reply is * not recorded, and prints {@code synced NAME} once it is back: the server has * handled everything this player sent before, and everything the server sent - * this player before the reply has been recorded), {@code quit NAME}, {@code + * this player before the reply has been recorded), {@code respawn NAME} (the + * death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN), + * {@code quit NAME}, {@code * players on|off} (whether each player also records the other players added * to and removed from its level, and its own respawns into a dimension), * {@code chat on|off} (whether the system chat each player receives is @@ -130,6 +132,8 @@ public final class LocatorBarClient { players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities)); } case "still" -> players.get(w[1]).still(); + case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket( + net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN)); case "sync" -> { try { players.get(w[1]).sync(); diff --git a/tools/test_hardcore_respawn_parity.py b/tools/test_hardcore_respawn_parity.py new file mode 100644 index 000000000..6e0669534 --- /dev/null +++ b/tools/test_hardcore_respawn_parity.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Respawning in a hardcore world on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one hardcore +flat world vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of +vanilla's protocol code) joins, runs /kill on itself and presses the death screen's button +(ServerboundClientCommandPacket PERFORM_RESPAWN). Vanilla's ServerGamePacketListenerImpl.handleClientCommand +respawns the player, then makes it a spectator because the server is hardcore. The game mode changes and respawns +Alice is sent in the death and respawn steps are compared exactly. +""" +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +try: + from . import lab, test_rust_parity as base, test_locator_bar_parity as locator + from .test_dedicated_server_parity import Server +except ImportError: + import lab + import test_rust_parity as base + import test_locator_bar_parity as locator + from test_dedicated_server_parity import Server + +NAME = 'hardcore-respawn-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, hardcore='true', gamemode='survival') +SCRIPT = [ + ('death', ['cmd Alice kill @s']), + ('respawn', ['respawn Alice']), +] +STEP_SECONDS = 4.0 +SHOWN = ('gamemode', 'respawn') + + +def run_side(label, server_command, client_command, build, world): + directory = build / label + port = locator.server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], + cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, + text=True) + + def tell(line): + client.stdin.write(line + '\n') + client.stdin.flush() + try: + tell('players on') + tell('state on') + tell('step joins') + tell('join Alice') + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(STEP_SECONDS) + for step, actions in SCRIPT: + tell(f'step {step}') + for action in actions: + tell(action) + time.sleep(STEP_SECONDS) + tell('end') + client.wait(timeout=60) + finally: + if client.poll() is None: + client.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + +def shown(lines): + """Alice's game mode changes and respawns in the death and respawn steps.""" + keep = [] + for line in lines: + parts = line.split(' ', 3) + if len(parts) >= 3 and parts[1] in ('step=death', 'step=respawn') and parts[2] in SHOWN: + keep.append(line) + return keep + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / NAME + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' + classes = build / 'classes' + shutil.rmtree(classes, ignore_errors=True) + classes.mkdir() + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', + lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], + cwd=lab.ROOT, check=True, capture_output=True) + subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, + check=True, capture_output=True) + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], + cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client') + vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] + horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui'] + client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + + world = locator.generate_world(vanilla_server, build) + vanilla_all = run_side('vanilla', vanilla_server, client, build, world) + horizon_all = run_side('horizon', horizon_server, client, build, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + if 'Alice step=respawn gamemode 3' not in vanilla: + raise AssertionError(f'vanilla did not make the respawned player a spectator: the script did not run ' + f'(see {build})') + lab.write_json(report, dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), + scope='A player dying and respawning in a hardcore world on a dedicated server: the respawn and the game ' + 'mode it is sent (spectator).')) + print(f'hardcore respawn: {len(vanilla)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From f48cfb79661b9181ed62435e5009f1cd2bfc0543 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:48:47 +0100 Subject: [PATCH 16/57] Let /ride mount and dismount mob riders /ride now looks up a mob's vehicle as well as the player's, so mobs can mount mobs, get the already-riding and loop errors, and dismount to the vehicle's dismount spot. execute on vehicle uses the same lookup. The command_exec corpus gets a mob riding group. --- .../src/game/commands/execute.rs | 5 +- .../src/game/commands/world_cmds.rs | 88 +++++++++++++++---- tools/command_exec_corpus.py | 26 ++++++ 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/commands/execute.rs b/crates/horizon-client/src/game/commands/execute.rs index 463cd542e..3f7e4c2a0 100644 --- a/crates/horizon-client/src/game/commands/execute.rs +++ b/crates/horizon-client/src/game/commands/execute.rs @@ -606,10 +606,7 @@ impl Game<'_> { .collect(), _ => Vec::new(), }, - Some("vehicle") => match entity { - Target::Player => self.riding.map(Target::Mob).into_iter().collect(), - _ => Vec::new(), - }, + Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(), Some("controller") | Some("passengers") => match entity { Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => { vec![Target::Player] diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 2f3792a57..c8bd220be 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -1074,15 +1074,15 @@ impl Game<'_> { }; let name = self.target_name(target); if id == "ride.dismount" { - let vehicle = match target { - Target::Player => self.riding.map(Target::Mob), - _ => None, - }; - let Some(v) = vehicle else { + let Some(v) = self.ride_vehicle(target) else { return Err(err("commands.ride.not_riding", &[&name.plain()])); }; let vehicle_name = self.target_name(v); - self.dismount_mount(); + match target { + Target::Player => self.dismount_mount(), + Target::Mob(rider) => self.dismount_mob(rider), + _ => {} + } self.feedback(Text::translate( "commands.ride.dismount.success", &[name, vehicle_name], @@ -1094,11 +1094,8 @@ impl Game<'_> { return Ok(0); }; let vehicle_name = self.target_name(vehicle); - if target == Target::Player && self.riding.is_some() { - let current = self - .riding - .map(Target::Mob) - .map_or_else(Text::empty, |v| self.target_name(v)); + if let Some(current) = self.ride_vehicle(target) { + let current = self.target_name(current); return Err(err( "commands.ride.already_riding", &[&name.plain(), ¤t.plain()], @@ -1107,22 +1104,42 @@ impl Game<'_> { if vehicle == Target::Player { return Err(err("commands.ride.mount.failure.cant_ride_players", &[])); } - if target == vehicle { - return Err(err("commands.ride.mount.failure.loop", &[])); + // getSelfAndPassengers: the vehicle is the target or rides it. + let mut up = Some(vehicle); + while let Some(v) = up { + if v == target { + return Err(err("commands.ride.mount.failure.loop", &[])); + } + up = self.ride_vehicle(v); } - // The player can ride mobs that carry a rider. + // Players and mobs can ride mobs. let Target::Mob(mob_id) = vehicle else { return Err(err( "commands.ride.mount.failure.generic", &[&name.plain(), &vehicle_name.plain()], )); }; - if target != Target::Player || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) { + let rider = match target { + Target::Mob(id) => Some(id), + _ => None, + }; + if !(target == Target::Player || rider.is_some()) + || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) + { return Err(err( "commands.ride.mount.failure.generic", &[&name.plain(), &vehicle_name.plain()], )); } + if let Some(rider) = rider { + // Entity.startRiding. + self.mount_mob(rider, mob_id); + self.feedback(Text::translate( + "commands.ride.mount.success", + &[name, vehicle_name], + )); + return Ok(1); + } if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) { m.extra.flag = false; m.extra.counter = 0; @@ -1138,6 +1155,47 @@ impl Game<'_> { Ok(1) } + /// Entity.getVehicle: the player's mount, or a mob's. + pub(super) fn ride_vehicle(&self, t: Target) -> Option { + let id = match t { + Target::Player => return self.riding.map(Target::Mob), + Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?, + _ => return None, + }; + Some(if self.boats.iter().any(|b| b.id == id) { + Target::Boat(id) + } else { + Target::Mob(id) + }) + } + /// LivingEntity.stopRiding for a mob: off its vehicle + /// (Entity.removePassenger), then to where the vehicle lets it out + /// (LivingEntity.dismountVehicle). + fn dismount_mob(&mut self, rider: u32) { + let Some(i) = self.mobs.iter().position(|m| m.id == rider) else { + return; + }; + let Some(v) = self.mobs[i].extra.mount.take() else { + return; + }; + let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw); + let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) { + self.boat_dismount_target(b, width, yaw) + } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) { + m.extra.rider = None; + // Entity.getDismountLocationForPassenger: on top of the vehicle. + let p = m.position; + horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z) + } else { + self.mobs[i].position + }; + let m = &mut self.mobs[i]; + m.extra.passenger = false; + m.extra.boarding_cooldown = 60; + m.position = at; + m.previous = at; + } + // ---- /spreadplayers ----------------------------------------------------------------------- pub(super) fn cmd_spreadplayers(&mut self, ctx: &Context) -> R { diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 0e376e260..ba03fc817 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -327,6 +327,31 @@ def riding_teleport(): '/tp @s 8.5 230 8.5 0 0') +def mob_riding(): + """Mobs mount and dismount mobs with /ride, NBT passengers included (#1520).""" + rider, vehicle = '@e[tag=ride_r,limit=1]', '@e[tag=ride_v,limit=1]' + return [ + *setup('/summon minecraft:cow 4.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_v"]}', + '/summon minecraft:pig 6.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_r"]}'), + cmd(f'/ride {rider} dismount'), + cmd(f'/ride {rider} mount @s'), + cmd(f'/ride {rider} mount {rider}'), + cmd(f'/ride {rider} mount {vehicle}'), + cmd(f'/ride {rider} mount {vehicle}'), + cmd(f'/ride {vehicle} mount {rider}'), + cmd('/execute as @e[tag=ride_r] on vehicle run say carrying'), + cmd(f'/ride {rider} dismount'), + cmd(f'/data get entity {rider} Pos'), + cmd('/execute as @e[tag=ride_r] on vehicle run say still carrying'), + *setup('/kill @e[tag=ride_r]', '/kill @e[tag=ride_v]'), + *setup('/summon minecraft:cow 4.5 220 12.5 {NoAI:1b,NoGravity:1b,Tags:["ride_v"],' + 'Passengers:[{id:"minecraft:pig",NoAI:1b,NoGravity:1b,Tags:["ride_r"]}]}'), + cmd(f'/ride {rider} dismount'), + cmd(f'/data get entity {rider} Pos'), + *setup('/kill @e[tag=ride_r]', '/kill @e[tag=ride_v]'), + ] + + def scores(): return [ cmd('/scoreboard objectives add harness dummy'), @@ -1017,6 +1042,7 @@ def groups(): ('blocks and border', world_blocks()), ('entities', entities()), ('riding teleport', riding_teleport()), + ('mob riding', mob_riding()), ('scores, teams and bossbars', scores()), ('execute', execute()), ('levels', levels()), From 436c2fefb47d16e33b0e13ad2218dc731ec43afe Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:40:05 +0100 Subject: [PATCH 17/57] Freeze server-thread players in powder snow With the server thread, the host's copy of a player never set isInPowderSnow from the blocks along its moves and never ran LivingEntity.aiStep's freezing, so a frozen player took no freezing damage and the client's own damage was overwritten. Record powder snow's FREEZE on the server path and run the freezing countdown and its damage every 40 ticks there. The guest's freeze-immune armor now comes from its worn armor, as for the local player. --- crates/horizon-client/src/game.rs | 24 ++++++++++-------- crates/horizon-client/src/game/lan_actions.rs | 2 ++ crates/horizon-client/src/motion.rs | 25 ++++++++++++++++--- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 699468651..4e4865c7a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1844,6 +1844,19 @@ impl Game<'_> { /// (decreaseAirSupply), the wings, leather boots on powder snow /// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear /// (LivingEntity.canFreeze) and the dimension's FAST_LAVA. + /// `LivingEntity.canFreeze`'s armor half: any worn piece in + /// `freeze_immune_wearables`. + pub(crate) fn wears_freeze_immune(&self) -> bool { + let immune = self + .res + .items() + .item_tags + .get("minecraft:freeze_immune_wearables"); + self.armor + .iter() + .flatten() + .any(|s| immune.is_some_and(|t| t.contains(&*s.item))) + } pub(crate) fn sync_player_body(&mut self) { let glider = self.glider_worn(); let item_use_speed = self @@ -1863,16 +1876,7 @@ impl Game<'_> { let leather_boots = self.armor[3] .as_ref() .is_some_and(|s| &*s.item == "minecraft:leather_boots"); - let immune = self - .res - .items() - .item_tags - .get("minecraft:freeze_immune_wearables"); - let freeze_immune = self - .armor - .iter() - .flatten() - .any(|s| immune.is_some_and(|t| t.contains(&*s.item))); + let freeze_immune = self.wears_freeze_immune(); let Some(player) = self.player.as_mut() else { return; }; diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..10307dca3 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1942,6 +1942,7 @@ impl Game<'_> { let events = self.as_guest(host, conn, |game| { let (rules, motion) = (game.world.health_rules, game.motion); let health = &motion.health; + let freeze_immune = game.wears_freeze_immune(); let Some(player) = game.player.as_mut() else { return (Vec::new(), None); }; @@ -2005,6 +2006,7 @@ impl Game<'_> { // Entity.applyEffectsFromBlocks along those moves: magma, cactus, // lit campfires and grown sweet berry bushes hurt (#1305). w.player.shift_key_down = sneaking; + w.player.freeze_immune = freeze_immune; let _ = w.server_block_damage(start); let _ = w.tick_environment_damage(); let _ = w.apply_fire_contact(); diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 560cadc80..e7809055d 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1083,11 +1083,26 @@ impl MotionWorld<'_, '_, '_> { } /// What `applyEffectsFromBlocks` does to a server's player along the /// moves its client sent since `from` (in its history): the damage of - /// magma, cactus, lit campfires and grown sweet berry bushes. Its client - /// moves it, so the slowing and the rest are the client's. + /// magma, cactus, lit campfires and grown sweet berry bushes, and powder + /// snow's FREEZE, then `LivingEntity.aiStep`'s freezing and its damage. + /// Its client moves it, so the slowing and the rest are the client's. pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> { self.player.old_position = from; - self.effects_from_blocks(true) + // Entity.baseTick + self.player.was_in_powder_snow = self.player.in_powder_snow; + self.player.in_powder_snow = false; + self.effects_from_blocks(true)?; + // LivingEntity.aiStep "freezing" (server). + if !self.player.in_powder_snow || !self.player.can_freeze() { + self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0); + } + if self.player.health.tick_count % 40 == 0 + && self.player.is_fully_frozen() + && self.player.can_freeze() + { + self.freeze_damage()?; + } + Ok(()) } fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> { use horizon_world::entity_move::MovementRecord; @@ -1191,7 +1206,7 @@ impl MotionWorld<'_, '_, '_> { groups += 1; last_step = step; } - if groups == 1 { + if groups == 1 && !damage_only { // A later group finds the fire already out. self.player.powder_snow_entered.push(p); } @@ -1316,6 +1331,8 @@ impl MotionWorld<'_, '_, '_> { "minecraft:sweet_berry_bush" if damage_only => { self.sweet_berry_bush_damage(state)?; } + // PowderSnowBlock.entityInside's FREEZE. + "minecraft:powder_snow" if damage_only => entered.push((step, p)), _ => {} } if damage_only { From 10efca9e6cd922ff7b9a4a3cc3f5a467e97f52f2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:40:29 +0100 Subject: [PATCH 18/57] Discard a dead player's ender pearl when the rule says so With ender_pearls_vanish_on_death on, a pearl thrown just before death kept flying and pulled the respawned player back to it. Vanilla's ThrownEnderpearl.tick discards the pearl instead of ticking it while its owner is dead. Add a harness that throws a pearl, dies and respawns on a dedicated server and compares the teleports with vanilla's. --- crates/horizon-client/src/game/creatures.rs | 19 +++ .../horizonlab/parity/LocatorBarClient.java | 15 +- tools/test_pearl_death_parity.py | 142 ++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tools/test_pearl_death_parity.py diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 0bb7bbe85..ff07e9011 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1694,7 +1694,26 @@ impl Game<'_> { targets: targets_guests, }; let guest_entities = self.lan_guest_entities(); + // ThrownEnderpearl.tick: a dead player's pearl is discarded in + // place of its tick while ender_pearls_vanish_on_death is on. + let vanish = self.ui.rules.bool("ender_pearls_vanish_on_death"); + let host_dead = self.player_view().is_some_and(|p| !p.alive); + let dead_guests: Vec = self.lan.as_ref().map_or_else(Vec::new, |h| { + h.hub + .guests + .iter() + .filter(|g| g.dead) + .map(|g| g.conn) + .collect() + }); projectiles.retain_mut(|p| { + if vanish + && matches!(p.kind, ProjectileKind::EnderPearl) + && p.owner == Some(Who::Player) + && p.guest.map_or(host_dead, |c| dead_guests.contains(&c)) + { + return false; + } let mob_shot = !matches!(p.owner, Some(Who::Player)); // A player's arrows, snowballs and eggs meet every player; a // guest's flies from that guest (Projectile.getOwner), not diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index 76a951894..818746336 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -84,7 +84,10 @@ import net.minecraft.world.phys.Vec3; * still), {@code sync NAME} (runs {@code time query gametime}, whose reply is * not recorded, and prints {@code synced NAME} once it is back: the server has * handled everything this player sent before, and everything the server sent - * this player before the reply has been recorded), {@code quit NAME}, {@code + * this player before the reply has been recorded), {@code respawn NAME} (the + * death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN), + * {@code use NAME PITCH} (the main hand's item used looking that way, + * ServerboundUseItemPacket), {@code quit NAME}, {@code * players on|off} (whether each player also records the other players added * to and removed from its level, and its own respawns into a dimension), * {@code chat on|off} (whether the system chat each player receives is @@ -130,6 +133,9 @@ public final class LocatorBarClient { players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities)); } case "still" -> players.get(w[1]).still(); + case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket( + net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN)); + case "use" -> players.get(w[1]).use(Float.parseFloat(w[2])); case "sync" -> { try { players.get(w[1]).sync(); @@ -250,6 +256,13 @@ public final class LocatorBarClient { } } + private int sequence; + + void use(float pitch) throws Exception { + send(new net.minecraft.network.protocol.game.ServerboundUseItemPacket( + net.minecraft.world.InteractionHand.MAIN_HAND, ++sequence, position.yRot(), pitch)); + } + void still() throws Exception { PositionMoveRotation at = position; // A tick of its own: the server takes one position per client tick (receivedPositionThisTick). diff --git a/tools/test_pearl_death_parity.py b/tools/test_pearl_death_parity.py new file mode 100644 index 000000000..a6795c749 --- /dev/null +++ b/tools/test_pearl_death_parity.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""An ender pearl in flight when its thrower dies, on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world +vanilla generated first, with Alice an operator in survival. LocatorBarClient (a player made of vanilla's protocol +code) throws an ender pearl straight up (ServerboundUseItemPacket), runs /kill on itself while the pearl flies, +presses the death screen's button (PERFORM_RESPAWN) and waits for the pearl to come down, with the rule +ender_pearls_vanish_on_death true: ThrownEnderpearl.tick discards a dead owner's pearl, so the respawned player is +never teleported. The teleports, game mode changes and respawns Alice is sent from the throw on are compared +exactly. +""" +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +try: + from . import lab, test_rust_parity as base, test_locator_bar_parity as locator + from .test_dedicated_server_parity import Server +except ImportError: + import lab + import test_rust_parity as base + import test_locator_bar_parity as locator + from test_dedicated_server_parity import Server + +NAME = 'pearl-death-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival') +STEP_SECONDS = 4.0 +SHOWN = ('teleport', 'gamemode', 'respawn') + + +SCRIPT = [ + ('setup', ['cmd Alice gamerule ender_pearls_vanish_on_death true', 'cmd Alice give @s minecraft:ender_pearl']), + # Straight up: it comes down about four seconds later, near where it was thrown. + ('throw', ['use Alice -90', 'cmd Alice kill @s']), + ('respawn', ['respawn Alice']), + ('landed', []), +] + + +def run_side(label, server_command, client_command, build, world): + directory = build / label + port = locator.server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], + cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, + text=True) + + def tell(line): + client.stdin.write(line + '\n') + client.stdin.flush() + try: + tell('players on') + tell('state on') + tell('step joins') + tell('join Alice') + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(STEP_SECONDS) + for step, actions in SCRIPT: + tell(f'step {step}') + for action in actions: + tell(action) + time.sleep(STEP_SECONDS) + tell('end') + client.wait(timeout=60) + finally: + if client.poll() is None: + client.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + +def shown(lines): + """Alice's teleports, game mode changes and respawns from the throws on.""" + steps = {f'step={step}' for step, _ in SCRIPT if step != 'setup'} + return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] in steps + and parts[2] in SHOWN] + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / NAME + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' + classes = build / 'classes' + shutil.rmtree(classes, ignore_errors=True) + classes.mkdir() + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', + lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], + cwd=lab.ROOT, check=True, capture_output=True) + subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, + check=True, capture_output=True) + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], + cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client') + vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] + horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui'] + client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + + world = locator.generate_world(vanilla_server, build) + vanilla_all = run_side('vanilla', vanilla_server, client, build, world) + horizon_all = run_side('horizon', horizon_server, client, build, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + killed = any(l.startswith('Alice step=throw killed') for l in vanilla_all) + if not killed or 'Alice step=respawn respawn minecraft:overworld' not in vanilla: + raise AssertionError(f'vanilla did not kill and respawn Alice: the script did not run (see {build})') + lab.write_json(report, dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), + scope='An ender pearl in flight when its thrower dies and respawns on a dedicated server, with ' + 'ender_pearls_vanish_on_death true: the pearl vanishes and never teleports the player.')) + print(f'pearl death: {len(vanilla)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From 0c017678eb50c5819f0e2948f3f8432093e027ac Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:44:16 +0100 Subject: [PATCH 19/57] Keep shulker boxes out of shulker boxes on the server ShulkerBoxSlot.mayPlace refuses items that cannot fit inside container items, which BlockItem says of every shulker box. The server's menus for guests treated a shulker box's slots like a chest's, so with the server thread a shulker box could be clicked, shift-clicked or swapped into another. Apply the slot rule when the open menu is a shulker box. --- crates/horizon-client/src/lan/containers.rs | 9 +++++++++ crates/horizon-client/src/lan/menu.rs | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index d43cb5c27..3dc085a6d 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -344,6 +344,14 @@ impl Hub { return Vec::new(); } let source = self.guests[i].menu.as_ref().map(|m| m.slots); + let shulker_box = self + .sync + .builtin + .get("minecraft:menu") + .and_then(|m| m.id("minecraft:shulker_box")); + let shulker = in_menu + && shulker_box.is_some() + && self.guests[i].menu.as_ref().map(|m| m.kind) == shulker_box; let mut drag = if in_menu { self.guests[i].menu.as_mut().and_then(|m| m.drag.take()) } else { @@ -357,6 +365,7 @@ impl Hub { let mut view = View { guest, container: items.as_deref_mut(), + shulker, }; let (drops, changed) = menu::click( &mut view, diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index de9fb9608..fefcb1933 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -111,6 +111,8 @@ pub struct View<'a> { pub guest: &'a mut Guest, /// The container's slots, or None for the inventory menu. pub container: Option<&'a mut [Option]>, + /// The container is a shulker box (`ShulkerBoxSlot`). + pub shulker: bool, } impl View<'_> { @@ -153,6 +155,14 @@ impl View<'_> { if self.container.is_none() && (5..=8).contains(&i) { return rules.armor_index(&stack.item) == Some(i - 5); } + // ShulkerBoxSlot.mayPlace: Item.canFitInsideContainerItems, false + // for shulker boxes. + if self.shulker + && self.container.as_ref().is_some_and(|c| i < c.len()) + && stack.item.ends_with("shulker_box") + { + return false; + } self.enabled(i) } fn slot_max(&self, i: usize, stack: &ItemStack, rules: &dyn Rules) -> u32 { @@ -704,6 +714,7 @@ mod tests { let mut view = View { guest: &mut g, container: Some(&mut chest), + shulker: false, }; view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1)); // Left click with the bundle: the stone goes in. From 5bfc649edd18aaf9738dc428bc24ae9e991cf87d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Sun, 25 Oct 2026 15:52:29 +0100 Subject: [PATCH 20/57] Drop the cursor stack of a dead player A stack held on the inventory cursor at death stayed there through the respawn, even with keep_inventory off. In vanilla the dead player is removed 20 ticks after dying (or when it respawns sooner), and Player.remove's inventoryMenu.removed drops what the cursor held where the player died. Do the same for server players and the in-frame player, and add a harness that dies with a cursor stack on a dedicated server and compares with vanilla. --- crates/horizon-client/src/game.rs | 20 ++- crates/horizon-client/src/game/chat.rs | 2 + crates/horizon-client/src/game/lan_actions.rs | 27 +++- crates/horizon-client/src/lan/guest.rs | 3 + .../horizonlab/parity/LocatorBarClient.java | 10 +- tools/test_cursor_death_parity.py | 143 ++++++++++++++++++ 6 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 tools/test_cursor_death_parity.py diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 699468651..667eff52a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2173,6 +2173,11 @@ impl Game<'_> { // Player.drop: the drop statistics. self.stats.add("dropped", &thrown.item, thrown.count as i32); self.stats.custom("drop", 1); + self.throw_from_eyes(thrown, eye); + } + /// `Player.createItemStackToDrop`: an item entity thrown from the eyes + /// along the view direction. + fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) { let (yaw, pitch) = ( f64::from(self.yaw).to_radians(), f64::from(self.pitch).to_radians(), @@ -4335,7 +4340,8 @@ impl Game<'_> { // Out of the world. let _ = player.set_health(0.0, false); } - if player.health.dead_or_dying() && self.screen != Screen::Dead { + let dead = player.health.dead_or_dying(); + if dead && self.screen != Screen::Dead { // Inventory.dropAll: every stack thrown in a random direction. let feet = [player.position.x, player.position.y, player.position.z]; let at = [feet[0], feet[1] + 1.32, feet[2]]; @@ -4369,6 +4375,18 @@ impl Game<'_> { } self.screen = Screen::Dead; self.capture(false); + self.ui.death_time = 0; + } else if dead { + // LivingEntity.tickDeath: 20 ticks after dying the player is + // removed, and Player.remove's inventoryMenu.removed drops what + // the cursor held. + self.ui.death_time += 1; + if self.ui.death_time >= 20 + && let Some(stack) = self.carried.take() + && let Some(eye) = self.eye_position() + { + self.throw_from_eyes(stack, eye); + } } self.tick_world(running) } diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index ec85732ae..15e34bbb6 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -99,6 +99,8 @@ pub struct UiState { /// Death screen cause and when the player died (buttons wait 20 ticks). pub death_text: Option, pub died_at: Option, + /// `LivingEntity.deathTime` of the window's player: ticks since it died. + pub death_time: u32, } impl Game<'_> { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..de6ae51dc 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1234,6 +1234,24 @@ impl Game<'_> { Some(out) } + /// `LivingEntity.tickDeath`: 20 ticks after dying the player is removed, + /// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor + /// held (`dropOrPlaceInInventory` for a removed player). Respawning + /// sooner removes it too. + fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) { + let g = &mut host.hub.guests[gi]; + g.death_time += 1; + if g.death_time >= 20 { + self.guest_drop_carried(host, gi); + } + } + + fn guest_drop_carried(&mut self, host: &mut LanHost, gi: usize) { + if let Some(c) = host.hub.guests[gi].carried.take() { + self.drop_guest_items(host, gi, vec![c]); + } + } + /// `AbstractContainerMenu.removed`: what a closing player held on the /// cursor goes back into their inventory (`placeItemBackInInventory`, /// dropped only if it doesn't fit), and their client's cursor empties @@ -1573,6 +1591,8 @@ impl Game<'_> { if !host.hub.guests[i].dead { return; } + // PlayerList.respawn removes the dead player (Player.remove). + self.guest_drop_carried(host, i); // PlayerList.respawn: a server's player comes back in its respawn // point's level: the Overworld, where the world spawn and beds are, // or another one's anchor or forced point, which its block resolves @@ -1613,6 +1633,7 @@ impl Game<'_> { } let g = &mut host.hub.guests[i]; g.dead = false; + g.death_time = 0; g.health = 20.0; g.food = 20; g.saturation = 5.0; @@ -1694,7 +1715,11 @@ impl Game<'_> { continue; }; self.guest_sleep_tick(host, gi); - if host.hub.guests[gi].dead || self.on_server_trip(conn) { + if host.hub.guests[gi].dead { + self.guest_tick_death(host, gi); + continue; + } + if self.on_server_trip(conn) { continue; } let play = host.play.entry(conn).or_default(); diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 440b58284..70510a5b4 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -223,6 +223,8 @@ pub struct Guest { /// Whether the guest was on the ground before the first of `moves`. pub moves_from_ground: bool, pub dead: bool, + /// `LivingEntity.deathTime`: ticks since the guest died. + pub death_time: u32, /// The join packets were sent (PlayerList.placeNewPlayer ran). pub placed: bool, /// The level the player is in (`ServerPlayer.level().dimension()`): the @@ -349,6 +351,7 @@ impl Guest { moves: Vec::new(), moves_from_ground: false, dead: false, + death_time: 0, placed: false, dimension: "minecraft:overworld".into(), menu: None, diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index 76a951894..e4aeef25e 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -84,7 +84,10 @@ import net.minecraft.world.phys.Vec3; * still), {@code sync NAME} (runs {@code time query gametime}, whose reply is * not recorded, and prints {@code synced NAME} once it is back: the server has * handled everything this player sent before, and everything the server sent - * this player before the reply has been recorded), {@code quit NAME}, {@code + * this player before the reply has been recorded), {@code respawn NAME} (the + * death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN), + * {@code pickup NAME SLOT} (a left click on that slot of the player's + * inventory menu, claiming nothing), {@code quit NAME}, {@code * players on|off} (whether each player also records the other players added * to and removed from its level, and its own respawns into a dimension), * {@code chat on|off} (whether the system chat each player receives is @@ -130,6 +133,11 @@ public final class LocatorBarClient { players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundPlayerAbilitiesPacket(abilities)); } case "still" -> players.get(w[1]).still(); + case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket( + net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN)); + case "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket( + 0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP, + new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY)); case "sync" -> { try { players.get(w[1]).sync(); diff --git a/tools/test_cursor_death_parity.py b/tools/test_cursor_death_parity.py new file mode 100644 index 000000000..8da944350 --- /dev/null +++ b/tools/test_cursor_death_parity.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""What a player holds on the cursor when they die, on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world +vanilla generated first, with Alice an operator in survival and keep_inventory off. LocatorBarClient (a player made +of vanilla's protocol code) is given seven diamonds, picks them up onto its inventory menu's cursor (a PICKUP click) +ten blocks from the spawn, runs /kill on itself, waits on the death screen and respawns (PERFORM_RESPAWN). Twenty +ticks after dying vanilla removes the dead player (LivingEntity.tickDeath), and Player.remove's +inventoryMenu.removed drops the cursor's stack where it died. Alice then asks whether an item lies around the death +site; the reply, and the inventory slots, respawns and teleports she is sent from the death on, are compared +exactly. +""" +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import time + +try: + from . import lab, test_rust_parity as base, test_locator_bar_parity as locator + from .test_dedicated_server_parity import Server +except ImportError: + import lab + import test_rust_parity as base + import test_locator_bar_parity as locator + from test_dedicated_server_parity import Server + +NAME = 'cursor-death-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival') +SCRIPT = [ + ('setup', ['cmd Alice gamerule keep_inventory false', 'cmd Alice clear @s', + 'cmd Alice give @s minecraft:diamond 7', 'cmd Alice tp @s 10.5 -60 0.5']), + # Hotbar slot 0 is the inventory menu's slot 36. + ('pickup', ['pickup Alice 36']), + ('death', ['cmd Alice kill @s']), + ('respawn', ['respawn Alice']), + ('check', ['chat Alice on', + 'cmd Alice execute if entity @e[type=minecraft:item,x=10.5,y=-60,z=0.5,distance=..4]']), +] +STEP_SECONDS = 4.0 +SHOWN = ('slot', 'inventory', 'respawn', 'teleport', 'chat') + + +def run_side(label, server_command, client_command, build, world): + directory = build / label + port = locator.server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], + cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, + text=True) + + def tell(line): + client.stdin.write(line + '\n') + client.stdin.flush() + try: + tell('players on') + tell('state on') + tell('step joins') + tell('join Alice') + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(STEP_SECONDS) + for step, actions in SCRIPT: + tell(f'step {step}') + for action in actions: + tell(action.replace('chat Alice', 'chat')) + time.sleep(STEP_SECONDS) + tell('end') + client.wait(timeout=60) + finally: + if client.poll() is None: + client.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + +def shown(lines): + """Alice's slots, respawns, teleports and chat from the death on.""" + steps = {'step=death', 'step=respawn', 'step=check'} + return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] in steps + and parts[2] in SHOWN] + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / NAME + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' + classes = build / 'classes' + shutil.rmtree(classes, ignore_errors=True) + classes.mkdir() + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', + lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], + cwd=lab.ROOT, check=True, capture_output=True) + subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, + check=True, capture_output=True) + metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], + cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client') + vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] + horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui'] + client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + + world = locator.generate_world(vanilla_server, build) + vanilla_all = run_side('vanilla', vanilla_server, client, build, world) + horizon_all = run_side('horizon', horizon_server, client, build, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + if not any(l.startswith('Alice step=check chat') for l in vanilla): + raise AssertionError(f'vanilla answered no check command: the script did not run (see {build})') + lab.write_json(report, dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), + scope='A player dying with a stack on the inventory menu cursor (keep_inventory off) and respawning on a ' + 'dedicated server: the stack is dropped where it died, not kept.')) + print(f'cursor death: {len(vanilla)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From bf81ae6358638575fb14192f2a5f82495a0ce13f Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 18:28:48 +0300 Subject: [PATCH 21/57] Furnace: pay the experience a smelt earned RecipesUsed was written as an empty compound and never read, so a furnace paid nothing when its output was taken and nothing when it was broken. - Furnace keeps the per-recipe counter, saves and loads it, and floors each recipe on its own as AbstractFurnaceBlockEntity.createExperience does. - FurnaceResultSlot.checkTakeAchievements pays on a take: the counter is read and cleared when the output slot loses an item. - AbstractFurnaceBlockEntity.playerDestroy pays the same counter as orbs at the block when it is broken, whatever the tool. Fixes #1732 Co-Authored-By: Hermes Agent (Nous Research) --- .../examples/probe/menu_fixture.rs | 1 + crates/horizon-client/src/containers.rs | 47 +++++++++++++++++ crates/horizon-client/src/crafting.rs | 8 +++ crates/horizon-client/src/game.rs | 51 +++++++++++++++++++ crates/horizon-client/src/game/remote.rs | 1 + crates/horizon-client/src/save.rs | 35 ++++++++++++- 6 files changed, 142 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/examples/probe/menu_fixture.rs b/crates/horizon-client/examples/probe/menu_fixture.rs index 3c83b20bc..9ec558de3 100644 --- a/crates/horizon-client/examples/probe/menu_fixture.rs +++ b/crates/horizon-client/examples/probe/menu_fixture.rs @@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> { cooking_timer: menu.data[2], cooking_total: menu.data[3], speed: 1.0, + recipes_used: Default::default(), }); menu.drops.clear(); menu.equipped.clear(); diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index de2e1b2e9..3701fefaf 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -44,6 +44,10 @@ pub struct Furnace { pub cooking_timer: i32, pub cooking_total: i32, pub speed: f32, + /// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has + /// made since a player last took from its output. The experience is owed per recipe + /// rather than per item, and a save keeps it as the `RecipesUsed` compound. + pub recipes_used: HashMap, } /// A hopper's slots: the only plain container with five. pub const HOPPER_SLOTS: usize = 5; @@ -190,6 +194,7 @@ impl BlockEntity { cooking_timer: 0, cooking_total: 0, speed: 1.0, + recipes_used: HashMap::new(), })); } if name == "minecraft:creaking_heart" { @@ -483,6 +488,26 @@ impl Furnace { self.cooking_timer = 0; } } + /// `AbstractFurnaceBlockEntity.createExperience`: each recipe's own share is floored, + /// then the floors are added. Flooring the sum instead pays one point for two recipes + /// worth 0.6 each, which vanilla does not. + fn owed(used: &HashMap, experience: impl Fn(&str) -> f32) -> u32 { + used.iter() + .map(|(id, count)| (experience(id) * *count as f32).floor().max(0.0) as u32) + .sum() + } + /// What this furnace owes right now, without forgetting it: breaking the block reads + /// the same counter, and only a payout clears it. + pub fn experience_owed(&self, recipes: &Recipes) -> u32 { + Self::owed(&self.recipes_used, |id| recipes.cooking_experience(id)) + } + /// `AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience`: the experience this + /// furnace owes, forgotten as it is paid. + pub fn take_experience(&mut self, recipes: &Recipes) -> u32 { + let owed = self.experience_owed(recipes); + self.recipes_used.clear(); + owed + } /// A menu click wrote its slots into `items`, and `before` was the input /// slot until then: the menu called `setItem` on it only if it changed /// (`Slot.setByPlayer`; partial takes and merges keep the stack). @@ -564,6 +589,7 @@ impl Furnace { Some(out) => out.count += recipe.result.count, None => self.items[2] = Some(recipe.result.clone()), } + *self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1; if let Some(i) = self.items[0].as_mut() { i.count -= 1; if i.count == 0 { @@ -594,3 +620,24 @@ fn total_time(time: u32, speed: f32) -> i32 { time as i32 } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `createExperience` floors each recipe on its own. Two recipes worth 0.6 each pay + /// nothing, where flooring their sum would pay one point. + #[test] + fn experience_is_floored_per_recipe_and_not_on_the_sum() { + let mut used = HashMap::new(); + used.insert("a".to_owned(), 1); + used.insert("b".to_owned(), 1); + assert_eq!(Furnace::owed(&used, |_| 0.6), 0); + + used.insert("c".to_owned(), 10); + assert_eq!( + Furnace::owed(&used, |id| if id == "c" { 0.1 } else { 0.0 }), + 1 + ); + } +} diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs index d0b8906ee..690383963 100644 --- a/crates/horizon-client/src/crafting.rs +++ b/crates/horizon-client/src/crafting.rs @@ -1610,6 +1610,14 @@ impl Recipes { } out } + /// The `experience` of the cooking recipe with this file name, and 0 for one this data + /// pack does not have (a save from another pack can name it). + pub fn cooking_experience(&self, id: &str) -> f32 { + self.cooking + .iter() + .find(|c| c.id == id) + .map_or(0.0, |c| c.experience) + } pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> { let index = self.cook_index.get_or_init(|| { let mut index = CookIndex::new(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 699468651..7c0bbc917 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3246,6 +3246,12 @@ impl Game<'_> { } // The block entity as it was, for what the drops copy from it. let key = (pos[0], pos[1], pos[2]); + // AbstractFurnaceBlockEntity.playerDestroy: the experience its recipes earned drops + // with the block. Read now, paid only if the block is really removed. + let furnace_xp = match self.world.block_entities.get(&key) { + Some(BlockEntity::Furnace(f)) => f.experience_owed(self.res.recipes()), + _ => 0, + }; if let Some(entity) = self.world.block_entities.get(&key).cloned() { let name = self.world.block_names.get(&key).cloned(); self.pending.record(pos, &entity, &facts.name, name); @@ -3315,6 +3321,18 @@ impl Game<'_> { ); } } + // Stored recipe experience is not a loot drop: it leaves with the block + // whatever the tool, as `AbstractFurnaceBlockEntity` does. + if furnace_xp > 0 { + self.spawn_orbs( + [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5, + ], + furnace_xp, + ); + } } self.pending.clear(pos); self.process_level_events()?; @@ -5936,6 +5954,12 @@ impl Game<'_> { // Attached after the slots were set: setting them is not a click. menu.merchant = session.map(|(s, _)| s); } + // `FurnaceResultSlot.checkTakeAchievements` pays out on what left the output slot. + let furnace_before = if matches!(kind, Kind::Furnace(_)) { + menu.slot(2).cloned().map_or(0, |s| s.count) + } else { + 0 + }; let outcome = run(&mut menu, &env, &to_menu); let made = maps.made.take(); match outcome { @@ -5953,6 +5977,12 @@ impl Game<'_> { self.merchant_sync_out(s); } let after: Vec> = (0..total).map(|i| menu.slot(i).cloned()).collect(); + // Only a furnace has a result slot to compare: an enchanting table has two slots. + let furnace_after = if matches!(kind, Kind::Furnace(_)) { + after[2].as_ref().map_or(0, |s| s.count) + } else { + 0 + }; let carried = menu.carried.take(); let dropped = std::mem::take(&mut menu.dropped); let sounds = std::mem::take(&mut menu.sounds); @@ -5977,6 +6007,15 @@ impl Game<'_> { self.player_drop(stack); } self.award_crafted(&crafted); + // shortcut: paid straight to the player instead of through `ExperienceOrb.award` + // (no orb flies at them), which a furnace's orbs reach within a tick anyway; + // upgrade if the visual matters or another block pays out far from the player. + if furnace_before > furnace_after { + let owed = self.take_furnace_experience(); + if owed > 0 { + self.give_orb_points(owed); + } + } } if kind == Kind::Loom { if let Some(s) = self.station.as_mut() { @@ -8768,6 +8807,18 @@ impl Game<'_> { self.give_orb_points(gained); } } + /// `AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience`: what the open furnace + /// owes for everything it made since the last payout, cleared as it is read. + fn take_furnace_experience(&mut self) -> u32 { + let Some(pos) = self.container else { + return 0; + }; + let recipes = self.res.recipes(); + let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else { + return 0; + }; + f.take_experience(recipes) + } /// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up /// sound every fifth level (at most every 100 ticks). pub(super) fn give_orb_points(&mut self, gained: u32) { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 044e43835..12e012a06 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4919,6 +4919,7 @@ impl Game<'_> { cooking_timer: 0, cooking_total: 0, speed: 1.0, + recipes_used: std::collections::HashMap::new(), })) } // HopperMenu, and DispenserMenu (MenuType.GENERIC_3x3: dispensers diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index da5735c17..937519da9 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -488,7 +488,12 @@ pub fn encode_block_entity( entries.push(("cooking_time_spent", Tag::Int(f.cooking_timer))); entries.push(("cooking_total_time", Tag::Int(f.cooking_total))); entries.push(("speed_multiplier", Tag::Float(f.speed.to_bits()))); - entries.push(("RecipesUsed", Tag::Compound(Compound::new()))); + let used: Compound = f + .recipes_used + .iter() + .map(|(id, count)| (id.as_str().into(), Tag::Int(*count as i32))) + .collect(); + entries.push(("RecipesUsed", Tag::Compound(used))); } BlockEntity::Brewing(b) => { entries.push(("Items", encode_items(&b.items))); @@ -640,6 +645,17 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain if let Some(speed) = float(tag.get("speed_multiplier")) { f.speed = speed as f32; } + // A save without it owes nothing: the field is newer than some worlds. + if let Some(Tag::Compound(used)) = tag.get("RecipesUsed") { + for (id, count) in used { + let count = match count { + Tag::Int(n) => *n, + _ => continue, + }; + f.recipes_used + .insert(id.to_string_lossy(), count.max(0) as u32); + } + } } BlockEntity::Brewing(b) => { let items = decode_items(tag.get("Items"), 5); @@ -4276,4 +4292,21 @@ mod tests { .unwrap_or(0); assert_eq!(flying_survival, 0); } + + /// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the + /// player nothing after a reload. + #[test] + fn a_furnaces_recipes_used_survive_a_save() { + use crate::containers::BlockEntity; + let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else { + panic!("a furnace"); + }; + f.recipes_used + .insert("minecraft:cooked_porkchop".to_owned(), 3); + let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace"); + let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else { + panic!("a furnace back"); + }; + assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3)); + } } From feec1b67e8446f5d81a4418f956b8b6b0f394e1e Mon Sep 17 00:00:00 2001 From: JasonGu <1456970218@qq.com> Date: Sun, 25 Oct 2026 23:29:23 +0800 Subject: [PATCH 22/57] Server deaths: build the message from the player's own combat record A guest's death only had `death_cause`, which `/kill` alone sets, so a server-thread, LAN or dedicated death fell back to "X died" or a generic cause. Give each guest its own CombatTracker, fed by the motion events the server already records, and take the message from CombatTracker.getDeathMessage with the dying player's own name. The wither's skull names the wither that launched it (projectile owner). The death screen now resolves the server's component in the client's current language instead of the session's en_us table, so it matches the chat line. Co-Authored-By: OpenCode DeepSeek V4.1 Flash --- crates/horizon-client/src/game.rs | 3 + crates/horizon-client/src/game/combat.rs | 6 +- crates/horizon-client/src/game/lan_actions.rs | 83 +++++++++++++------ crates/horizon-client/src/game/remote.rs | 10 +-- crates/horizon-client/src/game/wither.rs | 2 + 5 files changed, 72 insertions(+), 32 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 699468651..848061ff5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1568,6 +1568,8 @@ struct Game<'a> { cmds: commands::extra::CommandExtras, ui: chat::UiState, combat: combat::CombatTracker, + /// Each guest's own `CombatTracker`, out of `combat` between turns. + guest_combat: std::collections::HashMap, mount: mounts::MountState, stats: stats::Stats, score: scoreboard::ScoreState, @@ -12001,6 +12003,7 @@ impl<'a> Game<'a> { cmds: Default::default(), ui: Default::default(), combat: Default::default(), + guest_combat: Default::default(), mount: Default::default(), stats: Default::default(), score: Default::default(), diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 70ae082aa..227aa0332 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -191,8 +191,12 @@ impl Game<'_> { } /// `CombatTracker.getDeathMessage`. pub(super) fn combat_death_message(&mut self) -> Text { + self.combat_death_message_for(Text::literal(PLAYER_NAME)) + } + /// `CombatTracker.getDeathMessage` for a player whose display name is + /// `me`: a server's guest is not `PLAYER_NAME`. + pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text { self.recheck_combat(); - let me = Text::literal(PLAYER_NAME); let entries = std::mem::take(&mut self.combat.entries); self.combat.in_combat = false; let Some(killing) = entries.last() else { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..f5d419357 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -143,6 +143,8 @@ pub(super) struct GuestSwap { adv_loaded: bool, last_inventory: u64, death_cause: Option, + /// The guest's own damage record, in place while they act. + combat: super::combat::CombatTracker, /// The guest whose turn this one nests in, if any. acting: (Option, Option, Option), /// The packets queued for that guest. @@ -256,6 +258,14 @@ impl Game<'_> { host.play.insert(conn, play); return None; }; + // The guest's own CombatTracker: damage to them is not the host's. + // A new body starts with no record of the last one. + let guest_combat = if fresh { + Default::default() + } else { + self.guest_combat.remove(&conn).unwrap_or_default() + }; + let host_combat = std::mem::replace(&mut self.combat, guest_combat); self.potions.swap_player(&mut g.effects, &mut play.potions); motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]); motion.ground.on_ground = g.on_ground; @@ -418,6 +428,7 @@ impl Game<'_> { swings, projectiles, modifiers, + combat: host_combat, }) } @@ -468,12 +479,16 @@ impl Game<'_> { adv_loaded, last_inventory, death_cause, + combat, acting, packets, swings, projectiles, modifiers, } = swap; + // The guest's damage record back into their slot, the host's in place. + let guest_combat = std::mem::replace(&mut self.combat, combat); + self.guest_combat.insert(conn, guest_combat); play.modifiers = std::mem::replace(&mut self.applied_modifiers, modifiers); // What the guest shot is theirs (Projectile.setOwner). for p in self.creatures.projectiles.iter_mut().skip(projectiles) { @@ -1792,32 +1807,31 @@ impl Game<'_> { let drops = (g.game_mode != SPECTATOR) .then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]); let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take()); - let message = match cause { - // ServerPlayer.die: the death message, to everyone unless - // the player's team hides it. - Some(key) if self.ui.rules.bool("show_death_messages") => { - let text = horizon_client::text::Text::translate( - &key, - &[self.player_text(&name, uuid)], - ); - let message = horizon_client::lan::data::component_nbt(&text.component()); - let team = self.score.board.players_team(&name); - if horizon_client::scoreboard::own_death_message_shown(team) { - host.hub.system_message(message.clone()); - if self.options.dedicated.is_some() { - super::dedicated::log( - "INFO", - &format!("System chat: {}", text.plain()), - ); - } else if self.player.is_some() { - self.system_message(text); - } - } - message + let me = self.player_text(&name, uuid); + let text = match cause { + Some(key) => horizon_client::text::Text::translate(&key, &[me]), + None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => { + horizon_client::text::Text::translate("death.attack.outOfWorld", &[me]) } - Some(_) => literal(""), - None => literal(&format!("{name} died")), + None => self.guest_death_message(conn, me), }; + let message = horizon_client::lan::data::component_nbt(&text.component()); + // ServerPlayer.die: the message goes to everyone unless the + // player's team hides it, and always to the dying player. + if self.ui.rules.bool("show_death_messages") { + let team = self.score.board.players_team(&name); + if horizon_client::scoreboard::own_death_message_shown(team) { + host.hub.system_message(message.clone()); + if self.options.dedicated.is_some() { + super::dedicated::log( + "INFO", + &format!("System chat: {}", text.plain()), + ); + } else if self.player.is_some() { + self.system_message(text); + } + } + } host.hub.send_to( conn, &C::PlayerCombatKill(play::PlayerCombatKill { id, message }), @@ -1832,6 +1846,21 @@ impl Game<'_> { } } + /// `ServerPlayer.die`'s message from a guest's own `CombatTracker`, which + /// sits in `guest_combat` between turns. + fn guest_death_message( + &mut self, + conn: u32, + me: horizon_client::text::Text, + ) -> horizon_client::text::Text { + let mut tracker = self.guest_combat.remove(&conn).unwrap_or_default(); + std::mem::swap(&mut self.combat, &mut tracker); + let text = self.combat_death_message_for(me); + std::mem::swap(&mut self.combat, &mut tracker); + self.guest_combat.insert(conn, tracker); + text + } + /// `Player.tick`'s sleep counter, and `LivingEntity.tick`'s wake-up of a /// sleeper who died or whose bed is gone. fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) { @@ -2033,10 +2062,14 @@ impl Game<'_> { resources: game.motion, } .server_travel(); + // Landing damage from this travel reaches the tracker too. + events.append(&mut player.events); // A hurt since the last tick marked the velocity (Entity.markHurt): // ServerEntity.sendChanges sends it as it is after this travel. let marked = std::mem::take(&mut player.health.sync_velocity); - (events, marked.then_some(player.velocity)) + let velocity = marked.then_some(player.velocity); + game.record_combat(&events); + (events, velocity) }); let Some((events, marked)) = events else { return; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 044e43835..53f7ea186 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2285,12 +2285,10 @@ impl Game<'_> { } } C::PlayerCombatKill(p) => { - let lang = self - .remote - .as_ref() - .map(|r| r.lang.clone()) - .unwrap_or_default(); - let message = plain(&p.message, &lang); + // The component resolves in the client's current language, as + // the chat line does, not in the language the session joined + // with (`Remote.lang` is the vanilla server-language table). + let message = remote_text(&p.message).plain(); let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id); if let Some(r) = self.remote.as_mut() { r.death_message = Some(message); diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 91fe91b00..8084425ca 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -92,6 +92,8 @@ impl Game<'_> { match who { Some(Who::Player) => { if !self.creative { + // WitherSkull.onHitEntity's source: the wither launched it. + self.combat.projectile_owner = owner; self.mob_hits_player_kind( amount, [p.previous.x, p.previous.y, p.previous.z], From d9360338f655668351008f323e6181baf44ab7d0 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Sun, 25 Oct 2026 16:33:35 +0100 Subject: [PATCH 23/57] Show item lore in tooltips with vanilla purple italic styles. Stack tooltips skipped minecraft:lore entirely; draw the component lines as Text so colours and explicit non-italic spans survive. --- crates/horizon-client/src/game/beacon.rs | 7 +- crates/horizon-client/src/game/loom.rs | 9 +- crates/horizon-client/src/game/recipe_book.rs | 21 ++- crates/horizon-client/src/game/stations.rs | 134 ++++++++++++------ crates/horizon-client/src/item_tooltip.rs | 56 +++++++- 5 files changed, 167 insertions(+), 60 deletions(-) diff --git a/crates/horizon-client/src/game/beacon.rs b/crates/horizon-client/src/game/beacon.rs index c8a0bcba6..24ead3a3d 100644 --- a/crates/horizon-client/src/game/beacon.rs +++ b/crates/horizon-client/src/game/beacon.rs @@ -519,7 +519,12 @@ impl Game<'_> { self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); } else if let Some(key) = tooltip { let text = self.translate(&key, &key); - self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1); + self.draw_tooltip_lines( + batch, + &[(horizon_client::text::Text::literal(text), u32::MAX)], + self.cursor.0, + self.cursor.1, + ); } } /// `BeaconRenderer`: each section's rotating solid beam and its glow. diff --git a/crates/horizon-client/src/game/loom.rs b/crates/horizon-client/src/game/loom.rs index ef49c8cf1..3d69aaca6 100644 --- a/crates/horizon-client/src/game/loom.rs +++ b/crates/horizon-client/src/game/loom.rs @@ -218,7 +218,7 @@ impl Game<'_> { oy: f32, draws: &[ScreenDraw], background: bool, - ) -> Option> { + ) -> Option> { let atlas = &self.gui.atlas; let mut tooltip = None; let mut sheet: Option = None; @@ -261,7 +261,10 @@ impl Game<'_> { .. } => self.draw_banner_result(batch, ox + *x0 as f32, oy + *y0 as f32, base, layers), ScreenDraw::Tooltip { key } => { - tooltip = Some(vec![(self.translate(key, key), u32::MAX)]); + tooltip = Some(vec![( + horizon_client::text::Text::literal(self.translate(key, key)), + u32::MAX, + )]); } ScreenDraw::SpriteOf { id } => sheet = Some(id.clone()), ScreenDraw::Fill { @@ -314,7 +317,7 @@ impl Game<'_> { ox: f32, oy: f32, local: (f32, f32), - ) -> Option> { + ) -> Option> { let s = self.station.as_ref()?; let l = &s.loom; let dye = s.inputs[1] diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index d3c14ffc7..2536c3e03 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -1163,8 +1163,12 @@ impl Game<'_> { } /// Paints recipe book calls; returns the first tooltip set (vanilla keeps /// the first one a frame asks for). - fn paint_book(&mut self, batch: &mut HudBatch, calls: &[Drawn]) -> Option> { - let mut tooltip: Option> = None; + fn paint_book( + &mut self, + batch: &mut HudBatch, + calls: &[Drawn], + ) -> Option> { + let mut tooltip: Option> = None; for d in calls { let start = batch.vertices.len(); let atlas = &self.gui.atlas; @@ -1259,7 +1263,14 @@ impl Game<'_> { tooltip = Some( lines .iter() - .map(|l| (l.iter().map(|r| r.text.as_str()).collect(), u32::MAX)) + .map(|l| { + ( + horizon_client::text::Text::literal( + l.iter().map(|r| r.text.as_str()).collect::(), + ), + u32::MAX, + ) + }) .collect(), ); } @@ -1269,10 +1280,10 @@ impl Game<'_> { let mut lines = self.stack_tooltip(stack); if *more { lines.push(( - self.translate( + horizon_client::text::Text::literal(self.translate( "gui.recipebook.moreRecipes", "Right Click for More", - ), + )), u32::MAX, )); } diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index ffe56d699..fd7bd2642 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -1096,7 +1096,7 @@ impl Game<'_> { } let (ox, oy) = self.inventory_origin(batch); let local = (self.cursor.0 - ox, self.cursor.1 - oy); - let mut tooltip: Option> = None; + let mut tooltip: Option> = None; { let atlas = &self.gui.atlas; batch.fill( @@ -1345,16 +1345,19 @@ impl Game<'_> { let clue = self .translate("container.enchant.clue", "%s . . . ?") .replace("%s", &name); - let mut lines = vec![(clue, u32::MAX)]; + let mut lines = + vec![(horizon_client::text::Text::literal(clue), u32::MAX)]; if !self.creative { - lines.push((String::new(), u32::MAX)); + lines.push((horizon_client::text::Text::literal(""), u32::MAX)); if level < cost { lines.push(( - self.translate( - "container.enchant.level.requirement", - "Level Requirement: %s", - ) - .replace("%s", &cost.to_string()), + horizon_client::text::Text::literal( + self.translate( + "container.enchant.level.requirement", + "Level Requirement: %s", + ) + .replace("%s", &cost.to_string()), + ), rgba(255, 85, 85, 255), )); } else { @@ -1372,7 +1375,7 @@ impl Game<'_> { .replace("%s", &n.to_string()) }; lines.push(( - lapis, + horizon_client::text::Text::literal(lapis), if gold >= n as i32 { rgba(170, 170, 170, 255) } else { @@ -1391,7 +1394,10 @@ impl Game<'_> { ) .replace("%s", &n.to_string()) }; - lines.push((levels, rgba(170, 170, 170, 255))); + lines.push(( + horizon_client::text::Text::literal(levels), + rgba(170, 170, 170, 255), + )); } } tooltip = Some(lines); @@ -1500,10 +1506,10 @@ impl Game<'_> { ); if inside(local, 65.0, 46.0, 28.0, 21.0) { tooltip = Some(vec![( - self.translate( + horizon_client::text::Text::literal(self.translate( "container.upgrade.error_tooltip", "Item can't be upgraded this way", - ), + )), u32::MAX, )]); } @@ -1630,9 +1636,12 @@ impl Game<'_> { // ---------------------------------------------------------------- tooltips - /// `ItemStack.getTooltipLines` subset: name in rarity colour, potion - /// effects, enchantments (gray, curses red), stored enchantments, trims. - pub(super) fn stack_tooltip(&self, stack: &ItemStack) -> Vec<(String, u32)> { + /// `ItemStack.getTooltipLines` subset: name in rarity colour, lore, + /// potion effects, enchantments (gray, curses red), stored enchantments, trims. + pub(super) fn stack_tooltip( + &self, + stack: &ItemStack, + ) -> Vec<(horizon_client::text::Text, u32)> { let e: &Enchantments = &self.res.stations().enchantments; let props = e.props(&stack.item); let rarity = horizon_client::item_tooltip::rarity( @@ -1663,24 +1672,33 @@ impl Game<'_> { let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack); let mut lines = Vec::new(); if display.name_visible(self.screen == Screen::Creative) { - lines.push((name, color)); + lines.push((horizon_client::text::Text::legacy(&name), color)); + } + // ItemLore.addToTooltip: after the name, before other component lines. + if display.shows("minecraft:lore") { + for text in horizon_client::item_tooltip::lore_texts(stack) { + lines.push((text, u32::MAX)); + } } let translate = |k: &str| e.translate(k).map(str::to_owned); + let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| { + lines.push((horizon_client::text::Text::legacy(&s), c)); + }; // ItemContainerContents.addToTooltip. let hover_name = |s: &ItemStack| { s.custom_name() .map_or_else(|| self.pack.names.item(&s.item), str::to_owned) }; if display.shows("minecraft:container") { - for line in + for text in horizon_client::item_tooltip::container_lines(stack, &hover_name, &translate) { - lines.push((line, u32::MAX)); + push(&mut lines, text, u32::MAX); } } // Fireworks / FireworkExplosion.addToTooltip. - for line in horizon_client::item_tooltip::firework_lines(stack, &translate) { - lines.push((line, rgba(170, 170, 170, 255))); + for text in horizon_client::item_tooltip::firework_lines(stack, &translate) { + push(&mut lines, text, rgba(170, 170, 170, 255)); } if display.shows("minecraft:potion_contents") && (stack.item.ends_with("potion") || &*stack.item == "minecraft:tipped_arrow") @@ -1690,7 +1708,7 @@ impl Game<'_> { "minecraft:tipped_arrow" => 0.125, _ => 1.0, }; - for (line, cat) in brewing::tooltip(stack.potion(), scale, &|k| { + for (text, cat) in brewing::tooltip(stack.potion(), scale, &|k| { e.translate(k).map(str::to_owned) }) { let c = match cat { @@ -1698,18 +1716,19 @@ impl Game<'_> { brewing::Category::Harmful => rgba(255, 85, 85, 255), brewing::Category::Neutral => rgba(170, 170, 170, 255), }; - lines.push((line, c)); + push(&mut lines, text, c); } } if display.shows("minecraft:trim") && let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone()) { - lines.push(( + push( + &mut lines, e.translate("item.minecraft.smithing_template.upgrade") .unwrap_or("Upgrade:") .to_owned(), rgba(170, 170, 170, 255), - )); + ); let short = |s: &str| s.trim_start_matches("minecraft:").to_owned(); let p = e .translate(&format!("trim_pattern.minecraft.{}", short(&pattern))) @@ -1717,38 +1736,44 @@ impl Game<'_> { let m = e .translate(&format!("trim_material.minecraft.{}", short(&material))) .map_or_else(|| short(&material), str::to_owned); - lines.push((format!(" {p}"), rgba(170, 170, 170, 255))); - lines.push((format!(" {m}"), rgba(170, 170, 170, 255))); + push(&mut lines, format!(" {p}"), rgba(170, 170, 170, 255)); + push(&mut lines, format!(" {m}"), rgba(170, 170, 170, 255)); } if display.shows("minecraft:stored_enchantments") { for (text, curse) in e.tooltip(stack.stored_enchantments()) { - lines.push(( + push( + &mut lines, text, if curse { rgba(255, 85, 85, 255) } else { rgba(170, 170, 170, 255) }, - )); + ); } } if display.shows("minecraft:enchantments") { for (text, curse) in e.tooltip(stack.enchantments()) { - lines.push(( + push( + &mut lines, text, if curse { rgba(255, 85, 85, 255) } else { rgba(170, 170, 170, 255) }, - )); + ); } } // BannerPatternLayers.addToTooltip: up to six layer names. if display.shows("minecraft:banner_patterns") { for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) { let key = self.res.stations().banners.layer_key(layer); - lines.push((self.translate(&key, &key), rgba(170, 170, 170, 255))); + push( + &mut lines, + self.translate(&key, &key), + rgba(170, 170, 170, 255), + ); } } // Spawner.appendHoverText: what a spawner or trial spawner item spawns. @@ -1762,15 +1787,15 @@ impl Game<'_> { match block_entity_item::spawner_entity(stack) { Some(id) => { let key = format!("entity.{}", id.replacen(':', ".", 1)); - lines.push((self.translate(&key, &id), gray)); + push(&mut lines, self.translate(&key, &id), gray); } None => { - lines.push((String::new(), u32::MAX)); + push(&mut lines, String::new(), u32::MAX); let desc1 = self.translate("block.minecraft.spawner.desc1", "Interact with Spawn Egg:"); - lines.push((desc1, gray)); + push(&mut lines, desc1, gray); let desc2 = self.translate("block.minecraft.spawner.desc2", "Sets Mob Type"); - lines.push((format!(" {desc2}"), rgba(85, 85, 255, 255))); + push(&mut lines, format!(" {desc2}"), rgba(85, 85, 255, 255)); } } } @@ -1798,11 +1823,13 @@ impl Game<'_> { horizon_client::attack_strength::BASE_ATTACK_SPEED, ), ]; - lines.extend(horizon_client::item_tooltip::attribute_lines( + for (text, c) in horizon_client::item_tooltip::attribute_lines( &info.modifiers, Some(player_base), &translate, - )); + ) { + push(&mut lines, text, c); + } } if display.shows("minecraft:damage") && let Some(info) = self.res.items().get(&stack.item) @@ -1810,14 +1837,15 @@ impl Game<'_> { && stack.damage > 0 && self.options_advanced_tooltips() { - lines.push(( + push( + &mut lines, format!( "Durability: {} / {}", info.max_damage - stack.damage, info.max_damage ), u32::MAX, - )); + ); } // ItemStack.OP_NBT_WARNING, for a player with game master permission. if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) { @@ -1832,7 +1860,11 @@ impl Game<'_> { "Do not use unless you know the exact contents!", ), ] { - lines.push((self.translate(key, fallback), rgba(255, 85, 85, 255))); + push( + &mut lines, + self.translate(key, fallback), + rgba(255, 85, 85, 255), + ); } } lines @@ -1863,20 +1895,27 @@ impl Game<'_> { self.settings.file.bool("advancedItemTooltips") } /// `TooltipRenderUtil`: dark box with the purple border, first line spaced. - pub(super) fn draw_tooltip_lines( + pub(super) fn draw_tooltip_lines>( &self, batch: &mut HudBatch, - lines: &[(String, u32)], + lines: &[(T, u32)], x: f32, y: f32, ) { if lines.is_empty() { return; } + let lines: Vec<_> = lines + .iter() + .map(|(text, color)| { + let text: horizon_client::text::Text = text.clone().into(); + (text.resolve_legacy(), *color) + }) + .collect(); let atlas = &self.gui.atlas; let w = lines .iter() - .map(|(t, _)| HudBatch::text_width(atlas, t)) + .map(|(t, _)| atlas.font.width_text(t)) .fold(0.0, f32::max); let h = if lines.len() == 1 { 8.0 @@ -1929,7 +1968,7 @@ impl Game<'_> { ); let mut ty = y; for (i, (text, color)) in lines.iter().enumerate() { - batch.text(atlas, text, x, ty, *color, true); + batch.text_styled(atlas, text, x, ty, *color, true); ty += if i == 0 { 12.0 } else { 10.0 }; } } @@ -1946,7 +1985,12 @@ impl Game<'_> { let lines = self.stack_tooltip(&stack); self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); } else if let Some(text) = self.crafter_tooltip(slot) { - self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1); + self.draw_tooltip_lines( + batch, + &[(horizon_client::text::Text::literal(text), u32::MAX)], + self.cursor.0, + self.cursor.1, + ); } } diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index ca6ff481b..6e51de391 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -487,23 +487,31 @@ pub fn lines( ) -> Vec { let mut lines = vec![name_line(stack, translate)]; if TooltipDisplay::for_stack(stack).shows("minecraft:lore") { - lines.extend(lore_lines(stack)); + lines.extend(lore_texts(stack).into_iter().map(|t| t.plain())); } lines.extend(detail_lines(stack, info, translate)); lines } -/// `LoreComponent.addToTooltip`: the saved component lines, as plain text. -fn lore_lines(stack: &ItemStack) -> Vec { +/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with +/// the component's own styles (including explicit `false`) winning on children. +pub fn lore_texts(stack: &ItemStack) -> Vec { let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else { return Vec::new(); }; + let base = crate::text::Style { + color: Some(crate::text::DARK_PURPLE), + italic: Some(true), + ..Default::default() + }; entries .iter() .map(|entry| { - let json = crate::scoreboard::tag_to_json(entry); - let text = crate::component::to_text(&json, &mut |s| crate::text::Text::literal(s)); - text.plain() + let mut text = crate::entity_nbt::name_text(entry).resolve_legacy(); + for span in &mut text.spans { + span.style.inherit(&base); + } + text }) .collect() } @@ -512,6 +520,42 @@ fn lore_lines(stack: &ItemStack) -> Vec { mod tests { use super::*; + #[test] + fn lore_keeps_component_colours_under_dark_purple_italic() { + let mut stack = ItemStack::new("minecraft:stick", 1); + stack.data_mut().set_other( + "minecraft:lore", + Some(Tag::List(vec![ + Tag::compound([ + ("text", Tag::String("Gold line".into())), + ("color", Tag::String("gold".into())), + ("italic", Tag::Byte(0)), + ]), + Tag::String("Plain lore".into()), + ])), + ); + let lines = lore_texts(&stack); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].plain(), "Gold line"); + assert_eq!(lines[0].spans[0].style.color, Some(crate::text::GOLD)); + assert!(!lines[0].spans[0].style.is_italic()); + assert_eq!(lines[1].plain(), "Plain lore"); + assert_eq!( + lines[1].spans[0].style.color, + Some(crate::text::DARK_PURPLE) + ); + assert!(lines[1].spans[0].style.is_italic()); + assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore")); + stack.data_mut().set_other( + "minecraft:tooltip_display", + Some(Tag::compound([( + "hidden_components", + Tag::List(vec![Tag::String("minecraft:lore".into())]), + )])), + ); + assert!(!TooltipDisplay::for_stack(&stack).shows("minecraft:lore")); + } + #[test] fn tooltip_display_controls_name_and_component_visibility() { let mut stack = ItemStack::new("minecraft:diamond_sword", 1); From af7a685b5c5871764b9c7b16d948b04dd2cdb867 Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 17:09:25 +0100 Subject: [PATCH 24/57] =?UTF-8?q?Mod=20API:=20capability=20`ui`=20?= =?UTF-8?q?=E2=80=94=20mod-owned=20screens=20and=20settings=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New WIT interface `ui` (text, item and list widgets; open-screen, close-screen, set-settings) plus a `ui-events` export (select, closed, settings-opened), in a second world `horizon-ui-mod` so mods built before it keep loading. The game lays out and draws the widgets with its own menus' code: a screen the mod owns (Esc/Done to leave) and a Settings button on the mod's Options > Mods row that opens its page. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 2 +- Cargo.lock | 7 + Cargo.toml | 2 +- crates/horizon-client/src/game.rs | 11 + crates/horizon-client/src/game/agent.rs | 1 + crates/horizon-client/src/game/control.rs | 1 + crates/horizon-client/src/game/mods.rs | 336 +++++++++++++++++- crates/horizon-client/src/game/options.rs | 1 + crates/horizon-client/src/game/screens.rs | 3 +- .../horizon-client/src/game/startup_menu.rs | 2 + crates/horizon-client/src/mod_list.rs | 4 + crates/horizon-client/src/options_ui.rs | 40 ++- crates/horizon-mod-sdk/src/lib.rs | 36 +- crates/horizon-mod-sdk/wit/horizon.wit | 92 +++++ crates/horizon-mods/src/manifest.rs | 9 +- crates/horizon-mods/src/runtime.rs | 200 ++++++++++- crates/horizon-mods/tests/sandbox.rs | 49 +++ crates/horizon-mods/tests/ui_guest/Cargo.toml | 16 + crates/horizon-mods/tests/ui_guest/src/lib.rs | 66 ++++ docs/api/mods-reference.md | 88 +++++ docs/mods.md | 4 +- 21 files changed, 936 insertions(+), 34 deletions(-) create mode 100644 crates/horizon-mods/tests/ui_guest/Cargo.toml create mode 100644 crates/horizon-mods/tests/ui_guest/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index a72c6723a..2d883b33b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end ~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it. - Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`). - **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined - `horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`. + `horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`. Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`). - Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build). - Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game): diff --git a/Cargo.lock b/Cargo.lock index 70c3c831c..6945e09cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1352,6 +1352,13 @@ dependencies = [ "horizon-mod-sdk", ] +[[package]] +name = "horizon-mods-test-ui-guest" +version = "0.1.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "horizon-port-pack" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 294e93aa9..4076b9079 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"] resolver = "2" [workspace.package] diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 699468651..eabbc22b8 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -482,6 +482,9 @@ enum Screen { CommandBlock, /// WinScreen: the end poem and the credits (game/win.rs). Win, + /// A `ui` mod's own screen, or its settings page from Options > Mods + /// (game/mods.rs). + Mod, } struct Mining { pos: [i32; 3], @@ -5047,6 +5050,7 @@ impl Game<'_> { self.draw_creative(&mut batch); } Screen::Chat => self.draw_chat_screen(&mut batch), + Screen::Mod => self.draw_mod_screen(&mut batch), Screen::Playing | Screen::Options | Screen::Win => {} } self.draw_toasts(&mut batch); @@ -10363,6 +10367,7 @@ impl Game<'_> { Screen::CommandBlock => self.close_command_block(), Screen::Chat => self.close_chat(), Screen::Win => self.close_win_screen()?, + Screen::Mod => self.close_mod_screen(true), }, KeyCode::Digit1 | KeyCode::Digit2 @@ -11009,6 +11014,9 @@ impl ApplicationHandler for Game<'_> { if self.screen == Screen::Options && d != 0.0 { self.options_scroll(d); } + if self.screen == Screen::Mod && d != 0.0 { + self.mod_screen_scroll(d); + } if self.screen == Screen::Creative && d != 0.0 { self.creative_screen.wheel(f64::from(d), &self.pack.names); } @@ -11231,6 +11239,9 @@ impl ApplicationHandler for Game<'_> { .map_or([1, 1], |r| [r.config.width, r.config.height]); self.stats_click(size); } + Screen::Mod if pressed && button == MouseButton::Left => { + self.mod_screen_click() + } _ => {} } } diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index f6b68aa74..bed331c32 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -416,6 +416,7 @@ impl Game<'_> { Screen::Friends => "friends", Screen::CommandBlock => "command-block", Screen::Win => "win", + Screen::Mod => "mod", } } pub(super) fn inventory_counts(&self) -> BTreeMap { diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index a0d8987d8..ffe05b518 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -799,6 +799,7 @@ impl Game<'_> { Screen::Friends => "friends", Screen::CommandBlock => "command-block", Screen::Win => "win", + Screen::Mod => "mod", } } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 572f9fe2b..688e29da1 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -20,10 +20,13 @@ use { horizon_client::{ extras::Corner, extras_hud, + items::ItemStack, mod_list::Installed, + options_ui::Page, text::{self, Text}, + ui, }, - horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status}, + horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget}, std::time::Instant, }; @@ -39,6 +42,23 @@ pub(super) struct ModsState { /// The chat's command tree while a running mod owns commands. #[cfg(feature = "mods")] tree: Option, + /// A `ui` mod's screen or settings page while it is up. + #[cfg(feature = "mods")] + screen: Option, +} + +/// A `ui` mod's own screen, or its settings page from Options > Mods: the +/// widgets the mod pushed, laid out and drawn with the menus' own widgets. +#[cfg(feature = "mods")] +struct ModScreen { + /// The running mod's index in `mods.mods.entries()`. + owner: usize, + /// Opened from Options > Mods: Done and Esc return there. + settings: bool, + /// What the mod's screen replaced (Playing, Chat, ...), to restore. + under: Screen, + /// One scroll per `list` widget, in widget order. + scrolls: Vec, } impl ModsState { /// The chat's command tree: vanilla's, plus the running mods' commands. @@ -166,6 +186,11 @@ impl Game<'_> { } pub(super) fn mods_chat(&mut self, _line: &str) {} pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {} + pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {} + pub(super) fn close_mod_screen(&mut self, _tell: bool) {} + pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {} + pub(super) fn mod_screen_click(&mut self) {} + pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {} } #[cfg(feature = "mods")] @@ -187,6 +212,8 @@ impl Game<'_> { let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id); let mods = Mods::load(&paths, on, vanilla_command); self.mods.mods = mods; + // Entries were replaced wholesale: a screen's owner index is stale. + self.mods.screen = None; self.list_mods(); self.sync_mod_commands(); self.show_mod_output(); @@ -212,6 +239,7 @@ impl Game<'_> { description: String::new(), problem, dev: false, + has_settings: false, }; }; // What it may do first: the row cuts long descriptions. @@ -227,6 +255,7 @@ impl Game<'_> { description: format!("{may} {}", m.description).trim().to_owned(), problem, dev: dev.contains(&e.path), + has_settings: e.has_ui_settings(), } }) .collect(); @@ -343,10 +372,22 @@ impl Game<'_> { Output::Feedback { text, error: false } => Text::literal(text), Output::Feedback { text, error: true } => Text::literal(text).with_color(text::RED), Output::Stopped { name, reason } => { + // Its screen goes with it: the entry's run is gone, so + // `mod_ui_view` finds no widgets and the screen closes. self.list_mods(); self.sync_mod_commands(); Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED) } + Output::OpenScreen { owner } => { + self.open_mod_screen(owner, false); + continue; + } + Output::CloseScreen { owner } => { + if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) { + self.close_mod_screen(false); + } + continue; + } }; // Not through system_message: a mod must not hear its own lines. let focused = self.screen == Screen::Chat; @@ -376,4 +417,297 @@ impl Game<'_> { extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines); } } + + /// A `ui` screen's title and widgets as the mod last pushed them; None + /// when the surface is gone (the mod stopped or closed it). + fn mod_ui_view(&self) -> Option<(String, Vec)> { + let ms = self.mods.screen.as_ref()?; + if ms.settings { + let widgets = self.mods.mods.settings_widgets(ms.owner); + if widgets.is_empty() { + return None; + } + let name = self + .mods + .mods + .entries() + .get(ms.owner) + .and_then(|e| e.manifest.as_ref()) + .map_or("Mod", |m| m.name.as_str()); + Some((name.to_string(), widgets.to_vec())) + } else { + let s: &UiScreen = self.mods.mods.screen(ms.owner)?; + Some((s.title.clone(), s.widgets.clone())) + } + } + /// The layout the mod screen's widgets get at `h`: (widget, top, + /// height), the column being `COLUMN` wide down the middle. + fn mod_ui_layout(&self, h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> { + let mut out = Vec::with_capacity(widgets.len()); + let mut y = MOD_UI_TOP; + for w in widgets { + let wh = match w { + UiWidget::Text(_) => 12.0, + UiWidget::Item(_) => 20.0, + UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0, + }; + // Nothing may cover Done; a crowded mod loses what won't fit. + if y + wh > h - 34.0 { + break; + } + out.push((y, wh)); + y += wh + 6.0; + } + out + } + /// A mod pushes its screen (`ui.open-screen`), or Options > Mods opens + /// its settings page (`settings`, the mod's `installed` index). + pub(super) fn open_mod_screen(&mut self, owner: usize, settings: bool) { + if self.title.is_some() { + return; + } + if settings { + self.mods.mods.ui_settings_opened(owner); + if self.mods.mods.settings_widgets(owner).is_empty() { + self.show_mod_output(); + return; + } + } + self.mods.screen = Some(ModScreen { + owner, + settings, + under: self.screen, + scrolls: Vec::new(), + }); + self.screen = Screen::Mod; + if self.player.is_some() { + self.capture(false); + } + self.show_mod_output(); + } + /// Leaves the mod screen: back to Options for a settings page, to what + /// it covered for a mod's own. `tell` is Esc/Done, heard as `closed`. + pub(super) fn close_mod_screen(&mut self, tell: bool) { + let Some(ms) = self.mods.screen.take() else { + return; + }; + if ms.settings { + self.open_options_at(self.options_from_title, Page::Mods); + } else { + self.screen = ms.under; + if self.screen == Screen::Playing && self.player.is_some() { + self.capture(true); + } + } + if tell { + self.mods.mods.ui_closed(ms.owner); + self.show_mod_output(); + } + } + /// The mod's screen or settings page: its title over widgets stacked + /// down the middle, a Done button, Esc to leave (vanilla's menus). + pub(super) fn draw_mod_screen(&mut self, batch: &mut HudBatch) { + if self.mods.screen.is_none() { + self.screen = Screen::Playing; + return; + } + let Some((title, widgets)) = self.mod_ui_view() else { + self.mods.screen = None; + self.screen = Screen::Playing; + return; + }; + // Item icons draw in a second pass: `draw_item` needs `&mut self`, + // so `atlas` may not stay borrowed across it. + let mut items: Vec<(String, f32, f32)> = Vec::new(); + let atlas = &self.gui.atlas; + let in_world = self.player.is_some(); + ui::menu_background(batch, &atlas, 0.0, 0.0, batch.width, batch.height, in_world); + ui::centered_text( + batch, + &atlas, + &Text::literal(title), + 0.0, + 15.0, + batch.width, + u32::MAX, + ); + let cx = (batch.width / 2.0).floor(); + let x = cx - MOD_UI_COLUMN / 2.0; + let layout = self.mod_ui_layout(batch.height, &widgets); + let mut list_i = 0usize; + for (w, &(wy, wh)) in widgets.iter().zip(&layout) { + match w { + UiWidget::Text(t) => { + let color = if t.color == 0 { + u32::MAX + } else { + t.color | 0xFF00_0000 + }; + ui::centered_text( + batch, + &atlas, + &Text::literal(t.text.clone()), + x, + wy + 1.0, + MOD_UI_COLUMN, + color, + ); + } + UiWidget::Item(i) => { + if self.res.items().get(&i.item).is_some() { + items.push((i.item.clone(), x + 2.0, wy + 2.0)); + } else { + batch.text(&atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true); + } + let name = if i.name.is_empty() { + i.item.rsplit(':').next().unwrap_or(&i.item).to_string() + } else { + i.name.clone() + }; + batch.text_styled( + &atlas, + &Text::literal(name), + x + 22.0, + wy + 6.0, + u32::MAX, + true, + ); + } + UiWidget::List(l) => { + let scroll = { + let ms = self.mods.screen.as_mut().expect("checked"); + ms.scrolls.resize_with(list_i + 1, ui::Scroll::default); + ms.scrolls[list_i] + }; + list_i += 1; + let rows_top = wy + 4.0; + let rows_bottom = wy + wh - 4.0; + batch.fill(&atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000); + for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] { + batch.fill(&atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050); + } + for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] { + batch.fill(&atlas, xx, yy, 1.0, wh, 0xFF50_5050); + } + let content = l.rows.len() as f32 * 12.0; + let first = (scroll.amount / 12.0) as usize; + let last = (first + ((rows_bottom - rows_top) / 12.0).ceil() as usize + 1) + .min(l.rows.len()); + for (vi, ri) in (first..last).enumerate() { + let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0); + if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) { + batch.fill(&atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF); + } + if l.selected == Some(ri as u32) { + batch.fill(&atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF); + } + batch.text_styled( + &atlas, + &Text::literal(l.rows[ri].clone()), + x + 6.0, + ry + 2.0, + u32::MAX, + true, + ); + } + ui::scrollbar( + batch, + &atlas, + x + MOD_UI_COLUMN - 5.0, + rows_top, + rows_bottom, + content, + scroll.amount, + ); + } + } + } + let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0); + ui::button( + batch, + &atlas, + cx - 100.0, + batch.height - 27.0, + 200.0, + 20.0, + &Text::literal("Done"), + true, + hover, + ); + for (id, ix, iy) in items { + let stack = ItemStack::new(id.as_str(), 1); + self.draw_item(batch, &stack, ix, iy); + } + } + /// A click on the mod screen: Done leaves it, a `list` row is the mod's + /// `ui-events::select`. + pub(super) fn mod_screen_click(&mut self) { + let (w, h) = self.gui_size(); + let cursor = self.cursor; + if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) { + self.audio.play_button_click(); + self.close_mod_screen(true); + return; + } + let Some((_, widgets)) = self.mod_ui_view() else { + return; + }; + let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0; + let layout = self.mod_ui_layout(h, &widgets); + let mut list_i = 0usize; + for (w, &(wy, wh)) in widgets.iter().zip(&layout) { + let UiWidget::List(l) = w else { continue }; + let scroll = self + .mods + .screen + .as_ref() + .and_then(|ms| ms.scrolls.get(list_i).copied()) + .unwrap_or_default(); + list_i += 1; + let row = ((cursor.1 - (wy + 4.0) + scroll.amount) / 12.0) as i64; + if ui::hovered(cursor, x + 1.0, wy + 1.0, MOD_UI_COLUMN - 7.0, wh - 2.0) + && row >= 0 + && (row as usize) < l.rows.len() + { + self.audio.play_button_click(); + self.mods.mods.ui_select( + self.mods.screen.as_ref().map_or(0, |ms| ms.owner), + l.id, + row as u32, + ); + self.show_mod_output(); + return; + } + } + } + /// The wheel over a `list` scrolls it. + pub(super) fn mod_screen_scroll(&mut self, delta: f32) { + let (w, h) = self.gui_size(); + let cursor = self.cursor; + let Some((_, widgets)) = self.mod_ui_view() else { + return; + }; + let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0; + let layout = self.mod_ui_layout(h, &widgets); + let mut list_i = 0usize; + for (w, &(wy, wh)) in widgets.iter().zip(&layout) { + let UiWidget::List(l) = w else { continue }; + let over = ui::hovered(cursor, x, wy, MOD_UI_COLUMN, wh); + let Some(ms) = self.mods.screen.as_mut() else { + return; + }; + ms.scrolls.resize_with(list_i + 1, ui::Scroll::default); + if over { + let content = l.rows.len() as f32 * 12.0; + ms.scrolls[list_i].wheel(delta, 12.0, wy + 4.0, wy + wh - 4.0, content); + return; + } + list_i += 1; + } + } } + +#[cfg(feature = "mods")] +const MOD_UI_TOP: f32 = 40.0; +#[cfg(feature = "mods")] +const MOD_UI_COLUMN: f32 = 200.0; diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index d947f91d0..812c9ce07 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -237,6 +237,7 @@ impl Game<'_> { let _ = std::fs::create_dir_all(&dir); super::packs::open_folder(&dir); } + Event::ModSettings(i) => self.open_mod_screen(i, true), Event::Credits => self.open_win_screen(false, super::win::Finish::Options), } } diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index cf1f3269f..44f3fbd51 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -17,7 +17,8 @@ impl Screen { | Self::Stats | Self::Advancements | Self::WorldOptions - | Self::Friends => true, + | Self::Friends + | Self::Mod => true, Self::Playing | Self::GameModeSwitcher | Self::Chat diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index 96af99bbc..94771f9ac 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -381,6 +381,8 @@ impl Loading { self.menu.win = Some((screen, Instant::now())); } } + // Mods' settings pages open only in a running game. + Event::ModSettings(_) => {} // The startup menu runs before the game opens a control socket. Event::ButtonClick | Event::CopyAgentDetails => {} } diff --git a/crates/horizon-client/src/mod_list.rs b/crates/horizon-client/src/mod_list.rs index ba3d93cda..db2ea9194 100644 --- a/crates/horizon-client/src/mod_list.rs +++ b/crates/horizon-client/src/mod_list.rs @@ -26,6 +26,9 @@ pub struct Installed { pub problem: Option, /// Loaded with `--dev-mod`: always on, reloaded when rebuilt. pub dev: bool, + /// The running mod set a page of widgets (capability `ui`): its row + /// gains a Settings button. + pub has_settings: bool, } impl ModList { @@ -96,6 +99,7 @@ mod tests { description: String::new(), problem: None, dev, + has_settings: false, }; let mut list = ModList { installed: vec![installed("a", false), installed("b", true)], diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 0bc1e933d..1d1f099eb 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -893,6 +893,9 @@ enum Act { ResetPadKey(&'static str), ResetPadKeys, ToggleMod(usize), + /// The installed mod's own settings page (capability `ui`), shown as a + /// screen: `Event::ModSettings` has the game open it. + ModSettings(usize), OpenModFolder, /// Credits & Attribution's Credits button (WinScreen without the poem). Credits, @@ -944,6 +947,9 @@ pub enum Event { Packs(Vec), OpenPackFolder, OpenModFolder, + /// Open the mod's own settings page (capability `ui`): the installed + /// mod's index in `mods.installed`. + ModSettings(usize), /// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`). Credits, /// Copy how an agent connects (the AI Agent page). @@ -1503,15 +1509,26 @@ impl OptionsUi { } else if installed.dev { label = label.append(" (Dev)"); } - let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i)); - Row::Buttons( - if installed.dev || installed.id.is_empty() { - inactive(widget) - } else { - widget - }, - None, - ) + let dead = installed.dev || installed.id.is_empty(); + // A running mod with a settings page (capability + // `ui`) gets its own button beside the toggle. + if installed.has_settings { + let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i)); + let settings = button( + 0.0, + 0.0, + 150.0, + Text::literal("Settings"), + Act::ModSettings(i), + ); + Row::Buttons( + if dead { inactive(toggle) } else { toggle }, + Some(settings), + ) + } else { + let widget = button(0.0, 0.0, 310.0, label, Act::ToggleMod(i)); + Row::Buttons(if dead { inactive(widget) } else { widget }, None) + } }) .collect() } @@ -2498,6 +2515,10 @@ impl OptionsUi { s.mods.toggle(i); events.push(Event::Changed); } + Act::ModSettings(i) => { + events.push(Event::ButtonClick); + events.push(Event::ModSettings(i)); + } Act::OpenModFolder => events.push(Event::OpenModFolder), Act::Credits => events.push(Event::Credits), Act::CopyAgentDetails => events.push(Event::CopyAgentDetails), @@ -2988,6 +3009,7 @@ mod tests { description: "May show HUD panels.".into(), problem: id.is_empty().then(|| "Invalid package".into()), dev, + has_settings: false, }); } let (widgets, ..) = ui.layout(&s, size.0, size.1); diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index 71c36cf80..aea52339f 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -21,18 +21,24 @@ //! ``` //! //! Every interface but `log` is a capability: list the ones the mod uses in -//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`). +//! `horizon-mod.toml` (`game-read`, `chat`, `hud`, `storage`, `ui`). +//! +//! Widgets (capability `ui`): a mod shows a screen it owns with +//! [`ui::open_screen`] and a page in Options > Mods with +//! [`ui::set_settings`]; what the player does with them comes back as +//! [`Mod::ui_select`], [`Mod::ui_closed`] and [`Mod::ui_settings_opened`]. wit_bindgen::generate!({ path: "wit", - world: "horizon-mod", + world: "horizon-ui-mod", pub_export_macro: true, export_macro_name: "__export_horizon_mod", default_bindings_module: "horizon_mod_sdk", }); pub use exports::horizon::mods::events::{Guest as __Guest, Registration}; -pub use horizon::mods::{chat, game, hud, log, storage}; +pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest; +pub use horizon::mods::{chat, game, hud, log, storage, ui}; /// The game's calls into a mod. Only the hooks [`Registration`] asks for /// are called; the others may stay as their defaults. @@ -51,6 +57,14 @@ pub trait Mod: Sized + 'static { } /// A chat line the player received, when [`Registration::chat`] is set. fn chat(&mut self, _text: &str) {} + /// A row of one of the mod's `list` widgets was picked (capability + /// `ui`): the widget's `id` and the row. + fn ui_select(&mut self, _id: u32, _row: u32) {} + /// The player left the mod's screen (Esc or Done; capability `ui`). + fn ui_closed(&mut self) {} + /// The player opened the mod's settings page (capability `ui`): call + /// [`ui::set_settings`] for fresh widgets. + fn ui_settings_opened(&mut self) {} } impl Registration { @@ -135,13 +149,27 @@ macro_rules! export_mod { with(|m| $crate::Mod::chat(m, &text)) } } + impl $crate::__UiEventsGuest for Export { + fn select(id: u32, row: u32) { + with(|m| $crate::Mod::ui_select(m, id, row)) + } + fn closed() { + with(|m| $crate::Mod::ui_closed(m)) + } + fn settings_opened() { + with(|m| $crate::Mod::ui_settings_opened(m)) + } + } // Only WebAssembly has the component's exports: a host build (cargo test or // clippy on the workspace) checks the mod without them, which macOS // couldn't link (its exported-symbols list cuts names at the `#`). #[cfg(target_arch = "wasm32")] $crate::__export_horizon_mod!(Export); #[cfg(not(target_arch = "wasm32"))] - let _ = ::init; + let _ = ( + ::init, + ::select, + ); }; }; } diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 9f00a12b1..1e49919c3 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -86,6 +86,83 @@ interface storage { delete: func(key: string); } +/// Capability `ui`: widgets the game draws with its own menus' widgets, for +/// a screen the mod owns and a settings page in Options > Mods. Layout and +/// drawing are the game's: a mod says what its widgets hold, never where +/// they sit or how they look, and the game clips everything it is handed. +interface ui { + /// A line of text. + record text-widget { + /// The mod's own name for the widget; `ui-events` calls echo it. + id: u32, + /// Up to 256 characters. + text: string, + /// 0xRRGGBB; 0 is the menus' white. + color: u32, + } + /// An item's icon and a name beside it, as recipe viewers show them. + record item-widget { + /// The mod's own name for the widget. + id: u32, + /// `minecraft:diamond`, `:`; an unknown id draws a "?". + item: string, + /// The name shown beside the icon (empty: the item's own). + name: string, + } + /// Rows the player picks one of, as an options page lists choices: a + /// pick is `ui-events::select`, `selected` is the row to mark now. + record list-widget { + /// The mod's own name for the widget; `ui-events::select` echoes it. + id: u32, + /// The rows' texts, at most 512, each up to 256 characters. + rows: list, + /// The row to mark as the current choice, if any. + selected: option, + } + /// A thing on a mod's screen or settings page. + variant widget { + /// Text the player reads. + text(text-widget), + /// An item's icon and name. + item(item-widget), + /// Rows the player picks from. + %list(list-widget), + } + /// A screen the mod owns: a title over widgets stacked down the middle, + /// the Done button the game adds, and Esc to leave it. + record screen { + /// Its title, up to 256 characters. + title: string, + /// Its widgets, in order, at most 64. + widgets: list, + } + /// Show `screen` over the game; a second call while it is open replaces + /// it. Only the last call of one hook counts. + open-screen: func(screen: screen); + /// Close the mod's screen when it is open (the mod hears no `closed` + /// for this: it was its own doing). + close-screen: func(); + /// The mod's page in Options > Mods, reached from a settings button on + /// its row: the widgets it shows, an empty list for no page. The player + /// opening the page is `ui-events::settings-opened`: push fresh widgets + /// then; later calls update the page while it shows. + set-settings: func(widgets: list); +} + +/// What the game calls on a mod that shows widgets (capability `ui`): +/// what the player did with them. +interface ui-events { + /// A row of one of the mod's `list` widgets was picked: the widget's + /// `id` and the row, on the screen or the settings page that showed it. + select: func(id: u32, row: u32); + /// The player left the mod's screen (Esc or Done); not for a screen the + /// mod closed itself or one that went away with the mod. + closed: func(); + /// The player opened the mod's settings page in Options > Mods: call + /// `ui::set-settings` now to fill it with fresh widgets. + settings-opened: func(); +} + /// What the game calls. Only the hooks `init` asks for are ever called. interface events { record registration { @@ -119,3 +196,18 @@ world horizon-mod { import storage; export events; } + +/// A mod with widgets (capability `ui`): the SDK builds every mod against +/// this world. Mods built for `horizon-mod` alone (no `ui-events` export) +/// still load and simply hear nothing of their widgets. +world horizon-ui-mod { + include horizon-mod; + import ui; + export ui-events; +} + +/// The game's side of `horizon-ui-mod` beyond `horizon-mod`. +world ui-host { + import ui; + export ui-events; +} diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 3a93b2512..f1ac123b1 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -75,14 +75,18 @@ pub enum Capability { /// New blocks and items from the mod's `data/` (API 0.2): no WIT /// interface, the game reads them before the world loads. Registries, + /// A screen the mod owns and a settings page in Options > Mods, drawn + /// with the menus' own widgets (interface `ui`, calls `ui-events`). + Ui, } impl Capability { - pub const ALL: [Capability; 5] = [ + pub const ALL: [Capability; 6] = [ Capability::GameRead, Capability::Chat, Capability::Hud, Capability::Storage, Capability::Registries, + Capability::Ui, ]; /// The WIT interface it grants, in `horizon:mods`. pub fn interface(self) -> &'static str { @@ -92,6 +96,7 @@ impl Capability { Capability::Hud => "hud", Capability::Storage => "storage", Capability::Registries => "registries", + Capability::Ui => "ui", } } /// What the player reads before turning the mod on: "May ". @@ -102,6 +107,7 @@ impl Capability { Capability::Hud => "draw text on your screen", Capability::Storage => "save its own settings to disk", Capability::Registries => "add blocks and items to your worlds", + Capability::Ui => "show its own screens and a settings page", } } /// The capability a component import needs, `Ok(None)` for one every @@ -132,6 +138,7 @@ impl fmt::Display for Capability { Capability::Hud => "hud", Capability::Storage => "storage", Capability::Registries => "registries", + Capability::Ui => "ui", }; f.write_str(name) } diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 52da641b4..1b1795037 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -16,6 +16,15 @@ mod bindings { world: "horizon-mod", }); } +/// Capability `ui`'s world beyond `horizon-mod` (docs/mods.md): a second +/// world, so mods built before it (no `ui-events` export) still instantiate +/// as `horizon-mod` and simply hear nothing of their widgets. +mod ui_bindings { + wasmtime::component::bindgen!({ + path: "../horizon-mod-sdk/wit", + world: "ui-host", + }); +} pub use bindings::horizon::mods::{ game::{Clock, Player}, hud::{Corner, Line}, @@ -25,6 +34,12 @@ use bindings::{ exports::horizon::mods::events::Registration, horizon::mods::{chat, game, hud, log as mod_log, storage}, }; +/// The widget model a `ui` screen or settings page shows (WIT `interface +/// ui`), for the game to draw with its menus' own widgets. +pub use ui_bindings::horizon::mods::ui::{ + ItemWidget, ListWidget, Screen as UiScreen, TextWidget, Widget as UiWidget, +}; +use ui_bindings::{UiHost, horizon::mods::ui}; /// A mod's linear memory cap. const MEMORY: usize = 64 << 20; @@ -66,6 +81,11 @@ pub enum Output { Feedback { text: String, error: bool }, /// The mod was turned off: it trapped, overran or failed to start. Stopped { name: String, reason: String }, + /// `ui.open-screen`: the mod's entry in `entries()`; what it shows is + /// [`Mods::screen`]. + OpenScreen { owner: usize }, + /// `ui.close-screen`: the mod closed its own screen (no `closed` back). + CloseScreen { owner: usize }, } /// A mod's text panel, drawn every frame until it changes. @@ -95,19 +115,44 @@ pub struct Entry { run: Option, modified: Option, } +impl Entry { + /// The running mod has a settings page (capability `ui` set widgets + /// with `ui.set-settings`): its row in Options > Mods gains a button. + pub fn has_ui_settings(&self) -> bool { + self.run + .as_ref() + .is_some_and(|r| !r.store.data().ui.settings.is_empty()) + } +} struct Running { store: Store, exports: HorizonMod, + /// The mod's `ui-events` export; none for a mod built before `ui`. + ui: Option, hooks: Registration, } +/// What capability `ui` pushed since the hooks last ran. +#[derive(Default)] +struct UiState { + /// The screen `ui.open-screen` last pushed, until `ui.close-screen`. + screen: Option, + /// The widgets `ui.set-settings` last pushed (the mod's page in + /// Options > Mods). + settings: Vec, + /// The calls since the last hook ended, for [`Output`]. + open: bool, + close: bool, +} + struct State { name: String, id: String, snapshot: Arc, out: Vec, panel: Option, + ui: UiState, /// Capability `storage`'s folder; made on first `set`. data_dir: PathBuf, /// Bytes `storage::set` may still write before this hook call ends @@ -184,6 +229,8 @@ impl Mods { let mut linker = Linker::new(&engine); HorizonMod::add_to_linker::>(&mut linker, |s| s) .map_err(|e| e.to_string())?; + UiHost::add_to_linker::>(&mut linker, |s| s) + .map_err(|e| e.to_string())?; self.engine = Some((engine, linker)); } Ok(self.engine.as_ref().expect("made above")) @@ -242,6 +289,7 @@ impl Mods { snapshot, out: Vec::new(), panel: None, + ui: UiState::default(), data_dir, storage_budget: STORAGE_WRITE_BUDGET, limits: StoreLimitsBuilder::new() @@ -252,8 +300,13 @@ impl Mods { let mut store = Store::new(engine, state); store.limiter(|s| &mut s.limits); store.set_fuel(FUEL_INIT).map_err(|e| e.to_string())?; - let exports = - HorizonMod::instantiate(&mut store, &component, linker).map_err(|e| reason(&e))?; + let instance = linker + .instantiate(&mut store, &component) + .map_err(|e| reason(&e))?; + let exports = HorizonMod::new(&mut store, &instance).map_err(|e| reason(&e))?; + // `ui-events` is a mod built since `ui`'s; older mods have no export + // and stay callable through `events` alone. + let ui = UiHost::new(&mut store, &instance).ok(); let mut hooks = exports .horizon_mods_events() .call_init(&mut store) @@ -269,6 +322,7 @@ impl Mods { Ok(Some(Running { store, exports, + ui, hooks, })) } @@ -290,7 +344,7 @@ impl Mods { &mut self, fuel: u64, wants: impl Fn(&Registration) -> bool, - mut f: impl FnMut(&HorizonMod, &mut Store) -> wasmtime::Result<()>, + mut f: impl FnMut(&mut Running) -> wasmtime::Result<()>, ) { for i in 0..self.entries.len() { if self.entries[i] @@ -308,20 +362,28 @@ impl Mods { &mut self, i: usize, fuel: u64, - f: impl FnOnce(&HorizonMod, &mut Store) -> wasmtime::Result, + f: impl FnOnce(&mut Running) -> wasmtime::Result, ) -> Option { let entry = &mut self.entries[i]; let run = entry.run.as_mut()?; run.store.data_mut().snapshot = self.snapshot.clone(); run.store.data_mut().storage_budget = STORAGE_WRITE_BUDGET; let started = Instant::now(); - let result = run - .store - .set_fuel(fuel) - .and_then(|()| f(&run.exports, &mut run.store)); + let result = run.store.set_fuel(fuel).and_then(|()| f(run)); // An average over about the last 64 calls. entry.cost = (entry.cost * 63 + started.elapsed()) / 64; - self.out.append(&mut run.store.data_mut().out); + let data = run.store.data_mut(); + let (open, close) = ( + std::mem::take(&mut data.ui.open), + std::mem::take(&mut data.ui.close), + ); + self.out.append(&mut data.out); + if open { + self.out.push(Output::OpenScreen { owner: i }); + } + if close { + self.out.push(Output::CloseScreen { owner: i }); + } match result { Ok(r) => Some(r), Err(e) => { @@ -344,7 +406,11 @@ impl Mods { self.each( FUEL_TICK, |h| h.tick, - |m, s| m.horizon_mods_events().call_tick(s, tick), + |run| { + run.exports + .horizon_mods_events() + .call_tick(&mut run.store, tick) + }, ); } /// A rendered frame, `seconds` after the last. @@ -352,7 +418,11 @@ impl Mods { self.each( FUEL_FRAME, |h| h.frame, - |m, s| m.horizon_mods_events().call_frame(s, seconds), + |run| { + run.exports + .horizon_mods_events() + .call_frame(&mut run.store, seconds) + }, ); } /// A chat line the player received, as plain text. @@ -360,7 +430,11 @@ impl Mods { self.each( FUEL_EVENT, |h| h.chat, - |m, s| m.horizon_mods_events().call_chat(s, text), + |run| { + run.exports + .horizon_mods_events() + .call_chat(&mut run.store, text) + }, ); } /// Whether a running mod owns `/name`. @@ -381,8 +455,10 @@ impl Mods { let Some(i) = self.command_owner(name) else { return false; }; - let result = self.call(i, FUEL_EVENT, |m, s| { - m.horizon_mods_events().call_command(s, name, args) + let result = self.call(i, FUEL_EVENT, |run| { + run.exports + .horizon_mods_events() + .call_command(&mut run.store, name, args) }); match result { Some(Ok(Some(text))) => self.out.push(Output::Feedback { text, error: false }), @@ -405,6 +481,60 @@ impl Mods { .iter() .filter_map(|e| e.run.as_ref()?.store.data().panel.as_ref()) } + /// The screen mod `i` last pushed with `ui.open-screen`, while it is up. + pub fn screen(&self, i: usize) -> Option<&UiScreen> { + self.entries + .get(i)? + .run + .as_ref()? + .store + .data() + .ui + .screen + .as_ref() + } + /// The widgets `ui.set-settings` pushed: mod `i`'s page in + /// Options > Mods. Empty while it has none. + pub fn settings_widgets(&self, i: usize) -> &[UiWidget] { + self.entries + .get(i) + .and_then(|e| e.run.as_ref()) + .map_or(&[], |r| &r.store.data().ui.settings) + } + /// A `list` widget's row was picked on a surface of mod `i`. + pub fn ui_select(&mut self, i: usize, id: u32, row: u32) { + self.ui_event(i, |ui, s| { + ui.horizon_mods_ui_events().call_select(s, id, row) + }); + } + /// The player left mod `i`'s screen (Esc or Done). + pub fn ui_closed(&mut self, i: usize) { + self.ui_event(i, |ui, s| ui.horizon_mods_ui_events().call_closed(s)); + } + /// The player opened mod `i`'s settings page. + pub fn ui_settings_opened(&mut self, i: usize) { + self.ui_event(i, |ui, s| { + ui.horizon_mods_ui_events().call_settings_opened(s) + }); + } + /// A `ui-events` call on mod `i`; no export, no call (mods built + /// before `ui` have none). + fn ui_event( + &mut self, + i: usize, + f: impl FnOnce(&UiHost, &mut Store) -> wasmtime::Result<()>, + ) { + let has_ui = self + .entries + .get(i) + .and_then(|e| e.run.as_ref()) + .is_some_and(|r| r.ui.is_some()); + if has_ui { + self.call(i, FUEL_EVENT, |run| { + f(run.ui.as_ref().expect("checked above"), &mut run.store) + }); + } + } /// What mods asked to show since the last call. pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.out) @@ -476,6 +606,33 @@ fn clip(text: String) -> String { } } +/// Widgets on a `ui` screen or settings page, and rows on one `list`. +const WIDGETS: usize = 64; +const LIST_ROWS: usize = 512; + +fn clip_widgets(widgets: Vec) -> Vec { + widgets + .into_iter() + .take(WIDGETS) + .map(|w| match w { + UiWidget::Text(mut t) => { + t.text = clip(t.text); + UiWidget::Text(t) + } + UiWidget::Item(mut i) => { + i.item = clip(i.item); + i.name = clip(i.name); + UiWidget::Item(i) + } + UiWidget::List(mut l) => { + l.rows = l.rows.into_iter().take(LIST_ROWS).map(clip).collect(); + l.selected = l.selected.filter(|&s| (s as usize) < l.rows.len()); + UiWidget::List(l) + } + }) + .collect() +} + impl mod_log::Host for State { fn write(&mut self, level: mod_log::Level, message: String) { let level = match level { @@ -527,6 +684,21 @@ impl hud::Host for State { self.panel = None; } } +impl ui::Host for State { + fn open_screen(&mut self, mut screen: ui::Screen) { + screen.title = clip(screen.title); + screen.widgets = clip_widgets(screen.widgets); + self.ui.screen = Some(screen); + self.ui.open = true; + } + fn close_screen(&mut self) { + self.ui.screen = None; + self.ui.close = true; + } + fn set_settings(&mut self, widgets: Vec) { + self.ui.settings = clip_widgets(widgets); + } +} impl storage::Host for State { fn get(&mut self, key: String) -> Option> { storage_key_ok(&key) diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index a246c07b0..a229f748e 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -17,6 +17,13 @@ version = "0.1.0" api = "0.1" capabilities = ["chat", "storage"] "#; +const UI_MANIFEST: &str = r#" +id = "test_ui_guest" +name = "Test UI Guest" +version = "0.1.0" +api = "0.1" +capabilities = ["chat", "ui"] +"#; /// Builds both mods once and writes their `.hmod` files; returns the folder. fn mods_dir() -> &'static Path { @@ -32,6 +39,8 @@ fn mods_dir() -> &'static Path { "coords-hud", "-p", "horizon-mods-test-guest", + "-p", + "horizon-mods-test-ui-guest", "--target-dir", ]) .arg(&target) @@ -48,6 +57,7 @@ fn mods_dir() -> &'static Path { let mods = [ ("coords_hud", std::fs::read_to_string(example).unwrap()), ("horizon_mods_test_guest", GUEST_MANIFEST.to_string()), + ("horizon_mods_test_ui_guest", UI_MANIFEST.to_string()), ]; for (name, manifest) in mods { let core = std::fs::read(built.join(format!("{name}.wasm"))).unwrap(); @@ -366,6 +376,45 @@ fn hook_overhead() { ); } +#[test] +fn ui_screens_settings_and_events() { + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + assert_eq!(mods.entries()[0].status, Status::Running); + assert!(!mods.entries()[0].has_ui_settings()); + // `/ui_screen` pushes the screen; the host clips nothing of this one. + assert!(mods.command("ui_screen")); + assert_eq!(mods.drain(), [Output::OpenScreen { owner: 0 }]); + let screen = mods.screen(0).expect("a pushed screen"); + assert_eq!(screen.title, "Guest's screen"); + assert_eq!(screen.widgets.len(), 3); + // What the player did comes back through `ui-events`. + mods.ui_select(0, 7, 2); + mods.ui_closed(0); + assert_eq!( + mods.drain(), + [ + Output::Chat { + name: "Test UI Guest".into(), + text: "selected 7:2".into(), + }, + Output::Chat { + name: "Test UI Guest".into(), + text: "closed".into(), + }, + ] + ); + // The settings page: empty until the mod fills it, refreshed on open. + assert!(mods.command("ui_settings")); + assert_eq!(mods.settings_widgets(0).len(), 3); + assert!(mods.entries()[0].has_ui_settings()); + mods.ui_settings_opened(0); + assert_eq!(mods.settings_widgets(0).len(), 1); + // `/ui_close` closes the mod's own screen. + assert!(mods.command("ui_close")); + assert!(mods.drain().contains(&Output::CloseScreen { owner: 0 })); + assert!(mods.screen(0).is_none()); +} + #[test] fn content_only_mods_load_without_an_engine() { let manifest = r#" diff --git a/crates/horizon-mods/tests/ui_guest/Cargo.toml b/crates/horizon-mods/tests/ui_guest/Cargo.toml new file mode 100644 index 000000000..ae6e0950e --- /dev/null +++ b/crates/horizon-mods/tests/ui_guest/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "horizon-mods-test-ui-guest" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false +description = "A mod that shows widgets, for horizon-mods' sandbox tests" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +horizon-mod-sdk = { path = "../../../horizon-mod-sdk" } + +[lints] +workspace = true diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs new file mode 100644 index 000000000..1af3985a1 --- /dev/null +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -0,0 +1,66 @@ +//! A mod for the runtime's `ui` tests: `/ui_screen` opens a screen with one +//! of each widget, `/ui_settings` sets a settings page, `/ui_close` closes +//! the screen; the `ui-events` calls answer in chat so the host sees them. +use horizon_mod_sdk::{ + Mod, Registration, chat, + ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget}, +}; + +struct Guest; + +fn widgets() -> Vec { + vec![ + Widget::Text(TextWidget { + id: 1, + text: "Pick a row".into(), + color: 0, + }), + Widget::Item(ItemWidget { + id: 2, + item: "minecraft:diamond".into(), + name: String::new(), + }), + Widget::List(ListWidget { + id: 7, + rows: vec!["first".into(), "second".into(), "third".into()], + selected: Some(1), + }), + ] +} + +impl Mod for Guest { + fn init() -> Result<(Self, Registration), String> { + let hooks = ["ui_screen", "ui_settings", "ui_close"] + .into_iter() + .fold(Registration::new(), Registration::command); + Ok((Guest, hooks)) + } + fn command(&mut self, name: &str, _args: &str) -> Result, String> { + match name { + "ui_screen" => ui::open_screen(&Screen { + title: "Guest's screen".into(), + widgets: widgets(), + }), + "ui_settings" => ui::set_settings(&widgets()), + "ui_close" => ui::close_screen(), + _ => {} + } + Ok(None) + } + fn ui_select(&mut self, id: u32, row: u32) { + chat::show(&format!("selected {id}:{row}")); + } + fn ui_closed(&mut self) { + chat::show("closed"); + } + fn ui_settings_opened(&mut self) { + ui::set_settings(&[Widget::Text(TextWidget { + id: 1, + text: "fresh".into(), + color: 0x33CC33, + })]); + chat::show("settings opened"); + } +} + +horizon_mod_sdk::export_mod!(Guest); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index ca2fe9d7a..0812cde54 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -12,12 +12,27 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`, | `hud` | [`hud`](#hud) | draw text on your screen | | `storage` | [`storage`](#storage) | save its own settings to disk | | `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds | +| `ui` | [`ui`](#ui) | show its own screens and a settings page | ## World `horizon-mod` - Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage` - Exports (what the mod provides): `events` +## World `horizon-ui-mod` + +A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets. + +- Imports (what the game offers): `ui`, `log`, `game`, `chat`, `hud`, `storage` +- Exports (what the mod provides): `ui-events`, `events` + +## World `ui-host` + +The game's side of `horizon-ui-mod` beyond `horizon-mod`. + +- Imports (what the game offers): `ui` +- Exports (what the mod provides): `ui-events` + ## `log` Access: always granted. @@ -117,6 +132,79 @@ Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/m | `set` | `func(key: string, value: list) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). | | `delete` | `func(key: string)` | No error if `key` was already absent. | +## `ui` + +Access: capability `ui`. + +Capability `ui`: widgets the game draws with its own menus' widgets, for a screen the mod owns and a settings page in Options > Mods. Layout and drawing are the game's: a mod says what its widgets hold, never where they sit or how they look, and the game clips everything it is handed. + +### record `text-widget` + +A line of text. + +| Field | Type | | +| --- | --- | --- | +| `id` | `u32` | The mod's own name for the widget; `ui-events` calls echo it. | +| `text` | `string` | Up to 256 characters. | +| `color` | `u32` | 0xRRGGBB; 0 is the menus' white. | + +### record `item-widget` + +An item's icon and a name beside it, as recipe viewers show them. + +| Field | Type | | +| --- | --- | --- | +| `id` | `u32` | The mod's own name for the widget. | +| `item` | `string` | `minecraft:diamond`, `:`; an unknown id draws a "?". | +| `name` | `string` | The name shown beside the icon (empty: the item's own). | + +### record `list-widget` + +Rows the player picks one of, as an options page lists choices: a pick is `ui-events::select`, `selected` is the row to mark now. + +| Field | Type | | +| --- | --- | --- | +| `id` | `u32` | The mod's own name for the widget; `ui-events::select` echoes it. | +| `rows` | `list` | The rows' texts, at most 512, each up to 256 characters. | +| `selected` | `option` | The row to mark as the current choice, if any. | + +### variant `widget` + +A thing on a mod's screen or settings page. + +| Case | Payload | | +| --- | --- | --- | +| `text` | `text-widget` | Text the player reads. | +| `item` | `item-widget` | An item's icon and name. | +| `list` | `list-widget` | Rows the player picks from. | + +### record `screen` + +A screen the mod owns: a title over widgets stacked down the middle, the Done button the game adds, and Esc to leave it. + +| Field | Type | | +| --- | --- | --- | +| `title` | `string` | Its title, up to 256 characters. | +| `widgets` | `list` | Its widgets, in order, at most 64. | + +| Function | Signature | What it does | +| --- | --- | --- | +| `open-screen` | `func(screen: screen)` | Show `screen` over the game; a second call while it is open replaces it. Only the last call of one hook counts. | +| `close-screen` | `func()` | Close the mod's screen when it is open (the mod hears no `closed` for this: it was its own doing). | +| `set-settings` | `func(widgets: list)` | The mod's page in Options > Mods, reached from a settings button on its row: the widgets it shows, an empty list for no page. The player opening the page is `ui-events::settings-opened`: push fresh widgets then; later calls update the page while it shows. | + +## `ui-events` + +Access: exported by the mod: the game calls it. + +What the game calls on a mod that shows widgets (capability `ui`): what the player did with them. + +| Function | Signature | What it does | +| --- | --- | --- | +| `select` | `func(id: u32, row: u32)` | A row of one of the mod's `list` widgets was picked: the widget's `id` and the row, on the screen or the settings page that showed it. | +| `closed` | `func()` | The player left the mod's screen (Esc or Done); not for a screen the mod closed itself or one that went away with the mod. | +| `settings-opened` | `func()` | The player opened the mod's settings page in Options > Mods: call `ui::set-settings` now to fill it with fresh widgets. | + ## `events` Access: exported by the mod: the game calls it. diff --git a/docs/mods.md b/docs/mods.md index bac207f60..45e5ae02e 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -12,7 +12,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | Part | Where | State | |---|---|---| -| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage; tick, frame, chat and command hooks | +| The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, ui; tick, frame, chat and command hooks | | SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 | | Example mod | `crates/horizon-mod-sdk/examples/coords-hud` | coordinates, facing and time HUD, `/where`, `/coords` | | Runtime | `crates/horizon-mods` | wasmtime 36 LTS, component model | @@ -86,6 +86,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) | | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, item and list widgets the game lays out and draws with its own menus' code | | Hook (export) | When | |---|---| @@ -112,7 +113,6 @@ loading. Ordered by what the first native ports need: | `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips | | `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers | | `recipes` | the recipe registry and lookups | JEI/EMI-style viewers | -| `ui` | widgets: text, item icons, lists, a screen the mod owns, a settings page in Options > Mods | built from vanilla's widgets (`ui`) so it matches the menus | | `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics | | `render` | world-space lines, boxes and ghost blocks in a per-frame budget | Litematica-style placement, waypoints | | `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) | From 204859a2cb893f71b14605ea689f91424ace0671 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:20:41 +0100 Subject: [PATCH 25/57] Launcher: a preparation reusing imported game files still finds the assets When an update's preparation reused the game files an earlier version imported, `preparer` handed the chosen folder as it was to the sound copy and the panorama, fonts, packs and languages step. A Prism Launcher instance or Modrinth App profile holds no asset store, so the game was silent with a grey title screen, and Prepare again did the same. It now resolves the folder (linking another launcher's files into the official layout) either way; with reused imports a missing one is a log line, not an error. Co-Authored-By: Claude Opus 5.5 --- launcher/src/backend.rs | 97 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 88 insertions(+), 9 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 9d1eeb111..5f631f8f8 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -1103,6 +1103,34 @@ fn with_java(cmd: &mut Command, home: &Path) { } } +/// The Minecraft folder a preparation whose game files are imported already reads: the sounds, +/// the title panorama, the fonts and the languages come from its asset store. It is the one +/// `resolve` found, linked into the official layout for another launcher's files; without one, the +/// chosen folder, and the preparation goes without them, saying so. Before, such a preparation +/// read the chosen folder as it was, which a Prism Launcher or Modrinth App install does not +/// keep its assets in: the game was silent and its title screen grey. +fn assets_folder( + resolved: Result, + chosen: &Path, + version: &str, + log: &dyn Fn(String), +) -> PathBuf { + match resolved { + Ok(sources::Resolved::Folder(dir)) => dir, + Ok(sources::Resolved::NotFound(_)) => { + log(format!( + "Minecraft {version}'s assets were not found in any launcher folder: the game \ + will be silent and the title screen plain" + )); + chosen.to_path_buf() + } + Err(e) => { + log(format!("Minecraft {version}'s assets: {e}")); + chosen.to_path_buf() + } + } +} + /// Checks what the preparation needs and picks how to run it. PLAY.txt describes the same /// steps: the player's own launcher install, the server jar from that version's own json, and /// Java (and Python, for versions before horizon-prepare). Also returns the Minecraft folder @@ -1117,9 +1145,8 @@ pub fn preparer( let dir = version_dir(tag); let reused = horizon_prepare::artifacts::reuse_imported(&dir, &|line| log(line.to_string()))?; let chosen = minecraft.to_path_buf(); - let minecraft = if reused { - minecraft.to_path_buf() - } else { + // Resolved even when the game files are reused: the assets still come from the install. + let minecraft = { let pins = read_json(&dir.join("research-lock.json")) .and_then(|lock| sources::Pins::read(&lock)) .ok_or("The version's research-lock.json doesn't pin the Minecraft files")?; @@ -1139,12 +1166,17 @@ pub fn preparer( Ok(body) }; let linked = data_dir().join("linked-minecraft"); - match sources::resolve(minecraft, &pins, &known, &linked, &fetch, log)? { - sources::Resolved::Folder(dir) => dir, - sources::Resolved::NotFound(checked) => { - let not_found = sources::not_found(&version, &checked, minecraft); - download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?; - own + let resolved = sources::resolve(minecraft, &pins, &known, &linked, &fetch, log); + if reused { + assets_folder(resolved, &chosen, &version, log) + } else { + match resolved? { + sources::Resolved::Folder(dir) => dir, + sources::Resolved::NotFound(checked) => { + let not_found = sources::not_found(&version, &checked, minecraft); + download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?; + own + } } } }; @@ -2353,6 +2385,53 @@ mod tests { use super::*; use std::cmp::Ordering::*; + /// A preparation that reuses the imported game files still reads the assets from the + /// launcher that has them: a Prism Launcher instance's are in the launcher root, linked into + /// the official layout (jan.sonki on Discord: silent, grey title screen since 0.2.48). + #[test] + fn reused_imports_take_the_assets_from_the_launcher_that_has_them() { + let dir = + std::env::temp_dir().join(format!("horizon-reused-assets-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let client = b"fake client jar"; + let manifest = br#"{"id": "26.3"}"#; + let write = |path: &str, data: &[u8]| { + let path = dir.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, data).unwrap(); + }; + let prism = "PrismLauncher"; + write( + &format!("{prism}/libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"), + client, + ); + write(&format!("{prism}/assets/indexes/34.json"), b"{}"); + let instance = dir.join(prism).join("instances/26.3/minecraft"); + fs::create_dir_all(&instance).unwrap(); + let pins = sources::Pins::read(&serde_json::json!({ "minecraft": { + "version": "26.3", + "client_sha1": sources::sha1_hex(client), + "manifest_sha1": sources::sha1_hex(manifest), + }})) + .unwrap(); + let linked = dir.join("linked"); + let fetch = |_: &str| Ok(manifest.to_vec()); + let lines = std::cell::RefCell::new(Vec::new()); + let log = |line: String| lines.borrow_mut().push(line); + let resolved = sources::resolve(&instance, &pins, &[], &linked, &fetch, &log); + let folder = assets_folder(resolved, &instance, "26.3", &log); + assert_eq!(folder, linked); + assert!(folder.join("assets/indexes/34.json").is_file()); + + // Nothing found: the chosen folder, and a line saying what the game will lack. + let empty = dir.join("empty"); + fs::create_dir_all(&empty).unwrap(); + let resolved = sources::resolve(&empty, &pins, &[], &linked, &fetch, &log); + assert_eq!(assets_folder(resolved, &empty, "26.3", &log), empty); + assert!(lines.borrow().last().unwrap().contains("will be silent")); + fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn resources_without_sounds_are_reported() { let dir = std::env::temp_dir().join(format!("horizon-no-sounds-{}", std::process::id())); From 50727eb5c3d389628549c9237bbcca76029c3b79 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:26:43 +0100 Subject: [PATCH 26/57] Harnesses: one scaffold for one-player dedicated server scripts test_hardcore_respawn_parity.py copied test_locator_bar_parity.py's build and run code; both now share locator.sides, and one-player scripts run through locator.one_player. Co-Authored-By: Claude Opus 5.5 --- tools/test_hardcore_respawn_parity.py | 114 +++----------------------- tools/test_locator_bar_parity.py | 76 ++++++++++++++++- 2 files changed, 84 insertions(+), 106 deletions(-) diff --git a/tools/test_hardcore_respawn_parity.py b/tools/test_hardcore_respawn_parity.py index 6e0669534..8c7a5394d 100644 --- a/tools/test_hardcore_respawn_parity.py +++ b/tools/test_hardcore_respawn_parity.py @@ -8,23 +8,12 @@ vanilla's protocol code) joins, runs /kill on itself and presses the death scree respawns the player, then makes it a spectator because the server is hardcore. The game mode changes and respawns Alice is sent in the death and respawn steps are compared exactly. """ -import json -import os -from pathlib import Path -import platform -import shutil -import subprocess import sys -import time try: - from . import lab, test_rust_parity as base, test_locator_bar_parity as locator - from .test_dedicated_server_parity import Server + from . import test_locator_bar_parity as locator except ImportError: - import lab - import test_rust_parity as base import test_locator_bar_parity as locator - from test_dedicated_server_parity import Server NAME = 'hardcore-respawn-parity' # server_dir and generate_world write these server.properties. @@ -33,103 +22,24 @@ SCRIPT = [ ('death', ['cmd Alice kill @s']), ('respawn', ['respawn Alice']), ] -STEP_SECONDS = 4.0 -SHOWN = ('gamemode', 'respawn') - - -def run_side(label, server_command, client_command, build, world): - directory = build / label - port = locator.server_dir(directory, world) - out = build / f'{label}.txt' - out.unlink(missing_ok=True) - server = Server(server_command, directory, label) - try: - server.wait_for(r'Done \(') - with (build / f'{label}-client.log').open('w') as log: - client = subprocess.Popen([*map(str, client_command), '127.0.0.1', str(port), str(out)], - cwd=lab.ROOT, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, - text=True) - - def tell(line): - client.stdin.write(line + '\n') - client.stdin.flush() - try: - tell('players on') - tell('state on') - tell('step joins') - tell('join Alice') - server.wait_for(r'Alice joined the game', timeout=120) - time.sleep(STEP_SECONDS) - for step, actions in SCRIPT: - tell(f'step {step}') - for action in actions: - tell(action) - time.sleep(STEP_SECONDS) - tell('end') - client.wait(timeout=60) - finally: - if client.poll() is None: - client.kill() - finally: - server.stop() - if not out.exists(): - raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') - return out.read_text(encoding='utf-8').splitlines() def shown(lines): """Alice's game mode changes and respawns in the death and respawn steps.""" - keep = [] - for line in lines: - parts = line.split(' ', 3) - if len(parts) >= 3 and parts[1] in ('step=death', 'step=respawn') and parts[2] in SHOWN: - keep.append(line) - return keep + return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 + and parts[1] in ('step=death', 'step=respawn') and parts[2] in ('gamemode', 'respawn')] + + +def check(vanilla, _): + if 'Alice step=respawn gamemode 3' not in vanilla: + return 'vanilla did not make the respawned player a spectator' + return None def main(): - lock, jars, libraries = base.verify_artifacts() - build = lab.LOCAL / NAME - build.mkdir(parents=True, exist_ok=True) - report = build / 'report.json' - report.unlink(missing_ok=True) - oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' - classes = build / 'classes' - shutil.rmtree(classes, ignore_errors=True) - classes.mkdir() - dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) - subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath', - lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle], - cwd=lab.ROOT, check=True, capture_output=True) - subprocess.run(['cargo', 'build', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, - check=True, capture_output=True) - metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], - cwd=lab.ROOT, text=True)) - binary = Path(metadata['target_directory']) / 'release' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client') - vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] - horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui'] - client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] - - world = locator.generate_world(vanilla_server, build) - vanilla_all = run_side('vanilla', vanilla_server, client, build, world) - horizon_all = run_side('horizon', horizon_server, client, build, world) - errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] - vanilla, horizon = shown(vanilla_all), shown(horizon_all) - if errors or vanilla != horizon: - import difflib - diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) - raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' - + '\n'.join(errors[:10]) + '\n' + diff[:6000]) - if 'Alice step=respawn gamemode 3' not in vanilla: - raise AssertionError(f'vanilla did not make the respawned player a spectator: the script did not run ' - f'(see {build})') - lab.write_json(report, dict( - schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), - java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], - lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), - scope='A player dying and respawning in a hardcore world on a dedicated server: the respawn and the game ' - 'mode it is sent (spectator).')) - print(f'hardcore respawn: {len(vanilla)} lines identical') + lines = locator.one_player(NAME, SCRIPT, shown, check, 'A player dying and respawning in a hardcore world on a ' + 'dedicated server: the respawn and the game mode it is sent (spectator).') + print(f'hardcore respawn: {len(lines)} lines identical') if __name__ == '__main__': diff --git a/tools/test_locator_bar_parity.py b/tools/test_locator_bar_parity.py index bc7d21750..985b87a35 100644 --- a/tools/test_locator_bar_parity.py +++ b/tools/test_locator_bar_parity.py @@ -205,12 +205,13 @@ def run_side(label, server_command, client_command, build, world): return out.read_text(encoding='utf-8').splitlines() -def main(): +def sides(name): + """The vanilla and Horizon dedicated servers and the LocatorBarClient that joins them, built for harness + `name`: (lock, build directory, vanilla server, Horizon server, client) commands.""" lock, jars, libraries = base.verify_artifacts() - build = lab.LOCAL / NAME + build = lab.LOCAL / name build.mkdir(parents=True, exist_ok=True) - report = build / 'report.json' - report.unlink(missing_ok=True) + (build / 'report.json').unlink(missing_ok=True) oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java' classes = build / 'classes' shutil.rmtree(classes, ignore_errors=True) @@ -228,7 +229,74 @@ def main(): vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui'] client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient'] + return lock, build, vanilla_server, horizon_server, client + +def one_player(name, script, shown, check, scope, step_seconds=4.0): + """A harness of one player, Alice: on each server she joins, then runs `script`, a list of (step, client + actions) `step_seconds` apart. The lines `shown(transcript)` keeps must match vanilla's exactly, and + `check(vanilla's shown lines, vanilla's transcript)` returns why vanilla's run is no proof (the script did + not run), or None.""" + lock, build, vanilla_server, horizon_server, client = sides(name) + + def run(label, server_command, world): + directory = build / label + port = server_dir(directory, world) + out = build / f'{label}.txt' + out.unlink(missing_ok=True) + server = Server(server_command, directory, label) + try: + server.wait_for(r'Done \(') + with (build / f'{label}-client.log').open('w') as log: + process = subprocess.Popen([*map(str, client), '127.0.0.1', str(port), str(out)], cwd=lab.ROOT, + stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT, text=True) + + def tell(line): + process.stdin.write(line + '\n') + process.stdin.flush() + try: + for line in ['players on', 'state on', 'step joins', 'join Alice']: + tell(line) + server.wait_for(r'Alice joined the game', timeout=120) + time.sleep(step_seconds) + for step, actions in script: + tell(f'step {step}') + for action in actions: + tell(action) + time.sleep(step_seconds) + tell('end') + process.wait(timeout=60) + finally: + if process.poll() is None: + process.kill() + finally: + server.stop() + if not out.exists(): + raise AssertionError(f'{label}: the client wrote no transcript (see {build}/{label}-client.log)') + return out.read_text(encoding='utf-8').splitlines() + + world = generate_world(vanilla_server, build) + vanilla_all = run('vanilla', vanilla_server, world) + horizon_all = run('horizon', horizon_server, world) + errors = [l for l in vanilla_all + horizon_all if ' error ' in f' {l} ' or 'disconnected' in l] + vanilla, horizon = shown(vanilla_all), shown(horizon_all) + if errors or vanilla != horizon: + import difflib + diff = '\n'.join(difflib.unified_diff(vanilla, horizon, 'vanilla', 'horizon', n=1, lineterm='')) + raise AssertionError(f'{len(errors)} errors, transcripts differ (see {build}/vanilla.txt and horizon.txt):\n' + + '\n'.join(errors[:10]) + '\n' + diff[:6000]) + if (why := check(vanilla, vanilla_all)) is not None: + raise AssertionError(f'{why}: the script did not run (see {build})') + lab.write_json(build / 'report.json', dict( + schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(), + java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'), scope=scope)) + return vanilla + + +def main(): + lock, build, vanilla_server, horizon_server, client = sides(NAME) + report = build / 'report.json' world = generate_world(vanilla_server, build) vanilla = run_side('vanilla', vanilla_server, client, build, world) horizon = run_side('horizon', horizon_server, client, build, world) From df23e1bfed0c2fde5d9c319ccae18fed3c6b036b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:27:41 +0100 Subject: [PATCH 27/57] Keep vanilla's death screen message; resolve with the loaded language - Leave ServerPlayer.die's message as it was: with showDeathMessages off vanilla sends CommonComponents.EMPTY to the death screen, and #1858 reworks the death message from the combat record. - The effects panel and recipe book tooltips use Names::display_get, so a key missing from the language still falls back instead of showing the key. - net::load_language reuses the language lang already loaded (packs included) instead of reading it from disk again. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 48 +++++++++---------- crates/horizon-client/src/game/potions.rs | 2 +- crates/horizon-client/src/game/recipe_book.rs | 2 +- crates/horizon-client/src/net.rs | 18 ++++--- 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 03bb15b65..2c7019c73 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1791,33 +1791,33 @@ impl Game<'_> { // ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3. let drops = (g.game_mode != SPECTATOR) .then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]); - // ServerPlayer.die: the death screen always shows the message; - // chat only when the rule allows and the team does not hide it. let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take()); - let key = cause.unwrap_or_else(|| { - if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) { - "death.attack.outOfWorld".into() - } else { - "death.attack.generic".into() - } - }); - let text = - horizon_client::text::Text::translate(&key, &[self.player_text(&name, uuid)]); - let message = horizon_client::lan::data::component_nbt(&text.component()); - if self.ui.rules.bool("show_death_messages") { - let team = self.score.board.players_team(&name); - if horizon_client::scoreboard::own_death_message_shown(team) { - host.hub.system_message(message.clone()); - if self.options.dedicated.is_some() { - super::dedicated::log( - "INFO", - &format!("System chat: {}", text.plain()), - ); - } else if self.player.is_some() { - self.system_message(text); + let message = match cause { + // ServerPlayer.die: the death message, to everyone unless + // the player's team hides it. + Some(key) if self.ui.rules.bool("show_death_messages") => { + let text = horizon_client::text::Text::translate( + &key, + &[self.player_text(&name, uuid)], + ); + let message = horizon_client::lan::data::component_nbt(&text.component()); + let team = self.score.board.players_team(&name); + if horizon_client::scoreboard::own_death_message_shown(team) { + host.hub.system_message(message.clone()); + if self.options.dedicated.is_some() { + super::dedicated::log( + "INFO", + &format!("System chat: {}", text.plain()), + ); + } else if self.player.is_some() { + self.system_message(text); + } } + message } - } + Some(_) => literal(""), + None => literal(&format!("{name} died")), + }; host.hub.send_to( conn, &C::PlayerCombatKill(play::PlayerCombatKill { id, message }), diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 49e171ce1..b28c2fb4d 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -755,7 +755,7 @@ impl Game<'_> { image_width, &self.potions.effects.list, self.clock.rate(), - &|key| Some(horizon_client::lang::get(key)), + &|key| self.pack.names.display_get(key), &|text| self.gui.atlas.font.width(text).ceil() as i32, ); if let Some(lines) = inventory_effects::paint( diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index f68460b7d..fd44041e5 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -502,7 +502,7 @@ impl Game<'_> { horizon_client::item_tooltip::lines( stack, self.res.items().get(&stack.item), - &|k| Some(horizon_client::lang::get(k)), + &|k| self.pack.names.display_get(k), ) }) { diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index 2b314f2a8..8acfa7ec9 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -597,14 +597,18 @@ pub fn json_text(v: &serde_json::Value, lang: &Language) -> String { } } -/// The language a session resolves server-sent components with: the client's -/// selected language, layered over `en_us` (`ClientLanguage.loadFrom`), so a -/// menu title the server sends as a key follows the language screen. +/// The language a session resolves server-sent components with: the selected +/// one as `lang` loaded it (`en_us` under it, packs included), so a menu title +/// the server sends as a key follows the language screen. A process with no +/// language set (a dedicated server's log) reads en_us.json. pub fn load_language(resources: &std::path::Path) -> Language { - let root = resources.to_path_buf(); - crate::lang::Language::load(&[&root], &crate::lang::code()) - .map() - .clone() + if let Some(lang) = crate::lang::current() { + return lang.map().clone(); + } + std::fs::read(resources.join("assets/minecraft/lang/en_us.json")) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() } /// Ask a server for its status (ServerStatusPinger): MOTD, players and the From 70aff6e90b1689bd2f365825fa34308aa46e65fa Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:30:01 +0100 Subject: [PATCH 28/57] Death screen: empty message with showDeathMessages off; forget a guest's record when they leave - ServerPlayer.die sends CommonComponents.EMPTY to the death screen when showDeathMessages is off, as the local player's death_message already does; the combat record is only read when the rule is on. - A guest's CombatTracker leaves guest_combat with them, so a long-running server doesn't keep one per connection. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan.rs | 1 + crates/horizon-client/src/game/lan_actions.rs | 30 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 8460f54e6..4315cc56d 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -979,6 +979,7 @@ impl Game<'_> { self.save_guest(&host, &guest); host.models.remove(&guest.conn); host.play.remove(&guest.conn); + self.guest_combat.remove(&guest.conn); if self.options.dedicated.is_some() && guest.placed { super::dedicated::log( "INFO", diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index f5d419357..fec2f7251 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1807,18 +1807,19 @@ impl Game<'_> { let drops = (g.game_mode != SPECTATOR) .then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]); let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take()); - let me = self.player_text(&name, uuid); - let text = match cause { - Some(key) => horizon_client::text::Text::translate(&key, &[me]), - None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => { - horizon_client::text::Text::translate("death.attack.outOfWorld", &[me]) - } - None => self.guest_death_message(conn, me), - }; - let message = horizon_client::lan::data::component_nbt(&text.component()); - // ServerPlayer.die: the message goes to everyone unless the - // player's team hides it, and always to the dying player. - if self.ui.rules.bool("show_death_messages") { + // ServerPlayer.die: with showDeathMessages, the message goes to + // the dying player's death screen and to everyone the team lets + // see it; without it, the death screen gets an empty one. + let message = if self.ui.rules.bool("show_death_messages") { + let me = self.player_text(&name, uuid); + let text = match cause { + Some(key) => horizon_client::text::Text::translate(&key, &[me]), + None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => { + horizon_client::text::Text::translate("death.attack.outOfWorld", &[me]) + } + None => self.guest_death_message(conn, me), + }; + let message = horizon_client::lan::data::component_nbt(&text.component()); let team = self.score.board.players_team(&name); if horizon_client::scoreboard::own_death_message_shown(team) { host.hub.system_message(message.clone()); @@ -1831,7 +1832,10 @@ impl Game<'_> { self.system_message(text); } } - } + message + } else { + literal("") + }; host.hub.send_to( conn, &C::PlayerCombatKill(play::PlayerCombatKill { id, message }), From 0609fb2bebdc082c335361c3da65a188a8f77ef0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:30:37 +0100 Subject: [PATCH 29/57] Tooltip: lore after the enchantments, as ItemStack.addDetailsToTooltip Vanilla adds LORE after CONTAINER, BANNER_PATTERNS, FIREWORKS, POTION_CONTENTS, TRIM and the enchantments, and before the attribute modifiers, not right under the name. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/stations.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index fd7bd2642..5d8a2c3fe 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -1674,12 +1674,6 @@ impl Game<'_> { if display.name_visible(self.screen == Screen::Creative) { lines.push((horizon_client::text::Text::legacy(&name), color)); } - // ItemLore.addToTooltip: after the name, before other component lines. - if display.shows("minecraft:lore") { - for text in horizon_client::item_tooltip::lore_texts(stack) { - lines.push((text, u32::MAX)); - } - } let translate = |k: &str| e.translate(k).map(str::to_owned); let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| { lines.push((horizon_client::text::Text::legacy(&s), c)); @@ -1776,6 +1770,13 @@ impl Game<'_> { ); } } + // ItemLore.addToTooltip: after the enchantments, before the attributes + // (ItemStack.addDetailsToTooltip). + if display.shows("minecraft:lore") { + for text in horizon_client::item_tooltip::lore_texts(stack) { + lines.push((text, u32::MAX)); + } + } // Spawner.appendHoverText: what a spawner or trial spawner item spawns. if display.shows("minecraft:block_entity_data") && matches!( From 0298d109886f0679f3a3ae11d001f0b81224026c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:31:37 +0100 Subject: [PATCH 30/57] Shulker boxes: one canFitInsideContainerItems for both menus, and a unit test The client's menu and the server's guest menus now share menu::fits_inside_container_items, and a resource-backed test checks that a guest's shulker box menu refuses shulker boxes by pickup, shift-click and number key while other containers take them. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/menu.rs | 70 +++++++++++++++++++++------ crates/horizon-client/src/menu.rs | 13 ++--- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index fefcb1933..8e8772055 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -155,11 +155,9 @@ impl View<'_> { if self.container.is_none() && (5..=8).contains(&i) { return rules.armor_index(&stack.item) == Some(i - 5); } - // ShulkerBoxSlot.mayPlace: Item.canFitInsideContainerItems, false - // for shulker boxes. if self.shulker && self.container.as_ref().is_some_and(|c| i < c.len()) - && stack.item.ends_with("shulker_box") + && !crate::menu::fits_inside_container_items(stack) { return false; } @@ -675,6 +673,24 @@ mod tests { ) } + /// The prepared resources' items, or None (the test is skipped) without them. + fn rules() -> Option { + let root = std::env::var_os("HORIZON_RESOURCES") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION) + }); + if !root.join("items.json").exists() { + eprintln!("skipping resource-backed menu check: {}", root.display()); + return None; + } + let registry = horizon_world::level::data::load_registry(&root).unwrap(); + let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap(); + Some(ItemRules(Items::load(&root, ®istry, &facts).unwrap())) + } + fn contents(stack: &Option) -> Vec<(String, u32)> { let inside = stack.as_ref().and_then(|s| s.data()); inside.map_or(Vec::new(), |d| { @@ -690,20 +706,9 @@ mod tests { /// slot; a stack clicked onto a bundle goes in, as far as it fits. #[test] fn guest_chest_clicks_fill_and_empty_bundles() { - let root = std::env::var_os("HORIZON_RESOURCES") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.local/horizon-client") - .join(horizon_core::VANILLA_VERSION) - }); - if !root.join("items.json").exists() { - eprintln!("skipping resource-backed bundle check: {}", root.display()); + let Some(rules) = rules() else { return; - } - let registry = horizon_world::level::data::load_registry(&root).unwrap(); - let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap(); - let rules = ItemRules(Items::load(&root, ®istry, &facts).unwrap()); + }; let mut g = guest(); let mut chest = vec![ Some(ItemStack::new("minecraft:stone", 48)), @@ -747,4 +752,37 @@ mod tests { ); assert_eq!(contents(&view.get(2)), []); } + + #[test] + fn guest_shulker_box_menu_refuses_shulker_boxes() { + let Some(rules) = rules() else { + return; + }; + for shulker in [true, false] { + let mut g = guest(); + let mut container = vec![None; 27]; + let mut drag = None; + let mut view = View { + guest: &mut g, + container: Some(&mut container), + shulker, + }; + // Carried onto an empty slot, shift-clicked from the hotbar + // (menu slot 27 + 27) and swapped in with the number key 1. + view.guest.carried = Some(ItemStack::new("minecraft:red_shulker_box", 1)); + click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules); + assert_eq!(view.get(0).is_none(), shulker); + view.guest.carried = None; + view.guest.inventory[0] = Some(ItemStack::new("minecraft:shulker_box", 1)); + click(&mut view, &mut drag, 54, 0, input::QUICK_MOVE, &rules); + assert_eq!(view.guest.inventory[0].is_some(), shulker); + view.guest.inventory[0] = Some(ItemStack::new("minecraft:blue_shulker_box", 1)); + click(&mut view, &mut drag, 5, 0, input::SWAP, &rules); + assert_eq!(view.get(5).is_none(), shulker); + // Anything else goes in. + view.guest.carried = Some(ItemStack::new("minecraft:diamond", 1)); + click(&mut view, &mut drag, 9, 0, input::PICKUP, &rules); + assert!(view.get(9).is_some()); + } + } } diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index 07e4ba2b9..764abe0d9 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -247,6 +247,12 @@ pub fn beacon_required_levels(effect: Option<&str>) -> i32 { .map_or(i32::MAX, |i| i as i32 + 1) } +/// `Item.canFitInsideContainerItems` (`ShulkerBoxSlot.mayPlace`): shulker +/// boxes cannot nest. +pub fn fits_inside_container_items(stack: &ItemStack) -> bool { + !stack.item.ends_with("shulker_box") +} + /// The containers a menu spans. const PLAYER: usize = 0; const MAIN: usize = 1; @@ -1210,11 +1216,6 @@ impl Menu { env.items.get(&stack.item).is_some_and(|i| i.fuel.is_some()) } - /// `Item.canFitInsideContainerItems`: shulker boxes cannot nest. - fn fits_inside(stack: &ItemStack) -> bool { - !(stack.item.ends_with("shulker_box")) - } - // ---- Slot ---- fn may_place(&self, env: &Env, s: usize, stack: &ItemStack) -> bool { @@ -1247,7 +1248,7 @@ impl Menu { .as_deref() .is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m))) }), - Role::Shulker => Self::fits_inside(stack), + Role::Shulker => fits_inside_container_items(stack), Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack), Role::Output | Role::CraftResult | Role::Inert => false, Role::CrafterIn => self.data.get(self.slots[s].index) != Some(&1), From b729cfc83361456ffcb3ff926dbb97ad617cf600 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:37:22 +0100 Subject: [PATCH 31/57] Furnace experience: createExperience's chance, orbs, every removal, guests - createExperience floors count * experience and adds one with the chance of the fraction (a level random nextFloat); flooring alone paid nothing for a single iron ore (0.7). - The payout pops ExperienceOrb.award's orbs at the player, recipe by recipe, instead of adding points directly. - A server's player (the default single-player path, LAN and dedicated) gets it too: lan_machine_click pays when the guest takes from the output. - AbstractFurnaceBlockEntity.preRemoveSideEffects pays at the block's centre on any removal (explosions, /setblock, creative breaking), after the contents spill, so it moves from break_block to the removal side effects. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/containers.rs | 69 ++++++++++--------- crates/horizon-client/src/game.rs | 64 ++++++++--------- .../horizon-client/src/game/lan_crafting.rs | 7 ++ crates/horizon-client/src/game/spill.rs | 19 ++++- 4 files changed, 90 insertions(+), 69 deletions(-) diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index 3701fefaf..26ca193a2 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -488,25 +488,13 @@ impl Furnace { self.cooking_timer = 0; } } - /// `AbstractFurnaceBlockEntity.createExperience`: each recipe's own share is floored, - /// then the floors are added. Flooring the sum instead pays one point for two recipes - /// worth 0.6 each, which vanilla does not. - fn owed(used: &HashMap, experience: impl Fn(&str) -> f32) -> u32 { - used.iter() - .map(|(id, count)| (experience(id) * *count as f32).floor().max(0.0) as u32) - .sum() - } - /// What this furnace owes right now, without forgetting it: breaking the block reads - /// the same counter, and only a payout clears it. - pub fn experience_owed(&self, recipes: &Recipes) -> u32 { - Self::owed(&self.recipes_used, |id| recipes.cooking_experience(id)) - } - /// `AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience`: the experience this - /// furnace owes, forgotten as it is paid. - pub fn take_experience(&mut self, recipes: &Recipes) -> u32 { - let owed = self.experience_owed(recipes); - self.recipes_used.clear(); - owed + /// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does + /// not have), in the order `getRecipesToAwardAndPopExperience` pays them. + pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> { + self.recipes_used + .iter() + .map(|(id, count)| (*count, recipes.cooking_experience(id))) + .collect() } /// A menu click wrote its slots into `items`, and `before` was the input /// slot until then: the menu called `setItem` on it only if it changed @@ -621,23 +609,40 @@ fn total_time(time: u32, speed: f32) -> i32 { } } +/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one +/// more with the chance of what is left over (a level random draw only when there is some). +pub fn create_experience( + count: u32, + experience: f32, + random: &mut impl horizon_core::random::RandomSource, +) -> u32 { + let total = count as f32 * experience; + let whole = total.floor(); + let fraction = total - whole; + let mut xp = whole.max(0.0) as u32; + if fraction != 0.0 && random.next_float() < fraction { + xp += 1; + } + xp +} + #[cfg(test)] mod tests { use super::*; + use horizon_core::random::{LegacyRandom, RandomSource}; - /// `createExperience` floors each recipe on its own. Two recipes worth 0.6 each pay - /// nothing, where flooring their sum would pay one point. + /// A whole amount pays itself without a draw; a fraction is a chance of one more. #[test] - fn experience_is_floored_per_recipe_and_not_on_the_sum() { - let mut used = HashMap::new(); - used.insert("a".to_owned(), 1); - used.insert("b".to_owned(), 1); - assert_eq!(Furnace::owed(&used, |_| 0.6), 0); - - used.insert("c".to_owned(), 10); - assert_eq!( - Furnace::owed(&used, |id| if id == "c" { 0.1 } else { 0.0 }), - 1 - ); + fn create_experience_rounds_the_fraction_by_chance() { + let mut random = LegacyRandom::new(7); + let mut untouched = LegacyRandom::new(7); + assert_eq!(create_experience(4, 0.5, &mut random), 2); + assert_eq!(create_experience(3, 0.0, &mut random), 0); + assert_eq!(random.next_long(), untouched.next_long()); + let paid: u32 = (0..1000) + .map(|_| create_experience(1, 0.35, &mut random)) + .sum(); + assert!((250..450).contains(&paid), "{paid}"); + assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random)))); } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7c0bbc917..808ca087f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -537,6 +537,8 @@ struct GameLevel<'w> { seed: i64, behaviours: &'w Behaviours, items: &'w Items, + /// Cooking recipes' experience, which a removed furnace pays out. + recipes: &'w horizon_client::crafting::Recipes, /// Container loot tables (`unpackLootTable`). loot: Option>, registry: &'static horizon_world::registry::BlockRegistry, @@ -2420,6 +2422,7 @@ impl Game<'_> { seed: self.level.seed, behaviours: self.res.behaviours(), items: self.res.items(), + recipes: self.res.recipes(), loot: horizon_client::structure_blocks::loot_data(&self.options.resources), registry: self.pack.assets.registry, rules, @@ -3246,12 +3249,6 @@ impl Game<'_> { } // The block entity as it was, for what the drops copy from it. let key = (pos[0], pos[1], pos[2]); - // AbstractFurnaceBlockEntity.playerDestroy: the experience its recipes earned drops - // with the block. Read now, paid only if the block is really removed. - let furnace_xp = match self.world.block_entities.get(&key) { - Some(BlockEntity::Furnace(f)) => f.experience_owed(self.res.recipes()), - _ => 0, - }; if let Some(entity) = self.world.block_entities.get(&key).cloned() { let name = self.world.block_names.get(&key).cloned(); self.pending.record(pos, &entity, &facts.name, name); @@ -3321,18 +3318,6 @@ impl Game<'_> { ); } } - // Stored recipe experience is not a loot drop: it leaves with the block - // whatever the tool, as `AbstractFurnaceBlockEntity` does. - if furnace_xp > 0 { - self.spawn_orbs( - [ - f64::from(pos[0]) + 0.5, - f64::from(pos[1]) + 0.5, - f64::from(pos[2]) + 0.5, - ], - furnace_xp, - ); - } } self.pending.clear(pos); self.process_level_events()?; @@ -6007,14 +5992,12 @@ impl Game<'_> { self.player_drop(stack); } self.award_crafted(&crafted); - // shortcut: paid straight to the player instead of through `ExperienceOrb.award` - // (no orb flies at them), which a furnace's orbs reach within a tick anyway; - // upgrade if the visual matters or another block pays out far from the player. - if furnace_before > furnace_after { - let owed = self.take_furnace_experience(); - if owed > 0 { - self.give_orb_points(owed); - } + if furnace_before > furnace_after + && self.remote.is_none() + && let (Some(pos), Some(p)) = (self.container, self.player.as_ref()) + { + let at = [p.position.x, p.position.y, p.position.z]; + self.award_furnace_recipes(pos, at); } } if kind == Kind::Loom { @@ -8807,17 +8790,26 @@ impl Game<'_> { self.give_orb_points(gained); } } - /// `AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience`: what the open furnace - /// owes for everything it made since the last payout, cleared as it is read. - fn take_furnace_experience(&mut self) -> u32 { - let Some(pos) = self.container else { - return 0; + /// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the + /// furnace at `pos`, which pops the experience of what it made at them + /// (`awardUsedRecipesAndPopExperience`) and forgets it. + pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) { + let used = match self.world.block_entities.get_mut(&pos) { + Some(BlockEntity::Furnace(f)) => { + let used = f.recipe_experience(self.res.recipes()); + f.recipes_used.clear(); + used + } + _ => return, }; - let recipes = self.res.recipes(); - let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else { - return 0; - }; - f.take_experience(recipes) + for (count, experience) in used { + let xp = horizon_client::containers::create_experience( + count, + experience, + &mut self.world.random, + ); + self.spawn_orbs(at, xp); + } } /// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up /// sound every fifth level (at most every 100 ticks). diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index cb3b0a2c9..3199894dd 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -225,6 +225,7 @@ impl Game<'_> { Some(BlockEntity::Crafter(c)) => (Kind::CrafterBlock, c.items.to_vec()), _ => return false, }; + let furnace = matches!(kind, Kind::Furnace(_)); // CrafterBlockEntity.containerData: the disabled slots, then triggered. let crafter_data = match self.world.block_entities.get(&pos) { Some(BlockEntity::Crafter(c)) => Some((c.disabled, c.triggered)), @@ -284,6 +285,12 @@ impl Game<'_> { } _ => {} } + // FurnaceResultSlot.checkTakeAchievements: the guest took from the output. + let count = |s: Option<&ItemStack>| s.map_or(0, |s| s.count); + if furnace && count(menu.slot(2)) < count(items[2].as_ref()) { + let at = host.hub.guests[gi].position; + self.award_furnace_recipes(pos, at); + } host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu)); host.hub.inventory_clicked(conn, p, &self.world, &changed); true diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 20dcdf0de..b7283c57b 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -373,12 +373,29 @@ impl GameLevel<'_> { let mut neighbour_updates = 0; if side_effects { match kind { - PreRemove::Contents { slots, .. } => { + PreRemove::Contents { slots, furnace } => { let items = entity.as_ref().map_or(&[][..], BlockEntity::slots); for slot in 0..slots { let stack = items.get(slot).cloned().flatten(); self.drop_item_stack(pos, stack); } + // AbstractFurnaceBlockEntity.preRemoveSideEffects: then the + // experience of what it made, at the block's centre. + if furnace && let Some(BlockEntity::Furnace(f)) = &entity { + let at = [ + f64::from(pos.x) + 0.5, + f64::from(pos.y) + 0.5, + f64::from(pos.z) + 0.5, + ]; + for (count, experience) in f.recipe_experience(self.recipes) { + let xp = horizon_client::containers::create_experience( + count, + experience, + &mut self.world.random, + ); + self.award_orbs(at, xp); + } + } } PreRemove::Campfire => { for _ in 0..4 { From a495fcf7a7af9ee0cc475bbccb810ae1c23d9755 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:37:51 +0100 Subject: [PATCH 32/57] End crystal: one blast call for both paths The respawn check is read before the blast, so the two copies of the explosion collapse into one. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dragon.rs | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 701c09e64..ddd5a716b 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -880,30 +880,22 @@ impl Game<'_> { return; } let c = self.crystals.remove(index); - if self.fight.respawn.is_some() + // Read before the blast, which can take other respawn crystals. + let respawn_crystal = self.fight.respawn.is_some() && self .fight .respawn_crystals .iter() - .any(|p| p.distance_squared(c.position) < 0.01) - { - if damage == CrystalDamage::Direct { - let at = [c.position.x, c.position.y, c.position.z]; - let _ = self.server_explode( - at, - 6.0, - super::explosion::BlockInteraction::Destroy, - false, - ); - } - self.abort_respawn(); - return; - } + .any(|p| p.distance_squared(c.position) < 0.01); if damage == CrystalDamage::Direct { let at = [c.position.x, c.position.y, c.position.z]; let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false); } + if respawn_crystal { + self.abort_respawn(); + return; + } // EnderDragonFight.onCrystalDestroyed. self.count_crystals(); if let Some(d) = self.dragon.as_mut() { From 0dfc6d8700554d0d0b8bfb93cc00cd14062daa49 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:38:28 +0100 Subject: [PATCH 33/57] command_exec harness: summoned mobs' saved attribute bases and health (#1483) A cow summoned with max_health and movement_speed bases, and a zombie with an attack_damage base and a saved Health, against vanilla. Co-Authored-By: Claude Opus 5.5 --- tools/command_exec_corpus.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 0e376e260..be4e16a8a 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -291,6 +291,19 @@ def entities(): cmd('/data get entity @e[tag=root,limit=1] Passengers[0].Health'), cmd('/data get entity @e[tag=root,limit=1] Passengers[0].id'), cmd('/kill @e[tag=root]'), + # Saved attribute bases are read for every mob, then Health or the max + # health when there is none (LivingEntity.readAdditionalSaveData, #1483). + cmd('/summon minecraft:cow 4.5 210 8.5 {Tags:["attr"],NoAI:1b,NoGravity:1b,' + 'attributes:[{id:"minecraft:max_health",base:40.0d},{id:"minecraft:movement_speed",base:0.5d}]}'), + cmd('/data get entity @e[tag=attr,limit=1] Health'), + cmd('/attribute @e[tag=attr,limit=1] minecraft:max_health base get'), + cmd('/attribute @e[tag=attr,limit=1] minecraft:movement_speed base get'), + cmd('/kill @e[tag=attr]'), + cmd('/summon minecraft:zombie 4.5 210 8.5 {Tags:["attr"],NoAI:1b,NoGravity:1b,Health:7.0f,' + 'attributes:[{id:"minecraft:attack_damage",base:9.0d}]}'), + cmd('/data get entity @e[tag=attr,limit=1] Health'), + cmd('/attribute @e[tag=attr,limit=1] minecraft:attack_damage base get'), + cmd('/kill @e[tag=attr]'), *setup('/kill @e[type=minecraft:armor_stand]'), {'op': 'tick', 'n': 25}, cmd('/execute if entity @e[tag=h]'), From 8c54e1858741b47dcca050cd5dc35b6d32f2d23c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:40:41 +0100 Subject: [PATCH 34/57] /ride dismount: the boats' BOARDING_COOLDOWN, not a second 60 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/boat_passengers.rs | 2 +- crates/horizon-client/src/game/commands/world_cmds.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/boat_passengers.rs b/crates/horizon-client/src/game/boat_passengers.rs index 96686f69b..7055cccfd 100644 --- a/crates/horizon-client/src/game/boat_passengers.rs +++ b/crates/horizon-client/src/game/boat_passengers.rs @@ -13,7 +13,7 @@ use horizon_client::{ use horizon_core::geometry::{Aabb, Vec3}; /// `Entity.boardingCooldown` once a mob leaves its boat. -const BOARDING_COOLDOWN: i32 = 60; +pub(super) const BOARDING_COOLDOWN: i32 = 60; /// `Player.DEFAULT_VEHICLE_ATTACHMENT`: how far below the seat a player's /// feet are. const PLAYER_RIDING_OFFSET: f64 = 0.6; diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index c8bd220be..30f019997 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -1191,7 +1191,7 @@ impl Game<'_> { }; let m = &mut self.mobs[i]; m.extra.passenger = false; - m.extra.boarding_cooldown = 60; + m.extra.boarding_cooldown = super::super::boat_passengers::BOARDING_COOLDOWN; m.position = at; m.previous = at; } From b873f30c29b66a551b6f3744be5efa7468795c9c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:41:04 +0100 Subject: [PATCH 35/57] wears_freeze_immune above sync_player_body's doc comment, which it had taken over Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 4e4865c7a..6dd5f4678 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1837,13 +1837,6 @@ impl Game<'_> { .is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage)) }) } - /// What the window decides for its player's body every tick, in frame and - /// on a server alike: the look (Player.travel steers sprint-swimming by - /// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an - /// item in use (LocalPlayer.modifyInput), the helmet's Respiration - /// (decreaseAirSupply), the wings, leather boots on powder snow - /// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear - /// (LivingEntity.canFreeze) and the dimension's FAST_LAVA. /// `LivingEntity.canFreeze`'s armor half: any worn piece in /// `freeze_immune_wearables`. pub(crate) fn wears_freeze_immune(&self) -> bool { @@ -1857,6 +1850,13 @@ impl Game<'_> { .flatten() .any(|s| immune.is_some_and(|t| t.contains(&*s.item))) } + /// What the window decides for its player's body every tick, in frame and + /// on a server alike: the look (Player.travel steers sprint-swimming by + /// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an + /// item in use (LocalPlayer.modifyInput), the helmet's Respiration + /// (decreaseAirSupply), the wings, leather boots on powder snow + /// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear + /// (LivingEntity.canFreeze) and the dimension's FAST_LAVA. pub(crate) fn sync_player_body(&mut self) { let glider = self.glider_worn(); let item_use_speed = self From 644384763338a5912405063d25ef52e60995d322 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:42:00 +0100 Subject: [PATCH 36/57] Launcher: add the panorama, Unifont, languages and sounds prepared resources lack Versions prepared while the asset store could not be read (Prism Launcher installs since 0.2.48) are silent with a grey title screen. Like the built-in packs (#1539), the launcher now copies each of these from the player's own asset store into prepared resources that lack it, at start, after an install and before Play, without preparing again. Co-Authored-By: Claude Opus 5.5 --- launcher/src/backend.rs | 241 +++++++++++++++++++++++++++++----------- launcher/src/main.rs | 7 +- 2 files changed, 178 insertions(+), 70 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 9d1eeb111..a7485468a 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -552,7 +552,7 @@ pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> fs::write(target.join(COMPLETE), "").map_err(|e| e.to_string())?; let _ = fs::remove_dir_all(&partial); reuse_prepared(&root, tag); - complete_packs(tag); + complete_assets(tag); progress(1.0); Ok(()) } @@ -774,13 +774,13 @@ fn reuse_prepared(versions: &Path, tag: &str) -> Option { /// `reuse_prepared` for every installed version that is not prepared, oldest first, so a /// version prepared this way can serve the next. Launchers before 0.1.4 installed new versions /// without it; this catches those up. Returns each (version, where its resources came from). -/// Prepared versions also get the run-time reports (`complete_reports`) and built-in packs -/// (`complete_packs`) they lack. +/// Prepared versions also get the run-time reports (`complete_reports`) and the built-in packs, +/// panorama and sounds (`complete_assets`) they lack. pub fn reuse_prepared_everywhere() -> Vec<(String, String)> { let versions = versions_dir(); let reused = reuse_prepared_in(&versions); for tag in installed_in(&versions) { - complete_packs(&tag); + complete_assets(&tag); } reused } @@ -856,19 +856,77 @@ fn complete_reports(versions: &Path, tag: &str) -> bool { complete } -/// Adds vanilla's built-in Programmer Art and High Contrast packs to a prepared version that -/// lacks them, as `horizon-prepare packs` does: preparers before #1538 left them out, and a new -/// version reuses the resources of the one before (`reuse_prepared`). They come from the player's -/// own Minecraft asset store; without them there, one line in the log, once per launcher run, and -/// the version plays as before. Nothing is prepared again. -pub fn complete_packs(tag: &str) { - complete_packs_in(&versions_dir(), tag, minecraft_folders, &|line| { +/// What a prepared version may lack that the launcher adds without preparing again, each copied +/// from the player's own Minecraft asset store as `horizon-prepare ` does: what the +/// player misses without it, whether the resources lack it, and the copy. +type Completion = ( + &'static str, + fn(&Path) -> bool, + fn(&Path, Option<&Path>, horizon_prepare::Log) -> Result, +); + +const COMPLETIONS: [Completion; 5] = [ + // Preparers before #1538 left the built-in packs out. + ( + "Programmer Art and High Contrast", + lacks_packs, + horizon_prepare::packs, + ), + // A preparation that could not read the asset store keeps the client jar's 1x1 grey + // placeholders, its English alone, and no sounds (#1867: Prism Launcher installs prepared + // since 0.2.48). + ( + "The title panorama", + lacks_panorama, + horizon_prepare::panorama, + ), + ("The Unifont glyphs", lacks_unifont, horizon_prepare::fonts), + ("The languages", lacks_languages, horizon_prepare::languages), + ( + "The sounds", + |resources| !sounds_in(resources), + horizon_prepare::sounds, + ), +]; + +fn lacks_unifont(resources: &Path) -> bool { + !resources + .join("assets/minecraft/font/unifont.zip") + .is_file() +} + +/// Whether `en_us`, the one language the client jar has, is the only one. +fn lacks_languages(resources: &Path) -> bool { + fs::read_dir(resources.join("assets/minecraft/lang")).map_or(true, |dir| dir.count() <= 1) +} + +fn lacks_packs(resources: &Path) -> bool { + horizon_prepare::BUILT_IN_PACKS + .iter() + .any(|pack| !resources.join("assets").join(pack).is_file()) +} + +/// Whether the first panorama face is missing or the client jar's 1x1 placeholder (a PNG's +/// width is the big-endian word at byte 16, in the IHDR chunk). +fn lacks_panorama(resources: &Path) -> bool { + let face = resources.join("assets/minecraft/textures/gui/title/background/panorama_0.png"); + fs::read(face).map_or(true, |png| { + png.get(16..20).is_none_or(|width| width == [0, 0, 0, 1]) + }) +} + +/// Adds what a prepared version lacks of `COMPLETIONS`: a new version reuses the resources of the +/// one before (`reuse_prepared`), which may come from a preparer that left them out. Without them +/// in any Minecraft folder, one line each in the log, once per launcher run, and the version plays +/// as before. Nothing is prepared again. +pub fn complete_assets(tag: &str) { + complete_assets_in(&versions_dir(), tag, minecraft_folders, &|line| { eprintln!("{line}") }); } -/// `folders`: where to look, asked only when the version lacks a pack. -fn complete_packs_in( +/// `folders`: where to look, asked only when the version lacks something. +fn complete_assets_in( versions: &Path, tag: &str, folders: impl Fn() -> Vec, @@ -879,37 +937,41 @@ fn complete_packs_in( static TRIED: Mutex> = Mutex::new(Vec::new()); let root = versions.join(tag); let resources = resources_at(&root); - let lacking = || { - resources.join(RESOURCES_MANIFEST).exists() - && horizon_prepare::BUILT_IN_PACKS - .iter() - .any(|pack| !resources.join("assets").join(pack).is_file()) + let lacking = || -> Vec<&Completion> { + if !resources.join(RESOURCES_MANIFEST).exists() { + return Vec::new(); + } + COMPLETIONS + .iter() + .filter(|(_, lacks, _)| lacks(&resources)) + .collect() }; - if !lacking() { + if lacking().is_empty() { return; } let mut tried = TRIED.lock().unwrap_or_else(|e| e.into_inner()); - if tried.contains(&root) || !lacking() { + let lacking = lacking(); + if tried.contains(&root) || lacking.is_empty() { return; } tried.push(root.clone()); - let mut first_error = None; - for folder in folders() { - let copied = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - horizon_prepare::packs(&root, Some(&folder), &|line| log(line.to_string())) - })) - .unwrap_or_else(|_| Err("the copy stopped unexpectedly".into())); - match copied { - Ok(_) => return, - Err(e) => { - first_error.get_or_insert(e); - } + let folders = folders(); + for (what, _, copy) in lacking { + let mut first_error = None; + let added = folders.iter().any(|folder| { + let copied = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + copy(&root, Some(folder), &|line| log(line.to_string())) + })) + .unwrap_or_else(|_| Err("the copy stopped unexpectedly".into())); + copied.map_err(|e| first_error.get_or_insert(e)).is_ok() + }); + if !added { + log(format!( + "{tag}: {what} not added: {}", + first_error.unwrap_or_else(|| "no Minecraft folder found".into()) + )); } } - log(format!( - "{tag}: Programmer Art and High Contrast not added: {}", - first_error.unwrap_or_else(|| "no Minecraft folder found".into()) - )); } /// The Minecraft folders whose asset store can hold what a prepared version lacks: the one @@ -1635,7 +1697,7 @@ pub fn need_prepared(tag: &str) -> Result<(), String> { /// The client's command, once its resources are prepared. pub fn client(tag: &str) -> Result { need_prepared(tag)?; - complete_packs(tag); + complete_assets(tag); crate::browser::horizon::before_play(&game_home()); let mut cmd = client_command(tag); // A sign-in that can't be renewed (no network, expired) still lets the game start, offline. @@ -3069,7 +3131,7 @@ mod tests { } #[test] - fn prepared_versions_get_the_built_in_packs_without_preparing_again() { + fn prepared_versions_get_what_the_asset_store_adds_without_preparing_again() { let dir = std::env::temp_dir().join(format!( "horizon-launcher-packs-test-{}", std::process::id() @@ -3080,17 +3142,42 @@ mod tests { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(path, data).unwrap(); }; - // An official launcher folder whose asset store holds the two packs. - let packs = horizon_prepare::BUILT_IN_PACKS.map(|name| (name, name.as_bytes())); + // An official launcher folder whose asset store holds the two packs, a panorama (fake + // 2x2 PNG headers), Unifont, a language and the sound list. + let background = "minecraft/textures/gui/title/background"; + let png = |n: u8| { + let mut data = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR\0\0\0\x02\0\0\0\x02".to_vec(); + data.push(n); + data + }; + let mut assets: Vec<(String, Vec)> = horizon_prepare::BUILT_IN_PACKS + .iter() + .map(|name| (name.to_string(), name.as_bytes().to_vec())) + .collect(); + for face in ["0", "1", "2", "3", "4", "5", "overlay"] { + assets.push(( + format!("{background}/panorama_{face}.png"), + png(face.as_bytes()[0]), + )); + } + for name in [ + "minecraft/font/include/unifont.json", + "minecraft/font/unifont.zip", + "pack.mcmeta", + "minecraft/lang/fr_fr.json", + "minecraft/sounds.json", + ] { + assets.push((name.into(), format!("{{\"{name}\": 1}}").into_bytes())); + } let mut index = serde_json::Map::new(); - for (name, data) in packs { + for (name, data) in &assets { let hash = sources::sha1_hex(data); write( store.join("assets/objects").join(&hash[..2]).join(&hash), data, ); index.insert( - name.into(), + name.clone(), serde_json::json!({"hash": hash, "size": data.len()}), ); } @@ -3105,6 +3192,8 @@ mod tests { .to_string(); write(store.join("versions/9.9/9.9.json"), manifest.as_bytes()); fs::create_dir_all(&empty).unwrap(); + // The client jar's 1x1 placeholder. + let placeholder = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR\0\0\0\x01\0\0\0\x01"; let release = |tag: &str, prepared: bool| { let root = versions.join(tag); write(root.join(COMPLETE), b""); @@ -3112,27 +3201,27 @@ mod tests { "client_sha1": "c", "server_sha1": "s", "manifest_sha1": sources::sha1_hex(manifest.as_bytes())}}); write(root.join("research-lock.json"), lock.to_string().as_bytes()); if prepared { - write(resources_at(&root).join(RESOURCES_MANIFEST), b"{}"); + let resources = resources_at(&root); + write(resources.join(RESOURCES_MANIFEST), b"{}"); + write(resources.join("assets/minecraft/lang/en_us.json"), b"{}"); write( - resources_at(&root).join("assets/minecraft/lang/en_us.json"), - b"{}", + resources + .join("assets") + .join(background) + .join("panorama_0.png"), + placeholder, ); } }; let lines = std::cell::RefCell::new(Vec::new()); let log = |line: String| lines.borrow_mut().push(line); - let pack = |tag: &str, i: usize| { - fs::read( - resources_at(&versions.join(tag)) - .join("assets") - .join(packs[i].0), - ) - .ok() + let asset = |tag: &str, name: &str| { + fs::read(resources_at(&versions.join(tag)).join("assets").join(name)).ok() }; - // Resources prepared before the packs were: a folder without an asset store is skipped. + // Resources prepared without any of them: a folder without an asset store is skipped. release("v0.2.1", true); - complete_packs_in( + complete_assets_in( &versions, "v0.2.1", || vec![empty.clone(), store.clone()], @@ -3140,37 +3229,55 @@ mod tests { ); assert_eq!( *lines.borrow(), - ["Built-in pack assets ready: 2 files, 0.0 MB"] + [ + "Built-in pack assets ready: 2 files, 0.0 MB", + "Panorama assets ready: 7 files, 0.0 MB", + "Font assets ready: 2 files, 0.0 MB", + "Language assets ready: 2 files, 0.0 MB", + "Sound assets ready: 1 files, 0.0 MB", + ] ); - for (i, (_, data)) in packs.iter().enumerate() { - assert_eq!(pack("v0.2.1", i).as_deref(), Some(*data)); + for (name, data) in &assets { + assert_eq!(asset("v0.2.1", name).as_ref(), Some(data), "{name}"); } // Nothing else is prepared again. let resources = resources_at(&versions.join("v0.2.1")); assert_eq!(fs::read(resources.join(RESOURCES_MANIFEST)).unwrap(), b"{}"); - assert!(!resources.join("assets/minecraft/sounds.json").exists()); - // No asset store anywhere: one line, the version stays prepared, and no second try. + // No asset store anywhere: one line each, the version stays prepared, and no second try. lines.borrow_mut().clear(); release("v0.2.2", true); - complete_packs_in(&versions, "v0.2.2", || vec![empty.clone()], &log); - complete_packs_in(&versions, "v0.2.2", || vec![empty.clone()], &log); - assert_eq!(lines.borrow().len(), 1); - assert!( - lines.borrow()[0].starts_with("v0.2.2: Programmer Art and High Contrast not added: "), - "{lines:?}" + complete_assets_in(&versions, "v0.2.2", || vec![empty.clone()], &log); + complete_assets_in(&versions, "v0.2.2", || vec![empty.clone()], &log); + let what = lines + .borrow() + .iter() + .map(|line| line.split(" not added: ").next().unwrap().to_string()) + .collect::>(); + assert_eq!( + what, + [ + "v0.2.2: Programmer Art and High Contrast", + "v0.2.2: The title panorama", + "v0.2.2: The Unifont glyphs", + "v0.2.2: The languages", + "v0.2.2: The sounds", + ] ); assert!(resources_at(&versions.join("v0.2.2")) .join(RESOURCES_MANIFEST) .exists()); - assert_eq!(pack("v0.2.2", 0), None); + assert_eq!( + asset("v0.2.2", &format!("{background}/panorama_0.png")).as_deref(), + Some(&placeholder[..]) + ); // Not prepared, or complete: nothing to do, without even looking for a Minecraft folder. lines.borrow_mut().clear(); release("v0.2.3", false); let unasked = || -> Vec { unreachable!("no folder is needed") }; - complete_packs_in(&versions, "v0.2.3", unasked, &log); - complete_packs_in(&versions, "v0.2.1", unasked, &log); + complete_assets_in(&versions, "v0.2.3", unasked, &log); + complete_assets_in(&versions, "v0.2.1", unasked, &log); assert!(lines.borrow().is_empty()); assert!(!resources_at(&versions.join("v0.2.3")).exists()); let _ = fs::remove_dir_all(&dir); diff --git a/launcher/src/main.rs b/launcher/src/main.rs index afc8dde45..9105f71a2 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -831,13 +831,14 @@ impl Launcher { // A little earlier than the spawn: file times can be coarse. self.client_started = Some(std::time::SystemTime::now() - std::time::Duration::from_secs(2)); - // The built-in packs the resources lack and the session (a network round trip when the - // token needs renewing) come first; `drain` starts the game when the session arrives. + // What the resources lack (built-in packs, panorama, sounds) and the session (a network + // round trip when the token needs renewing) come first; `drain` starts the game when the + // session arrives. self.starting_client = Some(tag.clone()); self.play_pressed = Some(Instant::now()); let tx = self.tx.clone(); std::thread::spawn(move || { - backend::complete_packs(&tag); + backend::complete_assets(&tag); let session = (|| { let mut accounts = Accounts::load(); let Some((a, secrets)) = account::session_for_launch( From 9fe77c7a56042ceb82ee634f0abc1610ede4f767 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 19:49:02 +0300 Subject: [PATCH 37/57] Creative: a stack keeps its components when it crosses the wire `Game::to_network` wrote damage alone into SetCreativeModeSlot, and `item_from_network` read back damage and enchantments, so anything else a creative stack carried was gone once the server echoed the inventory: a named sword lost its name, trimmed armour its trim, an enchanted book its stored enchantments and its repair cost. `item_sync` is the pair for the components Horizon models: `encode` writes them with the synced registries' ids (an unknown name is left out rather than sent as id 0, which is a different entry) and `apply` reads them back into the same `StackData` fields `item_data::decode_components` fills. Potions are not carried yet: `minecraft:potion_contents` has no id or codec in horizon-protocol, so a creative potion still arrives as an uncraftable one (#1832). Nor are containers, charged projectiles, written books and a map id, which the packet models but this patch does not write. Refs #1832 Co-Authored-By: Hermes Agent (Nous Research) --- crates/horizon-client/src/game/lan_actions.rs | 19 +- crates/horizon-client/src/game/remote.rs | 12 +- crates/horizon-client/src/item_data.rs | 2 +- crates/horizon-client/src/item_sync.rs | 170 ++++++++++++++++++ crates/horizon-client/src/lan/hub.rs | 8 + crates/horizon-client/src/lib.rs | 1 + crates/horizon-protocol/src/registry.rs | 6 + 7 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 crates/horizon-client/src/item_sync.rs diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 2c7019c73..1b4989055 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1216,21 +1216,22 @@ impl Game<'_> { host: &LanHost, stack: &Option, ) -> Option { - use horizon_protocol::item::{Component, component}; let s = stack.as_ref()?; let name = host .hub .item_name(s.item) .unwrap_or_else(|| "minecraft:air".into()); let mut out = ItemStack::new(&name, s.count.max(1) as u32); - out.damage = s.damage().max(0) as u32; - if let Some(Component::Enchantments(list)) = s.component(component::ENCHANTMENTS) { - for (id, level) in list { - if let Some(e) = host.hub.enchantment_name(*id) { - out.enchant(&e, *level as u32); - } - } - } + // The same components the encoder wrote, so a creative slot keeps what + // the client held instead of just damage and enchantments. + horizon_client::item_sync::apply( + s, + &mut out, + |id| host.hub.enchantment_name(id).map(|n| n.into()), + |id| host.hub.trim_name("minecraft:trim_material", id), + |id| host.hub.trim_name("minecraft:trim_pattern", id), + ); + out.tidy(); Some(out) } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index a3ab8d281..bbd688eef 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -761,10 +761,14 @@ impl Game<'_> { fn to_network(&self, s: &Option) -> Option { let s = s.as_ref()?; let mut stack = Stack::new(self.item_id(&s.item), s.count as i32); - if s.damage > 0 { - stack.added.push(( - horizon_protocol::item::component::DAMAGE, - horizon_protocol::item::Component::Int(s.damage as i32), + // The components the client models, with the synced registries' ids: + // without them the server's echo hands back a bare item. + if let Some(r) = self.remote.as_ref() { + stack.added.extend(horizon_client::item_sync::encode( + s, + |name| r.registries.id("minecraft:enchantment", name), + |name| r.registries.id("minecraft:trim_material", name), + |name| r.registries.id("minecraft:trim_pattern", name), )); } Some(stack) diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index c07e2f352..00fd0f074 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -291,7 +291,7 @@ fn filterable_text(t: &Tag) -> Option { None => read_text(t), } } -fn read_text(t: &Tag) -> Option { +pub(crate) fn read_text(t: &Tag) -> Option { match t { Tag::String(s) => Some(s.to_string_lossy()), // A component's root `text` may be empty with the visible words in diff --git a/crates/horizon-client/src/item_sync.rs b/crates/horizon-client/src/item_sync.rs new file mode 100644 index 000000000..4e48498ac --- /dev/null +++ b/crates/horizon-client/src/item_sync.rs @@ -0,0 +1,170 @@ +//! The data components a stack keeps when it crosses the wire, and how the +//! other side reads them back. A creative slot's packet carries a component +//! patch, and before this the encoder wrote damage alone, so everything else +//! (enchantments, a custom name, trim, repair cost) was gone once the server +//! echoed the inventory back. +use crate::item_data::{Levels, StackData}; +use crate::items::ItemStack; +use horizon_protocol::item::{Component, Stack, component}; +use horizon_world::nbt::Tag; +use std::sync::Arc; + +/// `Slot.STREAM_CODEC`'s patch for the components Horizon models, with the +/// registries' ids for enchantments and trim. A name the registry does not +/// know is left out: id 0 is a different entry, not a missing one. +pub fn encode( + stack: &ItemStack, + enchantment: impl Fn(&str) -> Option, + trim_material: impl Fn(&str) -> Option, + trim_pattern: impl Fn(&str) -> Option, +) -> Vec<(i32, Component)> { + let mut out = Vec::new(); + if stack.damage > 0 { + out.push((component::DAMAGE, Component::Int(stack.damage as i32))); + } + let Some(d) = stack.data() else { + return out; + }; + let levels = |levels: &Levels| -> Option { + let list: Vec<(i32, i32)> = levels + .iter() + .filter_map(|(name, level)| Some((enchantment(name)?, *level as i32))) + .collect(); + (!list.is_empty()).then_some(Component::Enchantments(list)) + }; + if let Some(c) = levels(&d.enchantments) { + out.push((component::ENCHANTMENTS, c)); + } + if let Some(c) = levels(&d.stored_enchantments) { + out.push((component::STORED_ENCHANTMENTS, c)); + } + if d.repair_cost > 0 { + out.push((component::REPAIR_COST, Component::Int(d.repair_cost as i32))); + } + if let Some(name) = &d.custom_name { + // A plain string is a valid text component, and `decode_components` + // reads it back as one. + out.push(( + component::CUSTOM_NAME, + Component::Text(Tag::String(name.as_str().into())), + )); + } + if let Some((material, pattern)) = &d.trim + && let (Some(material), Some(pattern)) = (trim_material(material), trim_pattern(pattern)) + { + out.push((component::TRIM, Component::Trim { material, pattern })); + } + out +} + +/// The same patch read back into `out`, the fields `item_data::decode_components` +/// fills from the equivalent `components` compound. +pub fn apply( + stack: &Stack, + out: &mut ItemStack, + enchantment: impl Fn(i32) -> Option>, + trim_material: impl Fn(i32) -> Option>, + trim_pattern: impl Fn(i32) -> Option>, +) { + out.damage = stack.damage().max(0) as u32; + let read_levels = |kind: i32, into: &mut Levels| { + if let Some(Component::Enchantments(list)) = stack.component(kind) { + for (id, level) in list { + if let Some(name) = enchantment(*id) { + into.push((name, (*level).max(0) as u32)); + } + } + } + }; + let data: &mut StackData = out.data_mut(); + read_levels(component::ENCHANTMENTS, &mut data.enchantments); + read_levels( + component::STORED_ENCHANTMENTS, + &mut data.stored_enchantments, + ); + if let Some(Component::Int(v)) = stack.component(component::REPAIR_COST) { + data.repair_cost = (*v).max(0) as u32; + } + if let Some(Component::Text(t)) = stack.component(component::CUSTOM_NAME) { + data.custom_name = crate::item_data::read_text(t); + } + if let Some(Component::Trim { material, pattern }) = stack.component(component::TRIM) + && let (Some(material), Some(pattern)) = (trim_material(*material), trim_pattern(*pattern)) + { + data.trim = Some((material, pattern)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn named(name: &str) -> Option { + Some(match name { + "minecraft:sharpness" => 3, + "minecraft:gold" => 7, + "minecraft:sentry" => 2, + _ => return None, + }) + } + fn name_of(id: i32) -> Option> { + Some(Arc::from(match id { + 3 => "minecraft:sharpness", + 7 => "minecraft:gold", + 2 => "minecraft:sentry", + _ => return None, + })) + } + + fn stack_with_data() -> ItemStack { + let mut stack = ItemStack::new("minecraft:diamond_sword", 1); + stack.damage = 5; + let d = stack.data_mut(); + d.enchantments.push((Arc::from("minecraft:sharpness"), 4)); + d.stored_enchantments + .push((Arc::from("minecraft:sharpness"), 2)); + d.repair_cost = 9; + d.custom_name = Some("Keeper".to_owned()); + d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry"))); + stack + } + + /// Every modelled component survives the packet, so the server's echo does + /// not hand back a bare item. + #[test] + fn a_stack_keeps_its_components_across_the_wire() { + let stack = stack_with_data(); + let mut packet = Stack::new(1, 1); + packet.added = encode(&stack, named, named, named); + + let mut back = ItemStack::new("minecraft:diamond_sword", 1); + apply(&packet, &mut back, name_of, name_of, name_of); + back.tidy(); + + assert_eq!(back.damage, 5); + let d = back.data().expect("components"); + assert_eq!(d.enchantments, vec![(Arc::from("minecraft:sharpness"), 4)]); + assert_eq!( + d.stored_enchantments, + vec![(Arc::from("minecraft:sharpness"), 2)] + ); + assert_eq!(d.repair_cost, 9); + assert_eq!(d.custom_name.as_deref(), Some("Keeper")); + assert_eq!( + d.trim, + Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry"))) + ); + } + + /// A name the registry does not know is a missing id, not id 0, which would + /// name the registry's first entry instead. + #[test] + fn an_unknown_enchantment_is_left_out_rather_than_sent_as_zero() { + let mut stack = ItemStack::new("minecraft:book", 1); + stack + .data_mut() + .enchantments + .push((Arc::from("mod:unknown"), 1)); + assert!(encode(&stack, named, named, named).is_empty()); + } +} diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index dd0a32f22..2667b1fed 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -2293,6 +2293,14 @@ impl Hub { .name("minecraft:enchantment", id) .map(str::to_owned) } + /// An entry of a synced registry by id, for the components a packet + /// carries by id (`minecraft:trim_material`, `minecraft:trim_pattern`). + pub fn trim_name(&self, registry: &str, id: i32) -> Option> { + self.sync + .dynamic + .name(registry, id) + .map(std::sync::Arc::from) + } /// ServerLevel.removePlayerImmediately, then addRespawnedPlayer: the other players /// lose the dead player's entity (ServerEntity.removePairing) and are sent the diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index ae95e1bdb..ad05153eb 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -96,6 +96,7 @@ pub mod inv_sort; pub mod inventory_effects; pub mod item_data; pub mod item_render; +pub mod item_sync; pub mod item_tooltip; pub mod items; pub mod java_text; diff --git a/crates/horizon-protocol/src/registry.rs b/crates/horizon-protocol/src/registry.rs index f97401670..400c21a37 100644 --- a/crates/horizon-protocol/src/registry.rs +++ b/crates/horizon-protocol/src/registry.rs @@ -102,6 +102,12 @@ impl Dynamic { .get(i) .map(|(n, _)| n.as_str()) } + /// The id of an entry by name, the reverse of `name`. A registry has one + /// entry per id, so the first match is the only one. + pub fn id(&self, registry: &str, name: &str) -> Option { + let list = self.registries.get(registry)?; + list.iter().position(|(n, _)| n == name).map(|i| i as i32) + } pub fn data(&self, registry: &str, id: i32) -> Option<&Tag> { let i = usize::try_from(id).ok()?; self.registries.get(registry)?.get(i)?.1.as_ref() From 5ee1130719904c4101a0b13748fc53b1adaab1ee Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:53:13 +0100 Subject: [PATCH 38/57] horizon-dualsense: iOS comments name only what exists The repo has no horizon-gamecontroller crate, and on iOS the Link never opens, so no rumble covers vibration there. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-dualsense/Cargo.toml | 5 ++--- crates/horizon-dualsense/src/device.rs | 3 +-- crates/horizon-dualsense/src/haptics_stub.rs | 5 ++--- crates/horizon-dualsense/src/lib.rs | 3 +-- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/horizon-dualsense/Cargo.toml b/crates/horizon-dualsense/Cargo.toml index 1a41e5703..feb25026b 100644 --- a/crates/horizon-dualsense/Cargo.toml +++ b/crates/horizon-dualsense/Cargo.toml @@ -10,9 +10,8 @@ description = "The DualSense's output over HID: rumble, adaptive triggers, light log = "0.4" # hidapi has no iOS backend and its build script stops there, so the output's two -# dependencies go only to the targets that have one. On iOS the crate builds empty: pads -# are read through GameController (horizon-gamecontroller), which only reads, and the -# DualSense's own output waits for a desktop. +# dependencies go only to the targets that have one. On iOS the crate builds without +# output: the Link never opens. [target.'cfg(not(target_os = "ios"))'.dependencies] # MIT; bundles the C hidapi under its BSD licence option. hidraw on Linux (libudev, as gilrs), # IOKit on macOS (opened shared, so gilrs keeps reading the pad), the HID API on Windows. diff --git a/crates/horizon-dualsense/src/device.rs b/crates/horizon-dualsense/src/device.rs index 0b11ac415..f8e544ea6 100644 --- a/crates/horizon-dualsense/src/device.rs +++ b/crates/horizon-dualsense/src/device.rs @@ -165,8 +165,7 @@ impl Link { } } - /// No output on iOS: hidapi has no backend there, and iOS reads pads through - /// GameController (horizon-gamecontroller), which only reads. The Link never opens. + /// No output on iOS: hidapi has no backend there. The Link never opens. #[cfg(target_os = "ios")] pub fn start() -> Link { Link { diff --git a/crates/horizon-dualsense/src/haptics_stub.rs b/crates/horizon-dualsense/src/haptics_stub.rs index 7afc944ca..dc4e61667 100644 --- a/crates/horizon-dualsense/src/haptics_stub.rs +++ b/crates/horizon-dualsense/src/haptics_stub.rs @@ -1,6 +1,5 @@ -//! No HD haptics on iOS: the trick plays vibrations through the pad's own four-channel audio -//! device over USB, and a phone has neither that device nor the audio stack the real module -//! needs. The motors' rumble through [`crate::Link`] covers vibration instead. +//! No HD haptics on iOS: they play through the pad's own four-channel audio device over USB, +//! which needs cpal, and cpal is left out there with hidapi (see Cargo.toml). /// The pad's audio output, playing the queued samples (left, right actuator). pub struct Haptics; diff --git a/crates/horizon-dualsense/src/lib.rs b/crates/horizon-dualsense/src/lib.rs index 80831ee00..4ae55de9a 100644 --- a/crates/horizon-dualsense/src/lib.rs +++ b/crates/horizon-dualsense/src/lib.rs @@ -1,8 +1,7 @@ //! The DualSense's output report, built by hand (docs/controllers.md "DualSense output"): the //! compatible-vibration motors, the adaptive triggers, the lightbar and the player LEDs. Gilrs //! keeps reading the pad; this crate only writes to it, over hidapi ([`device`]). -//! On iOS the crate builds without hidapi, which has no backend there: [`Link`] never opens, -//! and the pad is read through GameController, which only reads. +//! On iOS the crate builds without hidapi, which has no backend there: [`Link`] never opens. //! //! The byte layout is the one documented by the Linux kernel's `hid-playstation.c` and SDL's //! `SDL_hidapi_ps5.c`: a 47-byte block shared by both transports, sent as report 0x02 over USB From f2832f83ef4e811e744312ac01117114a098f746 Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 17:53:13 +0100 Subject: [PATCH 39/57] check: cover the ui open/close-screen outputs Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/cargo-horizon-mod/src/check.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index 0294bec98..5d4150ab9 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -215,6 +215,8 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { format!("{}: {text}", if error { "error" } else { "feedback" }) } Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")), + Output::OpenScreen { .. } => "opened a screen".to_string(), + Output::CloseScreen { .. } => "closed its screen".to_string(), }); } match failed(&mods) { From 72b9daabf01ee530be04b6dba5f3f58fe9ef2410 Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 18:24:46 +0100 Subject: [PATCH 40/57] =?UTF-8?q?Resolve=20legacy=20=C2=A7=20codes=20where?= =?UTF-8?q?=20the=20font=20draws=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name tags and styled HUD text drew a span's § codes as glyphs instead of restyling what follows, as StringDecomposer.iterateFormatted does. A team prefix like "§c" showed literally over players' heads, and a resource pack's styled menu.disconnect kept its codes on the leave-server button. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/font.rs | 5 ++++- crates/horizon-client/src/game/names.rs | 12 ++++++++---- crates/horizon-client/src/hud.rs | 3 +++ crates/horizon-client/src/text.rs | 25 +++++++++++++++++++++++++ crates/horizon-client/src/ui.rs | 12 ++++++++++-- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/font.rs b/crates/horizon-client/src/font.rs index 801a892d5..1903cab7e 100644 --- a/crates/horizon-client/src/font.rs +++ b/crates/horizon-client/src/font.rs @@ -891,8 +891,11 @@ impl Font { } s.chars().map(|c| self.glyph(c).advance).sum::().ceil() } + /// Font.width of a component: `§` codes in the spans are formatting, + /// not glyphs. pub fn width_text(&self, t: &Text) -> f32 { - t.chars() + t.resolved() + .chars() .map(|(c, s)| self.advance(c, s)) .sum::() .ceil() diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index be829f1bb..7655fb489 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -151,10 +151,13 @@ impl Game<'_> { .clone() .unwrap_or_else(|| Text::literal(&name)) }; + // StringDecomposer.iterateFormatted: `§` codes a server leaves + // inside the team prefix or the name itself apply at draw time. let shown = match team { Some(t) => t.formatted_name(member), None => member, - }; + } + .resolve_legacy(); let mut lines = Vec::new(); // EntityRenderer.extractNameTags: the score shows within the // below-name distance (10), under the name. @@ -162,7 +165,7 @@ impl Game<'_> { if distance_sqr < 10.0 * 10.0 && let Some(score) = holder.and_then(|h| self.below_name_text(&h)) { - lines.push((score, 0.0)); + lines.push((score.resolve_legacy(), 0.0)); lift = f64::from(9.0f32 * 1.15 * 0.025); } lines.push((shown, lift)); @@ -213,14 +216,15 @@ impl Game<'_> { let shown = match team { Some(t) => t.formatted_name(name.clone()), None => name.clone(), - }; + } + .resolve_legacy(); let mut lines = Vec::new(); let mut lift = 0.0; let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u)); if distance_sqr < 10.0 * 10.0 && let Some(score) = holder.and_then(|h| self.below_name_text(&h)) { - lines.push((score, 0.0)); + lines.push((score.resolve_legacy(), 0.0)); lift = f64::from(9.0f32 * 1.15 * 0.025); } lines.push((shown, lift)); diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs index 617126c2e..4e3290f15 100644 --- a/crates/horizon-client/src/hud.rs +++ b/crates/horizon-client/src/hud.rs @@ -890,6 +890,9 @@ impl HudBatch { color: u32, shadow: bool, ) -> f32 { + // StringDecomposer.iterateFormatted inside Font.drawInternal: + // `§` codes inside a span restyle what follows and are not drawn. + let text = text.resolved(); let font = &atlas.font; let alpha = (color >> 24) as u8; let mut glyphs = Vec::new(); diff --git a/crates/horizon-client/src/text.rs b/crates/horizon-client/src/text.rs index 1254d32f6..4c04c9da5 100644 --- a/crates/horizon-client/src/text.rs +++ b/crates/horizon-client/src/text.rs @@ -428,6 +428,15 @@ impl Text { } out } + /// `resolve_legacy` borrowing `self` when no span holds a `§` code (the + /// common case). + pub fn resolved(&self) -> std::borrow::Cow<'_, Text> { + if self.spans.iter().any(|s| s.text.contains('§')) { + std::borrow::Cow::Owned(self.resolve_legacy()) + } else { + std::borrow::Cow::Borrowed(self) + } + } /// The text without its legacy `§` codes (ChatFormatting.stripFormatting: /// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`). pub fn strip_legacy(&self) -> Text { @@ -695,4 +704,20 @@ mod tests { assert_eq!(t.spans[1].style.color, Some(GOLD)); assert_eq!(t.slice(1, 2).plain(), "y"); } + #[test] + fn resolved_borrows_clean_text_and_restyles_codes() { + let clean = Text::colored("Steve", GOLD); + assert!(matches!(clean.resolved(), std::borrow::Cow::Borrowed(_))); + // A colour code inside a span clears its decorations (a bold + // "§cSteve" draws "Steve" red, not bold). + let styled = Style { + bold: Some(true), + ..Style::default() + }; + let styled = Text::styled("§cSteve", styled); + let resolved = styled.resolved(); + assert_eq!(resolved.plain(), "Steve"); + assert_eq!(resolved.spans[0].style.color, Some(RED)); + assert_eq!(resolved.spans[0].style.bold, Some(false)); + } } diff --git a/crates/horizon-client/src/ui.rs b/crates/horizon-client/src/ui.rs index 1c6e93067..3cf7af9cb 100644 --- a/crates/horizon-client/src/ui.rs +++ b/crates/horizon-client/src/ui.rs @@ -279,9 +279,17 @@ pub fn centered_text( w: f32, color: u32, ) { - let width = atlas.font.width_text(text); + let text = text.resolved(); + let width = atlas.font.width_text(&text); if width <= w { - batch.text_styled(atlas, text, x + ((w - width) / 2.0).floor(), y, color, true); + batch.text_styled( + atlas, + &text, + x + ((w - width) / 2.0).floor(), + y, + color, + true, + ); } else { // Clip by characters (vanilla scrolls; we show the part that fits). let mut shown = Text::empty(); From 13508cbd9c6ee7122694dc3f1482a10683f408ca Mon Sep 17 00:00:00 2001 From: Jan Pres Date: Sun, 25 Oct 2026 17:41:07 +0000 Subject: [PATCH 41/57] Creative: Ctrl+Backspace deletes a word in the search The creative inventory's search box is an append-only EditBox of 50 characters, but its editing was a plain String pop, so Ctrl+Backspace removed one character instead of the word before the caret, unlike every other text field and vanilla's EditBox.keyPressed. Move EditBox.getWordPosition and deleteWords into one shared helper in ui.rs (the UI and chat text fields had two copies of it), and route a Ctrl+Backspace in the creative search through it. A Select All selection is still what backspace deletes first, as in vanilla. Fixes #1764. Co-Authored-By: OpenCode (DeepSeek V4.1 Flash Free) --- crates/horizon-client/src/game.rs | 5 +- crates/horizon-client/src/game/clipboard.rs | 22 ++++-- crates/horizon-client/src/game/control.rs | 2 +- crates/horizon-client/src/game/pad_menu.rs | 6 +- crates/horizon-client/src/screen_draw.rs | 21 +---- crates/horizon-client/src/ui.rs | 86 ++++++++++++++++----- 6 files changed, 92 insertions(+), 50 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7fcb3be89..6df6e531c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11272,9 +11272,10 @@ impl ApplicationHandler for Game<'_> { // The hovered slot took the key (`ignoreTextInput`). } else if let Some(shortcut) = self.edit_shortcut(&event) { self.creative_search_shortcut(shortcut); + } else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) { + self.creative_search_backspace(self.has_control_down()); } else { - let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace); - self.creative_search_edit(event.text.as_deref(), backspace); + self.creative_search_edit(event.text.as_deref()); } } WindowEvent::KeyboardInput { event, .. } diff --git a/crates/horizon-client/src/game/clipboard.rs b/crates/horizon-client/src/game/clipboard.rs index b9558ddfd..c87aa3681 100644 --- a/crates/horizon-client/src/game/clipboard.rs +++ b/crates/horizon-client/src/game/clipboard.rs @@ -97,15 +97,25 @@ impl Game<'_> { self.creative_screen.scroll = 0.0; } } - /// Typing or a backspace in the creative search box. - pub(super) fn creative_search_edit(&mut self, typed: Option<&str>, backspace: bool) { + /// A backspace in the creative search box: with Ctrl, the whole word + /// before the caret (`EditBox.deleteWords(-1)`), otherwise one character. + /// A Select All selection is what it deletes, as in `EditBox.keyPressed`. + pub(super) fn creative_search_backspace(&mut self, ctrl: bool) { let search = &mut self.creative_screen.search; - let cleared = self.text_selection.take(CREATIVE_SEARCH, search); - if backspace { - if !cleared { + if !self.text_selection.take(CREATIVE_SEARCH, search) { + if ctrl { + horizon_client::ui::delete_words(search, -1); + } else { search.pop(); } - } else if let Some(text) = typed { + } + self.creative_screen.scroll = 0.0; + } + /// Typing in the creative search box. + pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) { + let search = &mut self.creative_screen.search; + self.text_selection.take(CREATIVE_SEARCH, search); + if let Some(text) = typed { clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS); } self.creative_screen.scroll = 0.0; diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index a0d8987d8..987937066 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -569,7 +569,7 @@ impl Game<'_> { } Screen::Creative if self.creative_screen.tab().search => { // As typed: replaces a selected query, keeps the length limit. - self.creative_search_edit(Some(text), false) + self.creative_search_edit(Some(text)) } _ => { return Err( diff --git a/crates/horizon-client/src/game/pad_menu.rs b/crates/horizon-client/src/game/pad_menu.rs index 32acdd256..30f2e7337 100644 --- a/crates/horizon-client/src/game/pad_menu.rs +++ b/crates/horizon-client/src/game/pad_menu.rs @@ -921,8 +921,10 @@ impl Game<'_> { } else if self.book.is_some() { self.book_input(key, text); } else if self.screen == Screen::Creative && self.creative_screen.tab().search { - if key != Some(KeyCode::Enter) { - self.creative_search_edit(text, backspace); + if backspace { + self.creative_search_backspace(false); + } else if key != Some(KeyCode::Enter) { + self.creative_search_edit(text); } } else if self.recipe_search_focused() { if key != Some(KeyCode::Enter) { diff --git a/crates/horizon-client/src/screen_draw.rs b/crates/horizon-client/src/screen_draw.rs index 572ee202f..03f979a68 100644 --- a/crates/horizon-client/src/screen_draw.rs +++ b/crates/horizon-client/src/screen_draw.rs @@ -619,26 +619,7 @@ impl TextField { /// `getWordPosition(dir, from)`: past the next space run, or back to the /// start of the word. pub fn word_position(&self, dir: isize, from: usize) -> usize { - let v: Vec = self.value.chars().collect(); - let mut at = from; - if dir > 0 { - at = v - .iter() - .skip(at) - .position(|c| *c == ' ') - .map_or(v.len(), |p| p + at); - while at < v.len() && v[at] == ' ' { - at += 1; - } - } else { - while at > 0 && v[at - 1] == ' ' { - at -= 1; - } - while at > 0 && v[at - 1] != ' ' { - at -= 1; - } - } - at + crate::ui::word_position(&self.value, dir, from) } /// `keyPressed` of a focused, editable box: `Some(changed)` when handled. pub fn key( diff --git a/crates/horizon-client/src/ui.rs b/crates/horizon-client/src/ui.rs index 1c6e93067..5f8f7b9f9 100644 --- a/crates/horizon-client/src/ui.rs +++ b/crates/horizon-client/src/ui.rs @@ -821,6 +821,40 @@ fn byte_index(s: &str, chars: usize) -> usize { pub fn char_len(s: &str) -> usize { s.chars().count() } +/// `EditBox.getWordPosition(dir, from)`: the character index `dir` words away +/// from `from`. Forward, past the rest of the word and its spaces; backward, +/// over any spaces to the start of the word. +pub fn word_position(value: &str, dir: isize, from: usize) -> usize { + let chars: Vec = value.chars().collect(); + let mut pos = from.min(chars.len()); + if dir < 0 { + while pos > 0 && chars[pos - 1] == ' ' { + pos -= 1; + } + while pos > 0 && chars[pos - 1] != ' ' { + pos -= 1; + } + } else { + while pos < chars.len() && chars[pos] != ' ' { + pos += 1; + } + while pos < chars.len() && chars[pos] == ' ' { + pos += 1; + } + } + pos +} +/// `EditBox.deleteWords(dir)` for an append-only field, whose caret is at the +/// end: removes the word in that direction. True when the value changed. +pub fn delete_words(value: &mut String, dir: isize) -> bool { + let len = char_len(value); + let to = word_position(value, dir, len); + if to == len { + return false; + } + value.truncate(byte_index(value, to)); + true +} impl crate::clipboard::Editable for EditBox { fn select_all(&mut self) { self.move_cursor_to_end(false); @@ -919,25 +953,7 @@ impl EditBox { } /// EditBox.getWordPosition(dir, from). fn word_position_from(&self, dir: isize, from: usize) -> usize { - let chars: Vec = self.value.chars().collect(); - let mut pos = from.min(chars.len()); - if dir < 0 { - while pos > 0 && chars[pos - 1] == ' ' { - pos -= 1; - } - while pos > 0 && chars[pos - 1] != ' ' { - pos -= 1; - } - } else { - let len = chars.len(); - while pos < len && chars[pos] != ' ' { - pos += 1; - } - while pos < len && chars[pos] == ' ' { - pos += 1; - } - } - pos + word_position(&self.value, dir, from) } pub fn set_cursor(&mut self, pos: usize, select: bool) { self.cursor = pos.min(char_len(&self.value)); @@ -1451,4 +1467,36 @@ mod tests { assert_eq!(e.value, "éx worl"); assert_eq!(e.cursor, 2); } + #[test] + fn word_position_steps_over_a_word_and_its_spaces() { + assert_eq!(word_position("hello world", -1, 11), 6); + assert_eq!(word_position("hello world", -1, 6), 0); + assert_eq!(word_position("hello world", -1, 13), 8); + assert_eq!(word_position("hello world", 1, 5), 8); + // From inside a word: back to its start, forward past it and its spaces. + assert_eq!(word_position("hello world", -1, 8), 6); + assert_eq!(word_position("hello world", 1, 2), 6); + // The ends, and a position past the end. + assert_eq!(word_position("hello", -1, 0), 0); + assert_eq!(word_position("hello", 1, 5), 5); + assert_eq!(word_position("hi", -1, 99), 0); + assert_eq!(word_position("hi", 1, 99), 2); + } + #[test] + fn delete_words_removes_the_word_before_the_end() { + let mut v = "stone bricks".to_string(); + assert!(delete_words(&mut v, -1)); + assert_eq!(v, "stone "); + assert!(delete_words(&mut v, -1)); + assert_eq!(v, ""); + assert!(!delete_words(&mut v, -1)); + // Characters, not bytes. + let mut v = "a é§x".to_string(); + assert!(delete_words(&mut v, -1)); + assert_eq!(v, "a "); + // Nothing after the end to delete. + let mut v = "stone".to_string(); + assert!(!delete_words(&mut v, 1)); + assert_eq!(v, "stone"); + } } From bdbc816486ea2bc8aa3362a8547be73af897a9a1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:53:20 +0100 Subject: [PATCH 42/57] Creative search: match the localized hover name, not the item's base name The base name (Tipped Arrow, Potion, White Banner) matched where vanilla's first tooltip line (Arrow of Poison, Water Bottle, Ominous Banner) doesn't: the creative harness found extra results for 'e', 'potion', 'tipped' and 'white'. name_line builds the full hover name through display_get. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/creative.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index 593aad61d..249e40b11 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -346,10 +346,16 @@ impl Creative { } // FullTextSearchTree: tooltip lines, and the item id after a colon. // The export's lines are English (they come from the vanilla jars); - // the display name in the selected language is matched too, so a - // localized search finds the item. + // the hover name in the selected language is matched too, so a + // localized search finds the item. It is the whole name (a tipped + // arrow's potion, the ominous banner), as the first line is. let text = java_lower(term); - let localized = |i: usize| java_lower(&names.display_item(&self.search_items[i].item)); + let localized = |i: usize| { + java_lower(&crate::item_tooltip::name_line( + &self.search_items[i], + &|k| names.display_get(k), + )) + }; all.filter(|&i| { let e = &self.search_index[i]; let fallback; From 90eda915d5dec7d02c929ec867262bfe867d396f Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Sun, 25 Oct 2026 18:58:52 +0100 Subject: [PATCH 43/57] Add a lore tooltip harness against ItemLore.addToTooltip. The styled lines have to keep matching vanilla, including an explicit non-italic span and a lore component hidden by tooltip_display. --- .../examples/probe/lore_tooltip_fixture.rs | 53 +++++++++++++++++ crates/horizon-client/examples/probe/main.rs | 2 + .../parity/VanillaLoreTooltipOracle.java | 58 +++++++++++++++++++ tools/test_lore_tooltip_parity.py | 41 +++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 crates/horizon-client/examples/probe/lore_tooltip_fixture.rs create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaLoreTooltipOracle.java create mode 100644 tools/test_lore_tooltip_parity.py diff --git a/crates/horizon-client/examples/probe/lore_tooltip_fixture.rs b/crates/horizon-client/examples/probe/lore_tooltip_fixture.rs new file mode 100644 index 000000000..350d9be81 --- /dev/null +++ b/crates/horizon-client/examples/probe/lore_tooltip_fixture.rs @@ -0,0 +1,53 @@ +//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans +//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line). +use horizon_client::{ + item_data::parse_stack, + item_tooltip::{TooltipDisplay, lore_texts}, +}; +use std::{error::Error, fmt::Write}; + +pub fn main() -> Result<(), Box> { + let args: Vec = crate::args().collect(); + let input = std::fs::read_to_string(&args[1])?; + let mut output = String::new(); + for line in input + .lines() + .filter(|l| !l.is_empty() && !l.starts_with('#')) + { + let stack = parse_stack(line)?.ok_or("empty stack")?; + let display = TooltipDisplay::for_stack(&stack); + if !display.shows("minecraft:lore") { + writeln!(output, "-")?; + continue; + } + let lines = lore_texts(&stack); + if lines.is_empty() { + writeln!(output, "-")?; + continue; + } + let mut rendered = Vec::new(); + for text in lines { + let mut spans = Vec::new(); + for span in &text.spans { + if span.text.is_empty() { + continue; + } + let style = &span.style; + spans.push(format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}", + span.text, + style.color.map(|c| c as i32).unwrap_or(-1), + style.bold == Some(true), + style.italic == Some(true), + style.underlined == Some(true), + style.strikethrough == Some(true), + style.obfuscated == Some(true), + )); + } + rendered.push(spans.join(" | ")); + } + writeln!(output, "{}", rendered.join(" || "))?; + } + std::fs::write(&args[2], output)?; + Ok(()) +} diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index f05c2a662..e80088fd0 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -49,6 +49,7 @@ mod input_probe; mod interaction_input_fixture; mod lan_probe; mod light_parity; +mod lore_tooltip_fixture; mod marker_particles_probe; mod menu_fixture; mod mipmap_probe; @@ -133,6 +134,7 @@ probes! { interaction_input_fixture, lan_probe, light_parity, + lore_tooltip_fixture, marker_particles_probe, menu_fixture, mipmap_probe, diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaLoreTooltipOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaLoreTooltipOracle.java new file mode 100644 index 000000000..462a8016a --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaLoreTooltipOracle.java @@ -0,0 +1,58 @@ +package dev.horizonlab.parity; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Optional; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.ItemLore; +import net.minecraft.world.item.component.TooltipDisplay; + +/** + * The styled lines {@code ItemLore.addToTooltip} adds, one stack (SNBT) per input + * line. Each line is its visited spans ({@code text, colour, bold, italic, + * underlined, strikethrough, obfuscated}); lines are separated by {@code ||}. + */ +public final class VanillaLoreTooltipOracle { + public static void main(String[] args) throws Exception { + ParityData.init(); + var context = Item.TooltipContext.of(ParityData.lookup); + var output = new ArrayList(); + for (String line : Files.readAllLines(Path.of(args[0]))) { + if (line.isBlank() || line.startsWith("#")) continue; + var stack = ParityData.stack(line); + var display = stack.getOrDefault(DataComponents.TOOLTIP_DISPLAY, TooltipDisplay.DEFAULT); + ItemLore lore = stack.get(DataComponents.LORE); + if (lore == null || !display.shows(DataComponents.LORE)) { + output.add("-"); + continue; + } + var lines = new ArrayList(); + lore.addToTooltip(context, component -> lines.add(spans(component)), TooltipFlag.NORMAL, stack); + output.add(lines.isEmpty() ? "-" : String.join(" || ", lines)); + } + Files.write(Path.of(args[1]), output); + } + + /** {@code Component.visit} from {@code Style.EMPTY}: the style a tooltip draws. */ + static String spans(Component component) { + var out = new StringBuilder(); + component.visit((style, text) -> { + if (text.isEmpty()) return Optional.empty(); + if (out.length() > 0) out.append(" | "); + int color = style.getColor() == null ? -1 : style.getColor().getValue(); + out.append(text).append('\t').append(color).append('\t') + .append(style.isBold()).append('\t') + .append(style.isItalic()).append('\t') + .append(style.isUnderlined()).append('\t') + .append(style.isStrikethrough()).append('\t') + .append(style.isObfuscated()); + return Optional.empty(); + }, Style.EMPTY); + return out.toString(); + } +} diff --git a/tools/test_lore_tooltip_parity.py b/tools/test_lore_tooltip_parity.py new file mode 100644 index 000000000..d262a3542 --- /dev/null +++ b/tools/test_lore_tooltip_parity.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Compare item lore tooltip styles with the pinned vanilla client. + +Vanilla prints what ``ItemLore.addToTooltip`` adds after ``ComponentUtils.mergeStyles`` +with dark purple italic (VanillaLoreTooltipOracle). Horizon prints what +``item_tooltip::lore_texts`` gives (examples/probe/lore_tooltip_fixture). +""" +try: + from . import lab, oracle_harness as harness +except ImportError: + import lab, oracle_harness as harness + +# SNBT stacks. A lore line is a text component; italic:0b is an explicit false. +CASES = [ + '{id:"minecraft:stick",count:1}', + '{id:"minecraft:stick",count:1,components:{"minecraft:lore":[\'"Plain"\']}}', + '{id:"minecraft:stick",count:1,components:{"minecraft:lore":[\'{"text":"Gold","color":"gold","italic":0b}\']}}', + '{id:"minecraft:stick",count:1,components:{"minecraft:lore":[\'{"text":"A","extra":[{"text":"B","color":"aqua","italic":0b}]}\']}}', + '{id:"minecraft:stick",count:1,components:{"minecraft:lore":[\'"One"\',\'{"text":"Two","bold":1b}\']}}', + '{id:"minecraft:stick",count:1,components:{"minecraft:lore":[\'"Hidden"\'],"minecraft:tooltip_display":{hidden_components:["minecraft:lore"]}}}', + '{id:"minecraft:stick",count:1,components:{"minecraft:lore":[\'{"text":"Line","underlined":1b,"strikethrough":1b}\']}}', +] + + +def cases(build): + path = build / 'cases.txt' + path.write_text(''.join(line + '\n' for line in CASES)) + return path, len(CASES) + + +if __name__ == '__main__': + harness.differential( + 'lore-tooltip-parity', 'VanillaLoreTooltipOracle', 'lore_tooltip_fixture', cases, + 'Item lore tooltip lines: dark purple italic, child colours, explicit non-italic, and tooltip_display', + sources=[ + lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', + lab.ROOT / 'crates/horizon-client/src/item_data.rs', + lab.ROOT / 'crates/horizon-client/src/text.rs', + lab.ROOT / 'crates/horizon-client/src/component.rs', + ], + expected_lines=len(CASES)) From b3840adccdaf48f9c04f9e67f5b4decf74fd2545 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:08:03 +0000 Subject: [PATCH 44/57] Fix renderer backend fallback after surface creation failure Keep surface creation errors in the backend failure list so Windows Auto can try DirectX 12 after Vulkan fails. Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/gfx.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 31853ad92..2c54a0e53 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -685,7 +685,7 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor { /// device. `api` is the player's choice (`GraphicsApi::with_env` applied); /// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends /// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when -/// Vulkan has no adapter (no Vulkan driver) or no device. A native crash in +/// Vulkan has no surface, adapter (no Vulkan driver) or device. A native crash in /// the driver can't be caught in the game; the launcher switches the setting /// to DirectX 12 after one at the renderer's start (#746). fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec { @@ -793,7 +793,13 @@ async fn open_device( let surface = if offscreen { None } else { - Some(instance.create_surface(window.clone())?) + match instance.create_surface(window.clone()) { + Ok(surface) => Some(surface), + Err(e) => { + failures.push(format!("{name} surface: {e}")); + continue; + } + } }; let adapter = match instance .request_adapter(&wgpu::RequestAdapterOptions { From f969415c7a91ab19c83c6b3d98d1d6c9950eca6c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:36:52 +0100 Subject: [PATCH 45/57] Creative stacks: reuse the wire codec both sides already had Horizon already encoded stacks for the wire (lan::guest::stack_to_network, what a Horizon server sends its clients: damage, enchantments, stored enchantments, repair cost, custom name, dyed colour, trim, maps, charged projectiles, books) and decoded them on the client (remote.rs's protocol_components + item_data::decode_components, every component it has a codec for). The new encode/apply pair covered six components and duplicated both. - The client's SetCreativeModeSlot stack goes through stack_to_network. - The decoder moves into item_sync::from_network, used by the client for what its server sends and by the server (Hub::stack_from_network) for a creative client's slot, so the server now keeps every component the client's decoder reads, not six. - One round-trip test covers encode then decode with registries. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 17 +- crates/horizon-client/src/game/remote.rs | 198 +-------- crates/horizon-client/src/item_data.rs | 2 +- crates/horizon-client/src/item_sync.rs | 378 ++++++++++++------ crates/horizon-client/src/lan/hub.rs | 11 +- 5 files changed, 270 insertions(+), 336 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 1b4989055..84633836c 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1217,21 +1217,8 @@ impl Game<'_> { stack: &Option, ) -> Option { let s = stack.as_ref()?; - let name = host - .hub - .item_name(s.item) - .unwrap_or_else(|| "minecraft:air".into()); - let mut out = ItemStack::new(&name, s.count.max(1) as u32); - // The same components the encoder wrote, so a creative slot keeps what - // the client held instead of just damage and enchantments. - horizon_client::item_sync::apply( - s, - &mut out, - |id| host.hub.enchantment_name(id).map(|n| n.into()), - |id| host.hub.trim_name("minecraft:trim_material", id), - |id| host.hub.trim_name("minecraft:trim_pattern", id), - ); - out.tidy(); + let mut out = host.hub.stack_from_network(s); + out.count = out.count.max(1); Some(out) } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 55d259c1a..2c6ab4a26 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -223,30 +223,6 @@ fn face_direction(face: [i32; 3]) -> u8 { } } -/// A `minecraft:profile` component as the item data JSON the model reads. -fn profile_tag(p: &horizon_protocol::item::Profile) -> Tag { - let properties = p - .properties - .iter() - .map(|(name, value, signature)| { - let mut entries: Vec<(&'static str, Tag)> = vec![ - ("name", Tag::String(name.as_str().into())), - ("value", Tag::String(value.as_str().into())), - ]; - if let Some(signature) = signature { - entries.push(("signature", Tag::String(signature.as_str().into()))); - } - Tag::compound(entries) - }) - .collect::>(); - let mut entries: Vec<(&'static str, Tag)> = Vec::new(); - if let Some(name) = &p.name { - entries.push(("name", Tag::String(name.as_str().into()))); - } - entries.push(("properties", Tag::List(properties))); - Tag::compound(entries) -} - /// A protocol number format as the scoreboard's. fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFormat { use horizon_client::scoreboard::{NumberFormat, tag_to_json}; @@ -610,168 +586,26 @@ impl Game<'_> { .and_then(|i| i.id(name)) .unwrap_or(0) } + /// A stack from the server, decoded as the saved and command item NBT + /// are, so tooltips and models see every field. pub(super) fn to_stack(&self, s: &Option) -> Option { - let s = s.as_ref()?; - let mut out = ItemStack { - item: self.item_name(s.item), - count: s.count.max(0) as u32, - damage: 0, - extra: None, - }; - // A vanilla `components` compound, then the same decoder the saved - // and command item NBT use, so tooltips and models see every field. - let components = self.protocol_components(s); - let (damage, data) = horizon_client::item_data::decode_components(components.as_ref()); - out.damage = damage; - if let Some(data) = data { - *out.data_mut() = *data; - } - out.tidy(); - Some(out) - } - /// The item's patched components as vanilla's `components` compound. - fn protocol_components(&self, s: &Stack) -> Option { - use horizon_protocol::item::{Component, component}; let r = self.remote.as_ref()?; - let name = |id: i32| r.builtin.get("minecraft:data_component_type")?.name(id); - let mut entries: Vec<(&str, Tag)> = Vec::new(); - for (kind, c) in &s.added { - let Some(key) = name(*kind) else { - continue; - }; - let value = match c { - // Read past, not decoded: the item keeps its prototype's value. - Component::Raw(_) => continue, - // Trim materials and patterns are synced registries, as enchantments are. - Component::Trim { material, pattern } => { - let (Some(material), Some(pattern)) = ( - r.registries.name("minecraft:trim_material", *material), - r.registries.name("minecraft:trim_pattern", *pattern), - ) else { - continue; - }; - Tag::compound([ - ("material", Tag::String(material.into())), - ("pattern", Tag::String(pattern.into())), - ]) - } - Component::Int(v) if *kind == component::RARITY => { - let rarity = ["common", "uncommon", "rare", "epic"]; - Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into()) - } - Component::Int(v) => Tag::Int(*v), - Component::Unit => Tag::Compound(Default::default()), - Component::Bool(v) => Tag::Byte(i8::from(*v)), - Component::Text(t) | Component::Nbt(t) => t.clone(), - Component::Ident(v) => Tag::String(v.as_str().into()), - Component::Lore(lines) => Tag::List(lines.clone()), - Component::WritableBook(pages) => Tag::compound([( - "pages", - Tag::List( - pages - .iter() - .map(|p| Tag::String(p.as_str().into())) - .collect(), - ), - )]), - Component::WrittenBook(b) => Tag::compound([ - ("title", Tag::String(b.title.as_str().into())), - ("author", Tag::String(b.author.as_str().into())), - ("generation", Tag::Int(b.generation)), - ("pages", Tag::List(b.pages.clone())), - ("resolved", Tag::Byte(i8::from(b.resolved))), - ]), - Component::Enchantments(list) => { - // Enchantments are a synced (data-driven) registry: the - // ids index the server's RegistryData, not the builtin - // report, so a datapack enchantment names correctly. - let levels = list.iter().filter_map(|(id, level)| { - let name = r.registries.name("minecraft:enchantment", *id)?; - Some((name, Tag::Int(*level))) - }); - Tag::compound(levels) - } - Component::AttackRange(v) => Tag::compound([ - ("min_reach", Tag::Float(v[0].to_bits())), - ("max_reach", Tag::Float(v[1].to_bits())), - ("min_attack_momentum", Tag::Float(v[2].to_bits())), - ("max_attack_momentum", Tag::Float(v[3].to_bits())), - ("hitbox_margin", Tag::Float(v[4].to_bits())), - ("mob_factor", Tag::Float(v[5].to_bits())), - ]), - Component::Profile(p) => profile_tag(p), - // ItemStackTemplate.CODEC: id, count and components. - Component::Stacks(stacks) => Tag::List( - stacks - .iter() - .map(|t| { - let mut entries = vec![ - ("id", Tag::String(self.item_name(t.item).as_ref().into())), - ("count", Tag::Int(t.count)), - ]; - if let Some(c) = self.protocol_components(t) { - entries.push(("components", c)); - } - Tag::compound(entries) - }) - .collect(), - ), - Component::CustomModelData { - floats, - flags, - strings, - colors, - } => Tag::compound([ - ( - "floats", - Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()), - ), - ( - "flags", - Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()), - ), - ( - "strings", - Tag::List( - strings - .iter() - .map(|s| Tag::String(s.as_str().into())) - .collect(), - ), - ), - ( - "colors", - Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()), - ), - ]), - Component::TooltipDisplay { hide, hidden } => { - let hidden = hidden - .iter() - .filter_map(|id| Some(Tag::String(name(*id)?.into()))); - Tag::compound([ - ("hide_tooltip", Tag::Byte(i8::from(*hide))), - ("hidden_components", Tag::List(hidden.collect())), - ]) - } - }; - entries.push((key, value)); - } - Some(Tag::compound(entries)) + Some(horizon_client::item_sync::from_network( + s.as_ref()?, + &r.builtin, + &r.registries, + )) } + /// A stack to the server (`SetCreativeModeSlot`): the same component + /// patch a Horizon server sends its clients, with the synced registries' + /// ids, so the server's echo doesn't hand back a bare item. fn to_network(&self, s: &Option) -> Option { - let s = s.as_ref()?; - let mut stack = Stack::new(self.item_id(&s.item), s.count as i32); - // The components the client models, with the synced registries' ids: - // without them the server's echo hands back a bare item. - if let Some(r) = self.remote.as_ref() { - stack.added.extend(horizon_client::item_sync::encode( - s, - |name| r.registries.id("minecraft:enchantment", name), - |name| r.registries.id("minecraft:trim_material", name), - |name| r.registries.id("minecraft:trim_pattern", name), - )); - } - Some(stack) + let r = self.remote.as_ref()?; + horizon_client::lan::guest::stack_to_network( + |name| self.item_id(name), + |registry, name| r.registries.id(registry, name), + s, + ) } /// A line in the connection's chat, with its styles, click and hover /// events (ChatComponent.addMessage). diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index 00fd0f074..c07e2f352 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -291,7 +291,7 @@ fn filterable_text(t: &Tag) -> Option { None => read_text(t), } } -pub(crate) fn read_text(t: &Tag) -> Option { +fn read_text(t: &Tag) -> Option { match t { Tag::String(s) => Some(s.to_string_lossy()), // A component's root `text` may be empty with the visible words in diff --git a/crates/horizon-client/src/item_sync.rs b/crates/horizon-client/src/item_sync.rs index 4e48498ac..6ac2e894e 100644 --- a/crates/horizon-client/src/item_sync.rs +++ b/crates/horizon-client/src/item_sync.rs @@ -1,149 +1,277 @@ -//! The data components a stack keeps when it crosses the wire, and how the -//! other side reads them back. A creative slot's packet carries a component -//! patch, and before this the encoder wrote damage alone, so everything else -//! (enchantments, a custom name, trim, repair cost) was gone once the server -//! echoed the inventory back. -use crate::item_data::{Levels, StackData}; +//! Item stacks on the wire (`ItemStack.OPTIONAL_STREAM_CODEC`): the component +//! patch read back as vanilla's `components` compound, so the decoder the saved +//! and command item NBT use fills every field. Both sides use it: a client for +//! what its server sends, a server for a creative client's +//! `SetCreativeModeSlot`. The other direction is `lan::guest::stack_to_network`. use crate::items::ItemStack; -use horizon_protocol::item::{Component, Stack, component}; +use horizon_protocol::item::{Component, Profile, Stack, component}; +use horizon_protocol::registry::{Builtin, Dynamic}; use horizon_world::nbt::Tag; use std::sync::Arc; -/// `Slot.STREAM_CODEC`'s patch for the components Horizon models, with the -/// registries' ids for enchantments and trim. A name the registry does not -/// know is left out: id 0 is a different entry, not a missing one. -pub fn encode( - stack: &ItemStack, - enchantment: impl Fn(&str) -> Option, - trim_material: impl Fn(&str) -> Option, - trim_pattern: impl Fn(&str) -> Option, -) -> Vec<(i32, Component)> { - let mut out = Vec::new(); - if stack.damage > 0 { - out.push((component::DAMAGE, Component::Int(stack.damage as i32))); - } - let Some(d) = stack.data() else { - return out; +/// A stack from the wire: its item by the built-in registry's id, its +/// components by the synced registries' (enchantments, trims). +pub fn from_network(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> ItemStack { + let mut out = ItemStack { + item: item_name(builtin, s.item), + count: s.count.max(0) as u32, + damage: 0, + extra: None, }; - let levels = |levels: &Levels| -> Option { - let list: Vec<(i32, i32)> = levels - .iter() - .filter_map(|(name, level)| Some((enchantment(name)?, *level as i32))) - .collect(); - (!list.is_empty()).then_some(Component::Enchantments(list)) - }; - if let Some(c) = levels(&d.enchantments) { - out.push((component::ENCHANTMENTS, c)); - } - if let Some(c) = levels(&d.stored_enchantments) { - out.push((component::STORED_ENCHANTMENTS, c)); - } - if d.repair_cost > 0 { - out.push((component::REPAIR_COST, Component::Int(d.repair_cost as i32))); - } - if let Some(name) = &d.custom_name { - // A plain string is a valid text component, and `decode_components` - // reads it back as one. - out.push(( - component::CUSTOM_NAME, - Component::Text(Tag::String(name.as_str().into())), - )); - } - if let Some((material, pattern)) = &d.trim - && let (Some(material), Some(pattern)) = (trim_material(material), trim_pattern(pattern)) - { - out.push((component::TRIM, Component::Trim { material, pattern })); + let components = components_tag(s, builtin, synced); + let (damage, data) = crate::item_data::decode_components(components.as_ref()); + out.damage = damage; + if let Some(data) = data { + *out.data_mut() = *data; } + out.tidy(); out } -/// The same patch read back into `out`, the fields `item_data::decode_components` -/// fills from the equivalent `components` compound. -pub fn apply( - stack: &Stack, - out: &mut ItemStack, - enchantment: impl Fn(i32) -> Option>, - trim_material: impl Fn(i32) -> Option>, - trim_pattern: impl Fn(i32) -> Option>, -) { - out.damage = stack.damage().max(0) as u32; - let read_levels = |kind: i32, into: &mut Levels| { - if let Some(Component::Enchantments(list)) = stack.component(kind) { - for (id, level) in list { - if let Some(name) = enchantment(*id) { - into.push((name, (*level).max(0) as u32)); - } +fn item_name(builtin: &Builtin, id: i32) -> Arc { + Arc::from( + builtin + .get("minecraft:item") + .and_then(|i| i.name(id)) + .unwrap_or("minecraft:air"), + ) +} + +/// The item's patched components as vanilla's `components` compound. +fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option { + let name = |id: i32| builtin.get("minecraft:data_component_type")?.name(id); + let mut entries: Vec<(&str, Tag)> = Vec::new(); + for (kind, c) in &s.added { + let Some(key) = name(*kind) else { + continue; + }; + let value = match c { + // Read past, not decoded: the item keeps its prototype's value. + Component::Raw(_) => continue, + // Trim materials and patterns are synced registries, as enchantments are. + Component::Trim { material, pattern } => { + let (Some(material), Some(pattern)) = ( + synced.name("minecraft:trim_material", *material), + synced.name("minecraft:trim_pattern", *pattern), + ) else { + continue; + }; + Tag::compound([ + ("material", Tag::String(material.into())), + ("pattern", Tag::String(pattern.into())), + ]) } - } - }; - let data: &mut StackData = out.data_mut(); - read_levels(component::ENCHANTMENTS, &mut data.enchantments); - read_levels( - component::STORED_ENCHANTMENTS, - &mut data.stored_enchantments, - ); - if let Some(Component::Int(v)) = stack.component(component::REPAIR_COST) { - data.repair_cost = (*v).max(0) as u32; + Component::Int(v) if *kind == component::RARITY => { + let rarity = ["common", "uncommon", "rare", "epic"]; + Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into()) + } + Component::Int(v) => Tag::Int(*v), + Component::Unit => Tag::Compound(Default::default()), + Component::Bool(v) => Tag::Byte(i8::from(*v)), + Component::Text(t) | Component::Nbt(t) => t.clone(), + Component::Ident(v) => Tag::String(v.as_str().into()), + Component::Lore(lines) => Tag::List(lines.clone()), + Component::WritableBook(pages) => Tag::compound([( + "pages", + Tag::List( + pages + .iter() + .map(|p| Tag::String(p.as_str().into())) + .collect(), + ), + )]), + Component::WrittenBook(b) => Tag::compound([ + ("title", Tag::String(b.title.as_str().into())), + ("author", Tag::String(b.author.as_str().into())), + ("generation", Tag::Int(b.generation)), + ("pages", Tag::List(b.pages.clone())), + ("resolved", Tag::Byte(i8::from(b.resolved))), + ]), + Component::Enchantments(list) => { + // Enchantments are a synced (data-driven) registry: the + // ids index the server's RegistryData, not the builtin + // report, so a datapack enchantment names correctly. + let levels = list.iter().filter_map(|(id, level)| { + let name = synced.name("minecraft:enchantment", *id)?; + Some((name, Tag::Int(*level))) + }); + Tag::compound(levels) + } + Component::AttackRange(v) => Tag::compound([ + ("min_reach", Tag::Float(v[0].to_bits())), + ("max_reach", Tag::Float(v[1].to_bits())), + ("min_attack_momentum", Tag::Float(v[2].to_bits())), + ("max_attack_momentum", Tag::Float(v[3].to_bits())), + ("hitbox_margin", Tag::Float(v[4].to_bits())), + ("mob_factor", Tag::Float(v[5].to_bits())), + ]), + Component::Profile(p) => profile_tag(p), + // ItemStackTemplate.CODEC: id, count and components. + Component::Stacks(stacks) => Tag::List( + stacks + .iter() + .map(|t| { + let mut entries = vec![ + ( + "id", + Tag::String(item_name(builtin, t.item).as_ref().into()), + ), + ("count", Tag::Int(t.count)), + ]; + if let Some(c) = components_tag(t, builtin, synced) { + entries.push(("components", c)); + } + Tag::compound(entries) + }) + .collect(), + ), + Component::CustomModelData { + floats, + flags, + strings, + colors, + } => Tag::compound([ + ( + "floats", + Tag::List(floats.iter().map(|f| Tag::Float(f.to_bits())).collect()), + ), + ( + "flags", + Tag::List(flags.iter().map(|b| Tag::Byte(i8::from(*b))).collect()), + ), + ( + "strings", + Tag::List( + strings + .iter() + .map(|s| Tag::String(s.as_str().into())) + .collect(), + ), + ), + ( + "colors", + Tag::List(colors.iter().map(|c| Tag::Int(*c)).collect()), + ), + ]), + Component::TooltipDisplay { hide, hidden } => { + let hidden = hidden + .iter() + .filter_map(|id| Some(Tag::String(name(*id)?.into()))); + Tag::compound([ + ("hide_tooltip", Tag::Byte(i8::from(*hide))), + ("hidden_components", Tag::List(hidden.collect())), + ]) + } + }; + entries.push((key, value)); } - if let Some(Component::Text(t)) = stack.component(component::CUSTOM_NAME) { - data.custom_name = crate::item_data::read_text(t); - } - if let Some(Component::Trim { material, pattern }) = stack.component(component::TRIM) - && let (Some(material), Some(pattern)) = (trim_material(*material), trim_pattern(*pattern)) - { - data.trim = Some((material, pattern)); + Some(Tag::compound(entries)) +} + +/// A `minecraft:profile` component as the item data JSON the model reads. +fn profile_tag(p: &Profile) -> Tag { + let properties = p + .properties + .iter() + .map(|(name, value, signature)| { + let mut entries: Vec<(&'static str, Tag)> = vec![ + ("name", Tag::String(name.as_str().into())), + ("value", Tag::String(value.as_str().into())), + ]; + if let Some(signature) = signature { + entries.push(("signature", Tag::String(signature.as_str().into()))); + } + Tag::compound(entries) + }) + .collect::>(); + let mut entries: Vec<(&'static str, Tag)> = Vec::new(); + if let Some(name) = &p.name { + entries.push(("name", Tag::String(name.as_str().into()))); } + entries.push(("properties", Tag::List(properties))); + Tag::compound(entries) } #[cfg(test)] mod tests { use super::*; + use horizon_protocol::registry::Ids; - fn named(name: &str) -> Option { - Some(match name { - "minecraft:sharpness" => 3, - "minecraft:gold" => 7, - "minecraft:sentry" => 2, - _ => return None, - }) - } - fn name_of(id: i32) -> Option> { - Some(Arc::from(match id { - 3 => "minecraft:sharpness", - 7 => "minecraft:gold", - 2 => "minecraft:sentry", - _ => return None, - })) - } - - fn stack_with_data() -> ItemStack { - let mut stack = ItemStack::new("minecraft:diamond_sword", 1); + /// What a creative client sends (`stack_to_network`) comes back whole on + /// the server: damage, both enchantment lists, repair cost, name, trim. + #[test] + fn a_creative_stack_keeps_its_components_across_the_wire() { + let mut components = vec![String::new(); 60]; + for (id, name) in [ + (component::DAMAGE, "minecraft:damage"), + (component::CUSTOM_NAME, "minecraft:custom_name"), + (component::ENCHANTMENTS, "minecraft:enchantments"), + (component::REPAIR_COST, "minecraft:repair_cost"), + ( + component::STORED_ENCHANTMENTS, + "minecraft:stored_enchantments", + ), + (component::TRIM, "minecraft:trim"), + ] { + components[id as usize] = name.to_owned(); + } + let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::>(); + let builtin = Builtin { + registries: [ + ( + "minecraft:item".to_owned(), + Ids::from_names(names(&["minecraft:air", "minecraft:diamond_chestplate"])), + ), + ( + "minecraft:data_component_type".to_owned(), + Ids::from_names(components), + ), + ] + .into(), + }; + let synced = |n: &[&str]| n.iter().map(|s| (s.to_string(), None)).collect::>(); + let dynamic = Dynamic { + registries: [ + ( + "minecraft:enchantment".to_owned(), + synced(&["minecraft:protection", "minecraft:sharpness"]), + ), + ( + "minecraft:trim_material".to_owned(), + synced(&["minecraft:gold"]), + ), + ( + "minecraft:trim_pattern".to_owned(), + synced(&["minecraft:sentry"]), + ), + ] + .into(), + }; + let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1); stack.damage = 5; + stack.enchant("minecraft:protection", 4); let d = stack.data_mut(); - d.enchantments.push((Arc::from("minecraft:sharpness"), 4)); d.stored_enchantments .push((Arc::from("minecraft:sharpness"), 2)); d.repair_cost = 9; d.custom_name = Some("Keeper".to_owned()); d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry"))); - stack - } - - /// Every modelled component survives the packet, so the server's echo does - /// not hand back a bare item. - #[test] - fn a_stack_keeps_its_components_across_the_wire() { - let stack = stack_with_data(); - let mut packet = Stack::new(1, 1); - packet.added = encode(&stack, named, named, named); - - let mut back = ItemStack::new("minecraft:diamond_sword", 1); - apply(&packet, &mut back, name_of, name_of, name_of); - back.tidy(); - + let wire = crate::lan::guest::stack_to_network( + |name| { + builtin + .get("minecraft:item") + .and_then(|i| i.id(name)) + .unwrap_or(0) + }, + |registry, name| dynamic.id(registry, name), + &Some(stack.clone()), + ) + .unwrap(); + let back = from_network(&wire, &builtin, &dynamic); + assert_eq!(back.item, stack.item); assert_eq!(back.damage, 5); let d = back.data().expect("components"); - assert_eq!(d.enchantments, vec![(Arc::from("minecraft:sharpness"), 4)]); + assert_eq!(d.enchantments, vec![(Arc::from("minecraft:protection"), 4)]); assert_eq!( d.stored_enchantments, vec![(Arc::from("minecraft:sharpness"), 2)] @@ -155,16 +283,4 @@ mod tests { Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry"))) ); } - - /// A name the registry does not know is a missing id, not id 0, which would - /// name the registry's first entry instead. - #[test] - fn an_unknown_enchantment_is_left_out_rather_than_sent_as_zero() { - let mut stack = ItemStack::new("minecraft:book", 1); - stack - .data_mut() - .enchantments - .push((Arc::from("mod:unknown"), 1)); - assert!(encode(&stack, named, named, named).is_empty()); - } } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 2667b1fed..dddecf0ca 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -2293,13 +2293,10 @@ impl Hub { .name("minecraft:enchantment", id) .map(str::to_owned) } - /// An entry of a synced registry by id, for the components a packet - /// carries by id (`minecraft:trim_material`, `minecraft:trim_pattern`). - pub fn trim_name(&self, registry: &str, id: i32) -> Option> { - self.sync - .dynamic - .name(registry, id) - .map(std::sync::Arc::from) + /// A stack a client sent (`SetCreativeModeSlot`), read with this + /// server's registries. + pub fn stack_from_network(&self, s: &horizon_protocol::item::Stack) -> crate::items::ItemStack { + crate::item_sync::from_network(s, &self.sync.builtin, &self.sync.dynamic) } /// ServerLevel.removePlayerImmediately, then addRespawnedPlayer: the other players From 9ec5f1ddbc31908eaa27735a01ecd8ba9cf44182 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:51:09 +0100 Subject: [PATCH 46/57] Rockets: a guest's boost rocket hurts them; fireworks sent to a server's clients FireworkRocketEntity.dealExplosionDamage hurts attachedToEntity with the full 5 + 2 per explosion: a guest's boost rocket now hurts that guest instead of nobody. The host's encoder (network_stack) now sends minecraft:fireworks and minecraft:firework_explosion too, so a rocket's flight duration and a star's explosion reach the server thread's window, LAN guests and a dedicated server's players; the creative slot encoder shares it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/fireworks.rs | 15 ++++++-- crates/horizon-client/src/game/remote.rs | 21 +++--------- crates/horizon-client/src/lan/guest.rs | 38 +++++++++++++++++++++ 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 623e0e147..7838c9590 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -251,8 +251,19 @@ impl Game<'_> { let at = [r.position.x, r.position.y, r.position.z]; if !r.explosions.is_empty() { let damage = 5.0 + r.explosions.len() as f32 * 2.0; - if r.attached && r.guest.is_none() { - self.hurt_player_by("minecraft:fireworks", damage); + // The player it boosts takes the full damage, a guest as well. + if r.attached { + match r.guest { + None => self.hurt_player_by("minecraft:fireworks", damage), + Some(conn) => { + if let Some(mut host) = self.lan.take() { + self.as_guest(&mut host, conn, |g| { + g.hurt_player_by("minecraft:fireworks", damage); + }); + self.lan = Some(host); + } + } + } } let from = r.position; let hits: Vec<(u32, f32)> = { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5a8582ac9..d8b370af4 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -769,23 +769,10 @@ impl Game<'_> { horizon_protocol::item::Component::Int(s.damage as i32), )); } - if let Some((flight, explosions)) = s.data().and_then(|d| d.fireworks.clone()) { - stack.added.push(( - horizon_protocol::item::component::FIREWORKS, - horizon_protocol::item::Component::Fireworks { - flight: i32::from(flight), - explosions: explosions - .iter() - .map(|e| horizon_protocol::item::FireworkExplosion { - shape: e.shape as i32, - colors: e.colors.iter().map(|c| *c as i32).collect(), - fade: e.fade.iter().map(|c| *c as i32).collect(), - trail: e.trail, - twinkle: e.twinkle, - }) - .collect(), - }, - )); + if let Some(d) = s.data() { + stack + .added + .extend(horizon_client::lan::guest::firework_components(d)); } Some(stack) } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 440b58284..4bafe7866 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -431,6 +431,43 @@ impl Guest { } } +/// `FireworkExplosion.STREAM_CODEC`'s fields (`Shape` by its BY_ID order). +fn network_explosion( + e: &crate::particles::FireworkExplosion, +) -> horizon_protocol::item::FireworkExplosion { + horizon_protocol::item::FireworkExplosion { + shape: e.shape as i32, + colors: e.colors.iter().map(|c| *c as i32).collect(), + fade: e.fade.iter().map(|c| *c as i32).collect(), + trail: e.trail, + twinkle: e.twinkle, + } +} + +/// A rocket's `minecraft:fireworks` and a star's `minecraft:firework_explosion`. +pub fn firework_components( + d: &crate::item_data::StackData, +) -> Vec<(i32, horizon_protocol::item::Component)> { + use horizon_protocol::item::{Component, component}; + let mut out = Vec::new(); + if let Some((flight, explosions)) = &d.fireworks { + out.push(( + component::FIREWORKS, + Component::Fireworks { + flight: i32::from(*flight), + explosions: explosions.iter().map(network_explosion).collect(), + }, + )); + } + if let Some(e) = &d.firework_explosion { + out.push(( + component::FIREWORK_EXPLOSION, + Component::FireworkExplosion(network_explosion(e)), + )); + } + out +} + /// The off hand slot is conventionally 40 in saves and 45 in the menu. /// `synced_id` names an entry of a synced registry (enchantments, trim /// materials and patterns) by its id on this connection. @@ -521,6 +558,7 @@ fn network_stack( horizon_protocol::item::Component::Int(i32::from(mode)), )); } + stack.added.extend(firework_components(d)); // A loaded crossbow's ammunition. if !d.charged.is_empty() { let charged = d From 4513af89954872409e1f02909633789c6293a5a9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:13:36 +0100 Subject: [PATCH 47/57] horizon-server: the lock test waits for a fork-held descriptor to close Tests in the same binary spawn processes (tar, the engine); between fork and exec the child holds a copy of every open descriptor, the lock's too, so relocking right after drop could see WouldBlock. Seen twice on CI (#1833, #1895). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-server/src/supervisor.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/horizon-server/src/supervisor.rs b/crates/horizon-server/src/supervisor.rs index 4de011362..f2e811700 100644 --- a/crates/horizon-server/src/supervisor.rs +++ b/crates/horizon-server/src/supervisor.rs @@ -849,7 +849,17 @@ mod tests { let first = lock(&dir).unwrap(); assert!(lock(&dir).unwrap_err().contains("already running")); drop(first); - lock(&dir).unwrap(); + // A process another test spawns holds a copy of the lock's descriptor between its fork and exec: + // the lock comes back once that child has exec'd. + let mut again = lock(&dir); + for _ in 0..50 { + if again.is_ok() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + again = lock(&dir); + } + again.unwrap(); std::fs::remove_dir_all(&dir).unwrap(); } } From 05396b4edfd63209708ecbf767e0b93eea272272 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:57:10 +0100 Subject: [PATCH 48/57] ui: experimental, opted into with experimental = ["ui"] in the manifest docs/api/stability.md: a new API starts experimental, usable only by opting in with a manifest field naming it. Capability ui now needs experimental = ["ui"] beside capabilities; without it the manifest is refused with that line to add. A capability promoted later stays accepted in the list, so such manifests keep loading. The ui test guest opts in; stability.md gets ui's row; docs/mods.md shows the key. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mods/src/manifest.rs | 38 ++++++++++++++++++++++++++++ crates/horizon-mods/tests/sandbox.rs | 1 + docs/api/stability.md | 1 + docs/mods.md | 7 +++-- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index f1ac123b1..6070f99bd 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -49,6 +49,12 @@ pub struct Manifest { /// that are on may share one (`content::clash`). #[serde(default)] pub namespaces: Vec, + /// The experimental capabilities the mod opts into (`["ui"]`): each one + /// it lists in `capabilities` must be named here too, since it may change + /// in any release (docs/api/stability.md). One no longer experimental is + /// accepted here, so the manifest keeps loading once it is promoted. + #[serde(default)] + pub experimental: Vec, } #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] @@ -99,6 +105,11 @@ impl Capability { Capability::Ui => "ui", } } + /// May change in any release: a mod uses it only with + /// `experimental = [""]` in its manifest. + pub fn is_experimental(self) -> bool { + matches!(self, Capability::Ui) + } /// What the player reads before turning the mod on: "May ". pub fn describe(self) -> &'static str { match self { @@ -169,6 +180,16 @@ impl Manifest { "{MANIFEST}: capability registries needs api = \"0.2\"" )); } + if let Some(c) = m + .capabilities + .iter() + .find(|c| c.is_experimental() && !m.experimental.contains(c)) + { + return Err(format!( + "{MANIFEST}: capability {c} is experimental and may change in any release: \ + add experimental = [\"{c}\"] to use it" + )); + } if !m.namespaces.is_empty() && !m.capabilities.contains(&Capability::Registries) { return Err(format!( "{MANIFEST}: namespaces are for content, which needs capability registries" @@ -421,4 +442,21 @@ capabilities = ["game-read", "hud"] assert_eq!(p.wasm.as_deref(), Some(&b"\0asm"[..])); std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn experimental_capabilities_need_the_opt_in() { + let base = "id = \"m\"\nname = \"M\"\nversion = \"1\"\napi = \"0.1\"\n"; + let err = Manifest::parse(&format!("{base}capabilities = [\"ui\"]\n")).unwrap_err(); + assert!(err.contains("experimental = [\"ui\"]"), "{err}"); + let m = Manifest::parse(&format!( + "{base}capabilities = [\"ui\"]\nexperimental = [\"ui\"]\n" + )) + .unwrap(); + assert_eq!(m.experimental, [Capability::Ui]); + // A capability that is (or became) stable is fine in the list. + Manifest::parse(&format!( + "{base}capabilities = [\"hud\"]\nexperimental = [\"hud\"]\n" + )) + .unwrap(); + } } diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index a229f748e..77f0975f3 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -23,6 +23,7 @@ name = "Test UI Guest" version = "0.1.0" api = "0.1" capabilities = ["chat", "ui"] +experimental = ["ui"] "#; /// Builds both mods once and writes their `.hmod` files; returns the folder. diff --git a/docs/api/stability.md b/docs/api/stability.md index 2d4476fc7..ffa9d9b11 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -87,6 +87,7 @@ Deprecations and breaking changes are in bold. The release notes of the game rel | Part | Level | Version | | --- | --- | --- | | Mod API: `log`, `game`, `chat`, `hud`, `storage`, `events` | preview | 0.1.0 | +| Mod API: `ui`, `ui-events` (a mod's screens and settings page) | experimental | manifest `experimental = ["ui"]` | | Content mods (`registries`: data-driven blocks and items) | preview | manifest `api = "0.2"` | | Server mods (events, world, server commands) | not started | manifest `api = "0.3"`, experimental first | | Script mods (Lua run by an interpreter mod, [mods.md](mods.md#ways-in)) | not started | | diff --git a/docs/mods.md b/docs/mods.md index 45e5ae02e..9b3b8aa94 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -86,7 +86,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `chat` | `chat` | `show(text)`: a line only the local player sees, after the mod's name (8 per call) | | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, item and list widgets the game lays out and draws with its own menus' code | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | | Hook (export) | When | |---|---| @@ -284,10 +284,13 @@ authors = ["..."] api = "0.1" # the API the SDK targets side = "client" # client | server | both capabilities = ["game-read", "hud", "chat"] +experimental = [] # experimental capabilities it uses, e.g. ["ui"] ``` Unknown keys are refused, so a typo is an error, not a silently ignored -permission. +permission. An experimental capability (today `ui`) may change in any release +([stability](api/stability.md)): a mod uses it only by naming it in +`experimental` as well, so nobody depends on it by accident. ### Content mods (API 0.2) From befec1995f0cb89d11b4fd07354116805f6796a5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:57:10 +0100 Subject: [PATCH 49/57] ui: a Settings button appears when the mod sets its page later has_settings was read once, when the mods loaded, so a mod that called set-settings from a command or a tick never got its Settings button until a reload. The Mods page's rows now follow each mod call's output. Also clippy with --features mods: needless borrows of the atlas in draw_mod_screen. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mods.rs | 32 +++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 688e29da1..47a3ce07b 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -395,6 +395,12 @@ impl Game<'_> { .chat .add(&self.gui.atlas, line, None, self.ticks, focused); } + // A mod may set or clear its settings page in any call, not only + // while loading: its row's Settings button follows. + let rows = self.settings.mods.installed.iter_mut(); + for (row, e) in rows.zip(self.mods.mods.entries()) { + row.has_settings = e.has_ui_settings(); + } } /// The mods' HUD panels, stacked with the extras in their corners. pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) { @@ -521,10 +527,10 @@ impl Game<'_> { let mut items: Vec<(String, f32, f32)> = Vec::new(); let atlas = &self.gui.atlas; let in_world = self.player.is_some(); - ui::menu_background(batch, &atlas, 0.0, 0.0, batch.width, batch.height, in_world); + ui::menu_background(batch, atlas, 0.0, 0.0, batch.width, batch.height, in_world); ui::centered_text( batch, - &atlas, + atlas, &Text::literal(title), 0.0, 15.0, @@ -545,7 +551,7 @@ impl Game<'_> { }; ui::centered_text( batch, - &atlas, + atlas, &Text::literal(t.text.clone()), x, wy + 1.0, @@ -557,7 +563,7 @@ impl Game<'_> { if self.res.items().get(&i.item).is_some() { items.push((i.item.clone(), x + 2.0, wy + 2.0)); } else { - batch.text(&atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true); + batch.text(atlas, "?", x + 6.0, wy + 6.0, u32::MAX, true); } let name = if i.name.is_empty() { i.item.rsplit(':').next().unwrap_or(&i.item).to_string() @@ -565,7 +571,7 @@ impl Game<'_> { i.name.clone() }; batch.text_styled( - &atlas, + atlas, &Text::literal(name), x + 22.0, wy + 6.0, @@ -582,12 +588,12 @@ impl Game<'_> { list_i += 1; let rows_top = wy + 4.0; let rows_bottom = wy + wh - 4.0; - batch.fill(&atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000); + batch.fill(atlas, x, wy, MOD_UI_COLUMN, wh, 0x8000_0000); for (b, yy) in [(x, wy), (x, wy + wh - 1.0)] { - batch.fill(&atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050); + batch.fill(atlas, b, yy, MOD_UI_COLUMN, 1.0, 0xFF50_5050); } for (xx, yy) in [(x, wy), (x + MOD_UI_COLUMN - 1.0, wy)] { - batch.fill(&atlas, xx, yy, 1.0, wh, 0xFF50_5050); + batch.fill(atlas, xx, yy, 1.0, wh, 0xFF50_5050); } let content = l.rows.len() as f32 * 12.0; let first = (scroll.amount / 12.0) as usize; @@ -596,13 +602,13 @@ impl Game<'_> { for (vi, ri) in (first..last).enumerate() { let ry = rows_top + vi as f32 * 12.0 - (scroll.amount % 12.0); if ui::hovered(self.cursor, x, ry, MOD_UI_COLUMN - 6.0, 12.0) { - batch.fill(&atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF); + batch.fill(atlas, x + 1.0, ry, MOD_UI_COLUMN - 8.0, 12.0, 0x20FF_FFFF); } if l.selected == Some(ri as u32) { - batch.fill(&atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF); + batch.fill(atlas, x + 1.0, ry, 3.0, 12.0, 0xFFFF_FFFF); } batch.text_styled( - &atlas, + atlas, &Text::literal(l.rows[ri].clone()), x + 6.0, ry + 2.0, @@ -612,7 +618,7 @@ impl Game<'_> { } ui::scrollbar( batch, - &atlas, + atlas, x + MOD_UI_COLUMN - 5.0, rows_top, rows_bottom, @@ -625,7 +631,7 @@ impl Game<'_> { let hover = ui::hovered(self.cursor, cx - 100.0, batch.height - 27.0, 200.0, 20.0); ui::button( batch, - &atlas, + atlas, cx - 100.0, batch.height - 27.0, 200.0, From 03a5cbdcd800bb6d41c3f6ffb88e875ce25e9f07 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:11:47 +0100 Subject: [PATCH 50/57] Renderer: OpenGL as the last fallback, for GPUs with no Vulkan or DirectX 12 driver wgpu's GL backend (WGL on Windows, EGL on Linux). Auto tries Vulkan, then DirectX 12 on Windows, then OpenGL; graphicsApi:opengl (or HORIZON_GRAPHICS_API=opengl) picks it. A Radeon HD 7400M (TeraScale 2: DirectX 11, OpenGL 4.x) could not start Horizon at all. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/Cargo.toml | 5 +++ crates/horizon-client/src/gfx.rs | 64 +++++++++++++++------------ crates/horizon-client/src/graphics.rs | 15 +++++-- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index ea5859c23..4877c18f2 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -68,6 +68,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] } # on the PATH. wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] } +[target.'cfg(not(target_os = "macos"))'.dependencies] +# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or +# DirectX 12 driver (`graphicsApi`, graphics.rs). +wgpu = { version = "30.0.1", default-features = false, features = ["gles"] } + [target.'cfg(windows)'.build-dependencies] # The icon resource in the .exe (build.rs). winresource = "0.1" diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index b43202074..f2fdfb6b3 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -683,25 +683,31 @@ fn instance_descriptor(backends: wgpu::Backends) -> wgpu::InstanceDescriptor { } /// The backends to open a device on, each tried in turn until one gives a /// device. `api` is the player's choice (`GraphicsApi::with_env` applied); -/// `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends -/// itself. Only Windows has two: Auto tries Vulkan, then DirectX 12 when -/// Vulkan has no surface, adapter (no Vulkan driver) or device. A native crash in -/// the driver can't be caught in the game; the launcher switches the setting -/// to DirectX 12 after one at the renderer's start (#746). -fn backend_order(api: GraphicsApi, windows: bool, wgpu_backend: bool) -> Vec { - if !windows || wgpu_backend { - return vec![wgpu::Backends::default()]; +/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set, +/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows, +/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one +/// before has no surface, adapter or device. A native crash in the driver can't be +/// caught in the game; the launcher switches the setting to DirectX 12 after one at +/// the renderer's start (#746). macOS has Metal only. +fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec { + use wgpu::Backends; + if os == "macos" || wgpu_backend { + return vec![Backends::default()]; } + let windows = os == "windows"; match api { - GraphicsApi::Auto => vec![wgpu::Backends::VULKAN, wgpu::Backends::DX12], - GraphicsApi::Vulkan => vec![wgpu::Backends::VULKAN], - GraphicsApi::Dx12 => vec![wgpu::Backends::DX12], + GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL], + GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL], + GraphicsApi::Dx12 if windows => vec![Backends::DX12], + GraphicsApi::Vulkan | GraphicsApi::Dx12 => vec![Backends::VULKAN], + GraphicsApi::OpenGl => vec![Backends::GL], } } fn backends_name(backends: wgpu::Backends) -> &'static str { match backends { wgpu::Backends::VULKAN => "Vulkan", wgpu::Backends::DX12 => "DirectX 12", + wgpu::Backends::GL => "OpenGL", _ => "graphics", } } @@ -780,7 +786,7 @@ async fn open_device( ) -> Result { let order = backend_order( api.with_env(), - cfg!(windows), + std::env::consts::OS, std::env::var_os("WGPU_BACKEND").is_some(), ); let mut failures = Vec::new(); @@ -869,14 +875,14 @@ async fn open_device( /// Vulkan is loaded at run time (the game links no graphics library), so a /// missing driver shows here rather than as a missing-DLL dialog. #[cfg(target_os = "windows")] -const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan or DirectX 12 found: Horizon needs a GPU \ - driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \ +const NO_GPU_DRIVER: &str = "No graphics driver with Vulkan, DirectX 12 or OpenGL found: Horizon needs \ + a GPU driver with one of them. Install the latest driver for your NVIDIA, AMD or Intel GPU and \ start the game again"; #[cfg(target_os = "macos")] const NO_GPU_DRIVER: &str = "No Metal graphics device found"; #[cfg(not(any(target_os = "windows", target_os = "macos")))] -const NO_GPU_DRIVER: &str = "No Vulkan graphics driver found: Horizon needs the Vulkan loader \ - (libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's)"; +const NO_GPU_DRIVER: &str = "No Vulkan or OpenGL graphics driver found: Horizon needs the Vulkan loader \ + (libvulkan.so.1) and a Vulkan driver for your GPU (Mesa's, or NVIDIA's), or OpenGL through EGL"; /// Logs a step of the renderer's start, for a native crash in the graphics /// driver: it leaves no panic or crash report, so the log's last line is the /// only sign of where the game stopped. The log file is written unbuffered; @@ -4475,25 +4481,27 @@ mod tests { ); } #[test] - fn windows_tries_vulkan_then_directx_12() { + fn auto_tries_vulkan_then_directx_12_then_opengl() { use super::{GraphicsApi, backend_order}; use wgpu::Backends; + let order = |api, os| backend_order(api, os, false); assert_eq!( - backend_order(GraphicsApi::Auto, true, false), - [Backends::VULKAN, Backends::DX12] + order(GraphicsApi::Auto, "windows"), + [Backends::VULKAN, Backends::DX12, Backends::GL] ); + assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]); + assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]); + assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]); assert_eq!( - backend_order(GraphicsApi::Vulkan, true, false), - [Backends::VULKAN] + order(GraphicsApi::Auto, "linux"), + [Backends::VULKAN, Backends::GL] ); - assert_eq!( - backend_order(GraphicsApi::Dx12, true, false), - [Backends::DX12] - ); - // WGPU_BACKEND picks the backends itself; other systems have one. + assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]); + assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]); + // WGPU_BACKEND picks the backends itself; macOS has Metal only. for api in GraphicsApi::ALL { - assert_eq!(backend_order(api, true, true), [Backends::default()]); - assert_eq!(backend_order(api, false, false), [Backends::default()]); + assert_eq!(backend_order(api, "windows", true), [Backends::default()]); + assert_eq!(order(api, "macos"), [Backends::default()]); } } /// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend diff --git a/crates/horizon-client/src/graphics.rs b/crates/horizon-client/src/graphics.rs index 3cf1402d2..21d38539e 100644 --- a/crates/horizon-client/src/graphics.rs +++ b/crates/horizon-client/src/graphics.rs @@ -98,19 +98,27 @@ impl PresentMode { /// macOS). Applies at the next start. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum GraphicsApi { - /// Vulkan, and DirectX 12 when Vulkan has no adapter or device. + /// Vulkan, then DirectX 12 (Windows), then OpenGL when neither has a surface, adapter or device. #[default] Auto, Vulkan, Dx12, + /// OpenGL, for GPUs with no Vulkan or DirectX 12 driver (Windows and Linux). + OpenGl, } impl GraphicsApi { - pub const ALL: [GraphicsApi; 3] = [GraphicsApi::Auto, GraphicsApi::Vulkan, GraphicsApi::Dx12]; + pub const ALL: [GraphicsApi; 4] = [ + GraphicsApi::Auto, + GraphicsApi::Vulkan, + GraphicsApi::Dx12, + GraphicsApi::OpenGl, + ]; pub fn name(self) -> &'static str { match self { GraphicsApi::Auto => "auto", GraphicsApi::Vulkan => "vulkan", GraphicsApi::Dx12 => "dx12", + GraphicsApi::OpenGl => "opengl", } } pub fn label(self) -> &'static str { @@ -118,6 +126,7 @@ impl GraphicsApi { GraphicsApi::Auto => "Auto", GraphicsApi::Vulkan => "Vulkan", GraphicsApi::Dx12 => "DirectX 12", + GraphicsApi::OpenGl => "OpenGL", } } /// The API a name stands for, ignoring case; `None` for anything else. @@ -126,7 +135,7 @@ impl GraphicsApi { .into_iter() .find(|api| api.name().eq_ignore_ascii_case(s.trim())) } - /// `HORIZON_GRAPHICS_API` (auto, vulkan or dx12) if set, else the setting. + /// `HORIZON_GRAPHICS_API` (auto, vulkan, dx12 or opengl) if set, else the setting. pub fn with_env(self) -> Self { std::env::var("HORIZON_GRAPHICS_API") .ok() From 54bb3e4fbe869bfc223d0daa2ccf2fa329c721e8 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:19:47 +0100 Subject: [PATCH 51/57] Screen effects: one sampler per entry point, so the shader compiles for OpenGL GLSL combines a texture with its sampler, and naga refuses an entry point that reads one texture through two (ImageMultipleSamplers): the BLUR override left both branches in fs_screen and fs_vignette, so creating the screen effect pipelines failed on wgpu's GL backend at startup. Blurred sprites now have their own entry points (fs_screen_blur, fs_vignette_blur); each pipeline still samples once per pixel. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 17 +++++++------- crates/horizon-client/src/screen.wgsl | 34 +++++++++++++++++---------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index f2fdfb6b3..b55022510 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -2069,16 +2069,15 @@ impl Renderer { // One per blend and `.png.mcmeta` sampling (screen_effects::SAMPLE_*): // the shader is specialised, so a pixel pays for none of the others. let screen_pipelines: [wgpu::RenderPipeline; 8] = std::array::from_fn(|i| { - let (entry, blend) = if i & 4 == 0 { - ("fs_screen", alpha_blend) - } else { - ("fs_vignette", vignette_blend) + let blur = i as u32 & crate::screen_effects::SAMPLE_BLUR != 0; + let (entry, blend) = match (i & 4 == 0, blur) { + (true, false) => ("fs_screen", alpha_blend), + (true, true) => ("fs_screen_blur", alpha_blend), + (false, false) => ("fs_vignette", vignette_blend), + (false, true) => ("fs_vignette_blur", vignette_blend), }; - let on = |bit: u32| f64::from(u8::from(i as u32 & bit != 0)); - let constants = [ - ("BLUR", on(crate::screen_effects::SAMPLE_BLUR)), - ("CLAMP", on(crate::screen_effects::SAMPLE_CLAMP)), - ]; + let clamp = i as u32 & crate::screen_effects::SAMPLE_CLAMP != 0; + let constants = [("CLAMP", f64::from(u8::from(clamp)))]; let compilation_options = wgpu::PipelineCompilationOptions { constants: &constants, ..Default::default() diff --git a/crates/horizon-client/src/screen.wgsl b/crates/horizon-client/src/screen.wgsl index e70aeb896..1497b36fa 100644 --- a/crates/horizon-client/src/screen.wgsl +++ b/crates/horizon-client/src/screen.wgsl @@ -8,10 +8,12 @@ // atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear // sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half // a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel -// costs what it did with nearest and repeat only: one sample. +// costs what it did with nearest and repeat only: one sample. Each entry +// point reads `atlas` through one sampler only: GLSL (wgpu's OpenGL backend) +// combines a texture with its sampler, and an override would not remove the +// other sampler's branch. @group(0) @binding(2) var nearest_sampler: sampler; @group(0) @binding(4) var linear_sampler: sampler; -override BLUR: bool = false; override CLAMP: bool = false; struct Out { @builtin(position) clip: vec4, @@ -32,28 +34,36 @@ struct Out { out.edges = vec4(rect.xy + half, rect.zw - half); return out; } -fn sample(in: Out) -> vec4 { +// The sprite's atlas coordinates of this pixel. +fn atlas_uv(in: Out) -> vec2 { var f = in.uv; if !CLAMP { // Repeat: one period of the sprite. f = fract(f); } - var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy); + let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy); if CLAMP { - uv = clamp(uv, in.edges.xy, in.edges.zw); + return clamp(uv, in.edges.xy, in.edges.zw); } - if BLUR { - return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color; - } - return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color; + return uv; } -@fragment fn fs_screen(in: Out) -> @location(0) vec4 { - let c = sample(in); +fn screen(c: vec4) -> vec4 { if c.a <= 0.0 { discard; } return c; } +@fragment fn fs_screen(in: Out) -> @location(0) vec4 { + return screen(textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color); +} +@fragment fn fs_screen_blur(in: Out) -> @location(0) vec4 { + return screen(textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color); +} // RenderPipelines.VIGNETTE blends ZERO, ONE_MINUS_SRC_COLOR: output the // texture colour; the blend state darkens the frame by it. @fragment fn fs_vignette(in: Out) -> @location(0) vec4 { - return vec4(sample(in).rgb, 1.0); + let c = textureSampleLevel(atlas, nearest_sampler, atlas_uv(in), 0.0) * in.color; + return vec4(c.rgb, 1.0); +} +@fragment fn fs_vignette_blur(in: Out) -> @location(0) vec4 { + let c = textureSampleLevel(atlas, linear_sampler, atlas_uv(in), 0.0) * in.color; + return vec4(c.rgb, 1.0); } From 903da4f1ab8f6735a8d60734a63bcecdd4edbe9e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:20:02 +0100 Subject: [PATCH 52/57] Cargo.lock: wgpu's GL backend dependencies (glow, khronos-egl, glutin_wgl_sys) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6945e09cf..4d749add6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1116,12 +1116,44 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glam" version = "0.30.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -1812,6 +1844,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "lazy_static" version = "1.5.1" @@ -3149,6 +3198,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -4274,6 +4332,7 @@ dependencies = [ "smallvec", "thiserror 2.0.21", "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -4289,6 +4348,15 @@ dependencies = [ "wgpu-hal", ] +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "30.0.1" @@ -4311,13 +4379,18 @@ dependencies = [ "bytemuck", "cfg-if", "cfg_aliases", + "glow", + "glutin_wgl_sys", "gpu-allocator", "hashbrown 0.17.1", + "js-sys", + "khronos-egl", "libc", "libloading", "log", "naga", "naga-types", + "ndk-sys", "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", @@ -4337,10 +4410,14 @@ dependencies = [ "smallvec", "static_assertions", "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", "wgpu-naga-bridge", "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] @@ -5081,6 +5158,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "xtask" version = "0.1.0" From b3e400a52673862fa237ef4341ba21d4440d3c52 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:25:37 +0100 Subject: [PATCH 53/57] Renderer: sprite arrays OpenGL can view as arrays wgpu's GL backend picks a texture's target from its layer count when it creates it: one layer makes a 2D texture and a multiple of six on a square texture a cube map, and neither can be bound as terrain.wgsl's texture_2d_array. On GL, the 1-layer stand-in for unused sprite arrays gets two layers and a sprite array of 1 or 6n layers one empty layer more. Other backends are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index b55022510..a02e6eb7f 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -516,6 +516,18 @@ fn sprite_split() -> u32 { } /// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...). const SPRITE_ARRAYS: u32 = 8; +/// The layers to give a square texture of `count` layers viewed as a 2D +/// array. wgpu's OpenGL backend picks a texture's target from its layer +/// count when it is created: one layer makes a 2D texture and a multiple of +/// six a cube map, neither of which can be viewed as an array. +fn array_layers(count: u32, gl: bool) -> u32 { + match count { + _ if !gl => count, + 0 | 1 => 2, + n if n % 6 == 0 => n + 1, + n => n, + } +} /// The views of terrain.wgsl's sprite arrays, the unused ones a 1-layer /// stand-in. fn sprite_views( @@ -1312,13 +1324,17 @@ impl Renderer { PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield) }); let mut mip_levels = 1; + let gl = adapter.get_info().backend == wgpu::Backend::Gl; let sprite_textures: Vec = prepared .sprites .into_iter() .enumerate() - .map(|(array, (mips, levels))| { + .map(|(array, (mut mips, levels))| { let first = array as u32 * split; - let count = layers.saturating_sub(first).clamp(1, split); + let used = layers.saturating_sub(first).clamp(1, split); + let count = array_layers(used, gl); + // The extra layers are empty. + mips.resize(mips.len() / used as usize * count as usize, 0); mip_levels = levels; device.create_texture_with_data( &queue, @@ -1359,7 +1375,7 @@ impl Renderer { size: wgpu::Extent3d { width: 1, height: 1, - depth_or_array_layers: 1, + depth_or_array_layers: array_layers(1, gl), }, mip_level_count: 1, sample_count: 1, @@ -4560,6 +4576,13 @@ mod tests { } } #[test] + fn gl_sprite_arrays_are_neither_2d_textures_nor_cube_maps() { + for (count, gl) in [(1, 2), (2, 2), (5, 5), (6, 7), (12, 13), (2048, 2048)] { + assert_eq!(super::array_layers(count, true), gl); + assert_eq!(super::array_layers(count, false), count); + } + } + #[test] fn entity_shader_validates_with_its_overlay_constants() { let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap(); naga::valid::Validator::new( From 1a16e35aaa45800285fca7d5be2a07e48f0421c0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:25:37 +0100 Subject: [PATCH 54/57] Animated sprites without compute shaders (OpenGL before 4.3) SpriteAnimator wrote every changed sprite with a compute pass, which OpenGL 3.3 to 4.2 has no equivalent for in wgpu: creating the pipeline failed. Without compute shaders and storage, the same jobs now run on the CPU with animate.wgsl's integer mix, and each level is copied into its layer through the upload belt. The GPU test runs both paths against the same CPU reference. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 1 + crates/horizon-client/src/gfx/animate.rs | 162 +++++++++++++++++++++-- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index a02e6eb7f..baa8c5099 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -1368,6 +1368,7 @@ impl Renderer { sprite_mips, &sprite_textures, split, + animate::SpriteAnimator::compute(&adapter, &device), ); let sprite_unused = device .create_texture(&wgpu::TextureDescriptor { diff --git a/crates/horizon-client/src/gfx/animate.rs b/crates/horizon-client/src/gfx/animate.rs index b7c44429b..c94d8034e 100644 --- a/crates/horizon-client/src/gfx/animate.rs +++ b/crates/horizon-client/src/gfx/animate.rs @@ -10,6 +10,10 @@ //! frames. Blending on the CPU at the array's size and copying every changed //! sprite level by level (a copy command each, a blit each in Mesa) was about //! a fifth of the render thread on an integrated GPU. +//! +//! Without compute shaders (OpenGL before 4.3, the last fallback) the same +//! jobs run on the CPU, the same integer mix as `animate.wgsl`, and each +//! changed level is copied into its layer. use super::upload::Uploads; use crate::mipmap::Settings; use crate::sprites::{FrameBlend, SpriteAnimation}; @@ -38,17 +42,32 @@ struct Sprite { struct Group { /// Where its jobs start in `SpriteAnimator::jobs`, in bytes. offset: u64, - /// One bind group per mip level of the array. + /// One bind group per mip level of the array (none on the CPU). levels: Vec, /// This tick's jobs. jobs: Vec, + /// Its sprite array and its frames, for the CPU. + array: usize, + buffer: usize, +} + +/// Where the jobs run. +enum Writer { + Gpu { + jobs: wgpu::Buffer, + pipeline: wgpu::ComputePipeline, + }, + /// Every frames buffer's words, and the sprite arrays to copy into. + Cpu { + frames: Vec>, + textures: Vec, + }, } pub(super) struct SpriteAnimator { sprites: Vec, groups: Vec, - jobs: wgpu::Buffer, - pipeline: wgpu::ComputePipeline, + writer: Writer, /// Edge of the arrays' layers. size: u32, /// The layers the last `animate` rewrote, counted over all arrays. @@ -57,7 +76,8 @@ pub(super) struct SpriteAnimator { impl SpriteAnimator { /// `textures` are the sprite arrays, `split` layers each, `size` pixels - /// wide; `animations` hold their frames at that size. + /// wide; `animations` hold their frames at that size. `compute`: whether + /// the device runs compute shaders writing storage textures. pub fn new( device: &wgpu::Device, animations: &[SpriteAnimation], @@ -65,11 +85,17 @@ impl SpriteAnimator { settings: &[Settings], textures: &[wgpu::Texture], split: u32, + compute: bool, ) -> Self { let limits = device.limits(); - let limit = limits - .max_storage_buffer_binding_size - .min(limits.max_buffer_size); + let limit = if compute { + limits + .max_storage_buffer_binding_size + .min(limits.max_buffer_size) + } else { + // Word offsets. + u64::from(u32::MAX) * 4 + }; let mut frames: Vec> = Vec::new(); // Each group's sprite array and frames buffer. let mut keys: Vec<(u32, usize)> = Vec::new(); @@ -119,6 +145,29 @@ impl SpriteAnimator { group, }); } + if !compute { + let groups = keys + .iter() + .zip(&counts) + .map(|(&(array, buffer), &count)| Group { + offset: 0, + levels: Vec::new(), + jobs: Vec::with_capacity(count * JOB_BYTES), + array: array as usize, + buffer, + }) + .collect(); + return Self { + sprites, + groups, + writer: Writer::Cpu { + frames, + textures: textures.to_vec(), + }, + size, + changed: Vec::new(), + }; + } let frames: Vec<_> = frames .iter() .map(|data| { @@ -198,19 +247,33 @@ impl SpriteAnimator { offset, levels, jobs: Vec::with_capacity(count * JOB_BYTES), + array: array as usize, + buffer, } }) .collect(); Self { sprites, groups, - jobs, - pipeline, + writer: Writer::Gpu { jobs, pipeline }, size, changed: Vec::new(), } } + /// Whether the jobs can run on the GPU: compute shaders writing a + /// storage texture and reading two storage buffers (not OpenGL before + /// 4.3). + pub fn compute(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool { + let limits = device.limits(); + adapter + .get_downlevel_capabilities() + .flags + .contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) + && limits.max_storage_textures_per_shader_stage >= 1 + && limits.max_storage_buffers_per_shader_stage >= 2 + } + /// The layers the last `animate` rewrote (for the shader pack atlas). pub fn changed(&self) -> &[u32] { &self.changed @@ -227,14 +290,47 @@ impl SpriteAnimator { if self.collect(tick) == 0 { return; } + let (jobs, pipeline) = match &self.writer { + Writer::Gpu { jobs, pipeline } => (jobs, pipeline), + Writer::Cpu { frames, textures } => { + for group in &mut self.groups { + let (frames, texture) = (&frames[group.buffer], &textures[group.array]); + for job in group.jobs.as_chunks::().0 { + let (words, _) = job.as_chunks::<4>(); + let job: [u32; 5] = std::array::from_fn(|i| u32::from_le_bytes(words[i])); + for level in 0..texture.mip_level_count() { + let width = self.size >> level; + uploads.texture( + encoder, + wgpu::TexelCopyTextureInfo { + texture, + mip_level: level, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: job[0], + }, + aspect: wgpu::TextureAspect::All, + }, + width, + width, + &mix_level(frames, job, width), + ); + } + } + group.jobs.clear(); + } + return; + } + }; for group in &self.groups { - uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs); + uploads.buffer(encoder, jobs, group.offset, &group.jobs); } let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("Animated sprites"), timestamp_writes: None, }); - pass.set_pipeline(&self.pipeline); + pass.set_pipeline(pipeline); for group in &mut self.groups { let count = (group.jobs.len() / JOB_BYTES) as u32; if count == 0 { @@ -278,6 +374,33 @@ impl SpriteAnimator { } } +/// `animate.wgsl` on the CPU: one `width` wide level of a job +/// (`[layer, current, next, millis, edge]`) from `frames`' chains. +fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u32) -> Vec { + let mut out = Vec::with_capacity((width * width * 4) as usize); + for y in 0..width { + for x in 0..width { + let texel = if width >= edge { + let scale = width / edge; + y / scale * edge + x / scale + } else { + 4 * (edge * edge - width * width) / 3 + y * width + x + }; + let at = |chain: u32| ((chain + texel) * 4) as usize; + let a = &frames[at(current)..at(current) + 4]; + if millis == 0 { + out.extend_from_slice(a); + continue; + } + let b = &frames[at(next)..at(next) + 4]; + out.extend(a.iter().zip(b).map(|(&a, &b)| { + ((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000) as u8 + })); + } + } + out +} + /// The mip chain a frame's layer is made of, and its first level's edge. /// `mipmap::generate` makes a sprite scaled up by whole pixels to the /// array's size from its own size's chain, its first level repeated into the @@ -512,6 +635,16 @@ mod tests { #[test] fn gpu_layers_match_the_cpu_chains_every_tick() { + layers_match_the_cpu_chains_every_tick(true); + } + + /// Without compute shaders (OpenGL before 4.3): the same layers. + #[test] + fn copied_layers_match_the_cpu_chains_every_tick() { + layers_match_the_cpu_chains_every_tick(false); + } + + fn layers_match_the_cpu_chains_every_tick(compute: bool) { let Some((device, queue)) = device() else { return; }; @@ -552,12 +685,15 @@ mod tests { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }) }) .collect(); - let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split); + let mut animator = + SpriteAnimator::new(&device, &animations, size, &mips, &textures, split, compute); let mut uploads = Uploads::new(&device); let mut writes = Vec::new(); for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] { From 21203de7f07aec02ac1bb325462c729d9ae8fc38 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:33:05 +0100 Subject: [PATCH 55/57] Animated sprites: the CPU path also when the driver cannot build the compute pipeline An OpenGL driver can list compute shaders among its extensions while wgpu writes GLSL of its core version, which has none (seen under MESA_GL_VERSION_OVERRIDE=3.3: the pipeline failed and the game panicked). The pipeline is now created inside error scopes, and a failure falls back to the CPU path with one warning. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx/animate.rs | 50 ++++++++++++++++-------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/gfx/animate.rs b/crates/horizon-client/src/gfx/animate.rs index c94d8034e..5a70f3b0f 100644 --- a/crates/horizon-client/src/gfx/animate.rs +++ b/crates/horizon-client/src/gfx/animate.rs @@ -77,7 +77,7 @@ pub(super) struct SpriteAnimator { impl SpriteAnimator { /// `textures` are the sprite arrays, `split` layers each, `size` pixels /// wide; `animations` hold their frames at that size. `compute`: whether - /// the device runs compute shaders writing storage textures. + /// the device says it runs compute shaders writing storage textures. pub fn new( device: &wgpu::Device, animations: &[SpriteAnimation], @@ -87,8 +87,9 @@ impl SpriteAnimator { split: u32, compute: bool, ) -> Self { + let pipeline = if compute { pipeline(device) } else { None }; let limits = device.limits(); - let limit = if compute { + let limit = if pipeline.is_some() { limits .max_storage_buffer_binding_size .min(limits.max_buffer_size) @@ -145,7 +146,7 @@ impl SpriteAnimator { group, }); } - if !compute { + let Some(pipeline) = pipeline else { let groups = keys .iter() .zip(&counts) @@ -167,7 +168,7 @@ impl SpriteAnimator { size, changed: Vec::new(), }; - } + }; let frames: Vec<_> = frames .iter() .map(|data| { @@ -191,18 +192,6 @@ impl SpriteAnimator { usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Animated sprites"), - source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()), - }); - let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("Animated sprites"), - layout: None, - module: &module, - entry_point: Some("animate"), - compilation_options: Default::default(), - cache: None, - }); let layout = pipeline.get_bind_group_layout(0); let groups = keys .iter() @@ -374,6 +363,35 @@ impl SpriteAnimator { } } +/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL +/// driver can list compute shaders among its extensions while wgpu writes +/// the GLSL version of its core version, which has none (Mesa's +/// `MESA_GL_VERSION_OVERRIDE`, older drivers). +fn pipeline(device: &wgpu::Device) -> Option { + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Animated sprites"), + source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()), + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("Animated sprites"), + layout: None, + module: &module, + entry_point: Some("animate"), + compilation_options: Default::default(), + cache: None, + }); + let internal = pollster::block_on(internal.pop()); + match internal.or(pollster::block_on(validation.pop())) { + None => Some(pipeline), + Some(e) => { + log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})"); + None + } + } +} + /// `animate.wgsl` on the CPU: one `width` wide level of a job /// (`[layer, current, next, millis, edge]`) from `frames`' chains. fn mix_level(frames: &[u8], [_, current, next, millis, edge]: [u32; 5], width: u32) -> Vec { From 2761349d98199a2695e629bf2f911c2d0db7a6c8 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:50:49 +0100 Subject: [PATCH 56/57] Renderer: no indirect buffer usage without indirect draws (OpenGL before 4.3) wgpu refuses to create a buffer with INDIRECT usage on a device without DownlevelFlags::INDIRECT_EXECUTION (OpenGL before 4.3 without compute shaders), so the game panicked creating 'Section draws'. Multi-draw now also needs indirect execution, and without it the buffer has no INDIRECT usage: sections are drawn one direct draw each, as they already were without INDIRECT_FIRST_INSTANCE. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index baa8c5099..cd40ea22e 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -2182,15 +2182,26 @@ impl Renderer { ) }); let sorted_indices = arena::Arena::indices(&device, 1 << 16, flat.is_some()); + let multi_draw = device + .features() + .contains(wgpu::Features::INDIRECT_FIRST_INSTANCE) + && adapter + .get_downlevel_capabilities() + .flags + .contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION); + // Without indirect draws (OpenGL before 4.3 without compute shaders) + // even creating an indirect buffer fails; one draw per section reads + // `draws` on the CPU instead. let indirect = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Section draws"), size: 1024 * 20, - usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST, + usage: if multi_draw { + wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST + } else { + wgpu::BufferUsages::COPY_DST + }, mapped_at_creation: false, }); - let multi_draw = device - .features() - .contains(wgpu::Features::INDIRECT_FIRST_INSTANCE); // HORIZON_GPU_CULL=0 turns occlusion culling off, `all` culls every // frame however few its draws and little it hides, `shader` tests in // the shader as on Metal (A/B checks). From a4a709c2d698788c30af6612d1870be9fc5e5763 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:16:18 +0100 Subject: [PATCH 57/57] Roadmap: OpenGL shipped in v0.2.63; three players' ideas kept by Ludovic One-command server install, launcher-style game menus (off by default), AppImage and a Nix flake. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 45 ++++++++++++++++++++++++----------- roadmap/roadmap.toml | 49 ++++++++++++++++++++++++++++++++++----- website/data/roadmap.json | 44 +++++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7e4cc9937..a04eca2ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru - **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players. - **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click. -**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included. +**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Lua and other languages for mods; Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command. **How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items. @@ -374,6 +374,16 @@ More: [docs/server.md](docs/server.md) +#### A server you install with one command + +Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines. + +
Technical + +A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in [docs/hosting.md](docs/hosting.md). + +
+ ### Mods and API #### Mods that read and change the world @@ -790,18 +800,6 @@ Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds -#### OpenGL for old computers - -A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL. - -
Technical - -Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships. - -More: [docs/graphics.md](docs/graphics.md) - -
- #### Android, iOS and the browser Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces. @@ -812,6 +810,16 @@ No official build. Android was first closed as not planned, with touch controls, +#### AppImage and a Nix flake + +Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake. + +
Technical + +A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today. + +
+ ### Horizon extras #### A complete video studio inside the game @@ -824,6 +832,16 @@ Three stages. Recording: capture gameplay in a keypress with an encoder thread a +#### Game menus in the launcher's style + +An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are. + +
Technical + +A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look. + +
+ ### AI and agents #### An optional AI teammate @@ -936,6 +954,7 @@ Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't ## Recently shipped +- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936) - **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570) - **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md) - **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434) diff --git a/roadmap/roadmap.toml b/roadmap/roadmap.toml index 34ae8a955..669596fba 100644 --- a/roadmap/roadmap.toml +++ b/roadmap/roadmap.toml @@ -968,13 +968,15 @@ links = ["#1709"] [[item]] id = "opengl-fallback" title = "OpenGL for old computers" -status = "later" +status = "shipped" +shipped_in = "v0.2.63" area = "platforms" -plain = "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL." -dev = """Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL \ -backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like \ -the DirectX 12 fallback that already ships.""" -links = ["docs/graphics.md"] +plain = """Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL \ +by itself, and Options > Graphics can pick OpenGL by hand.""" +dev = """wgpu's GL backend (WGL on Windows, EGL on Linux) as the last fallback after Vulkan and DirectX 12 (#1917), \ +sprite arrays and CPU sprite animation for drivers without compute (#1920), no indirect draws below GL 4.3 \ +(#1936). Minimum OpenGL 3.3 core. Shader packs don't run on it yet.""" +links = ["#1917", "#1920", "#1936"] [[item]] id = "phones-and-browser" @@ -1187,3 +1189,38 @@ plain = """The launcher's Servers page creates, starts, backs up and updates a H invites friends.""" dev = """The launcher's Servers page (#1026) over `horizon-server` (docs/hosting.md).""" links = ["#1026", "docs/hosting.md"] + +[[item]] +id = "cli-server-install" +title = "A server you install with one command" +status = "next" +area = "servers" +plain = """Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for \ +VPSes and headless machines.""" +dev = """A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script \ +(Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the \ +player's own game files and starts the server, documented in docs/hosting.md.""" +links = ["docs/hosting.md"] + +[[item]] +id = "launcher-look-menus" +title = "Game menus in the launcher's style" +status = "later" +area = "extras" +plain = """An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: \ +the vanilla menus stay as they are.""" +dev = """A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, \ +pause and options screens with the launcher's design tokens (crates/horizon-theme), following \ +docs/design-system.md; off by default because it changes the vanilla look.""" +links = ["docs/design-system.md"] + +[[item]] +id = "linux-packages" +title = "AppImage and a Nix flake" +status = "later" +area = "platforms" +plain = """Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.""" +dev = """A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a \ +flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), \ +the way horizon-parity's NixOS setup runs them today.""" +links = [] diff --git a/website/data/roadmap.json b/website/data/roadmap.json index 4af2a460f..58d697257 100644 --- a/website/data/roadmap.json +++ b/website/data/roadmap.json @@ -964,13 +964,16 @@ { "id": "opengl-fallback", "title": "OpenGL for old computers", - "status": "later", + "status": "shipped", "area": "platforms", - "plain": "A way to play on old PCs and Linux machines that have no Vulkan or DirectX 12, through OpenGL.", - "dev": "Players on older hardware ask whether Vulkan is the only way. Horizon draws with wgpu, which has an OpenGL backend; what is needed is to see which features hold up on it, how fast it runs, and a launcher switch like the DirectX 12 fallback that already ships.", + "plain": "Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand.", + "dev": "wgpu's GL backend (WGL on Windows, EGL on Linux) as the last fallback after Vulkan and DirectX 12 (#1917), sprite arrays and CPU sprite animation for drivers without compute (#1920), no indirect draws below GL 4.3 (#1936). Minimum OpenGL 3.3 core. Shader packs don't run on it yet.", "links": [ - "docs/graphics.md" - ] + "#1917", + "#1920", + "#1936" + ], + "shipped_in": "v0.2.63" }, { "id": "phones-and-browser", @@ -1202,6 +1205,37 @@ "docs/hosting.md" ], "shipped_in": "launcher-v0.15.0" + }, + { + "id": "cli-server-install", + "title": "A server you install with one command", + "status": "next", + "area": "servers", + "plain": "Install and run a Horizon dedicated server from a terminal with a single command, no launcher needed, for VPSes and headless machines.", + "dev": "A player's idea (#horizon-ideas). `horizon-server` already runs headless; left: an install script (Linux/macOS shell, Windows PowerShell) that fetches the signed release, checks it, asks for or points at the player's own game files and starts the server, documented in docs/hosting.md.", + "links": [ + "docs/hosting.md" + ] + }, + { + "id": "launcher-look-menus", + "title": "Game menus in the launcher's style", + "status": "later", + "area": "extras", + "plain": "An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.", + "dev": "A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following docs/design-system.md; off by default because it changes the vanilla look.", + "links": [ + "docs/design-system.md" + ] + }, + { + "id": "linux-packages", + "title": "AppImage and a Nix flake", + "status": "later", + "area": "platforms", + "plain": "Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.", + "dev": "A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.", + "links": [] } ], "journal": [