From 3e788469756ab71a5208394bbc0f4c888c290fb4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:28 +0100 Subject: [PATCH 01/26] Soul Speed: speed, no soul sand slowdown, boots wear, soul particles The enchantment's location_changed effects now run for the player: the movement_speed and movement_efficiency modifiers turn on over soul sand and soul soil and stay on while airborne, the boots wear with a 4% chance per block change, and walking on those blocks spawns soul particles and the soul escape sound. --- crates/horizon-client/src/game.rs | 1 + .../src/game/enchant_effects.rs | 157 +++++++++++++++++- crates/horizon-client/src/motion.rs | 37 +++++ 3 files changed, 192 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5760ffbe7..515a69cc8 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4342,6 +4342,7 @@ impl Game<'_> { }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; + self.tick_soul_speed(); self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index aa9f75e06..6550ba9d5 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -3,10 +3,15 @@ //! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power //! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`), //! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and -//! the armor attribute effects (respiration, depth strider, ...). +//! the armor attribute effects (respiration, depth strider, ...) and Soul +//! Speed's `location_changed` and `tick` effects. use super::Game; -use horizon_client::{enchant::EffectContext, items::ItemStack}; -use horizon_core::random::RandomSource; +use horizon_client::{ + enchant::{EffectContext, Enchantments, level_value}, + items::ItemStack, + particles::{ParticleOptions, ParticleType}, +}; +use horizon_core::{position::BlockPos, random::RandomSource}; /// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`). #[derive(Clone, Copy)] @@ -460,6 +465,127 @@ impl Game<'_> { } ignite } + /// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of + /// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The + /// `location_changed` effects run when the player enters a new block, lands, + /// or its boots change: + /// - the speed and movement efficiency modifiers turn on over a soul speed + /// block (not flying, not riding) and stay on while airborne, then off + /// (`enchantment_active_check`); + /// - the boots lose 1 durability with a 4% chance when on such a block. + /// + /// Every fifth tick the player walking on one also gets soul particles and + /// sometimes a sound. A remote server applies and syncs all of this itself. + pub(super) fn tick_soul_speed(&mut self) { + const ID: &str = "minecraft:soul_speed"; + let Some(player) = self.player.as_mut() else { + return; + }; + if player.client_side { + return; + } + let level = self.armor[3] + .as_ref() + .map_or(0, |s| s.enchantment_level(ID)); + let position = player.position; + let (on_ground, flying) = (player.ground.on_ground, player.flying); + let moved = position.subtract(player.old_position); + let (tick, width) = (player.health.tick_count, player.dimensions.width); + let fx = &mut player.location_effects; + let on_soul = fx.on_soul_speed_block; + let block = BlockPos::containing(position); + let changed_block = fx.block.replace(block) != Some(block); + let landed = std::mem::take(&mut fx.landed); + // Changed boots: `stopLocationBasedEffects`, then the effects run again. + let equipped = std::mem::replace(&mut fx.boots_level, level) != level; + let was_active = fx.applied_level; + let mut active = was_active > 0 && !equipped; + let mut wear = false; + if level > 0 && (changed_block || landed || equipped) { + let riding = self.riding.is_some(); + let wanted = !riding && !flying && (on_soul || (active && !on_ground)); + active = wanted; + wear = on_ground && on_soul && self.world.random.next_float() < 0.04; + } + fx.applied_level = if active { level } else { 0 }; + // `Entity.getKnownMovement` horizontal speed of at least 1.0E-5. + let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10; + let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0; + // The attribute effects were applied to the player before: swap them. + if was_active > 0 && (!active || equipped) { + for (attribute, id, _, op) in + soul_speed_modifiers(&self.res.stations().enchantments, was_active) + { + let _ = player.set_modifier(&attribute, &id, 0.0, &op, false); + } + } + if active && (was_active == 0 || equipped) { + for (attribute, id, amount, op) in + soul_speed_modifiers(&self.res.stations().enchantments, level) + { + let _ = player.set_modifier(&attribute, &id, amount, &op, true); + } + } + if wear && !self.creative { + self.wear_boots(1); + } + if tick_effects { + // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown + // back against the movement (-0.2) and up (0.1). + let r = &mut self.world.random; + let x = position.x + (r.next_double() - 0.5) * f64::from(width); + let z = position.z + (r.next_double() - 0.5) * f64::from(width); + self.particles.send_particles( + &ParticleOptions::simple(ParticleType::Soul), + [x, position.y + 0.1, z], + [moved.x * -0.2, 0.1, moved.z * -0.2], + 1.0, + 0, + ); + // `play_sound`: 35% chance, volume 0.6, pitch 0.6 to 1.0. + if r.next_float() < 0.35 { + let pitch = r.next_float() * 0.4 + 0.6; + self.audio.play( + "minecraft:particle.soul_escape", + Some([position.x, position.y, position.z]), + 0.6, + pitch, + ); + } + } + } + /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): + /// Unbreaking applies, and they break when worn out. + fn wear_boots(&mut self, amount: u32) { + let Some(stack) = self.armor[3].clone() else { + return; + }; + let loss = self.durability_loss(&stack, amount); + let max = self + .res + .items() + .get(&stack.item) + .map_or(0, |info| info.max_damage); + if max == 0 { + return; + } + let Some(boots) = self.armor[3].as_mut() else { + return; + }; + boots.damage += loss; + if boots.damage >= max { + self.armor[3] = None; + if let Some(eye) = self.eye_position() { + self.audio.play( + "minecraft:entity.item.break", + Some(eye), + 0.8, + 0.8 + self.world.random.next_float() * 0.4, + ); + } + self.apply_equipment(); + } + } /// Enchantment attribute modifiers of worn armor (respiration, depth strider...). /// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`), /// so the same enchantment on two pieces stacks instead of replacing itself. @@ -483,3 +609,28 @@ impl Game<'_> { self.potions.enchant_modifiers = wanted; } } + +/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation), +/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`. +fn soul_speed_modifiers( + enchantments: &Enchantments, + level: u32, +) -> Vec<(String, String, f64, String)> { + let Some(e) = enchantments.get("minecraft:soul_speed") else { + return Vec::new(); + }; + e.effects["minecraft:location_changed"][0]["effect"]["effects"] + .as_array() + .into_iter() + .flatten() + .filter(|a| a["type"] == "minecraft:attribute") + .map(|a| { + ( + a["attribute"].as_str().unwrap_or("").to_owned(), + format!("{}/feet", a["id"].as_str().unwrap_or("")), + f64::from(level_value(&a["amount"], level)), + a["operation"].as_str().unwrap_or("add_value").to_owned(), + ) + }) + .collect() +} diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 0bce8927c..156fc2549 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -50,6 +50,8 @@ pub struct MotionResources { pub attributes: Arc, pub blocks: BoundMovementProperties, pub climbable: BTreeSet, + /// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for. + pub soul_speed_blocks: BTreeSet, pub fall_reset: BTreeSet, pub landing: BlockLandingRegistry, pub health: crate::health::HealthResources, @@ -108,11 +110,29 @@ impl MotionResources { suppresses_bounce: tag("minecraft:suppresses_bounce")?, })?, climbable: tag("minecraft:climbable")?, + soul_speed_blocks: tag("minecraft:soul_speed_blocks")?, fall_reset: tag("minecraft:fall_damage_resetting")?, }) } } +/// What the `location_changed` enchantment effects (Soul Speed) need to know +/// between ticks. +#[derive(Clone, Debug, Default)] +pub struct LocationEffects { + /// `MovementAffectedByPredicate`: the block under the player + /// (`getBlockPosBelowThatAffectsMyMovement`) is in `#minecraft:soul_speed_blocks`. + pub on_soul_speed_block: bool, + /// `LivingEntity.lastPos`: the block the player was in last tick. + pub block: Option, + /// A landing ran `onChangedBlock` (`LivingEntity.checkFallDamage`). + pub landed: bool, + /// The Soul Speed level of the boots seen last tick (0: none). + pub boots_level: u32, + /// The Soul Speed level whose attribute modifiers are on the player (0: none). + pub applied_level: u32, +} + /// Ordered requests and phase records for world/audio/network consumers. Landing /// hurt dispatch updates stored health synchronously before these are drained. #[derive(Clone, Copy, Debug)] @@ -255,6 +275,8 @@ pub struct PlayerMotion { pub glide_ending: bool, /// Glider durability to take this tick (updateFallFlying). pub glider_wear: u32, + /// Soul Speed's `location_changed` bookkeeping. + pub location_effects: LocationEffects, /// `LivingEntity.autoSpinAttackTicks` and `autoSpinAttackDmg`: Riptide's /// spin. The damage stays until the game has dealt the hit. pub auto_spin_attack_ticks: i32, @@ -443,6 +465,7 @@ impl PlayerMotion { fall_fly_ticks: 0, glide_ending: false, glider_wear: 0, + location_effects: LocationEffects::default(), auto_spin_attack_ticks: 0, auto_spin_attack_dmg: 0.0, spin_targets: Vec::new(), @@ -984,6 +1007,7 @@ impl MotionWorld<'_, '_, '_> { ) }); self.player.try_add_frost(on_air)?; + self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; if self.player.health.tick_count % 40 == 0 && self.player.is_fully_frozen() && self.player.can_freeze() @@ -996,6 +1020,15 @@ impl MotionWorld<'_, '_, '_> { } Ok(()) } + /// `MovementAffectedByPredicate` for `#minecraft:soul_speed_blocks`. + fn on_soul_speed_block(&mut self) -> Result { + let on = self.support(movement_support::MOVEMENT_SUPPORT_OFFSET)?; + Ok(self + .assets + .registry + .state(self.block(on)) + .is_some_and(|s| self.resources.soul_speed_blocks.contains(&s.name))) + } /// Player.travel: swim steering, then LivingEntity.travel (fluids first, /// then gliding, then air), keeping 0.6 of the vertical speed while flying. fn player_travel(&mut self, input: Vec3) -> Result<()> { @@ -1834,6 +1867,10 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { if self.player.status.slow_falling || self.player.status.levitation.is_some() { self.player.fall_distance = 0.0; } + // LivingEntity.checkFallDamage: a landing runs onChangedBlock. + if grounded && self.player.fall_distance > 0.0 { + self.player.location_effects.landed = true; + } if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0 { self.player.events.push(MotionEvent::SmashLanding { From fd0cb002962ed5d1ae9488b913a4329f850532e1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:58 +0100 Subject: [PATCH 02/26] Axolotls dry out on land, squid squirt ink when a living entity hurts them --- .../src/game/commands/world_cmds.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs.rs | 15 +++++- crates/horizon-client/src/mobs/ai.rs | 32 +++++++++++- crates/horizon-client/src/mobs/aquatic.rs | 52 +++++++++++++++++++ crates/horizon-client/src/mobs/axolotl.rs | 2 + 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 30f019997..eac6f125f 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -108,6 +108,7 @@ impl Game<'_> { "in_fire" | "campfire" | "hot_floor" => DamageKind::InFire, "lava" => DamageKind::Lava, "drown" => DamageKind::Drown, + "dry_out" => DamageKind::DryOut, "magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic, "sting" => DamageKind::Sting, "cactus" | "sweet_berry_bush" => DamageKind::Cactus, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 194256040..b784b4c98 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -3272,6 +3272,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::InFire => "minecraft:in_fire", K::Lava => "minecraft:lava", K::Drown => "minecraft:drown", + K::DryOut => "minecraft:dry_out", K::Magic => "minecraft:magic", K::Sting => "minecraft:sting", K::Cactus => "minecraft:cactus", diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 1b5dddbe7..de9c7dfac 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -515,6 +515,8 @@ pub enum DamageKind { InFire, Lava, Drown, + /// `dry_out`: an axolotl out of water for too long. + DryOut, Magic, Sting, Cactus, @@ -573,7 +575,11 @@ impl Damage { pub fn causes_panic(&self) -> bool { !matches!( self.kind, - DamageKind::Fall | DamageKind::Drown | DamageKind::Generic | DamageKind::InWall + DamageKind::Fall + | DamageKind::Drown + | DamageKind::DryOut + | DamageKind::Generic + | DamageKind::InWall ) } /// DamageTypeTags.BYPASSES_ARMOR @@ -606,6 +612,7 @@ impl Damage { | DamageKind::InFire | DamageKind::Lava | DamageKind::Drown + | DamageKind::DryOut | DamageKind::Magic | DamageKind::Cactus | DamageKind::Generic @@ -1789,6 +1796,12 @@ impl Mob { } } } + // Squid.hurtServer: a squid hurt after a living entity hit it squirts ink. + if matches!(self.species(), Species::Squid | Species::GlowSquid) + && self.ai.last_hurt_by.is_some() + { + aquatic::spawn_ink(self); + } true } /// LivingEntity.knockback(power, xd, zd) towards the negative direction. diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8335cb745..403f63101 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -441,6 +441,34 @@ pub fn tick_swing(m: &mut Mob) { } m.anim.attack_anim = m.ai.swing_time as f32 / 6.0; } +/// `LivingEntity.baseTick` refills a breather's air by 4, then +/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills +/// it, anywhere else it runs down from the pre-tick value, and at -20 the +/// axolotl dries out. +fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { + let before = m.air; + m.air = (m.air + 4).min(super::axolotl::MAX_AIR); + if m.no_ai { + return; + } + // Entity.isInRain: rain at the block, or at the top of the box. + let (x, y, z) = ( + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ); + let top = (m.position.y + f64::from(m.height())).floor() as i32; + let in_rain = w.raining && (w.can_see_sky(x, y, z) || w.can_see_sky(x, top, z)); + if m.fluids.in_water || in_rain { + m.air = super::axolotl::MAX_AIR; + } else { + m.air = before - 1; + if m.air <= -20 { + m.air = 0; + m.damage(2.0, Damage::of(DamageKind::DryOut)); + } + } +} /// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick. fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.was_eye_in_water = m.fluids.eye_in_water; @@ -500,7 +528,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.damage(1.0, Damage::of(DamageKind::InWall)); } // Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs). - if m.kind.flags.needs_water { + if m.species() == Species::Axolotl { + axolotl_air_supply(m, w); + } else if m.kind.flags.needs_water { if !m.fluids.in_water { m.air -= 1; if m.air == -20 { diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index dc80c6ea4..9603d5e58 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -24,6 +24,57 @@ const Z_BODY_ROT: usize = 3; const TENTACLE_SPEED: usize = 4; const ROTATE_SPEED: usize = 5; +/// `Squid.spawnInk`: the squirt sound and 30 ink particles shot from under +/// the squid, turned by its body rotation. +pub fn spawn_ink(m: &mut Mob) { + let sound = if m.species() == Species::GlowSquid { + "entity.glow_squid.squirt" + } else { + "entity.squid.squirt" + }; + let kind = if m.species() == Species::GlowSquid { + "glow_squid_ink" + } else { + "squid_ink" + }; + if !m.silent { + let pitch = m.voice_pitch(); + let at = [m.position.x, m.position.y, m.position.z]; + m.outbox.push(MobEvent::Sound( + sound, + at, + super::ai::sound_volume(m), + pitch, + )); + } + // rotateVector: xRot(xBodyRotO), then yRot(-yBodyRotO). + let to_radians = (std::f64::consts::PI / 180.0) as f32; + let (x_rot, y_rot) = ( + m.anim.vo[X_BODY_ROT] * to_radians, + -m.previous_body_yaw * to_radians, + ); + let rotate = |v: Vec3| v.rotate_x(x_rot).rotate_y(y_rot); + let pos = rotate(Vec3::new(0.0, -1.0, 0.0)).add_vector(m.position); + let offset_scale: f32 = if m.is_baby() { 0.1 } else { 0.3 }; + for _ in 0..30 { + let x = f64::from(m.random.next_float()) * 0.6 - 0.3; + let z = f64::from(m.random.next_float()) * 0.6 - 0.3; + let dir = rotate(Vec3::new(x, -1.0, z)); + let d = dir.scale(f64::from(offset_scale + m.random.next_float() * 2.0)); + // ServerLevel.sendParticles with a count of 0 and speed 0.1: one + // particle moving at the float offset times the float speed. + let speed = f64::from(0.1f32); + m.outbox.push(MobEvent::ParticleAt { + kind, + at: [pos.x, pos.y + 0.5, pos.z], + velocity: [ + speed * f64::from(d.x as f32), + speed * f64::from(d.y as f32), + speed * f64::from(d.z as f32), + ], + }); + } +} pub fn register(m: &mut Mob) { use Species as S; match m.species() { @@ -36,6 +87,7 @@ pub fn register(m: &mut Mob) { m.anim.v[TENTACLE_SPEED] = speed; } S::Axolotl => { + m.air = super::axolotl::MAX_AIR; // Native controller scaling is essential: the base speed attribute is 1.0. m.ai.mov.kind = MoveKind::SmoothSwimming { max_turn_x: 85.0, diff --git a/crates/horizon-client/src/mobs/axolotl.rs b/crates/horizon-client/src/mobs/axolotl.rs index 55f15717c..2815418e3 100644 --- a/crates/horizon-client/src/mobs/axolotl.rs +++ b/crates/horizon-client/src/mobs/axolotl.rs @@ -3,6 +3,8 @@ use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::Anim use horizon_core::random::RandomSource; pub const PLAY_DEAD_TICKS: i32 = 200; pub const HUNTING_COOLDOWN: i64 = 2400; +/// `Axolotl.getMaxAirSupply`. +pub const MAX_AIR: i32 = 6000; /// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown. pub fn prey_eligible( From 1d51f9d873b7ec024f9ceba69b57c9e2b3178977 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:30 +0100 Subject: [PATCH 03/26] Soul Speed: draw the random chances in vanilla's order Both random_chance terms come first in their all_of conditions, so the level random is drawn before the block and movement checks: the boots' 4% on every location change, and the sound's 35% on every tick the boots are worn. Co-Authored-By: Claude Opus 5.5 --- .../src/game/enchant_effects.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 6550ba9d5..8ae54dba7 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -505,7 +505,10 @@ impl Game<'_> { let riding = self.riding.is_some(); let wanted = !riding && !flying && (on_soul || (active && !on_ground)); active = wanted; - wear = on_ground && on_soul && self.world.random.next_float() < 0.04; + // `random_chance` is the first term of the wear's `all_of`: it + // draws from the level random before the block is looked at. + let roll = self.world.random.next_float() < 0.04; + wear = roll && on_ground && on_soul; } fx.applied_level = if active { level } else { 0 }; // `Entity.getKnownMovement` horizontal speed of at least 1.0E-5. @@ -529,10 +532,13 @@ impl Game<'_> { if wear && !self.creative { self.wear_boots(1); } + if level == 0 { + return; + } + let r = &mut self.world.random; if tick_effects { // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown // back against the movement (-0.2) and up (0.1). - let r = &mut self.world.random; let x = position.x + (r.next_double() - 0.5) * f64::from(width); let z = position.z + (r.next_double() - 0.5) * f64::from(width); self.particles.send_particles( @@ -542,16 +548,18 @@ impl Game<'_> { 1.0, 0, ); - // `play_sound`: 35% chance, volume 0.6, pitch 0.6 to 1.0. - if r.next_float() < 0.35 { - let pitch = r.next_float() * 0.4 + 0.6; - self.audio.play( - "minecraft:particle.soul_escape", - Some([position.x, position.y, position.z]), - 0.6, - pitch, - ); - } + } + // `play_sound`: its `random_chance` (35%) comes first in its `all_of`, + // so the level random is drawn every tick the boots are worn; volume + // 0.6, pitch 0.6 to 1.0. + if r.next_float() < 0.35 && tick_effects { + let pitch = r.next_float() * 0.4 + 0.6; + self.audio.play( + "minecraft:particle.soul_escape", + Some([position.x, position.y, position.z]), + 0.6, + pitch, + ); } } /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): From 75f7029d016b0ff1109f030d20c28471242ce5e1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:48:37 +0100 Subject: [PATCH 04/26] Squid ink: draw the squirt's voice pitch even when silent Mob.makeSound calls getVoicePitch before Entity.playSound checks isSilent, so the squid's random advances either way. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/aquatic.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index 9603d5e58..f09f7c3b3 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -37,8 +37,9 @@ pub fn spawn_ink(m: &mut Mob) { } else { "squid_ink" }; + // makeSound draws the voice pitch even for a silent squid. + let pitch = m.voice_pitch(); if !m.silent { - let pitch = m.voice_pitch(); let at = [m.position.x, m.position.y, m.position.z]; m.outbox.push(MobEvent::Sound( sound, From aa839fa99a28e2649d31d807bc032942a4ec739e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:39:02 +0100 Subject: [PATCH 05/26] mods: browser, a recipe viewer's item panel (API level 5) A new interface under capability ui. browser.set-items lists the items of a panel the game shows right of the inventory and container screens (an empty list hides it); browser.poll returns what the player did since the last poll: a click on an item, the search text, or one of the mod's own keys pressed over an item (a slot's or the panel's), with that stack. The slot-clicked, action, page and back events are there now for the recipe pages to come, since adding variant cases later would break mods. At most 8192 items and 256 waiting events. The game side is the next PR. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/src/lib.rs | 4 +- crates/horizon-mod-sdk/wit/horizon.wit | 56 ++++++++++++ crates/horizon-mods/src/api_level.rs | 6 +- crates/horizon-mods/src/manifest.rs | 4 + crates/horizon-mods/src/runtime.rs | 87 ++++++++++++++++++- crates/horizon-mods/tests/sandbox.rs | 65 ++++++++++++++ crates/horizon-mods/tests/ui_guest/src/lib.rs | 58 ++++++++++++- docs/api/mods-reference.md | 66 +++++++++++++- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 10 files changed, 340 insertions(+), 10 deletions(-) diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index b00d06ecf..c87c2dbc4 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -39,7 +39,9 @@ wit_bindgen::generate!({ pub use exports::horizon::mods::events::{Guest as __Guest, Registration}; pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest; -pub use horizon::mods::{chat, command, game, hud, input, log, recipes, render, storage, ui}; +pub use horizon::mods::{ + browser, chat, command, game, hud, input, log, recipes, render, storage, ui, +}; /// The game's calls into a mod. Only the hooks [`Registration`] asks for /// are called; the others may stay as their defaults. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 7d7adaa0d..ab20648d8 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -509,6 +509,60 @@ interface recipes { known: func() -> list; } +/// Capability `ui` (experimental): a recipe viewer's panel beside the +/// game's inventory and container screens (not the creative inventory), on +/// the right of the screen. The game draws it with its own widgets and +/// slots, pages it and keeps its search box; the mod says what it holds. +/// The first loaded mod with a panel shows. +interface browser { + /// An item to show: its id, a count (1 draws none) and its components + /// beyond the item's own, in vanilla's item syntax, or "". + record stack { + item: string, + count: u32, + components: string, + } + /// A click on an item of the panel, by its index in `set-items`'s list. + record click { + index: u32, + right: bool, + } + /// A key the mod registered (`input.register-key`) went down while the + /// pointer was on an item: a slot of the screen's, or the panel's. + record item-key { + key: string, + stack: stack, + } + /// A click on a slot of the recipe the panel shows (`set-recipes`). + record slot-click { + /// The recipe's index on the page and the slot's in it; the + /// results come after the slots. + recipe: u32, + slot: u32, + /// The stack the slot showed at that moment. + stack: stack, + right: bool, + } + variant event { + clicked(click), + /// The search box's text, each time the player changes it. + search(string), + key(item-key), + slot-clicked(slot-click), + /// The action button of the recipe at this index on the page. + action(u32), + /// The page arrows of `set-recipes`: -1 or 1. + page(s32), + /// The back button of `set-recipes`. + back, + } + /// The panel's items, at most 8192 (an empty list hides the panel). + set-items: func(items: list); + /// What the player did on the panel since the last poll, at most 256 + /// kept. + poll: func() -> list; +} + /// Capability `players`, server mods only (manifest `api = "0.3"`, /// experimental): the players on the server, and messages to them. interface players { @@ -616,6 +670,7 @@ world horizon-ui-mod { import render; import input; import recipes; + import browser; export ui-events; } @@ -634,4 +689,5 @@ world render-host { /// The game's side of capability `recipes` (imports only). world recipes-host { import recipes; + import browser; } diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index ff06a94ae..2474dfddd 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -7,13 +7,13 @@ //! version's level so the launcher can tell before it installs. /// The newest level this Horizon serves. -pub const LEVEL: u32 = 4; +pub const LEVEL: u32 = 5; /// Every function the game offers mods, as `interface.function`, with its /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 37] = [ +pub const FUNCTIONS: [(&str, u32); 39] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -51,6 +51,8 @@ pub const FUNCTIONS: [(&str, u32); 37] = [ ("recipes.items", 4), ("recipes.all", 4), ("recipes.known", 4), + ("browser.set-items", 5), + ("browser.poll", 5), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 05cf96447..4c12fd805 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -173,6 +173,10 @@ impl Capability { if interface == "log" { return Ok(None); } + // A recipe viewer's panel is part of capability `ui`. + if interface == "browser" { + return Ok(Some(Capability::Ui)); + } Capability::ALL .into_iter() .filter(|c| *c != Capability::Registries) diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index ebce03944..0f2de0246 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -85,11 +85,16 @@ mod recipes_bindings { world: "recipes-host", }); } +/// Capability `ui`'s recipe viewer panel (WIT `interface browser`). +pub use recipes_bindings::horizon::mods::browser::{ + Click as BrowserClick, Event as BrowserEvent, ItemKey as BrowserItemKey, + SlotClick as BrowserSlotClick, Stack as BrowserStack, +}; /// What capability `recipes` reads (WIT `interface recipes`). pub use recipes_bindings::horizon::mods::recipes::{ Item as RecipeItem, Kind as RecipeKind, Recipe, Stack as RecipeStack, }; -use recipes_bindings::{RecipesHost, horizon::mods::recipes}; +use recipes_bindings::{RecipesHost, horizon::mods::browser, horizon::mods::recipes}; /// A mod's linear memory cap. const MEMORY: usize = 64 << 20; @@ -309,6 +314,28 @@ struct UiState { close: bool, } +/// Capability `ui`'s panel beside container screens (`browser`). +#[derive(Default)] +struct BrowserState { + items: Vec, + /// Which `set-items` call the items are from, unique across mods and + /// reloads, so the game converts them once. + version: u64, + events: std::collections::VecDeque, +} +static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// Items a panel may hold, and events kept for `browser.poll`. +const BROWSER_ITEMS: usize = 8192; +const BROWSER_EVENTS: usize = 256; +impl BrowserState { + fn push(&mut self, event: BrowserEvent) { + if self.events.len() == BROWSER_EVENTS { + self.events.pop_front(); + } + self.events.push_back(event); + } +} + struct State { name: String, id: String, @@ -325,6 +352,7 @@ struct State { /// Capability `render`'s minimap, when the mod shows one. minimap: Option, input: InputState, + browser: BrowserState, /// Capability `storage`'s folder; made on first `set`. data_dir: PathBuf, /// Bytes `storage::set` may still write before this hook call ends @@ -490,6 +518,7 @@ impl Mods { zoom: None, minimap: None, input: InputState::default(), + browser: BrowserState::default(), data_dir, storage_budget: STORAGE_WRITE_BUDGET, limits: StoreLimitsBuilder::new() @@ -799,6 +828,40 @@ impl Mods { .iter() .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) } + /// The panel beside container screens (`browser.set-items`): the first + /// running mod with items, its index, the items' version and the items. + pub fn browser_items(&self) -> Option<(usize, u64, &[BrowserStack])> { + self.entries.iter().enumerate().find_map(|(i, e)| { + let b = &e.run.as_ref()?.store.data().browser; + (!b.items.is_empty()).then_some((i, b.version, b.items.as_slice())) + }) + } + /// The player did `event` on mod `owner`'s panel. + pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) { + if let Some(run) = self.entries.get_mut(owner).and_then(|e| e.run.as_mut()) { + run.store.data_mut().browser.push(event); + } + } + /// The key `key` went down with the pointer on `stack` in a container + /// screen: each mod with a key on it hears its own name for it. + pub fn browser_key(&mut self, key: &str, stack: &BrowserStack) { + for run in self.entries.iter_mut().filter_map(|e| e.run.as_mut()) { + let data = run.store.data_mut(); + let names: Vec = data + .input + .keys + .iter() + .filter(|k| k.key == key) + .map(|k| k.name.clone()) + .collect(); + for key in names { + data.browser.push(BrowserEvent::Key(BrowserItemKey { + key, + stack: stack.clone(), + })); + } + } + } /// Some running mod reads capability `recipes`: the snapshot needs /// [`Snapshot::recipes`] and [`Snapshot::known_recipes`]. pub fn wants_recipes(&self) -> bool { @@ -1101,7 +1164,10 @@ fn reason(e: &wasmtime::Error) -> String { } fn clip(text: String) -> String { - match text.char_indices().nth(LINE_CHARS) { + clip_to(text, LINE_CHARS) +} +fn clip_to(text: String, chars: usize) -> String { + match text.char_indices().nth(chars) { Some((at, _)) => text[..at].to_string(), None => text, } @@ -1162,6 +1228,23 @@ impl game::Host for State { (*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone()) } } +impl browser::Host for State { + fn set_items(&mut self, items: Vec) { + self.browser.items = items + .into_iter() + .take(BROWSER_ITEMS) + .map(|s| BrowserStack { + item: clip(s.item), + components: clip_to(s.components, 4096), + ..s + }) + .collect(); + self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + fn poll(&mut self) -> Vec { + self.browser.events.drain(..).collect() + } +} impl recipes::Host for State { fn items(&mut self) -> Vec { self.snapshot diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 06a8e6aa2..576dc5942 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1098,3 +1098,68 @@ fn recipes_reads_the_tables() { ["0 items, 0 recipes, 0 known, 0 brewing"] ); } + +#[test] +fn browser_lists_items_and_hears_the_player() { + use horizon_mods::{BrowserClick, BrowserEvent, BrowserStack}; + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + let said = |mods: &mut Mods, command: &str| { + assert!(mods.command(command)); + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::Chat { text, .. } => Some(text), + _ => None, + }) + .collect::>() + }; + assert!(mods.browser_items().is_none()); + let potion = "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]"; + assert_eq!( + said( + &mut mods, + &format!("browser minecraft:stone minecraft:potion{potion}") + ), + ["panel of 2"] + ); + let (owner, version, items) = mods.browser_items().unwrap(); + assert_eq!( + (items[0].item.as_str(), items[1].components.as_str()), + ("minecraft:stone", potion) + ); + mods.browser_event( + owner, + BrowserEvent::Clicked(BrowserClick { + index: 1, + right: true, + }), + ); + mods.browser_event(owner, BrowserEvent::Search("oak".into())); + let log = BrowserStack { + item: "minecraft:oak_log".into(), + count: 3, + components: String::new(), + }; + // R is the guest's `recipe`; X is none of its keys. + mods.browser_key("key.keyboard.r", &log); + mods.browser_key("key.keyboard.x", &log); + assert_eq!( + said(&mut mods, "browser_poll"), + [ + "3 events", + "clicked 1 right", + "search \"oak\"", + "key recipe on minecraft:oak_log x3" + ] + ); + // Each `set-items` is a new version; an empty list hides the panel. + said(&mut mods, "browser minecraft:dirt"); + assert_ne!(mods.browser_items().unwrap().1, version); + assert_eq!(said(&mut mods, "browser"), ["panel of 0"]); + assert!(mods.browser_items().is_none()); + // At most 256 events wait for a poll: the oldest go. + for _ in 0..300 { + mods.browser_event(owner, BrowserEvent::Page(1)); + } + assert_eq!(said(&mut mods, "browser_poll")[0], "256 events"); +} diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 2fd835d3c..4fc2ee2c4 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -8,8 +8,11 @@ //! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs //! lines with `command.run-quiet` (`/quiet_many N`: N of them). `/recipes` //! counts what `recipes` reads; `/recipes ID` describes that recipe. +//! `/browser ID...` fills `browser`'s panel (`/browser N`: the first N +//! items, `/browser` alone empties it) and registers the keys `recipe` (R) +//! and `uses` (U); `/browser_poll` says what the player did on it. use horizon_mod_sdk::{ - Mod, Registration, chat, command, game, + Mod, Registration, browser, chat, command, game, input::{self, Event}, recipes, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, @@ -105,6 +108,8 @@ impl Mod for Guest { "quiet", "quiet_many", "recipes", + "browser", + "browser_poll", "corner", "readings", ] @@ -279,6 +284,57 @@ impl Mod for Guest { ), }); } + "browser" => { + let stack = |item: &str| { + let (item, components) = match item.find('[') { + Some(at) => (&item[..at], &item[at..]), + None => (item, ""), + }; + browser::Stack { + item: item.into(), + count: 1, + components: components.into(), + } + }; + let items: Vec = match args.parse::() { + Ok(n) => recipes::items() + .iter() + .skip(1) + .take(n) + .map(|i| stack(&i.id)) + .collect(), + Err(_) => args.split_whitespace().map(stack).collect(), + }; + browser::set_items(&items); + input::register_key("recipe", "Show recipes", "key.keyboard.r")?; + input::register_key("uses", "Show uses", "key.keyboard.u")?; + chat::show(&format!("panel of {}", items.len())); + } + "browser_poll" => { + let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components); + // A hook shows at most 8 chat lines: the count, then the first 7. + let events = browser::poll(); + chat::show(&format!("{} events", events.len())); + for event in events.into_iter().take(7) { + chat::show(&match event { + browser::Event::Clicked(c) => { + format!( + "clicked {} {}", + c.index, + if c.right { "right" } else { "left" } + ) + } + browser::Event::Search(text) => format!("search {text:?}"), + browser::Event::Key(k) => format!("key {} on {}", k.key, stack(&k.stack)), + browser::Event::SlotClicked(c) => { + format!("slot {} {} {}", c.recipe, c.slot, stack(&c.stack)) + } + browser::Event::Action(i) => format!("action {i}"), + browser::Event::Page(step) => format!("page {step}"), + browser::Event::Back => "back".into(), + }); + } + } // Clamped to (1, 10); the two after it shake nothing. "render_shake" => { render::shake(2.0, 99.0); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 1ee54e70f..111fefd5c 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -37,7 +37,7 @@ A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs whe A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets. -- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `log`, `game`, `chat`, `hud`, `storage`, `command` +- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `browser`, `log`, `game`, `chat`, `hud`, `storage`, `command` - Exports (what the mod provides): `ui-events`, `events` ## World `ui-host` @@ -58,7 +58,7 @@ The game's side of capability `render` (imports only). The game's side of capability `recipes` (imports only). -- Imports (what the game offers): `recipes` +- Imports (what the game offers): `recipes`, `browser` - Exports (what the mod provides): ## `log` @@ -531,6 +531,68 @@ The station a recipe is made at. | `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | | `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | +## `browser` + +Access: not granted by any capability. + +Capability `ui` (experimental): a recipe viewer's panel beside the game's inventory and container screens (not the creative inventory), on the right of the screen. The game draws it with its own widgets and slots, pages it and keeps its search box; the mod says what it holds. The first loaded mod with a panel shows. + +### record `stack` + +An item to show: its id, a count (1 draws none) and its components beyond the item's own, in vanilla's item syntax, or "". + +| Field | Type | | +| --- | --- | --- | +| `item` | `string` | | +| `count` | `u32` | | +| `components` | `string` | | + +### record `click` + +A click on an item of the panel, by its index in `set-items`'s list. + +| Field | Type | | +| --- | --- | --- | +| `index` | `u32` | | +| `right` | `bool` | | + +### record `item-key` + +A key the mod registered (`input.register-key`) went down while the pointer was on an item: a slot of the screen's, or the panel's. + +| Field | Type | | +| --- | --- | --- | +| `key` | `string` | | +| `stack` | `stack` | | + +### record `slot-click` + +A click on a slot of the recipe the panel shows (`set-recipes`). + +| Field | Type | | +| --- | --- | --- | +| `recipe` | `u32` | The recipe's index on the page and the slot's in it; the results come after the slots. | +| `slot` | `u32` | | +| `stack` | `stack` | The stack the slot showed at that moment. | +| `right` | `bool` | | + +### variant `event` + +| Case | Payload | | +| --- | --- | --- | +| `clicked` | `click` | | +| `search` | `string` | The search box's text, each time the player changes it. | +| `key` | `item-key` | | +| `slot-clicked` | `slot-click` | | +| `action` | `u32` | The action button of the recipe at this index on the page. | +| `page` | `s32` | The page arrows of `set-recipes`: -1 or 1. | +| `back` | | The back button of `set-recipes`. | + +| Function | Signature | What it does | +| --- | --- | --- | +| `set-items` | `func(items: list)` | The panel's items, at most 8192 (an empty list hides the panel). | +| `poll` | `func() -> list` | What the player did on the panel since the last poll, at most 256 kept. | + ## `players` Access: capability `players`. diff --git a/docs/api/stability.md b/docs/api/stability.md index 00b5541a9..bb0ddf501 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`). A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`). A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index 73ecfb9a5..9aed4897b 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -88,7 +88,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | | `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | | `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | From 487a461a8a7454871aaf92e11466cadc9504699b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:39:12 +0100 Subject: [PATCH 06/26] game: the item panel of a mod's browser beside container screens The panel sits right of the inventory or container window, from the top of the screen to a search box at its foot, in up to 12 columns of the game's own item slots, with the recipe book's page arrows. It shows on the survival inventory and every container screen (not the creative one) while a running mod has items in it, and hides when there is no room for two columns. Clicks on it never throw the carried stack; the carried stack and tooltips are drawn again above it. The mod hears clicks on items, the search text as it changes, and its own keys pressed with the pointer on an item, the panel's or a slot's. The search box takes every key but Escape while focused, as the recipe book's. Each set-items is turned into stacks once. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 56 ++- crates/horizon-client/src/game/control.rs | 3 + crates/horizon-client/src/game/mod_browser.rs | 440 ++++++++++++++++++ crates/horizon-client/src/game/mod_input.rs | 12 + crates/horizon-client/src/game/mod_recipes.rs | 2 +- crates/horizon-client/src/game/mods.rs | 3 + 6 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_browser.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 826ea73c1..6779d367a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -99,6 +99,7 @@ mod maps; mod minecarts; mod mob_blocks; mod mob_gear; +mod mod_browser; mod mod_input; #[cfg(feature = "mods")] mod mod_recipes; @@ -5217,6 +5218,7 @@ impl Game<'_> { Screen::CommandBlock => self.draw_command_block(&mut batch), Screen::Inventory => { self.draw_inventory(&mut batch); + self.draw_mod_browser(&mut batch); } Screen::Creative => { self.draw_creative(&mut batch); @@ -5595,6 +5597,7 @@ impl Game<'_> { } } pub(super) fn close_container(&mut self) { + self.mod_browser_closed(); self.close_station(); self.mount_closed(); self.recipe_ui.reset_screen(); @@ -6319,6 +6322,9 @@ impl Game<'_> { fn inventory_press(&mut self, right: bool) { use horizon_client::container_screen::Target; use horizon_client::menu::Input; + if self.mod_browser_press(right) { + return; + } if !right && self.sort_press() { return; } @@ -6495,24 +6501,8 @@ impl Game<'_> { } /// `hasClickedOutside` of the open screen at the cursor. fn clicked_outside(&self, batch: &HudBatch) -> bool { - use horizon_client::container_screen::{Book, Image, clicked_outside}; - let (ox, oy) = self.inventory_origin(batch); - let (shift, width, height) = if self.screen == Screen::Creative { - (0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32) - } else { - let (shift, width) = match self.station.as_ref().map(|s| s.kind) { - Some(stations::Kind::Merchant) => (-50.0, 276), - Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), - _ => (0.0, 176), - }; - (shift, width, self.screen_height() as i32) - }; - let image = Image { - left: (ox + shift) as i32, - top: oy as i32, - width, - height, - }; + use horizon_client::container_screen::{Book, clicked_outside}; + let image = self.container_image(batch); let book = self .recipe_book_area(batch) .map(|(visible, tab_hovered)| Book { @@ -6526,6 +6516,26 @@ impl Game<'_> { book, ) } + /// The open container screen's window: its place and size. + fn container_image(&self, batch: &HudBatch) -> horizon_client::container_screen::Image { + let (ox, oy) = self.inventory_origin(batch); + let (shift, width, height) = if self.screen == Screen::Creative { + (0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32) + } else { + let (shift, width) = match self.station.as_ref().map(|s| s.kind) { + Some(stations::Kind::Merchant) => (-50.0, 276), + Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), + _ => (0.0, 176), + }; + (shift, width, self.screen_height() as i32) + }; + horizon_client::container_screen::Image { + left: (ox + shift) as i32, + top: oy as i32, + width, + height, + } + } /// A click outside the window (slot id -999): `THROW` does nothing; a /// `PICKUP` drops the carried stack (left) or one of it (right), thrown /// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`). @@ -6686,7 +6696,7 @@ impl Game<'_> { } } fn click_inventory(&mut self, right: bool) { - if self.mount_click(right) { + if self.mod_browser_press(right) || self.mount_click(right) { return; } if self.recipe_book_click(right) { @@ -11645,6 +11655,14 @@ impl ApplicationHandler for Game<'_> { // The recipe book's search box takes every key but Escape. self.recipe_key(&event); } + WindowEvent::KeyboardInput { event, .. } + if self.mod_browser_typing() + && event.state == ElementState::Pressed + && !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) => + { + // A mod's item panel's search box, as the recipe book's. + self.mod_browser_key(&event); + } WindowEvent::KeyboardInput { event, .. } if self.station_typing() && event.state == ElementState::Pressed diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 3202b23f4..191fa8359 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -628,6 +628,9 @@ impl Game<'_> { // The port, or the game rules' search and number boxes. Screen::WorldOptions if text == "\\b" => self.world_options_text(None), Screen::WorldOptions => self.world_options_text(Some(text)), + Screen::Inventory if self.mod_browser_typing() => { + self.mod_browser_text((text != "\\b").then_some(text), text == "\\b") + } Screen::Inventory if self.recipe_search_focused() => { self.recipe_text(Some(text), false) } diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs new file mode 100644 index 000000000..2f317792f --- /dev/null +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -0,0 +1,440 @@ +//! Capability `ui`'s panel beside container screens (WIT `interface +//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as +//! the game's own item slots in pages right of the screen's window, with a +//! search box under them. The mod says what the panel holds; the game pages +//! it and tells the mod what the player clicked, typed, and which of the +//! mod's keys went down over an item. With no mod showing a panel, each +//! hook here returns after one check. +use super::Game; +#[cfg(feature = "mods")] +use { + super::Screen, + horizon_client::{ + hud::{HudBatch, rgba}, + items::ItemStack, + ui::EditBox, + }, + horizon_mods::{BrowserClick, BrowserEvent, BrowserStack}, +}; + +#[cfg(not(feature = "mods"))] +impl Game<'_> { + pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {} + pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool { + false + } + pub(super) fn mod_browser_typing(&self) -> bool { + false + } + pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {} + pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {} + pub(super) fn mod_browser_closed(&mut self) {} +} + +/// One slot of the panel, in GUI pixels. +#[cfg(feature = "mods")] +const SLOT: f32 = 18.0; +/// The most columns the panel takes, and the fewest it shows with. +#[cfg(feature = "mods")] +const COLUMNS: (usize, usize) = (12, 2); +/// The gap between the window and the panel, and the screen's edges. +#[cfg(feature = "mods")] +const MARGIN: f32 = 8.0; + +/// The panel's state in the game. +#[cfg(feature = "mods")] +#[derive(Default)] +pub(super) struct Browser { + /// The items' owner and version they were made from. + of: Option<(usize, u64)>, + /// The mod's items as stacks; `None` for an item the game doesn't have. + stacks: Vec>, + page: usize, + search: Option, + /// The search text the mod last heard. + sent: String, +} + +/// Where the panel's parts are this frame. +#[cfg(feature = "mods")] +struct Layout { + /// The panel's whole area, for clicks that hit none of its parts. + area: [f32; 4], + /// The grid's top left corner, its columns and rows. + grid: (f32, f32), + columns: usize, + rows: usize, + /// The page arrows' row. + arrows_y: f32, + search: [f32; 4], +} +#[cfg(feature = "mods")] +impl Layout { + fn per_page(&self) -> usize { + self.columns * self.rows + } + fn width(&self) -> f32 { + self.columns as f32 * SLOT + } + fn slot(&self, n: usize) -> (f32, f32) { + ( + self.grid.0 + (n % self.columns) as f32 * SLOT, + self.grid.1 + (n / self.columns) as f32 * SLOT, + ) + } + /// The page arrows: backward, then forward. + fn arrows(&self) -> [(f32, f32); 2] { + [ + (self.grid.0, self.arrows_y), + (self.grid.0 + self.width() - 12.0, self.arrows_y), + ] + } +} + +#[cfg(feature = "mods")] +fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { + p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h +} + +#[cfg(feature = "mods")] +impl Game<'_> { + fn browser_batch(&self) -> Option { + let r = self.renderer.as_ref()?; + Some(HudBatch::new([r.config.width, r.config.height])) + } + /// The mod showing a panel, with its items made into stacks once per + /// `set-items`; none while no mod shows one or no container screen is up. + fn browser_sync(&mut self) -> Option { + if self.screen != Screen::Inventory || self.title.is_some() { + return None; + } + let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else { + self.mods.browser.of = None; + self.mods.browser.stacks.clear(); + return None; + }; + if self.mods.browser.of != Some((owner, version)) { + let items = self.res.items(); + let stacks = self + .mods + .mods + .browser_items() + .map_or_else(Vec::new, |(_, _, list)| { + list.iter().map(|s| browser_stack(items, s)).collect() + }); + self.mods.browser.stacks = stacks; + self.mods.browser.of = Some((owner, version)); + } + Some(owner) + } + /// The panel's parts: right of the window, from the top of the screen to + /// the search box at its foot. None when the screen has no room for it. + fn browser_layout(&self, batch: &HudBatch) -> Option { + let image = self.container_image(batch); + let left = (image.left + image.width) as f32 + MARGIN; + let right = batch.width - MARGIN; + let columns = (((right - left) / SLOT).floor().max(0.0) as usize).min(COLUMNS.0); + if columns < COLUMNS.1 { + return None; + } + let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor(); + let arrows_y = MARGIN; + let top = arrows_y + 17.0 + 4.0; + let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0]; + let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize; + (rows > 0).then_some(Layout { + area: [left, 0.0, right - left, batch.height], + grid: (x, top), + columns, + rows, + arrows_y, + search, + }) + } + fn browser_pages(&self, layout: &Layout) -> usize { + self.mods + .browser + .stacks + .len() + .div_ceil(layout.per_page()) + .max(1) + } + /// The panel's item under the pointer: its index and stack. + fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> { + let first = self.mods.browser.page * layout.per_page(); + (0..layout.per_page()).find_map(|n| { + let (x, y) = layout.slot(n); + if !inside(self.cursor, [x, y, SLOT, SLOT]) { + return None; + } + let stack = self.mods.browser.stacks.get(first + n)?.clone()?; + Some((first + n, stack)) + }) + } + fn browser_search(&mut self, layout: &Layout) -> &mut EditBox { + let search = self.mods.browser.search.get_or_insert_with(|| { + let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0); + edit.max_length = 50; + edit.focused = false; + edit + }); + [search.x, search.y, search.width, search.height] = layout.search; + search + } + + /// Draws the panel over the open container screen, then the carried + /// stack or the tooltip again so they stay on top of it. + pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { + if self.browser_sync().is_none() { + return; + } + let Some(layout) = self.browser_layout(batch) else { + return; + }; + let pages = self.browser_pages(&layout); + self.mods.browser.page = self.mods.browser.page.min(pages - 1); + let page = self.mods.browser.page; + let cursor = self.cursor; + if pages > 1 { + let labels = [ + self.translate("gui.recipebook.previous_page", "Previous Page"), + self.translate("gui.recipebook.next_page", "Next Page"), + ]; + let shown = [page > 0, page + 1 < pages]; + let atlas = &self.gui.atlas; + for (((x, y), name), (label, shown)) in layout + .arrows() + .into_iter() + .zip(["page_backward", "page_forward"]) + .zip(labels.iter().zip(shown)) + { + if !shown { + continue; + } + let hover = inside(cursor, [x, y, 12.0, 17.0]); + let suffix = if hover { "_highlighted" } else { "" }; + batch.sprite( + atlas, + &format!("gui/sprites/recipe_book/{name}{suffix}"), + x, + y, + ); + horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true); + } + let text = format!("{}/{pages}", page + 1); + let w = HudBatch::text_width(atlas, &text); + let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor(); + batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true); + } + let first = page * layout.per_page(); + for n in 0..layout.per_page() { + let Some(stack) = self.mods.browser.stacks.get(first + n) else { + break; + }; + let Some(stack) = stack.clone() else { + continue; + }; + let (x, y) = layout.slot(n); + self.draw_item(batch, &stack, x + 1.0, y + 1.0); + let rect = [x + 1.0, y + 1.0, 16.0, 16.0]; + if inside(cursor, [x, y, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &name, + rect, + true, + horizon_client::widgets::Kind::Button, + ); + } + let hint = self.translate("gui.recipebook.search_hint", "Search..."); + let search = self.browser_search(&layout); + // The hint shows in an empty box without focus, as the recipe book's. + search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint); + let mut search = search.clone(); + search.draw(batch, &self.gui.atlas, None); + self.mods.browser.search = Some(search); + if self.carried.is_some() { + self.draw_carried(batch); + } else if let Some((_, stack)) = self.browser_hovered(&layout) { + let lines = self.stack_tooltip(&stack); + self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1); + } else { + self.draw_slot_tooltip(batch); + } + } + + /// A mouse press on a container screen: true when the panel took it + /// (its search box, page arrows, items or empty space; a click there + /// never throws the carried stack). + pub(super) fn mod_browser_press(&mut self, right: bool) -> bool { + let Some(owner) = self.browser_sync() else { + return false; + }; + let Some(batch) = self.browser_batch() else { + return false; + }; + let Some(layout) = self.browser_layout(&batch) else { + return false; + }; + let cursor = self.cursor; + let shift = self.has_shift_down(); + let atlas = &self.gui.atlas; + let search = self.mods.browser.search.get_or_insert_with(|| { + let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0); + edit.max_length = 50; + edit + }); + [search.x, search.y, search.width, search.height] = layout.search; + if inside(cursor, layout.search) { + search.focused = true; + search.click(atlas, cursor.0, shift, false); + search.reset_blink(); + return true; + } + search.focused = false; + let pages = self.browser_pages(&layout); + let page = self.mods.browser.page; + let [back, forward] = layout.arrows(); + let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) { + Some(page - 1) + } else if pages > 1 + && page + 1 < pages + && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) + { + Some(page + 1) + } else { + None + }; + if let Some(to) = step { + self.mods.browser.page = to; + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + return true; + } + if let Some((index, _)) = self.browser_hovered(&layout) { + let event = BrowserEvent::Clicked(BrowserClick { + index: index as u32, + right, + }); + self.mods.mods.browser_event(owner, event); + return true; + } + inside(cursor, layout.area) + } + + /// Whether keys go to the panel's search box. + pub(super) fn mod_browser_typing(&self) -> bool { + self.screen == Screen::Inventory + && self.mods.browser.of.is_some() + && self.mods.browser.search.as_ref().is_some_and(|s| s.focused) + } + /// A key press while the search box has focus. + pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) { + use winit::keyboard::PhysicalKey; + let shortcut = self.edit_shortcut(event); + let ctrl = self.has_control_down(); + let shift = self.has_shift_down(); + let Some(search) = self.mods.browser.search.as_mut() else { + return; + }; + if let Some(shortcut) = shortcut { + search.shortcut(shortcut, &mut self.clipboard); + } else { + let handled = match event.physical_key { + PhysicalKey::Code(code) => search.key(code, ctrl, shift), + _ => false, + }; + if !handled + && !ctrl + && let Some(text) = &event.text + { + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + search.insert_text(&text); + } + } + search.reset_blink(); + self.browser_searched(); + } + /// Text typed (or one backspace) into the search box (`horizon-ctl type`). + pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) { + let Some(search) = self.mods.browser.search.as_mut() else { + return; + }; + if backspace { + search.delete_chars(-1); + } else if let Some(text) = text { + search.insert_text(text); + } + self.browser_searched(); + } + /// The mod hears the search text when it changed. + fn browser_searched(&mut self) { + let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search) + else { + return; + }; + if search.value != self.mods.browser.sent { + let value = search.value.clone(); + self.mods.browser.sent.clone_from(&value); + self.mods.browser.page = 0; + self.mods + .mods + .browser_event(owner, BrowserEvent::Search(value)); + } + } + /// The container screen closed: the search box lets go of the keys. + pub(super) fn mod_browser_closed(&mut self) { + if let Some(search) = self.mods.browser.search.as_mut() { + search.focused = false; + } + } + /// The item under the pointer on a container screen, for the mods' keys + /// (`browser`'s `key` event): the panel's, else a slot's. + pub(super) fn mod_browser_pointed(&mut self) -> Option { + let batch = self.browser_batch()?; + let stack = match self + .browser_sync() + .and_then(|_| self.browser_layout(&batch)) + { + Some(layout) if inside(self.cursor, layout.area) => self.browser_hovered(&layout)?.1, + _ => { + let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?; + self.slot_view(slot)? + } + }; + Some(BrowserStack { + item: stack.item.to_string(), + count: stack.count, + components: super::mod_recipes::components(&stack), + }) + } +} + +/// A mod's item as a stack: an item the game has, its components read as +/// `/give` reads them (ones it can't read are left out). +#[cfg(feature = "mods")] +fn browser_stack(items: &horizon_client::items::Items, s: &BrowserStack) -> Option { + items.get(&s.item)?; + let (damage, extra) = if s.components.is_empty() { + (0, None) + } else { + horizon_client::item_data::command_components(&s.components).map_or((0, None), |tag| { + horizon_client::item_data::decode_components(Some(&tag)) + }) + }; + Some(ItemStack { + item: s.item.as_str().into(), + count: s.count.clamp(1, 99), + damage, + extra, + }) +} diff --git a/crates/horizon-client/src/game/mod_input.rs b/crates/horizon-client/src/game/mod_input.rs index 10faf6a81..3df62c3d4 100644 --- a/crates/horizon-client/src/game/mod_input.rs +++ b/crates/horizon-client/src/game/mod_input.rs @@ -111,6 +111,18 @@ impl Game<'_> { if repeat || !self.mods.mods.has_keys() { return; } + // Over an item on a container screen, the mods' keys are `browser` + // events (a recipe viewer's R and U). + if pressed + && self.screen == Screen::Inventory + && self.title.is_none() + && !self.mod_browser_typing() + && let Some(name) = horizon_client::keybinds::key_name(key) + && let Some(stack) = self.mod_browser_pointed() + { + self.mods.mods.browser_key(name, &stack); + return; + } if pressed && (self.screen != Screen::Playing || self.title.is_some()) { return; } diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index 0d57e1f94..f02eb6424 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -35,7 +35,7 @@ impl Index { /// A stack's components beyond its item's, as vanilla's item syntax writes /// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "". -fn components(s: &ItemStack) -> String { +pub(super) fn components(s: &ItemStack) -> String { use horizon_world::nbt::Tag; let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else { return String::new(); diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 156467e7a..40298a922 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -76,6 +76,9 @@ pub(super) struct ModsState { /// The recipe book's known displays, and the book's version they are of. #[cfg(feature = "mods")] pub(super) known_recipes: (Option, std::sync::Arc<[i32]>), + /// Capability `ui`'s item panel beside container screens. + #[cfg(feature = "mods")] + pub(super) browser: super::mod_browser::Browser, /// Extras that are official mods now, being installed. #[cfg(feature = "mods")] pub(super) moved: super::moved_extras::MovedState, From b8da45f5ef2d4b1b1584b0255b36ef389ac18a10 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:51:32 +0100 Subject: [PATCH 07/26] mods: browser's page event names the first recipe shown, set-recipes' indexes are its list's Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/wit/horizon.wit | 14 ++++++++------ crates/horizon-mods/tests/ui_guest/src/lib.rs | 2 +- docs/api/mods-reference.md | 8 ++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index ab20648d8..38a0e5879 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -533,10 +533,10 @@ interface browser { key: string, stack: stack, } - /// A click on a slot of the recipe the panel shows (`set-recipes`). + /// A click on a slot of a recipe the panel shows (`set-recipes`). record slot-click { - /// The recipe's index on the page and the slot's in it; the - /// results come after the slots. + /// The recipe's index in `set-recipes`'s list and the slot's in + /// it; the results come after the slots. recipe: u32, slot: u32, /// The stack the slot showed at that moment. @@ -549,10 +549,12 @@ interface browser { search(string), key(item-key), slot-clicked(slot-click), - /// The action button of the recipe at this index on the page. + /// The action button of the recipe at this index in + /// `set-recipes`'s list. action(u32), - /// The page arrows of `set-recipes`: -1 or 1. - page(s32), + /// The player turned the recipes' page (`set-recipes`): the index + /// of the first recipe now shown. + page(u32), /// The back button of `set-recipes`. back, } diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 4fc2ee2c4..43aa207e0 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -330,7 +330,7 @@ impl Mod for Guest { format!("slot {} {} {}", c.recipe, c.slot, stack(&c.stack)) } browser::Event::Action(i) => format!("action {i}"), - browser::Event::Page(step) => format!("page {step}"), + browser::Event::Page(first) => format!("page {first}"), browser::Event::Back => "back".into(), }); } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 111fefd5c..298db26ba 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -567,11 +567,11 @@ A key the mod registered (`input.register-key`) went down while the pointer was ### record `slot-click` -A click on a slot of the recipe the panel shows (`set-recipes`). +A click on a slot of a recipe the panel shows (`set-recipes`). | Field | Type | | | --- | --- | --- | -| `recipe` | `u32` | The recipe's index on the page and the slot's in it; the results come after the slots. | +| `recipe` | `u32` | The recipe's index in `set-recipes`'s list and the slot's in it; the results come after the slots. | | `slot` | `u32` | | | `stack` | `stack` | The stack the slot showed at that moment. | | `right` | `bool` | | @@ -584,8 +584,8 @@ A click on a slot of the recipe the panel shows (`set-recipes`). | `search` | `string` | The search box's text, each time the player changes it. | | `key` | `item-key` | | | `slot-clicked` | `slot-click` | | -| `action` | `u32` | The action button of the recipe at this index on the page. | -| `page` | `s32` | The page arrows of `set-recipes`: -1 or 1. | +| `action` | `u32` | The action button of the recipe at this index in `set-recipes`'s list. | +| `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. | | `back` | | The back button of `set-recipes`. | | Function | Signature | What it does | From fc9305bb155c090b35daa3534bcdf4d4daacb92f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:03:42 +0100 Subject: [PATCH 08/26] mods: browser.set-recipes, recipes in the item panel's place A mod gives the recipes to show (a layout: crafting grid, cooking, single input, smithing or brewing; the slots, each cycling through its stacks; the results; a note; and an optional action button, enabled or greyed) under a title; an empty list shows the items again. The game pages them and sends the slot-clicked, action, page and back events. At most 256 recipes, 9 slots each and 64 stacks a slot. Same level as the rest of browser (5): none of it has shipped yet. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/wit/horizon.wit | 33 +++++++++++ crates/horizon-mods/src/api_level.rs | 3 +- crates/horizon-mods/src/runtime.rs | 56 ++++++++++++++++-- crates/horizon-mods/tests/sandbox.rs | 52 ++++++++++++++++ crates/horizon-mods/tests/ui_guest/src/lib.rs | 59 ++++++++++++++++++- docs/api/mods-reference.md | 27 +++++++++ docs/api/stability.md | 2 +- docs/mods.md | 2 +- 8 files changed, 225 insertions(+), 9 deletions(-) diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 38a0e5879..4896c3e21 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -558,8 +558,41 @@ interface browser { /// The back button of `set-recipes`. back, } + /// How the game lays out a recipe of `set-recipes`. + enum layout { + /// A crafting grid of `width` x `height` slots, an arrow and the + /// result. + grid, + /// One input over a flame, an arrow and the result (furnaces, + /// smokers, campfires). + cooking, + /// One input, an arrow and the result (the stonecutter). + single, + /// Template, base and addition, an arrow and the result. + smithing, + /// A potion and an ingredient, an arrow and the result. + brewing, + } + /// A recipe to show. Each slot, and the result, cycles through its + /// stacks, one a second; an empty slot shows nothing. + record recipe-view { + layout: layout, + /// The grid's size (`grid` only), at most 3 x 3. + width: u32, + height: u32, + slots: list>, + results: list, + /// A short line under the recipe (a cooking time), or "". + note: string, + /// The action button beside it: none, enabled, or shown greyed. + action: option, + } /// The panel's items, at most 8192 (an empty list hides the panel). set-items: func(items: list); + /// Shows these recipes in the panel instead of its items, as many as + /// fit a page, under `title` and a back button; an empty list shows the + /// items again. At most 256 recipes. + set-recipes: func(title: string, recipes: list); /// What the player did on the panel since the last poll, at most 256 /// kept. poll: func() -> list; diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 2474dfddd..7bb87b674 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -13,7 +13,7 @@ pub const LEVEL: u32 = 5; /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 39] = [ +pub const FUNCTIONS: [(&str, u32); 40] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -53,6 +53,7 @@ pub const FUNCTIONS: [(&str, u32); 39] = [ ("recipes.known", 4), ("browser.set-items", 5), ("browser.poll", 5), + ("browser.set-recipes", 5), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 0f2de0246..43c9e3fac 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -88,7 +88,8 @@ mod recipes_bindings { /// Capability `ui`'s recipe viewer panel (WIT `interface browser`). pub use recipes_bindings::horizon::mods::browser::{ Click as BrowserClick, Event as BrowserEvent, ItemKey as BrowserItemKey, - SlotClick as BrowserSlotClick, Stack as BrowserStack, + Layout as BrowserLayout, RecipeView as BrowserRecipe, SlotClick as BrowserSlotClick, + Stack as BrowserStack, }; /// What capability `recipes` reads (WIT `interface recipes`). pub use recipes_bindings::horizon::mods::recipes::{ @@ -318,14 +319,20 @@ struct UiState { #[derive(Default)] struct BrowserState { items: Vec, - /// Which `set-items` call the items are from, unique across mods and - /// reloads, so the game converts them once. + /// Which `set-items` or `set-recipes` call the panel is from, unique + /// across mods and reloads, so the game converts them once. version: u64, + /// `set-recipes`' title and recipes, shown instead of the items. + recipes: Option<(String, Vec)>, events: std::collections::VecDeque, } static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Items a panel may hold, and events kept for `browser.poll`. const BROWSER_ITEMS: usize = 8192; +/// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles. +const BROWSER_RECIPES: usize = 256; +const RECIPE_SLOTS: usize = 9; +const SLOT_STACKS: usize = 64; const BROWSER_EVENTS: usize = 256; impl BrowserState { fn push(&mut self, event: BrowserEvent) { @@ -829,13 +836,26 @@ impl Mods { .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) } /// The panel beside container screens (`browser.set-items`): the first - /// running mod with items, its index, the items' version and the items. + /// running mod with items or recipes, its index, the panel's version and + /// the items. pub fn browser_items(&self) -> Option<(usize, u64, &[BrowserStack])> { self.entries.iter().enumerate().find_map(|(i, e)| { let b = &e.run.as_ref()?.store.data().browser; - (!b.items.is_empty()).then_some((i, b.version, b.items.as_slice())) + (!b.items.is_empty() || b.recipes.is_some()).then_some(( + i, + b.version, + b.items.as_slice(), + )) }) } + /// The recipes mod `owner`'s panel shows instead of its items + /// (`browser.set-recipes`): the title and the recipes. + pub fn browser_recipes(&self, owner: usize) -> Option<(&str, &[BrowserRecipe])> { + let b = &self.entries.get(owner)?.run.as_ref()?.store.data().browser; + b.recipes + .as_ref() + .map(|(title, list)| (title.as_str(), list.as_slice())) + } /// The player did `event` on mod `owner`'s panel. pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) { if let Some(run) = self.entries.get_mut(owner).and_then(|e| e.run.as_mut()) { @@ -1241,6 +1261,32 @@ impl browser::Host for State { .collect(); self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } + fn set_recipes(&mut self, title: String, recipes: Vec) { + let stack = |s: BrowserStack| BrowserStack { + item: clip(s.item), + components: clip_to(s.components, 4096), + ..s + }; + let cycle = |list: Vec| -> Vec { + list.into_iter().take(SLOT_STACKS).map(stack).collect() + }; + self.browser.recipes = (!recipes.is_empty()).then(|| { + let recipes = recipes + .into_iter() + .take(BROWSER_RECIPES) + .map(|r| BrowserRecipe { + width: r.width.clamp(1, 3), + height: r.height.clamp(1, 3), + slots: r.slots.into_iter().take(RECIPE_SLOTS).map(cycle).collect(), + results: cycle(r.results), + note: clip(r.note), + ..r + }) + .collect(); + (clip(title), recipes) + }); + self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } fn poll(&mut self) -> Vec { self.browser.events.drain(..).collect() } diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 576dc5942..081884dc2 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1157,6 +1157,58 @@ fn browser_lists_items_and_hears_the_player() { assert_ne!(mods.browser_items().unwrap().1, version); assert_eq!(said(&mut mods, "browser"), ["panel of 0"]); assert!(mods.browser_items().is_none()); + // Recipes in the panel's place: the guest shows those making planks. + use horizon_mods::{BrowserLayout, Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; + let item = |id: &str| RecipeItem { + id: id.into(), + name: id.into(), + tags: Vec::new(), + }; + let stack = |item| RecipeStack { + item, + count: 1, + components: String::new(), + }; + mods.set_snapshot(Snapshot { + recipes: Some(std::sync::Arc::new(Recipes { + items: vec![item("minecraft:oak_log"), item("minecraft:oak_planks")], + recipes: vec![Recipe { + id: "minecraft:oak_planks".into(), + kind: RecipeKind::CraftingShapeless, + category: "building".into(), + group: "planks".into(), + width: 0, + height: 0, + slots: vec![vec![stack(0)]], + results: vec![RecipeStack { + count: 4, + ..stack(1) + }], + cooking_time: 0, + experience: 0.0, + book_id: 1, + }], + })), + ..Snapshot::default() + }); + said(&mut mods, "browser minecraft:dirt"); + assert_eq!( + said(&mut mods, "browser_recipes minecraft:oak_planks"), + ["recipes shown: 1"] + ); + let (title, recipes) = mods.browser_recipes(owner).unwrap(); + assert_eq!(title, "Recipes for minecraft:oak_planks"); + let r = &recipes[0]; + assert_eq!( + (r.layout, r.width, r.height, r.action), + (BrowserLayout::Grid, 1, 1, Some(false)) + ); + assert_eq!( + (r.slots[0][0].item.as_str(), r.results[0].count), + ("minecraft:oak_log", 4) + ); + assert_eq!(said(&mut mods, "browser_recipes"), ["recipes shown: 0"]); + assert!(mods.browser_recipes(owner).is_none()); // At most 256 events wait for a poll: the oldest go. for _ in 0..300 { mods.browser_event(owner, BrowserEvent::Page(1)); diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 43aa207e0..751169565 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -10,7 +10,9 @@ //! counts what `recipes` reads; `/recipes ID` describes that recipe. //! `/browser ID...` fills `browser`'s panel (`/browser N`: the first N //! items, `/browser` alone empties it) and registers the keys `recipe` (R) -//! and `uses` (U); `/browser_poll` says what the player did on it. +//! and `uses` (U); `/browser_poll` says what the player did on it; +//! `/browser_recipes ID` shows the recipes making ID (`/browser_recipes` +//! alone: the items again). use horizon_mod_sdk::{ Mod, Registration, browser, chat, command, game, input::{self, Event}, @@ -110,6 +112,7 @@ impl Mod for Guest { "recipes", "browser", "browser_poll", + "browser_recipes", "corner", "readings", ] @@ -310,6 +313,60 @@ impl Mod for Guest { input::register_key("uses", "Show uses", "key.keyboard.u")?; chat::show(&format!("panel of {}", items.len())); } + "browser_recipes" => { + let items = recipes::items(); + let id = |i: u32| { + items + .get(i as usize) + .map_or(String::new(), |x| x.id.clone()) + }; + let stack = |s: &recipes::Stack| browser::Stack { + item: id(s.item), + count: s.count, + components: s.components.clone(), + }; + let views: Vec = recipes::all() + .iter() + .filter(|r| !args.is_empty() && r.results.iter().any(|s| id(s.item) == args)) + .map(|r| { + use recipes::Kind; + let crafting = + matches!(r.kind, Kind::CraftingShaped | Kind::CraftingShapeless); + let shapeless = (r.slots.len() as u32).clamp(1, 3); + browser::RecipeView { + layout: match r.kind { + Kind::CraftingShaped | Kind::CraftingShapeless => { + browser::Layout::Grid + } + Kind::Stonecutting => browser::Layout::Single, + Kind::Smithing => browser::Layout::Smithing, + Kind::Brewing => browser::Layout::Brewing, + _ => browser::Layout::Cooking, + }, + width: if r.width == 0 { shapeless } else { r.width }, + height: if r.height == 0 { + (r.slots.len() as u32).div_ceil(3).max(1) + } else { + r.height + }, + slots: r + .slots + .iter() + .map(|s| s.iter().map(stack).collect()) + .collect(), + results: r.results.iter().map(stack).collect(), + note: if r.cooking_time > 0 { + format!("{} ticks", r.cooking_time) + } else { + String::new() + }, + action: crafting.then_some(r.kind == Kind::CraftingShaped), + } + }) + .collect(); + browser::set_recipes(&format!("Recipes for {args}"), &views); + chat::show(&format!("recipes shown: {}", views.len())); + } "browser_poll" => { let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components); // A hook shows at most 8 chat lines: the count, then the first 7. diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 298db26ba..6af12fa5b 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -588,9 +588,36 @@ A click on a slot of a recipe the panel shows (`set-recipes`). | `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. | | `back` | | The back button of `set-recipes`. | +### enum `layout` + +How the game lays out a recipe of `set-recipes`. + +| Case | | +| --- | --- | +| `grid` | A crafting grid of `width` x `height` slots, an arrow and the result. | +| `cooking` | One input over a flame, an arrow and the result (furnaces, smokers, campfires). | +| `single` | One input, an arrow and the result (the stonecutter). | +| `smithing` | Template, base and addition, an arrow and the result. | +| `brewing` | A potion and an ingredient, an arrow and the result. | + +### record `recipe-view` + +A recipe to show. Each slot, and the result, cycles through its stacks, one a second; an empty slot shows nothing. + +| Field | Type | | +| --- | --- | --- | +| `layout` | `layout` | | +| `width` | `u32` | The grid's size (`grid` only), at most 3 x 3. | +| `height` | `u32` | | +| `slots` | `list>` | | +| `results` | `list` | | +| `note` | `string` | A short line under the recipe (a cooking time), or "". | +| `action` | `option` | The action button beside it: none, enabled, or shown greyed. | + | Function | Signature | What it does | | --- | --- | --- | | `set-items` | `func(items: list)` | The panel's items, at most 8192 (an empty list hides the panel). | +| `set-recipes` | `func(title: string, recipes: list)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button; an empty list shows the items again. At most 256 recipes. | | `poll` | `func() -> list` | What the player did on the panel since the last poll, at most 256 kept. | ## `players` diff --git a/docs/api/stability.md b/docs/api/stability.md index bb0ddf501..1314d9813 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`). A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`, `set-recipes`). A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index 9aed4897b..bbd14dc84 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -88,7 +88,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | | `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. `run-quiet(lines)`: up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen. **Experimental:** the manifest needs `experimental = ["ui"]` | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | | `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | From ad557fef4708767d3e60ab24f173192af04716e6 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:03:42 +0100 Subject: [PATCH 09/26] game: a mod's recipe pages in the item panel browser.set-recipes replaces the panel's items with the mod's recipes, laid out with the game's slot, furnace arrow and flame sprites and small buttons: a crafting grid, one input over a flame, one input, three inputs or two, then an arrow and the result, a note under it and the action button beside the result (under it when the panel is narrow). Slots cycle through their stacks once a second. As many recipes as fit the panel's height make a page, with the recipe book's arrows; a title row holds a Back button and the title. The same title sent again keeps the page. Clicks on the slots, the action button, the arrows and Back reach the mod; tooltips and the mods' keys work on the recipes' stacks as on the items. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/mod_browser.rs | 89 +++- .../src/game/mod_browser_recipes.rs | 419 ++++++++++++++++++ 3 files changed, 486 insertions(+), 24 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_browser_recipes.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 6779d367a..ec21bdbb9 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -100,6 +100,8 @@ mod minecarts; mod mob_blocks; mod mob_gear; mod mod_browser; +#[cfg(feature = "mods")] +mod mod_browser_recipes; mod mod_input; #[cfg(feature = "mods")] mod mod_recipes; diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 2f317792f..3121eb05b 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -53,27 +53,29 @@ pub(super) struct Browser { search: Option, /// The search text the mod last heard. sent: String, + /// `set-recipes`' recipes, shown instead of the items. + pub(super) recipes: Option, } /// Where the panel's parts are this frame. #[cfg(feature = "mods")] -struct Layout { +pub(super) struct Layout { /// The panel's whole area, for clicks that hit none of its parts. - area: [f32; 4], + pub(super) area: [f32; 4], /// The grid's top left corner, its columns and rows. - grid: (f32, f32), - columns: usize, - rows: usize, + pub(super) grid: (f32, f32), + pub(super) columns: usize, + pub(super) rows: usize, /// The page arrows' row. - arrows_y: f32, - search: [f32; 4], + pub(super) arrows_y: f32, + pub(super) search: [f32; 4], } #[cfg(feature = "mods")] impl Layout { fn per_page(&self) -> usize { self.columns * self.rows } - fn width(&self) -> f32 { + pub(super) fn width(&self) -> f32 { self.columns as f32 * SLOT } fn slot(&self, n: usize) -> (f32, f32) { @@ -83,7 +85,7 @@ impl Layout { ) } /// The page arrows: backward, then forward. - fn arrows(&self) -> [(f32, f32); 2] { + pub(super) fn arrows(&self) -> [(f32, f32); 2] { [ (self.grid.0, self.arrows_y), (self.grid.0 + self.width() - 12.0, self.arrows_y), @@ -92,7 +94,7 @@ impl Layout { } #[cfg(feature = "mods")] -fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { +pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h } @@ -123,6 +125,12 @@ impl Game<'_> { list.iter().map(|s| browser_stack(items, s)).collect() }); self.mods.browser.stacks = stacks; + // The same title again (the mod updating its buttons) keeps the page. + let old = self.mods.browser.recipes.take(); + self.mods.browser.recipes = + self.mods.mods.browser_recipes(owner).map(|(title, list)| { + super::mod_browser_recipes::Pages::new(items, title, list, old) + }); self.mods.browser.of = Some((owner, version)); } Some(owner) @@ -159,8 +167,22 @@ impl Game<'_> { .div_ceil(layout.per_page()) .max(1) } + /// The stack under the pointer on the panel, an item's or a recipe's, + /// as the mod sent it and as a stack. + fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> { + if self.mods.browser.recipes.is_some() { + let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?; + return Some((sent, stack)); + } + let (index, stack) = self.browser_hovered(layout)?; + let (_, _, items) = self.mods.mods.browser_items()?; + Some((items.get(index)?.clone(), stack)) + } /// The panel's item under the pointer: its index and stack. fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> { + if self.mods.browser.recipes.is_some() { + return None; + } let first = self.mods.browser.page * layout.per_page(); (0..layout.per_page()).find_map(|n| { let (x, y) = layout.slot(n); @@ -182,16 +204,9 @@ impl Game<'_> { search } - /// Draws the panel over the open container screen, then the carried - /// stack or the tooltip again so they stay on top of it. - pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { - if self.browser_sync().is_none() { - return; - } - let Some(layout) = self.browser_layout(batch) else { - return; - }; - let pages = self.browser_pages(&layout); + /// The items' page arrows and grid. + fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) { + let pages = self.browser_pages(layout); self.mods.browser.page = self.mods.browser.page.min(pages - 1); let page = self.mods.browser.page; let cursor = self.cursor; @@ -255,6 +270,22 @@ impl Game<'_> { horizon_client::widgets::Kind::Button, ); } + } + /// Draws the panel over the open container screen, then the carried + /// stack or the tooltip again so they stay on top of it. + pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { + if self.browser_sync().is_none() { + return; + } + let Some(layout) = self.browser_layout(batch) else { + return; + }; + let cursor = self.cursor; + if self.mods.browser.recipes.is_some() { + self.draw_browser_recipes(batch, &layout); + } else { + self.draw_browser_items(batch, &layout); + } let hint = self.translate("gui.recipebook.search_hint", "Search..."); let search = self.browser_search(&layout); // The hint shows in an empty box without focus, as the recipe book's. @@ -264,8 +295,8 @@ impl Game<'_> { self.mods.browser.search = Some(search); if self.carried.is_some() { self.draw_carried(batch); - } else if let Some((_, stack)) = self.browser_hovered(&layout) { - let lines = self.stack_tooltip(&stack); + } else if let Some(stack) = self.browser_pointed_stack(&layout) { + let lines = self.stack_tooltip(&stack.1); self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1); } else { self.draw_slot_tooltip(batch); @@ -301,6 +332,10 @@ impl Game<'_> { return true; } search.focused = false; + if self.mods.browser.recipes.is_some() { + return self.browser_recipes_press(&layout, owner, right) + || inside(cursor, layout.area); + } let pages = self.browser_pages(&layout); let page = self.mods.browser.page; let [back, forward] = layout.arrows(); @@ -405,7 +440,10 @@ impl Game<'_> { .browser_sync() .and_then(|_| self.browser_layout(&batch)) { - Some(layout) if inside(self.cursor, layout.area) => self.browser_hovered(&layout)?.1, + // The panel's stacks as the mod sent them. + Some(layout) if inside(self.cursor, layout.area) => { + return Some(self.browser_pointed_stack(&layout)?.0); + } _ => { let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?; self.slot_view(slot)? @@ -422,7 +460,10 @@ impl Game<'_> { /// A mod's item as a stack: an item the game has, its components read as /// `/give` reads them (ones it can't read are left out). #[cfg(feature = "mods")] -fn browser_stack(items: &horizon_client::items::Items, s: &BrowserStack) -> Option { +pub(super) fn browser_stack( + items: &horizon_client::items::Items, + s: &BrowserStack, +) -> Option { items.get(&s.item)?; let (damage, extra) = if s.components.is_empty() { (0, None) diff --git a/crates/horizon-client/src/game/mod_browser_recipes.rs b/crates/horizon-client/src/game/mod_browser_recipes.rs new file mode 100644 index 000000000..14848de90 --- /dev/null +++ b/crates/horizon-client/src/game/mod_browser_recipes.rs @@ -0,0 +1,419 @@ +//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item +//! panel's place, laid out by the game with its own slots, arrows and +//! buttons, paged to the panel's height, under a title and a back button. +//! The mod hears clicks on their slots, their action buttons, the back +//! button and page turns. +use super::{ + Game, + mod_browser::{Layout, browser_stack, inside}, +}; +use horizon_client::{ + hud::{HudBatch, rgba}, + items::ItemStack, +}; +use horizon_mods::{BrowserEvent, BrowserLayout, BrowserRecipe, BrowserSlotClick, BrowserStack}; +use std::time::Instant; + +const SLOT: f32 = 18.0; +/// The title row's height, and the gap under each recipe. +const TITLE: f32 = 18.0; +const GAP: f32 = 6.0; + +/// One recipe as the game draws it. +pub(super) struct Shown { + layout: BrowserLayout, + width: usize, + height: usize, + /// Each slot's stacks (the result last), as the mod sent them and as + /// stacks; a stack the game doesn't have is left out of the cycle. + slots: Vec>, + note: String, + action: Option, +} + +/// The recipes a panel shows, and its page. +pub(super) struct Pages { + title: String, + recipes: Vec, + page: usize, + since: Instant, +} + +impl Pages { + pub(super) fn new( + items: &horizon_client::items::Items, + title: &str, + list: &[BrowserRecipe], + old: Option, + ) -> Self { + let kept = old.filter(|o| o.title == title); + let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> { + stacks + .iter() + .filter_map(|s| Some((s.clone(), browser_stack(items, s)?))) + .collect() + }; + let recipes = list + .iter() + .map(|r| { + let mut slots: Vec<_> = r.slots.iter().map(|s| cycle(s)).collect(); + slots.push(cycle(&r.results)); + Shown { + layout: r.layout, + width: r.width as usize, + height: r.height as usize, + slots, + note: r.note.clone(), + action: r.action, + } + }) + .collect(); + Self { + title: title.to_owned(), + recipes, + page: kept.as_ref().map_or(0, |o| o.page), + since: kept.map_or_else(Instant::now, |o| o.since), + } + } +} + +impl Shown { + /// The inputs' block: its size and each input slot's place in it. + fn inputs(&self) -> ((f32, f32), Vec<(f32, f32)>) { + let row = |n: usize| (0..n).map(|i| (i as f32 * SLOT, 0.0)).collect(); + match self.layout { + BrowserLayout::Grid => { + let (w, h) = (self.width.max(1), self.height.max(1)); + let at = (0..w * h) + .map(|i| ((i % w) as f32 * SLOT, (i / w) as f32 * SLOT)) + .collect(); + ((w as f32 * SLOT, h as f32 * SLOT), at) + } + // The input over the flame. + BrowserLayout::Cooking => ((SLOT, 2.0 * SLOT), vec![(0.0, 0.0)]), + BrowserLayout::Single => ((SLOT, SLOT), row(1)), + BrowserLayout::Smithing => ((3.0 * SLOT, SLOT), row(3)), + BrowserLayout::Brewing => ((2.0 * SLOT, SLOT), row(2)), + } + } + /// The height of its slots, arrow and button, `room` wide. + fn block(&self, room: f32) -> f32 { + let h = self.inputs().0.1; + let (_, _, action) = self.places(room); + match self.action { + Some(_) => h.max(action.1 + 12.0), + None => h, + } + } + fn height(&self, room: f32) -> f32 { + let note = if self.note.is_empty() { 0.0 } else { 11.0 }; + self.block(room) + note + GAP + } + /// Every slot's place from the recipe's corner (the result last), the + /// arrow's and the action button's: beside the result, or under it + /// when the panel is `room` wide and that doesn't fit. + fn places(&self, room: f32) -> (Vec<(f32, f32)>, (f32, f32), (f32, f32)) { + let ((w, h), mut at) = self.inputs(); + // The mod's slots beyond the layout's are not shown. + at.truncate(self.slots.len() - 1); + let arrow = (w + 4.0, ((h - 16.0) / 2.0).floor()); + let result = (w + 32.0, ((h - SLOT) / 2.0).floor()); + let action = if result.0 + SLOT + 16.0 <= room { + (result.0 + SLOT + 4.0, ((h - 12.0) / 2.0).floor()) + } else { + (result.0 + 3.0, result.1 + SLOT + 2.0) + }; + while at.len() < self.slots.len() - 1 { + at.push((f32::NAN, f32::NAN)); + } + at.push(result); + (at, arrow, action) + } +} + +impl Game<'_> { + /// The first recipe of each page, filling the panel from the title row + /// to the search box. + fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec { + let room = layout.search[1] - 4.0 - (layout.grid.1 + TITLE); + let mut starts = vec![0]; + let mut used = 0.0; + for (i, r) in pages.recipes.iter().enumerate() { + let h = r.height(layout.width()); + if used > 0.0 && used + h > room { + starts.push(i); + used = 0.0; + } + used += h; + } + starts + } + /// The recipes on the open page, each with its corner. + fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> { + let Some(pages) = &self.mods.browser.recipes else { + return Vec::new(); + }; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len()); + let mut y = layout.grid.1 + TITLE; + (starts[page]..end) + .map(|i| { + let at = (layout.grid.0, y); + y += pages.recipes[i].height(layout.width()); + (i, at) + }) + .collect() + } + fn back_button(&self, layout: &Layout) -> ([f32; 4], String) { + let label = self.translate("gui.back", "Back"); + let w = HudBatch::text_width(&self.gui.atlas, &label) + 8.0; + ([layout.grid.0, layout.grid.1, w, 14.0], label) + } + /// What a slot of a shown recipe holds now: its stacks cycle once a second. + fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + let stacks = pages.recipes.get(recipe)?.slots.get(slot)?; + let n = pages.since.elapsed().as_secs() as usize; + stacks.get(n % stacks.len().max(1)) + } + /// The recipe slot under the pointer: recipe, slot, and its stack now. + pub(super) fn recipe_slot_hovered( + &self, + layout: &Layout, + ) -> Option<(usize, usize, BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + self.recipes_shown(layout) + .into_iter() + .find_map(|(i, (x, y))| { + let (at, _, _) = pages.recipes[i].places(layout.width()); + at.iter().enumerate().find_map(|(k, &(sx, sy))| { + if !inside(self.cursor, [x + sx, y + sy, SLOT, SLOT]) { + return None; + } + let (mod_stack, stack) = self.recipe_slot_now(i, k)?.clone(); + Some((i, k, mod_stack, stack)) + }) + }) + } + + /// Draws the title row, the page's recipes and the page arrows. + pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) { + let Some(pages) = &self.mods.browser.recipes else { + return; + }; + let cursor = self.cursor; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let (title, pages_n) = (pages.title.clone(), starts.len()); + let (back, label) = self.back_button(layout); + let atlas = &self.gui.atlas; + let hover = inside(cursor, back); + let sprite = if hover { + "gui/sprites/widget/button_highlighted" + } else { + "gui/sprites/widget/button" + }; + horizon_client::ui::nine_slice( + batch, + atlas, + sprite, + back[0], + back[1], + back[2], + back[3], + u32::MAX, + ); + batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true); + horizon_client::widgets::record(&label, back, true); + let room = layout.width() - back[2] - 4.0; + let fit = atlas.font.fit(&title, room); + let shown: String = title.chars().take(fit).collect(); + batch.text( + atlas, + &shown, + back[0] + back[2] + 4.0, + back[1] + 3.0, + u32::MAX, + true, + ); + if pages_n > 1 { + let labels = [ + self.translate("gui.recipebook.previous_page", "Previous Page"), + self.translate("gui.recipebook.next_page", "Next Page"), + ]; + let visible = [page > 0, page + 1 < pages_n]; + let atlas = &self.gui.atlas; + for (((x, y), name), (label, visible)) in layout + .arrows() + .into_iter() + .zip(["page_backward", "page_forward"]) + .zip(labels.iter().zip(visible)) + { + if !visible { + continue; + } + let suffix = if inside(cursor, [x, y, 12.0, 17.0]) { + "_highlighted" + } else { + "" + }; + batch.sprite( + atlas, + &format!("gui/sprites/recipe_book/{name}{suffix}"), + x, + y, + ); + horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true); + } + let text = format!("{}/{pages_n}", page + 1); + let w = HudBatch::text_width(atlas, &text); + let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor(); + batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true); + } + for (i, (x, y)) in self.recipes_shown(layout) { + let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else { + break; + }; + let (at, arrow, action) = r.places(layout.width()); + let h = r.block(layout.width()); + let (cooking, note, button) = + (r.layout == BrowserLayout::Cooking, r.note.clone(), r.action); + let atlas = &self.gui.atlas; + for &(sx, sy) in at.iter().filter(|p| p.0.is_finite()) { + batch.sprite(atlas, "gui/sprites/container/slot", x + sx, y + sy); + } + if cooking { + batch.sprite( + atlas, + "gui/sprites/container/furnace/lit_progress", + x + 2.0, + y + SLOT + 2.0, + ); + } + batch.sprite( + atlas, + "gui/sprites/container/furnace/burn_progress", + x + arrow.0, + y + arrow.1, + ); + if let Some(enabled) = button { + let rect = [x + action.0, y + action.1, 12.0, 12.0]; + let sprite = match (enabled, inside(cursor, rect)) { + (false, _) => "gui/sprites/widget/button_disabled", + (true, true) => "gui/sprites/widget/button_highlighted", + (true, false) => "gui/sprites/widget/button", + }; + horizon_client::ui::nine_slice( + batch, + atlas, + sprite, + rect[0], + rect[1], + 12.0, + 12.0, + u32::MAX, + ); + let color = if enabled { + u32::MAX + } else { + rgba(160, 160, 160, 255) + }; + batch.text(atlas, "+", rect[0] + 3.0, rect[1] + 2.0, color, true); + horizon_client::widgets::record(&format!("+ {i}"), rect, enabled); + } + if !note.is_empty() { + batch.text(atlas, ¬e, x, y + h + 2.0, rgba(224, 224, 224, 255), true); + } + for (k, &(sx, sy)) in at.iter().enumerate().filter(|(_, p)| p.0.is_finite()) { + let Some((_, stack)) = self.recipe_slot_now(i, k).cloned() else { + continue; + }; + self.draw_item(batch, &stack, x + sx + 1.0, y + sy + 1.0); + if inside(cursor, [x + sx, y + sy, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + sx + 1.0, + y + sy + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &format!("{name} {i}:{k}"), + [x + sx + 1.0, y + sy + 1.0, 16.0, 16.0], + true, + horizon_client::widgets::Kind::Button, + ); + } + } + } + + /// A press on the recipes: true when it hit one of their parts. + pub(super) fn browser_recipes_press( + &mut self, + layout: &Layout, + owner: usize, + right: bool, + ) -> bool { + let Some(pages) = &self.mods.browser.recipes else { + return false; + }; + let cursor = self.cursor; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let [back_arrow, forward] = layout.arrows(); + let turn = if page > 0 && inside(cursor, [back_arrow.0, back_arrow.1, 12.0, 17.0]) { + Some(page - 1) + } else if page + 1 < starts.len() && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) { + Some(page + 1) + } else { + None + }; + if let Some(to) = turn { + if let Some(p) = self.mods.browser.recipes.as_mut() { + p.page = to; + } + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Page(starts[to] as u32)); + return true; + } + if inside(cursor, self.back_button(layout).0) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods.mods.browser_event(owner, BrowserEvent::Back); + return true; + } + if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) { + let event = BrowserEvent::SlotClicked(BrowserSlotClick { + recipe: recipe as u32, + slot: slot as u32, + stack, + right, + }); + self.mods.mods.browser_event(owner, event); + return true; + } + let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| { + let r = &pages.recipes[i]; + let (_, _, (ax, ay)) = r.places(layout.width()); + r.action.is_some() && inside(cursor, [x + ax, y + ay, 12.0, 12.0]) + }); + if let Some((i, _)) = action { + if pages.recipes[i].action == Some(true) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Action(i as u32)); + } + return true; + } + false + } +} From db13c68585d2f491e0329f6897279176bf18d8ec Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:28:26 +0100 Subject: [PATCH 10/26] mods: recipes.place and recipes.give, a recipe viewer's actions recipes.place(book-id, all) is the recipe book's click: in the open inventory's or crafting table's grid it sends ServerboundPlaceRecipePacket on a server, or places it in this world, refused as handlePlaceRecipe refuses it (no crafting screen, a recipe the player's book doesn't hold, an impossible one); a recipe missing items shows as a ghost. recipes.give(stack) gives a creative player the stack as the creative inventory does; a server hears it as SetCreativeModeSlot through the inventory's sync. Both answer at once from the tick's snapshot (placing, creative, known) and take at most 36 calls a hook call. Co-Authored-By: Claude Opus 5.5 --- crates/cargo-horizon-mod/src/check.rs | 4 ++ crates/horizon-client/src/game/mod_recipes.rs | 18 ++++++ crates/horizon-client/src/game/mods.rs | 14 ++++ crates/horizon-client/src/game/recipe_book.rs | 28 ++++++++ crates/horizon-mod-sdk/wit/horizon.wit | 11 ++++ crates/horizon-mods/src/api_level.rs | 4 +- crates/horizon-mods/src/manifest.rs | 4 +- crates/horizon-mods/src/runtime.rs | 63 ++++++++++++++++++ crates/horizon-mods/tests/sandbox.rs | 43 ++++++++++++- crates/horizon-mods/tests/ui_guest/src/lib.rs | 64 +++++++++++++++++-- docs/api/mods-reference.md | 4 +- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 13 files changed, 251 insertions(+), 10 deletions(-) diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index 33095a115..cb3981955 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -268,6 +268,10 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")), Output::OpenScreen { .. } => "opened a screen".to_string(), Output::CloseScreen { .. } => "closed its screen".to_string(), + Output::PlaceRecipe { book_id, all } => { + format!("placed recipe {book_id}{}", if all { " (all)" } else { "" }) + } + Output::Give { item, count, .. } => format!("gave {count} {item}"), Output::Shake { strength, seconds } => { format!("shook the camera: {strength:.2} for {seconds:.1} s") } diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index f02eb6424..0f65b0b42 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -70,6 +70,24 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe { } impl Game<'_> { + /// `recipes.give`: a creative player takes the stack as from the + /// creative inventory; a server hears it as `SetCreativeModeSlot` + /// through the inventory's sync, and refuses it unless creative. + pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) { + if !self.creative || self.res.items().get(item).is_none() { + return; + } + let (damage, extra) = horizon_client::item_data::command_components(components) + .map_or((0, None), |tag| { + horizon_client::item_data::decode_components(Some(&tag)) + }); + let _ = self.give(ItemStack { + item: item.into(), + count, + damage, + extra, + }); + } /// Capability `recipes`'s tables and known displays for this tick's /// snapshot; nothing while no running mod reads them. pub(super) fn mod_recipes(&mut self) -> (Option>, Arc<[i32]>) { diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 40298a922..147a01df3 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -434,6 +434,8 @@ impl Game<'_> { view_hit: self.mod_view_hit(), recipes, known_recipes, + placing: self.mod_recipe_screen(), + creative: self.creative, } } /// The first block along the player's view within @@ -646,6 +648,18 @@ impl Game<'_> { self.mods.shake.add(strength, seconds); continue; } + Output::PlaceRecipe { book_id, all } => { + self.mod_place_recipe(book_id, all); + continue; + } + Output::Give { + item, + count, + components, + } => { + self.mod_give(&item, count, &components); + continue; + } Output::CloseScreen { owner } => { if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) { self.close_mod_screen(false); diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 045abeb6f..024d45734 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -845,6 +845,34 @@ impl Game<'_> { self.recipe_ui.ghost.clear(); self.place_recipe(book_type, entry_index, use_max); } + /// Whether the open screen takes `recipes.place`: a crafting grid's + /// book (the inventory's or a crafting table's). + #[cfg(feature = "mods")] + pub(super) fn mod_recipe_screen(&self) -> bool { + self.screen == Screen::Inventory + && self.active_book() == Some(BookType::Crafting) + && !self.spectator() + } + /// `recipes.place`: the book's click on the recipe with this display + /// id, refused as `handlePlaceRecipe` refuses it (no crafting screen, + /// not in the player's book; an impossible one in `place_recipe`). + #[cfg(feature = "mods")] + pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) { + if !self.mod_recipe_screen() || !self.recipe_ui.client.displays().contains(&display_id) { + return; + } + self.ensure_book(); + let Some(index) = self + .recipe_ui + .book + .as_ref() + .and_then(|b| b.entries.iter().position(|e| e.display_id == display_id)) + else { + return; + }; + self.recipe_ui.ghost.clear(); + self.place_recipe(BookType::Crafting, index, all); + } /// `ServerboundPlaceRecipePacket`: the server places the recipe /// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost /// recipe when the items are missing. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 4896c3e21..897f669e5 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -507,6 +507,17 @@ interface recipes { all: func() -> list; /// The `book-id`s of the recipes the player's recipe book holds now. known: func() -> list; + /// Places a recipe in the open screen's grid as a click in the recipe + /// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or + /// a crafting table. `all` places as many as the items allow, as with + /// Shift. Refused while no such screen is open, for a recipe the + /// player's book doesn't hold and for one that can't be placed; one + /// missing items shows as a ghost in the grid, as in the book. + place: func(book-id: s32, all: bool) -> result<_, string>; + /// Creative mode only: gives the player the stack, as taking it from + /// the creative inventory does (`ServerboundSetCreativeModeSlot`): the + /// selected slot first, then the hotbar, then the inventory. + give: func(stack: stack) -> result<_, string>; } /// Capability `ui` (experimental): a recipe viewer's panel beside the diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 7bb87b674..87984da02 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -13,7 +13,7 @@ pub const LEVEL: u32 = 5; /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 40] = [ +pub const FUNCTIONS: [(&str, u32); 42] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -51,6 +51,8 @@ pub const FUNCTIONS: [(&str, u32); 40] = [ ("recipes.items", 4), ("recipes.all", 4), ("recipes.known", 4), + ("recipes.place", 5), + ("recipes.give", 5), ("browser.set-items", 5), ("browser.poll", 5), ("browser.set-recipes", 5), diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 4c12fd805..6b2cfdf03 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -158,7 +158,9 @@ impl Capability { "see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys" } Capability::Players => "see who is on the server and message them", - Capability::Recipes => "read the game's recipes and items, and which recipes you know", + Capability::Recipes => { + "read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items" + } } } /// The capability a component import needs, `Ok(None)` for one every diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 43c9e3fac..fed4e5c4f 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -156,6 +156,10 @@ pub struct Snapshot { pub recipes: Option>, /// The `book-id`s of the recipes the player's book holds. pub known_recipes: Arc<[i32]>, + /// The open screen takes recipes (`recipes.place`). + pub placing: bool, + /// The player is in creative mode (`recipes.give`). + pub creative: bool, } /// Capability `recipes`'s tables: `items` in the registry's order, which @@ -220,6 +224,15 @@ pub enum Output { CloseScreen { owner: usize }, /// `render.shake`: strength 0 to 1, fading out over `seconds` (≤ 10). Shake { strength: f32, seconds: f32 }, + /// `recipes.place`: the recipe book's click on this display id, all of + /// it when `all`. + PlaceRecipe { book_id: i32, all: bool }, + /// `recipes.give`: a stack for a creative player. + Give { + item: String, + count: u32, + components: String, + }, } /// A mod's text panel, drawn every frame until it changes. @@ -329,6 +342,8 @@ struct BrowserState { static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Items a panel may hold, and events kept for `browser.poll`. const BROWSER_ITEMS: usize = 8192; +/// `recipes.place` and `give` calls a hook call may make. +const RECIPE_ACTIONS: usize = 36; /// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles. const BROWSER_RECIPES: usize = 256; const RECIPE_SLOTS: usize = 9; @@ -1307,6 +1322,54 @@ impl recipes::Host for State { fn known(&mut self) -> Vec { self.snapshot.known_recipes.to_vec() } + fn place(&mut self, book_id: i32, all: bool) -> Result<(), String> { + self.recipe_action()?; + if !self.snapshot.placing { + return Err("no screen that takes recipes is open".into()); + } + if !self.snapshot.known_recipes.contains(&book_id) { + return Err(format!( + "recipe {book_id} is not in the player's recipe book" + )); + } + self.out.push(Output::PlaceRecipe { book_id, all }); + Ok(()) + } + fn give(&mut self, stack: RecipeStack) -> Result<(), String> { + self.recipe_action()?; + if !self.snapshot.creative { + return Err("only in creative mode".into()); + } + let item = self + .snapshot + .recipes + .as_ref() + .and_then(|r| r.items.get(stack.item as usize)) + .ok_or_else(|| format!("no item {}", stack.item))? + .id + .clone(); + self.out.push(Output::Give { + item, + count: stack.count.clamp(1, 99), + components: clip_to(stack.components, 4096), + }); + Ok(()) + } +} +impl State { + /// `recipes.place` and `give` take at most [`RECIPE_ACTIONS`] a call. + fn recipe_action(&self) -> Result<(), String> { + let done = self + .out + .iter() + .filter(|o| matches!(o, Output::PlaceRecipe { .. } | Output::Give { .. })) + .count(); + if done < RECIPE_ACTIONS { + Ok(()) + } else { + Err(format!("at most {RECIPE_ACTIONS} places and gives a call")) + } + } } impl State { /// Queues a chat line unless the call already sent [`CHAT_PER_CALL`]. diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 081884dc2..ea8cc0536 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1059,8 +1059,9 @@ fn recipes_reads_the_tables() { recipes[1].kind = RecipeKind::Brewing; recipes[1].results[0].components = "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]".into(); + let mods_recipes = Some(std::sync::Arc::new(Recipes { items, recipes })); mods.set_snapshot(Snapshot { - recipes: Some(std::sync::Arc::new(Recipes { items, recipes })), + recipes: mods_recipes.clone(), known_recipes: vec![1, 5, 9].into(), ..Snapshot::default() }); @@ -1091,6 +1092,46 @@ fn recipes_reads_the_tables() { .all(|e| e.status == horizon_mods::Status::Running), "it ran out of fuel" ); + // Actions: refused without a crafting screen, for an unknown recipe, + // and outside creative mode. + assert_eq!( + said(&mut mods, "recipe_place minecraft:recipe_5"), + ["place minecraft:recipe_5: no screen that takes recipes is open"] + ); + let mut snap = Snapshot { + recipes: mods_recipes.clone(), + known_recipes: vec![1, 5, 9].into(), + placing: true, + ..Snapshot::default() + }; + mods.set_snapshot(snap.clone()); + assert_eq!( + said(&mut mods, "recipe_place minecraft:recipe_6"), + ["place minecraft:recipe_6: recipe 6 is not in the player's recipe book"] + ); + assert!(mods.command("recipe_place minecraft:recipe_5 all")); + assert_eq!( + mods.drain()[0], + Output::PlaceRecipe { + book_id: 5, + all: true + } + ); + assert_eq!( + said(&mut mods, "recipe_give minecraft:item_7 3"), + ["give minecraft:item_7: only in creative mode"] + ); + snap.creative = true; + mods.set_snapshot(snap); + assert!(mods.command("recipe_give minecraft:item_7 3")); + assert_eq!( + mods.drain()[0], + Output::Give { + item: "minecraft:item_7".into(), + count: 3, + components: String::new() + } + ); // Without the game's tables (no mod read them yet): empty, not a trap. mods.set_snapshot(Snapshot::default()); assert_eq!( diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 751169565..2b1646614 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -12,7 +12,9 @@ //! items, `/browser` alone empties it) and registers the keys `recipe` (R) //! and `uses` (U); `/browser_poll` says what the player did on it; //! `/browser_recipes ID` shows the recipes making ID (`/browser_recipes` -//! alone: the items again). +//! alone: the items again). `/recipe_place ID [all]` places that recipe +//! with `recipes.place`; `/recipe_give ITEM [COUNT]` gives with +//! `recipes.give`. use horizon_mod_sdk::{ Mod, Registration, browser, chat, command, game, input::{self, Event}, @@ -69,7 +71,11 @@ fn shapes() -> Vec { ] } -struct Guest; +struct Guest { + /// `/recipe_place ID [all] later`: placed on the first tick a crafting + /// screen is open. + pending: Option<(i32, bool, String)>, +} fn widgets() -> Vec { vec![ @@ -113,12 +119,15 @@ impl Mod for Guest { "browser", "browser_poll", "browser_recipes", + "recipe_place", + "recipe_give", "corner", "readings", ] .into_iter() - .fold(Registration::new(), Registration::command); - Ok((Guest, hooks)) + .fold(Registration::new(), Registration::command) + .on_tick(); + Ok((Guest { pending: None }, hooks)) } fn command(&mut self, name: &str, args: &str) -> Result, String> { match name { @@ -367,6 +376,44 @@ impl Mod for Guest { browser::set_recipes(&format!("Recipes for {args}"), &views); chat::show(&format!("recipes shown: {}", views.len())); } + "recipe_place" => { + let mut words = args.split_whitespace(); + let id = words.next().unwrap_or_default(); + let rest: Vec<&str> = words.collect(); + let all = rest.contains(&"all"); + let book = recipes::all() + .iter() + .find(|r| r.id == id) + .map(|r| r.book_id); + if let Some(b) = book.filter(|_| rest.contains(&"later")) { + self.pending = Some((b, all, id.to_owned())); + chat::show(&format!("place {id}: waiting")); + return Ok(None); + } + chat::show(&match book.map(|b| recipes::place(b, all)) { + Some(Ok(())) => format!("place {id}: ok"), + Some(Err(e)) => format!("place {id}: {e}"), + None => format!("place {id}: no such recipe"), + }); + } + "recipe_give" => { + let mut words = args.split_whitespace(); + let id = words.next().unwrap_or_default(); + let count = words.next().and_then(|c| c.parse().ok()).unwrap_or(1); + let item = recipes::items().iter().position(|i| i.id == id); + let result = item.map(|i| { + recipes::give(&recipes::Stack { + item: i as u32, + count, + components: String::new(), + }) + }); + chat::show(&match result { + Some(Ok(())) => format!("give {id}: ok"), + Some(Err(e)) => format!("give {id}: {e}"), + None => format!("give {id}: no such item"), + }); + } "browser_poll" => { let stack = |s: &browser::Stack| format!("{} x{}{}", s.item, s.count, s.components); // A hook shows at most 8 chat lines: the count, then the first 7. @@ -402,6 +449,15 @@ impl Mod for Guest { } Ok(None) } + fn tick(&mut self, _tick: u64) { + if let Some((book, all, id)) = self.pending.take() { + match recipes::place(book, all) { + Err(e) if e.starts_with("no screen") => self.pending = Some((book, all, id)), + Ok(()) => chat::show(&format!("place {id}: ok")), + Err(e) => chat::show(&format!("place {id}: {e}")), + } + } + } fn ui_select(&mut self, id: u32, row: u32) { chat::show(&format!("selected {id}:{row}")); } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 6af12fa5b..b79ea5247 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -17,7 +17,7 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`, | `render` | [`render`](#render) | draw on your screen, shake and zoom the camera | | `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys | | `players` | [`players`](#players) | see who is on the server and message them | -| `recipes` | [`recipes`](#recipes) | read the game's recipes and items, and which recipes you know | +| `recipes` | [`recipes`](#recipes) | read the game's recipes and items and which recipes you know, place recipes in your crafting grid and, in creative mode, give you items | ## World `horizon-mod` @@ -530,6 +530,8 @@ The station a recipe is made at. | `items` | `func() -> list` | Every item, in the registry's order: `stack.item` indexes this list. | | `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | | `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | +| `place` | `func(book-id: s32, all: bool) -> result<_, string>` | Places a recipe in the open screen's grid as a click in the recipe book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a crafting table. `all` places as many as the items allow, as with Shift. Refused while no such screen is open, for a recipe the player's book doesn't hold and for one that can't be placed; one missing items shows as a ghost in the grid, as in the book. | +| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. | ## `browser` diff --git a/docs/api/stability.md b/docs/api/stability.md index 1314d9813..616767689 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`, `set-recipes`). A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`), level 5 `browser` (`set-items`, `poll`, `set-recipes`) and `recipes.place` and `recipes.give`. A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index bbd14dc84..706e48bd6 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -91,7 +91,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; `browser.set-items`, `browser.poll`: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (`input.register-key`) pressed over any item on the screen; `browser.set-recipes`: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | -| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | +| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds; `place(book-id, all)`: the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); `give(stack)`: in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | | Hook (export) | When | |---|---| From 6a62ad67cce8fd23254f285ef6ba7881809216c7 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:39:46 +0100 Subject: [PATCH 11/26] mods: set-recipes takes a row of tabs, and the tab event says which one the player picked Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-sdk/wit/horizon.wit | 10 +++-- crates/horizon-mods/src/runtime.rs | 37 ++++++++++++++----- crates/horizon-mods/tests/sandbox.rs | 10 +++-- crates/horizon-mods/tests/ui_guest/src/lib.rs | 21 ++++++++++- docs/api/mods-reference.md | 3 +- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 4896c3e21..5f8677df0 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -557,6 +557,8 @@ interface browser { page(u32), /// The back button of `set-recipes`. back, + /// The player picked the tab at this index of `set-recipes`' tabs. + tab(u32), } /// How the game lays out a recipe of `set-recipes`. enum layout { @@ -590,9 +592,11 @@ interface browser { /// The panel's items, at most 8192 (an empty list hides the panel). set-items: func(items: list); /// Shows these recipes in the panel instead of its items, as many as - /// fit a page, under `title` and a back button; an empty list shows the - /// items again. At most 256 recipes. - set-recipes: func(title: string, recipes: list); + /// fit a page, under `title` and a back button, with a row of `tabs` + /// (each an icon; `tab` is the selected one) between them; an empty + /// list of recipes shows the items again. At most 256 recipes and 16 + /// tabs. + set-recipes: func(title: string, tabs: list, tab: u32, recipes: list); /// What the player did on the panel since the last poll, at most 256 /// kept. poll: func() -> list; diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 43c9e3fac..3b3acf5db 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -323,15 +323,25 @@ struct BrowserState { /// across mods and reloads, so the game converts them once. version: u64, /// `set-recipes`' title and recipes, shown instead of the items. - recipes: Option<(String, Vec)>, + recipes: Option, events: std::collections::VecDeque, } +/// What `browser.set-recipes` shows: a title, tabs (`tab` the selected +/// one) and the recipes. +#[derive(Clone, Debug)] +pub struct RecipePage { + pub title: String, + pub tabs: Vec, + pub tab: u32, + pub recipes: Vec, +} static BROWSER_VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); /// Items a panel may hold, and events kept for `browser.poll`. const BROWSER_ITEMS: usize = 8192; /// Recipes `set-recipes` may show, slots in one, and stacks a slot cycles. const BROWSER_RECIPES: usize = 256; const RECIPE_SLOTS: usize = 9; +const RECIPE_TABS: usize = 16; const SLOT_STACKS: usize = 64; const BROWSER_EVENTS: usize = 256; impl BrowserState { @@ -848,13 +858,11 @@ impl Mods { )) }) } - /// The recipes mod `owner`'s panel shows instead of its items - /// (`browser.set-recipes`): the title and the recipes. - pub fn browser_recipes(&self, owner: usize) -> Option<(&str, &[BrowserRecipe])> { + /// What mod `owner`'s panel shows instead of its items + /// (`browser.set-recipes`). + pub fn browser_recipes(&self, owner: usize) -> Option<&RecipePage> { let b = &self.entries.get(owner)?.run.as_ref()?.store.data().browser; - b.recipes - .as_ref() - .map(|(title, list)| (title.as_str(), list.as_slice())) + b.recipes.as_ref() } /// The player did `event` on mod `owner`'s panel. pub fn browser_event(&mut self, owner: usize, event: BrowserEvent) { @@ -1261,7 +1269,13 @@ impl browser::Host for State { .collect(); self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } - fn set_recipes(&mut self, title: String, recipes: Vec) { + fn set_recipes( + &mut self, + title: String, + tabs: Vec, + tab: u32, + recipes: Vec, + ) { let stack = |s: BrowserStack| BrowserStack { item: clip(s.item), components: clip_to(s.components, 4096), @@ -1283,7 +1297,12 @@ impl browser::Host for State { ..r }) .collect(); - (clip(title), recipes) + RecipePage { + title: clip(title), + tabs: tabs.into_iter().take(RECIPE_TABS).map(stack).collect(), + tab, + recipes, + } }); self.browser.version = BROWSER_VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 081884dc2..28fd73ec6 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -1196,9 +1196,13 @@ fn browser_lists_items_and_hears_the_player() { said(&mut mods, "browser_recipes minecraft:oak_planks"), ["recipes shown: 1"] ); - let (title, recipes) = mods.browser_recipes(owner).unwrap(); - assert_eq!(title, "Recipes for minecraft:oak_planks"); - let r = &recipes[0]; + let page = mods.browser_recipes(owner).unwrap(); + assert_eq!(page.title, "Recipes for minecraft:oak_planks"); + assert_eq!( + (page.tabs[0].item.as_str(), page.tab), + ("minecraft:crafting_table", 0) + ); + let r = &page.recipes[0]; assert_eq!( (r.layout, r.width, r.height, r.action), (BrowserLayout::Grid, 1, 1, Some(false)) diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 751169565..f879f4302 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -364,7 +364,25 @@ impl Mod for Guest { } }) .collect(); - browser::set_recipes(&format!("Recipes for {args}"), &views); + // A tab per layout shown, the first selected. + let mut tabs: Vec = Vec::new(); + for v in &views { + let icon = match v.layout { + browser::Layout::Grid => "minecraft:crafting_table", + browser::Layout::Cooking => "minecraft:furnace", + browser::Layout::Single => "minecraft:stonecutter", + browser::Layout::Smithing => "minecraft:smithing_table", + browser::Layout::Brewing => "minecraft:brewing_stand", + }; + if !tabs.iter().any(|t| t.item == icon) { + tabs.push(browser::Stack { + item: icon.into(), + count: 1, + components: String::new(), + }); + } + } + browser::set_recipes(&format!("Recipes for {args}"), &tabs, 0, &views); chat::show(&format!("recipes shown: {}", views.len())); } "browser_poll" => { @@ -389,6 +407,7 @@ impl Mod for Guest { browser::Event::Action(i) => format!("action {i}"), browser::Event::Page(first) => format!("page {first}"), browser::Event::Back => "back".into(), + browser::Event::Tab(i) => format!("tab {i}"), }); } } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 6af12fa5b..5e9593525 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -587,6 +587,7 @@ A click on a slot of a recipe the panel shows (`set-recipes`). | `action` | `u32` | The action button of the recipe at this index in `set-recipes`'s list. | | `page` | `u32` | The player turned the recipes' page (`set-recipes`): the index of the first recipe now shown. | | `back` | | The back button of `set-recipes`. | +| `tab` | `u32` | The player picked the tab at this index of `set-recipes`' tabs. | ### enum `layout` @@ -617,7 +618,7 @@ A recipe to show. Each slot, and the result, cycles through its stacks, one a se | Function | Signature | What it does | | --- | --- | --- | | `set-items` | `func(items: list)` | The panel's items, at most 8192 (an empty list hides the panel). | -| `set-recipes` | `func(title: string, recipes: list)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button; an empty list shows the items again. At most 256 recipes. | +| `set-recipes` | `func(title: string, tabs: list, tab: u32, recipes: list)` | Shows these recipes in the panel instead of its items, as many as fit a page, under `title` and a back button, with a row of `tabs` (each an icon; `tab` is the selected one) between them; an empty list of recipes shows the items again. At most 256 recipes and 16 tabs. | | `poll` | `func() -> list` | What the player did on the panel since the last poll, at most 256 kept. | ## `players` From e2b1dc66914a82108611316e978a8aa379463de1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 18:49:12 +0100 Subject: [PATCH 12/26] Soul Speed: run its effects for each server-side player --- crates/horizon-client/src/game.rs | 15 +++++++- .../src/game/enchant_effects.rs | 34 +++++++++---------- crates/horizon-client/src/game/lan_actions.rs | 8 +++++ .../horizon-client/src/game/lan_particles.rs | 29 ++++++++++++++++ crates/horizon-client/src/motion.rs | 9 ++++- 5 files changed, 75 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 515a69cc8..6f856d1cb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4342,7 +4342,20 @@ impl Game<'_> { }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; let crouching = player.pose == horizon_client::pose::Pose::Crouching; - self.tick_soul_speed(); + if let Some((at, velocity)) = self.tick_soul_speed() { + self.particles.send_particles( + &horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::Soul, + ), + at, + velocity, + 1.0, + 0, + ); + if let Some(host) = self.lan.as_ref() { + lan_particles::send_soul_particle(host, at, velocity); + } + } self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 8ae54dba7..ad9894da8 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -9,7 +9,6 @@ use super::Game; use horizon_client::{ enchant::{EffectContext, Enchantments, level_value}, items::ItemStack, - particles::{ParticleOptions, ParticleType}, }; use horizon_core::{position::BlockPos, random::RandomSource}; @@ -474,15 +473,16 @@ impl Game<'_> { /// (`enchantment_active_check`); /// - the boots lose 1 durability with a 4% chance when on such a block. /// - /// Every fifth tick the player walking on one also gets soul particles and - /// sometimes a sound. A remote server applies and syncs all of this itself. - pub(super) fn tick_soul_speed(&mut self) { + /// Every fifth tick the player walking on one also gets a soul particle + /// (returned as its position and velocity, for the caller to show or send + /// to the players around) and sometimes a sound. Runs for the window's own + /// player and for each guest on the server (`guest_server_tick`); a remote + /// client leaves it to its server. + pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> { const ID: &str = "minecraft:soul_speed"; - let Some(player) = self.player.as_mut() else { - return; - }; + let player = self.player.as_mut()?; if player.client_side { - return; + return None; } let level = self.armor[3] .as_ref() @@ -533,22 +533,19 @@ impl Game<'_> { self.wear_boots(1); } if level == 0 { - return; + return None; } let r = &mut self.world.random; - if tick_effects { - // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown - // back against the movement (-0.2) and up (0.1). + // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown + // back against the movement (-0.2) and up (0.1). + let particle = tick_effects.then(|| { let x = position.x + (r.next_double() - 0.5) * f64::from(width); let z = position.z + (r.next_double() - 0.5) * f64::from(width); - self.particles.send_particles( - &ParticleOptions::simple(ParticleType::Soul), + ( [x, position.y + 0.1, z], [moved.x * -0.2, 0.1, moved.z * -0.2], - 1.0, - 0, - ); - } + ) + }); // `play_sound`: its `random_chance` (35%) comes first in its `all_of`, // so the level random is drawn every tick the boots are worn; volume // 0.6, pitch 0.6 to 1.0. @@ -561,6 +558,7 @@ impl Game<'_> { pitch, ); } + particle } /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): /// Unbreaking applies, and they break when worn out. diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index bbb0c37c0..78227b475 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2115,6 +2115,7 @@ impl Game<'_> { let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height()); let at = g.position; let sneaking = g.sneaking; + let mut soul = None; let events = self.as_guest(host, conn, |game| { let (rules, motion) = (game.world.health_rules, game.motion); let health = &motion.health; @@ -2184,6 +2185,7 @@ impl Game<'_> { w.player.shift_key_down = sneaking; w.player.freeze_immune = freeze_immune; let _ = w.server_block_damage(start); + let _ = w.update_soul_speed_block(); let _ = w.tick_environment_damage(); let _ = w.apply_fire_contact(); let _ = w.server_hazards_tick(in_wall); @@ -2211,6 +2213,9 @@ impl Game<'_> { // LivingEntity.baseTick: outside the world border, then tickEffects. game.border_damage(); game.tick_mob_effects(); + // Soul Speed's effects (LivingEntity.baseTick's location_changed + // and tick effects); its particle goes to the players around. + soul = game.tick_soul_speed(); let Some(player) = game.player.as_mut() else { return (Vec::new(), None); }; @@ -2238,6 +2243,9 @@ impl Game<'_> { let Some((events, marked)) = events else { return; }; + if let Some((at, velocity)) = soul { + super::lan_particles::send_soul_particle(host, at, velocity); + } self.as_guest(host, conn, |game| game.tick_player_sculk_events()); for event in events { match event { diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 719fe79ab..a11547759 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -24,6 +24,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] { [c[0], c[1], c[2]] } +/// `ServerLevel.sendParticles` of one soul particle moving by `velocity` +/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks. +pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) { + let Some(id) = host + .hub + .registry_id("minecraft:particle_type", "minecraft:soul") + else { + return; + }; + let packet = C::LevelParticles(play::LevelParticles { + particle: Particle { id, data: None }, + override_limiter: false, + always_show: false, + pos: at, + dist: velocity.map(|v| v as f32), + max_speed: [1.0; 3], + count: 0, + randomization: 0, + }); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + let distance: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if distance < 32.0 * 32.0 { + g.send(&packet); + } + } +} + impl Game<'_> { /// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point. /// Keep the local event's rendering; connected players receive one at rest. diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 156fc2549..084793049 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1007,7 +1007,7 @@ impl MotionWorld<'_, '_, '_> { ) }); self.player.try_add_frost(on_air)?; - self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; + self.update_soul_speed_block()?; if self.player.health.tick_count % 40 == 0 && self.player.is_fully_frozen() && self.player.can_freeze() @@ -1020,6 +1020,13 @@ impl MotionWorld<'_, '_, '_> { } Ok(()) } + /// Refresh whether the player stands on a soul speed block, for the + /// `location_changed` enchantment effects (a server's player too, whose + /// client does the walking). + pub fn update_soul_speed_block(&mut self) -> Result<()> { + self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; + Ok(()) + } /// `MovementAffectedByPredicate` for `#minecraft:soul_speed_blocks`. fn on_soul_speed_block(&mut self) -> Result { let on = self.support(movement_support::MOVEMENT_SUPPORT_OFFSET)?; From 5d7097877bccd89bd0e448b875706ec8006ecdbf Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:19:30 +0100 Subject: [PATCH 13/26] game: station tabs on the recipe pages A row of tabs above the recipe pages shows `set-recipes`' stations (their item in a slot, a white frame on the selected one). A click sends `tab(i)` to the mod, R/U over a tab works like over any slot, and horizon-ctl lists them as " tab". Changing tab resets the page. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mod_browser.rs | 12 +- .../src/game/mod_browser_recipes.rs | 116 ++++++++++++++++-- 2 files changed, 115 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs index 3121eb05b..645415ccb 100644 --- a/crates/horizon-client/src/game/mod_browser.rs +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -127,10 +127,11 @@ impl Game<'_> { self.mods.browser.stacks = stacks; // The same title again (the mod updating its buttons) keeps the page. let old = self.mods.browser.recipes.take(); - self.mods.browser.recipes = - self.mods.mods.browser_recipes(owner).map(|(title, list)| { - super::mod_browser_recipes::Pages::new(items, title, list, old) - }); + self.mods.browser.recipes = self + .mods + .mods + .browser_recipes(owner) + .map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old)); self.mods.browser.of = Some((owner, version)); } Some(owner) @@ -171,6 +172,9 @@ impl Game<'_> { /// as the mod sent it and as a stack. fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> { if self.mods.browser.recipes.is_some() { + if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) { + return Some((sent, stack)); + } let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?; return Some((sent, stack)); } diff --git a/crates/horizon-client/src/game/mod_browser_recipes.rs b/crates/horizon-client/src/game/mod_browser_recipes.rs index 14848de90..13b3b78e1 100644 --- a/crates/horizon-client/src/game/mod_browser_recipes.rs +++ b/crates/horizon-client/src/game/mod_browser_recipes.rs @@ -11,12 +11,14 @@ use horizon_client::{ hud::{HudBatch, rgba}, items::ItemStack, }; -use horizon_mods::{BrowserEvent, BrowserLayout, BrowserRecipe, BrowserSlotClick, BrowserStack}; +use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage}; use std::time::Instant; const SLOT: f32 = 18.0; /// The title row's height, and the gap under each recipe. const TITLE: f32 = 18.0; +/// A tab's step: an 18 px slot and a gap. +const TAB: f32 = 20.0; const GAP: f32 = 6.0; /// One recipe as the game draws it. @@ -34,6 +36,9 @@ pub(super) struct Shown { /// The recipes a panel shows, and its page. pub(super) struct Pages { title: String, + /// The tabs' icons and the selected one. + tabs: Vec<(BrowserStack, Option)>, + tab: usize, recipes: Vec, page: usize, since: Instant, @@ -42,11 +47,11 @@ pub(super) struct Pages { impl Pages { pub(super) fn new( items: &horizon_client::items::Items, - title: &str, - list: &[BrowserRecipe], + shown: &RecipePage, old: Option, ) -> Self { - let kept = old.filter(|o| o.title == title); + let (title, list, tab) = (&shown.title, &shown.recipes, shown.tab as usize); + let kept = old.filter(|o| o.title == *title && o.tab == tab); let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> { stacks .iter() @@ -69,7 +74,13 @@ impl Pages { }) .collect(); Self { - title: title.to_owned(), + title: title.clone(), + tabs: shown + .tabs + .iter() + .map(|t| (t.clone(), browser_stack(items, t))) + .collect(), + tab, recipes, page: kept.as_ref().map_or(0, |o| o.page), since: kept.map_or_else(Instant::now, |o| o.since), @@ -131,11 +142,30 @@ impl Shown { } } +/// The tabs' places, in rows under the title row. +fn tab_places(pages: &Pages, layout: &Layout) -> Vec<(f32, f32)> { + let per_row = ((layout.width() / TAB).floor() as usize).max(1); + (0..pages.tabs.len()) + .map(|i| { + ( + layout.grid.0 + (i % per_row) as f32 * TAB, + layout.grid.1 + TITLE + 3.0 + (i / per_row) as f32 * TAB, + ) + }) + .collect() +} +/// Where the recipes start: under the title row and the tabs. +fn recipes_top(pages: &Pages, layout: &Layout) -> f32 { + let tabs = tab_places(pages, layout); + tabs.last() + .map_or(layout.grid.1 + TITLE, |&(_, y)| y + TAB + 3.0) +} + impl Game<'_> { - /// The first recipe of each page, filling the panel from the title row - /// to the search box. + /// The first recipe of each page, filling the panel from the tabs to + /// the search box. fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec { - let room = layout.search[1] - 4.0 - (layout.grid.1 + TITLE); + let room = layout.search[1] - 4.0 - recipes_top(pages, layout); let mut starts = vec![0]; let mut used = 0.0; for (i, r) in pages.recipes.iter().enumerate() { @@ -156,7 +186,7 @@ impl Game<'_> { let starts = Self::recipe_pages(pages, layout); let page = pages.page.min(starts.len() - 1); let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len()); - let mut y = layout.grid.1 + TITLE; + let mut y = recipes_top(pages, layout); (starts[page]..end) .map(|i| { let at = (layout.grid.0, y); @@ -177,6 +207,21 @@ impl Game<'_> { let n = pages.since.elapsed().as_secs() as usize; stacks.get(n % stacks.len().max(1)) } + /// The tab under the pointer: its index and icon. + pub(super) fn recipe_tab_hovered( + &self, + layout: &Layout, + ) -> Option<(usize, BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + let places = tab_places(pages, layout); + places.iter().enumerate().find_map(|(i, &(x, y))| { + if !inside(self.cursor, [x, y, SLOT, SLOT]) { + return None; + } + let (sent, stack) = pages.tabs.get(i)?; + Some((i, sent.clone(), stack.clone()?)) + }) + } /// The recipe slot under the pointer: recipe, slot, and its stack now. pub(super) fn recipe_slot_hovered( &self, @@ -237,6 +282,7 @@ impl Game<'_> { u32::MAX, true, ); + self.draw_recipe_tabs(batch, layout); if pages_n > 1 { let labels = [ self.translate("gui.recipebook.previous_page", "Previous Page"), @@ -351,6 +397,50 @@ impl Game<'_> { } } + /// The tabs, on slots; the selected one framed. + fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) { + let Some(pages) = &self.mods.browser.recipes else { + return; + }; + let places = tab_places(pages, layout); + let tabs: Vec> = pages.tabs.iter().map(|t| t.1.clone()).collect(); + let selected = pages.tab; + for (i, (&(x, y), stack)) in places.iter().zip(tabs).enumerate() { + let atlas = &self.gui.atlas; + batch.sprite(atlas, "gui/sprites/container/slot", x, y); + let Some(stack) = stack else { + continue; + }; + self.draw_item(batch, &stack, x + 1.0, y + 1.0); + // The selected tab: a white frame around its slot. + if i == selected { + let white = rgba(255, 255, 255, 255); + let atlas = &self.gui.atlas; + batch.fill(atlas, x - 1.0, y - 1.0, SLOT + 2.0, 1.0, white); + batch.fill(atlas, x - 1.0, y + SLOT, SLOT + 2.0, 1.0, white); + batch.fill(atlas, x - 1.0, y, 1.0, SLOT, white); + batch.fill(atlas, x + SLOT, y, 1.0, SLOT, white); + } + if inside(self.cursor, [x, y, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &format!("{name} tab"), + [x + 1.0, y + 1.0, 16.0, 16.0], + true, + horizon_client::widgets::Kind::Tab(i == selected), + ); + } + } + /// A press on the recipes: true when it hit one of their parts. pub(super) fn browser_recipes_press( &mut self, @@ -383,6 +473,14 @@ impl Game<'_> { .browser_event(owner, BrowserEvent::Page(starts[to] as u32)); return true; } + if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Tab(tab as u32)); + return true; + } if inside(cursor, self.back_button(layout).0) { self.audio .play("minecraft:ui.button.click", None, 0.25, 1.0); From b28b4991359b3111ac2249d0c3b9b6276684cc66 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:41:35 +0100 Subject: [PATCH 14/26] mods: recipes.give hands out one stack at most As the creative inventory does: a count over the item's stack size (64, 16 or 1) is cut to it, so a mod can ask for a full stack without knowing it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mod_recipes.rs | 8 ++++++-- crates/horizon-mod-sdk/wit/horizon.wit | 3 ++- docs/api/mods-reference.md | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs index 0f65b0b42..b97944e39 100644 --- a/crates/horizon-client/src/game/mod_recipes.rs +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -74,7 +74,10 @@ impl Game<'_> { /// creative inventory; a server hears it as `SetCreativeModeSlot` /// through the inventory's sync, and refuses it unless creative. pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) { - if !self.creative || self.res.items().get(item).is_none() { + let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else { + return; + }; + if !self.creative { return; } let (damage, extra) = horizon_client::item_data::command_components(components) @@ -83,7 +86,8 @@ impl Game<'_> { }); let _ = self.give(ItemStack { item: item.into(), - count, + // One stack at most, as the creative inventory hands out. + count: count.min(max), damage, extra, }); diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 3edc44872..1c1431273 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -516,7 +516,8 @@ interface recipes { place: func(book-id: s32, all: bool) -> result<_, string>; /// Creative mode only: gives the player the stack, as taking it from /// the creative inventory does (`ServerboundSetCreativeModeSlot`): the - /// selected slot first, then the hotbar, then the inventory. + /// selected slot first, then the hotbar, then the inventory. The count + /// is cut to one stack of the item (64, 16 or 1). give: func(stack: stack) -> result<_, string>; } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index ed8d8d4ad..6416245ad 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -531,7 +531,7 @@ The station a recipe is made at. | `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | | `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | | `place` | `func(book-id: s32, all: bool) -> result<_, string>` | Places a recipe in the open screen's grid as a click in the recipe book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a crafting table. `all` places as many as the items allow, as with Shift. Refused while no such screen is open, for a recipe the player's book doesn't hold and for one that can't be placed; one missing items shows as a ghost in the grid, as in the book. | -| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. | +| `give` | `func(stack: stack) -> result<_, string>` | Creative mode only: gives the player the stack, as taking it from the creative inventory does (`ServerboundSetCreativeModeSlot`): the selected slot first, then the hotbar, then the inventory. The count is cut to one stack of the item (64, 16 or 1). | ## `browser` From d241c54e059bbb9274f8e2c1b2a1db6e3f62e815 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:58:45 +0100 Subject: [PATCH 15/26] Official mod: Recipes, a recipe viewer Every item sits in the item panel beside the inventory and container screens, with a search box (words in the name or id, @namespace, #tag). R or a click shows an item's recipes, U or a right click its uses, with a tab per station; Back returns through the pages. + beside a crafting recipe the player knows fills the grid as the recipe book does. In creative mode, with "Click gives items" on, a click gives one item and a right click a stack. The keys are rebindable in Controls. Written from vanilla 26.3's recipe book and screens and Horizon's design system only. On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 + Cargo.toml | 2 +- docs/mods.md | 5 +- mods/official/recipes/Cargo.toml | 16 ++ mods/official/recipes/horizon-mod.toml | 9 + mods/official/recipes/src/lib.rs | 364 +++++++++++++++++++++++++ 6 files changed, 400 insertions(+), 3 deletions(-) create mode 100644 mods/official/recipes/Cargo.toml create mode 100644 mods/official/recipes/horizon-mod.toml create mode 100644 mods/official/recipes/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0e93bba0e..4d48c48cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2962,6 +2962,13 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "recipes" +version = "1.0.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "redox_syscall" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index e4628f962..f6ff38ad8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] +members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/recipes", "mods/official/zoom", "xtask"] resolver = "2" [workspace.package] diff --git a/docs/mods.md b/docs/mods.md index 706e48bd6..4c7ba012a 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -15,7 +15,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | The contract (WIT) | `crates/horizon-mod-sdk/wit/horizon.wit` | 0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks | | SDK for mod authors | `crates/horizon-mod-sdk` | 0.1 | | Example mods | `crates/horizon-mod-sdk/examples/coords-hud`, `examples/predator-missile` | coordinates, facing and time HUD, `/where`, `/coords`; `/missile`, a guided missile from vanilla commands | -| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap | +| Horizon's official mods | `mods/official/` | the extras moving to the registry: FPS Counter (`fps-overlay`), Keystrokes, Zoom, Minimap; and Recipes, a recipe viewer | | Runtime | `crates/horizon-mods` | wasmtime 48 LTS, component model | | Authoring tool | `crates/cargo-horizon-mod` | `cargo horizon-mod new [--template code\|content] / build / run / test / check`, and the registry's CI (`registry build / review / list`) | | In the game | `crates/horizon-client/src/game/mods.rs`, Options > Mods | client mods | @@ -521,7 +521,7 @@ says how). The formats: [api/mod-index.md](api/mod-index.md). ### Horizon's official mods -The Horizon extras that are mods now live in `mods/official//`, one crate each in the game's workspace, so +The Horizon extras that are mods now, and Horizon's own recipe viewer, live in `mods/official//`, one crate each in the game's workspace, so `cargo clippy --workspace` and pr_ready cover them. They use only the public API, like any other mod, with the same options on their settings page as the extra had and the same look. Each is published by a maintainer: build it on `main` with the toolchain CI uses (`cd mods/official/ && cargo horizon-mod build`), then open a pull request in @@ -535,6 +535,7 @@ so the same rustc and `Cargo.lock` give the same file. | Keystrokes | `keystrokes` | Extras > Keystrokes | `corner`: as above, default `top_left`; `cps`: `true` (default), `false` | | Zoom | `zoom` | Extras > Zoom | `level`: `2`, `3`, `4` (default), `6`, `8`, `10`; `smooth`: `true` (default), `false`; `sensitivity` (slower turning): `true` (default), `false`. The key is the game's: `key.zoom.zoom` in `horizon-mods.txt`, default `key.keyboard.z` | | Minimap | `minimap` | Extras > Minimap | `corner`: as above, default `top_right`; `size`: `64`, `96` (default), `128`; `shape`: `square` (default), `round`; `zoom` (blocks per pixel): `0.5`, `1` (default), `2`, `4`; `rotate`: `false` (default), `true`. The game draws the map (`render.set-minimap`) | +| Recipes | `recipes` | nothing (new) | `give` (a click on the item panel gives the item in creative mode: one, a stack with a right click): `false` (default), `true`. The keys are the game's: `key.recipes.recipe` (default `key.keyboard.r`) and `key.recipes.uses` (default `key.keyboard.u`) in `horizon-mods.txt`. Every item sits in the item panel (`browser`) with a search box (words in the name or id, `@namespace`, `#tag`); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (`recipes.place`). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book | ## Versions and compatibility diff --git a/mods/official/recipes/Cargo.toml b/mods/official/recipes/Cargo.toml new file mode 100644 index 000000000..5bf0f5d9c --- /dev/null +++ b/mods/official/recipes/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "recipes" +version = "1.0.0" +edition.workspace = true +license.workspace = true +publish = false +description = "Horizon's official recipe viewer: every item beside the inventory, its recipes and uses." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +horizon-mod-sdk = { path = "../../../crates/horizon-mod-sdk" } + +[lints] +workspace = true diff --git a/mods/official/recipes/horizon-mod.toml b/mods/official/recipes/horizon-mod.toml new file mode 100644 index 000000000..f4c04af0d --- /dev/null +++ b/mods/official/recipes/horizon-mod.toml @@ -0,0 +1,9 @@ +id = "recipes" +name = "Recipes" +version = "1.0.0" +description = "Every item beside your inventory, with a search box. R over an item shows its recipes, U its uses (both rebindable in Controls), and + fills the crafting grid with a recipe you know. In creative mode a click can give you the item (Mod settings). On servers that are not Horizon, it shows this game's own 26.3 recipes; what you know and what + can fill follow the server's recipe book." +authors = ["Horizon"] +api = "0.1" +side = "client" +capabilities = ["recipes", "ui", "input", "game-read", "chat", "storage"] +experimental = ["recipes", "ui", "input"] diff --git a/mods/official/recipes/src/lib.rs b/mods/official/recipes/src/lib.rs new file mode 100644 index 000000000..42f856016 --- /dev/null +++ b/mods/official/recipes/src/lib.rs @@ -0,0 +1,364 @@ +//! Horizon's official recipe viewer. Every item sits in the item panel the +//! game draws beside the inventory and container screens (`browser`), with +//! a search box. R over an item, the panel's or any slot's, shows its +//! recipes, U its uses (`input.register-key`, so both can be rebound in +//! Controls); a click does the same, left for recipes and right for uses. +//! A recipe page has a tab per station, and + beside a crafting recipe the +//! player knows fills the grid as the recipe book does (`recipes.place`). +//! In creative mode, with "Click gives items" on, a click on a panel item +//! gives one, a right click a stack (`recipes.give`). +//! +//! On a server that is not Horizon the recipes are the game's own 26.3 +//! ones; `known` and `place` follow the server's recipe book. +use horizon_mod_sdk::{ + Mod, Registration, browser, chat, game, input, recipes, + recipes::Kind, + storage, + ui::{self, ButtonWidget, Widget}, +}; + +const RECIPE_KEY: &str = "recipe"; +const USES_KEY: &str = "uses"; +const GIVE_BUTTON: u32 = 1; +/// What `set-recipes` takes at most. +const MAX_RECIPES: usize = 256; +const MAX_STACKS: usize = 64; +/// The stations, in the order of their tabs, with each one's icon. +const STATIONS: [&str; 8] = [ + "minecraft:crafting_table", + "minecraft:furnace", + "minecraft:blast_furnace", + "minecraft:smoker", + "minecraft:campfire", + "minecraft:stonecutter", + "minecraft:smithing_table", + "minecraft:brewing_stand", +]; + +fn station(kind: Kind) -> usize { + match kind { + Kind::CraftingShaped | Kind::CraftingShapeless => 0, + Kind::Smelting => 1, + Kind::Blasting => 2, + Kind::Smoking => 3, + Kind::Campfire => 4, + Kind::Stonecutting => 5, + Kind::Smithing => 6, + Kind::Brewing => 7, + } +} + +fn stack(item: &str) -> browser::Stack { + browser::Stack { + item: item.into(), + count: 1, + components: String::new(), + } +} + +/// A recipe page: an item's recipes or its uses, on one station's tab. +#[derive(Clone)] +struct View { + item: browser::Stack, + uses: bool, + tab: usize, +} + +#[derive(Default)] +struct Recipes { + items: Vec, + all: Vec, + /// The panel's items now: indexes into `items`, after the search. + listed: Vec, + search: String, + /// The page shown, then the ones Back returns to. + history: Vec, + /// The book id behind each recipe the page shows (-1: none). + shown: Vec, + give: bool, +} + +impl Recipes { + fn id(&self, s: &recipes::Stack) -> &str { + self.items + .get(s.item as usize) + .map_or("minecraft:air", |i| &i.id) + } + fn convert(&self, s: &recipes::Stack) -> browser::Stack { + browser::Stack { + item: self.id(s).into(), + count: s.count, + components: s.components.clone(), + } + } + /// The same item, and the same components when `want` names some. + fn matches(&self, s: &recipes::Stack, want: &browser::Stack) -> bool { + self.id(s) == want.item && (want.components.is_empty() || s.components == want.components) + } + fn name<'a>(&'a self, item: &'a str) -> &'a str { + self.items + .iter() + .find(|i| i.id == item) + .map_or(item, |i| &i.name) + } + + /// Loads the tables once a world is open, and drops them when it closes. + fn load(&mut self) { + let in_world = game::get_player().is_some(); + if in_world && self.items.is_empty() { + self.items = recipes::items(); + self.all = recipes::all(); + self.list(); + } else if !in_world && !self.items.is_empty() { + browser::set_recipes("", &[], 0, &[]); + browser::set_items(&[]); + *self = Recipes { + give: self.give, + ..Recipes::default() + }; + } + } + + /// Fills the panel with the items the search keeps: every word must be + /// in the name or the id; `@minecraft` keeps a namespace, `#logs` a tag. + fn list(&mut self) { + let words: Vec = self + .search + .to_lowercase() + .split_whitespace() + .map(String::from) + .collect(); + let keeps = |item: &recipes::Item| { + words.iter().all(|w| { + if let Some(ns) = w.strip_prefix('@') { + item.id.split(':').next().is_some_and(|n| n.starts_with(ns)) + } else if let Some(tag) = w.strip_prefix('#') { + item.tags.iter().any(|t| t.contains(tag)) + } else { + item.name.to_lowercase().contains(w.as_str()) || item.id.contains(w.as_str()) + } + }) + }; + self.listed = (self.items.iter().enumerate()) + .filter(|(_, i)| i.id != "minecraft:air" && keeps(i)) + .map(|(n, _)| n) + .collect(); + let stacks: Vec<_> = self + .listed + .iter() + .map(|&n| stack(&self.items[n].id)) + .collect(); + browser::set_items(&stacks); + } + + /// The recipes a view lists, all stations together. + fn found(&self, view: &View) -> Vec<&recipes::Recipe> { + (self.all.iter()) + .filter(|r| { + if view.uses { + r.slots + .iter() + .flatten() + .any(|s| self.matches(s, &view.item)) + } else { + r.results.iter().any(|s| self.matches(s, &view.item)) + } + }) + .collect() + } + + /// Opens a page, pushing it on the history; nothing when it is empty. + fn open(&mut self, item: browser::Stack, uses: bool) { + let view = View { item, uses, tab: 0 }; + if self.found(&view).is_empty() { + return; + } + self.history.push(view); + self.show(); + } + + /// Sends the page on top of the history, or the items when there is none. + fn show(&mut self) { + let Some(mut view) = self.history.last().cloned() else { + self.shown.clear(); + browser::set_recipes("", &[], 0, &[]); + return; + }; + let found = self.found(&view); + let stations: Vec = (0..STATIONS.len()) + .filter(|&s| found.iter().any(|r| station(r.kind) == s)) + .collect(); + view.tab = view.tab.min(stations.len().saturating_sub(1)); + let on: Vec<&recipes::Recipe> = (found.iter().copied()) + .filter(|r| stations.get(view.tab) == Some(&station(r.kind))) + .take(MAX_RECIPES) + .collect(); + let known = recipes::known(); + let views: Vec = on.iter().map(|r| self.view(r, &known)).collect(); + let mut title = format!( + "{}: {}", + if view.uses { "Uses" } else { "Recipes" }, + self.name(&view.item.item) + ); + let total = found + .iter() + .filter(|r| stations.get(view.tab) == Some(&station(r.kind))) + .count(); + if total > on.len() { + title.push_str(&format!(" ({} of {total})", on.len())); + } + let tabs: Vec<_> = stations.iter().map(|&s| stack(STATIONS[s])).collect(); + browser::set_recipes(&title, &tabs, view.tab as u32, &views); + self.shown = on.iter().map(|r| r.book_id).collect(); + if let Some(top) = self.history.last_mut() { + top.tab = view.tab; + } + } + + fn view(&self, r: &recipes::Recipe, known: &[i32]) -> browser::RecipeView { + let mut slots: Vec> = (r.slots.iter()) + .map(|s| s.iter().take(MAX_STACKS).map(|s| self.convert(s)).collect()) + .collect(); + let results = r + .results + .iter() + .take(MAX_STACKS) + .map(|s| self.convert(s)) + .collect(); + let (layout, width, height, note) = match r.kind { + Kind::CraftingShaped => (browser::Layout::Grid, r.width, r.height, String::new()), + Kind::CraftingShapeless => { + let n = slots.len() as u32; + let width = match n { + 0 | 1 => 1, + 2..=4 => 2, + _ => 3, + }; + let height = n.div_ceil(width).max(1); + slots.resize((width * height) as usize, Vec::new()); + (browser::Layout::Grid, width, height, "Shapeless".into()) + } + Kind::Smelting | Kind::Blasting | Kind::Smoking | Kind::Campfire => { + let seconds = r.cooking_time as f32 / 20.0; + let note = format!("{seconds} s, {} XP", (r.experience * 100.0).round() / 100.0); + (browser::Layout::Cooking, 1, 1, note) + } + Kind::Stonecutting => (browser::Layout::Single, 1, 1, String::new()), + Kind::Smithing => (browser::Layout::Smithing, 3, 1, String::new()), + Kind::Brewing => (browser::Layout::Brewing, 2, 1, String::new()), + }; + // + only for what the recipe book shows; greyed until the book has it. + let action = (r.book_id >= 0).then(|| known.contains(&r.book_id)); + browser::RecipeView { + layout, + width, + height, + slots, + results, + note, + action, + } + } + + fn handle(&mut self, event: browser::Event) { + match event { + browser::Event::Search(text) => { + self.search = text; + self.list(); + } + browser::Event::Clicked(c) => { + let Some(&n) = self.listed.get(c.index as usize) else { + return; + }; + let item = self.items[n].id.clone(); + if self.give { + let count = if c.right { 99 } else { 1 }; + let gift = recipes::Stack { + item: n as u32, + count, + components: String::new(), + }; + if recipes::give(&gift).is_ok() { + return; + } + } + self.open(stack(&item), c.right); + } + browser::Event::Key(k) if k.key == RECIPE_KEY || k.key == USES_KEY => { + let item = browser::Stack { + count: 1, + ..k.stack + }; + self.open(item, k.key == USES_KEY); + } + browser::Event::SlotClicked(c) => { + let item = browser::Stack { + count: 1, + ..c.stack + }; + self.open(item, c.right); + } + browser::Event::Tab(i) => { + if let Some(top) = self.history.last_mut() { + top.tab = i as usize; + } + self.show(); + } + browser::Event::Action(i) => { + let book = self.shown.get(i as usize).copied(); + if let Some(Err(e)) = book.map(|b| recipes::place(b, false)) { + chat::show(&e); + } + } + browser::Event::Back => { + self.history.pop(); + self.show(); + } + _ => {} + } + } + + fn page(&self) { + ui::set_settings(&[Widget::Button(ButtonWidget { + id: GIVE_BUTTON, + label: format!( + "Click gives items (creative): {}", + if self.give { "ON" } else { "OFF" } + ), + })]); + } +} + +impl Mod for Recipes { + fn init() -> Result<(Self, Registration), String> { + input::register_key(RECIPE_KEY, "Show recipes", "key.keyboard.r")?; + input::register_key(USES_KEY, "Show uses", "key.keyboard.u")?; + let give = storage::get("give").is_some_and(|v| v == b"true"); + let me = Recipes { + give, + ..Recipes::default() + }; + me.page(); + Ok((me, Registration::new().on_frame())) + } + fn frame(&mut self, _seconds: f32) { + self.load(); + // The keys matter only over an item, which `browser` reports. + let _ = input::poll(); + for event in browser::poll() { + self.handle(event); + } + } + fn ui_select(&mut self, id: u32, _row: u32) { + if id == GIVE_BUTTON { + self.give = !self.give; + let _ = storage::set("give", if self.give { b"true" } else { b"false" }); + self.page(); + } + } + fn ui_settings_opened(&mut self) { + self.page(); + } +} + +horizon_mod_sdk::export_mod!(Recipes); From efd6c60a272d20f69e498a48afe3e96a759cf164 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 20:07:36 +0100 Subject: [PATCH 16/26] Axolotl air: refill only where it rains locally, not in deserts or snowy biomes --- crates/horizon-client/src/mobs/ai.rs | 2 +- crates/horizon-client/src/mobs/world.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 403f63101..3499ab29d 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -458,7 +458,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { m.position.z.floor() as i32, ); let top = (m.position.y + f64::from(m.height())).floor() as i32; - let in_rain = w.raining && (w.can_see_sky(x, y, z) || w.can_see_sky(x, top, z)); + let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z); if m.fluids.in_water || in_rain { m.air = super::axolotl::MAX_AIR; } else { diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 7836a49ef..db2557e72 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -177,6 +177,25 @@ impl MobWorld<'_> { } self.world.light(x, y, z) >> 4 == 15 } + /// Level.isRainingAt: raining, open to the sky, nothing motion blocking + /// above, and the biome's precipitation at the position (its temperature + /// lowered above the snow level) is rain, not snow or none. + pub fn raining_at(&self, x: i32, y: i32, z: i32) -> bool { + if !self.raining || !self.can_see_sky(x, y, z) || self.motion_blocking_height(x, z) > y { + return false; + } + let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else { + return false; + }; + let biome = self.biomes.get(chunk.biome(x, y, z)); + let snow_level = self.sea_level + 17; + let temperature = if y > snow_level { + biome.temperature - (y - snow_level) as f32 * 0.05 / 40.0 + } else { + biome.temperature + }; + biome.precipitation && temperature >= 0.15 + } /// Level.getLightLevelDependentMagicValue: the dimension's brightness curve /// at the raw (sky darkened) light level. pub fn brightness(&self, x: i32, y: i32, z: i32) -> f32 { From bb9b0424b44606cd7af0bb57e4610d24c0af2a68 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:52:11 +0100 Subject: [PATCH 17/26] Axolotl rain: the biome's height-adjusted temperature Level.isRainingAt takes Biome.getHeightAdjustedTemperature, which applies the frozen oceans' temperature modifier and the height noise above the snow level; reuse horizon-world's version instead of a simplified copy. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/biome.rs | 25 ++++++++++++++++++++++++- crates/horizon-client/src/mobs/world.rs | 12 +++--------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 63ad004d4..9d68bfefa 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -1,7 +1,7 @@ //! Biome colours from the pinned biome definitions and colormaps //! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`). use crate::Result; -use horizon_core::{noise::SimplexNoise, random::LegacyRandom}; +use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom}; use serde_json::Value; use std::{collections::HashMap, fs, path::Path}; @@ -30,6 +30,8 @@ pub struct BiomeInfo { pub creature_probability: f32, pub name: String, pub temperature: f32, + /// `temperature_modifier: frozen` (the frozen oceans). + pub frozen: bool, pub downfall: f32, pub precipitation: bool, pub grass: u32, @@ -42,6 +44,26 @@ pub struct BiomeInfo { pub fog: Option, pub water_fog: u32, } +impl BiomeInfo { + /// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler + /// with height above `sea_level + 17`. + pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 { + use horizon_world::worldgen::biome_temperature::{ + BiomeTemperatureSettings, TemperatureModifier, + }; + BiomeTemperatureSettings { + base_temperature: self.temperature, + modifier: if self.frozen { + TemperatureModifier::Frozen + } else { + TemperatureModifier::None + }, + has_precipitation: self.precipitation, + downfall: self.downfall, + } + .height_adjusted(pos, sea_level) + } +} pub struct Biomes { pub list: Vec, pub index: HashMap, @@ -129,6 +151,7 @@ impl Biomes { .as_f64() .unwrap_or(0.1) as f32, temperature, + frozen: v["temperature_modifier"].as_str() == Some("frozen"), downfall, precipitation: v["has_precipitation"].as_bool().unwrap_or(true), grass: color(&effects["grass_color"]) diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index db2557e72..f893559f6 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -178,8 +178,8 @@ impl MobWorld<'_> { self.world.light(x, y, z) >> 4 == 15 } /// Level.isRainingAt: raining, open to the sky, nothing motion blocking - /// above, and the biome's precipitation at the position (its temperature - /// lowered above the snow level) is rain, not snow or none. + /// above, and the biome's precipitation at the position is rain, not snow + /// or none. pub fn raining_at(&self, x: i32, y: i32, z: i32) -> bool { if !self.raining || !self.can_see_sky(x, y, z) || self.motion_blocking_height(x, z) > y { return false; @@ -188,13 +188,7 @@ impl MobWorld<'_> { return false; }; let biome = self.biomes.get(chunk.biome(x, y, z)); - let snow_level = self.sea_level + 17; - let temperature = if y > snow_level { - biome.temperature - (y - snow_level) as f32 * 0.05 / 40.0 - } else { - biome.temperature - }; - biome.precipitation && temperature >= 0.15 + biome.precipitation && biome.temperature_at(BlockPos::new(x, y, z), self.sea_level) >= 0.15 } /// Level.getLightLevelDependentMagicValue: the dimension's brightness curve /// at the raw (sky darkened) light level. From 69bb81312816fc283a3c74c02c365e7391ef6608 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:16:34 +0100 Subject: [PATCH 18/26] game: cut a recipe page's title with an ellipsis A title wider than the panel ended at its edge mid-word ("Recipes: Oak P"); it now ends with vanilla's "..." (CommonComponents.ELLIPSIS). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mod_browser_recipes.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/mod_browser_recipes.rs b/crates/horizon-client/src/game/mod_browser_recipes.rs index 13b3b78e1..557ae9029 100644 --- a/crates/horizon-client/src/game/mod_browser_recipes.rs +++ b/crates/horizon-client/src/game/mod_browser_recipes.rs @@ -272,8 +272,14 @@ impl Game<'_> { batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true); horizon_client::widgets::record(&label, back, true); let room = layout.width() - back[2] - 4.0; - let fit = atlas.font.fit(&title, room); - let shown: String = title.chars().take(fit).collect(); + // Cut to the panel with vanilla's ellipsis (CommonComponents.ELLIPSIS). + let shown = if atlas.font.fit(&title, room) >= title.chars().count() { + title + } else { + let fit = atlas.font.fit(&title, room - atlas.font.width("...")); + let kept: String = title.chars().take(fit).collect(); + format!("{}...", kept.trim_end()) + }; batch.text( atlas, &shown, From 26561d315c5d187c4d1f70af4f92455d030f9967 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:26:47 +0100 Subject: [PATCH 19/26] Preload the world data while the player chooses a world The worldgen bundle, the loot tables and the registries a server sends are read on a helper thread from the moment the player opens Singleplayer or Create World, so a world opening finds them ready. A world opened meanwhile waits for the part being read. If the player goes back to the title or to Multiplayer and opens no world for a minute, the preload is dropped: the three caches can now let go of what they hold. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 12 +++ crates/horizon-client/src/game/lan.rs | 22 ++++-- .../horizon-client/src/game/world_loading.rs | 75 +++++++++++++++++++ crates/horizon-client/src/structure_blocks.rs | 19 ++++- crates/horizon-client/src/vanillagen.rs | 14 +++- 5 files changed, 129 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index f9edd98bb..5acacd05c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8069,6 +8069,7 @@ impl Game<'_> { creative: bool, create: Option<&horizon_client::title::WorldOptions>, ) -> Result<()> { + world_loading::world_data_used(); if self.defer_world_entry(PendingWorldEntry::Local { folder: folder.to_owned(), name: name.map(str::to_owned), @@ -13025,6 +13026,17 @@ impl Game<'_> { if self.rest_loader.is_some() { return Ok(()); } + let on_world_screens = self.title.as_ref().is_some_and(|title| { + !matches!( + title.mode, + horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_) + ) + }); + world_loading::preload_world_data( + on_world_screens, + &self.options.resources, + self.pack.assets.registry, + ); if std::mem::take(&mut self.pending_pack_reload) { self.reload_packs(); } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..3ec2cca47 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -24,11 +24,24 @@ use horizon_protocol::{ }; use std::{ collections::HashMap, - sync::{Arc, OnceLock}, + sync::{Arc, Mutex}, }; /// The registries and tags are the same for every world: load them once. -static SYNC: OnceLock, String>> = OnceLock::new(); +static SYNC: Mutex, String>>> = Mutex::new(None); + +/// The registries and tags a server sends, loaded from `root` the first time. +pub(super) fn sync_data(root: &std::path::Path) -> std::result::Result, String> { + let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner()); + sync.get_or_insert_with(|| SyncData::load(root).map(Arc::new)) + .clone() +} + +/// Let go of what `sync_data` loaded: for a preload no world used +/// (game/world_loading.rs). +pub(super) fn forget_sync_data() { + *SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None; +} /// The settings of the open world (WorldOptionsScreen's multiplayer section). #[derive(Clone, Copy, Debug)] @@ -562,10 +575,7 @@ impl Game<'_> { config: HubConfig, host: Option, ) -> Result<()> { - let root = self.options.resources.clone(); - let sync = SYNC - .get_or_init(|| SyncData::load(&root).map(Arc::new)) - .clone()?; + let sync = sync_data(&self.options.resources)?; let biome_names: Vec = self .res .biomes() diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index e9ee8e3e8..b16bcffb6 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -196,6 +196,81 @@ impl WorldLoader { } } +/// How long the preloaded world data outlives the world screens: a player +/// who goes back to the title or to Multiplayer and opens no world for this +/// long gets the memory back. +const PRELOAD_KEPT: std::time::Duration = std::time::Duration::from_secs(60); + +enum Preload { + Idle, + /// Read, or being read, on the "World data preload" thread. `left`: when + /// the player last left the world screens. + Started { + thread: std::thread::JoinHandle<()>, + left: Option, + }, + /// A world opened: the data is the world's now, and stays. + Used, +} + +static PRELOAD: std::sync::Mutex = std::sync::Mutex::new(Preload::Idle); + +/// Each frame of the title: read what every world opening loads and keeps +/// for the rest of the process (the worldgen bundle, the loot tables and the +/// registries the server sends) on a helper thread while the player is on the +/// world screens (`on_world_screens`: Singleplayer, Create World and their +/// dialogs), so it overlaps the time spent choosing a world. A world opened +/// meanwhile waits for the part being read instead of reading it again. Not +/// at the title screen itself: many players only go to Multiplayer, and +/// small machines need the memory. Dropped after `PRELOAD_KEPT` away from +/// the world screens without a world opening. +pub(super) fn preload_world_data( + on_world_screens: bool, + resources: &std::path::Path, + registry: &'static horizon_world::registry::BlockRegistry, +) { + let mut preload = PRELOAD.lock().unwrap_or_else(|e| e.into_inner()); + match &mut *preload { + Preload::Used => {} + Preload::Idle if on_world_screens => { + let resources = resources.to_owned(); + let spawned = std::thread::Builder::new() + .name("World data preload".into()) + .spawn(move || { + horizon_threads::behind_server(); + // Errors are left for the world opening to report. + let _ = horizon_client::vanillagen::shared_data( + &resources.join("worldgen"), + registry, + ); + let _ = horizon_client::structure_blocks::loot_data(&resources); + let _ = super::lan::sync_data(&resources); + }); + match spawned { + Ok(thread) => *preload = Preload::Started { thread, left: None }, + Err(e) => log::warn!("Horizon: world data preload: {e}"), + } + } + Preload::Idle => {} + Preload::Started { left, .. } if on_world_screens => *left = None, + Preload::Started { thread, left } => { + let left = *left.get_or_insert_with(std::time::Instant::now); + if left.elapsed() >= PRELOAD_KEPT && thread.is_finished() { + horizon_client::vanillagen::forget_shared_data(); + horizon_client::structure_blocks::forget_loot_data(); + super::lan::forget_sync_data(); + *preload = Preload::Idle; + log::info!("Horizon: preloaded world data dropped, no world opened"); + } + } + } +} + +/// A world opens: what `preload_world_data` read is the world's from now on. +pub(super) fn world_data_used() { + *PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used; +} + pub(super) fn make_streaming( res: &Resources, pack: &PackResources, diff --git a/crates/horizon-client/src/structure_blocks.rs b/crates/horizon-client/src/structure_blocks.rs index 604cf7925..01c60ed71 100644 --- a/crates/horizon-client/src/structure_blocks.rs +++ b/crates/horizon-client/src/structure_blocks.rs @@ -9,7 +9,7 @@ use horizon_world::{ }; use std::{ path::Path, - sync::{Arc, OnceLock}, + sync::{Arc, Mutex}, }; /// The `custom_data` key holding an exploration map's search until the game @@ -114,11 +114,13 @@ pub fn has_pending_map(item: &ItemStack) -> bool { }) } -static LOOT: OnceLock>> = OnceLock::new(); +/// None until `loot_data` runs; `Some(None)` when the tables failed to load. +static LOOT: Mutex>>> = Mutex::new(None); /// Loot data of the resource root, loaded once per process. pub fn loot_data(root: &Path) -> Option> { - LOOT.get_or_init(|| match LootData::load(root) { + let mut loot = LOOT.lock().unwrap_or_else(|e| e.into_inner()); + loot.get_or_insert_with(|| match LootData::load(root) { Ok(data) => Some(Arc::new(data)), Err(e) => { log::warn!("Horizon: loot tables unavailable, structure containers stay empty: {e}"); @@ -130,7 +132,16 @@ pub fn loot_data(root: &Path) -> Option> { /// The loot data `loot_data` loaded, if it ran. pub fn loaded_loot_data() -> Option> { - LOOT.get().cloned().flatten() + LOOT.lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .flatten() +} + +/// Let go of the loot data: for a preload no world used +/// (game/world_loading.rs). The next `loot_data` loads it again. +pub fn forget_loot_data() { + *LOOT.lock().unwrap_or_else(|e| e.into_inner()) = None; } /// `RandomizableContainer.unpackLootTable` of a container at `pos`: `table` diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index 923818a08..79d316969 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -333,15 +333,23 @@ impl VanillaTerrain { u64::try_from(self.created.elapsed().as_millis()).unwrap_or(u64::MAX) } } +static SHARED_DATA: std::sync::Mutex)>> = + std::sync::Mutex::new(Vec::new()); +/// Let go of the bundles `shared_data` loaded: for a preload no world used +/// (game/world_loading.rs). A generator that holds one keeps it. +pub fn forget_shared_data() { + SHARED_DATA + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); +} /// The worldgen bundle at `dir`, loaded once and shared by every generator /// and by live feature placement (sapling growth, bone meal). pub fn shared_data( dir: &Path, registry: &'static BlockRegistry, ) -> crate::Result> { - static CACHE: std::sync::Mutex)>> = - std::sync::Mutex::new(Vec::new()); - let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = SHARED_DATA.lock().unwrap_or_else(|e| e.into_inner()); if let Some((_, data)) = cache.iter().find(|(path, _)| path == dir) { return Ok(data.clone()); } From 031f1e58a3984cb9fb793414f4e94ee670d414fe Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:07:12 +0100 Subject: [PATCH 20/26] remote_fireball: a slow fireball, sampled from one ctl process The harness sampled the fireball from a new horizon-ctl process each time, 10-16 ticks apart on a loaded runner: the fireball (~2 blocks a tick) left the cleared corridor or the guest's tracking range before the sixth sample, and the harness failed. It now flies slowly (acceleration_power 0.02, under 0.4 blocks a tick) down a longer corridor, and the guest takes 16 samples in one ctl script (lan_e2e's Game.script returns every reply with every=True). Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 7 ++++--- tools/test_remote_fireball_parity.py | 14 +++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index ab3fcde1b..dfa7df454 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -339,8 +339,9 @@ class Game: def wait(self, ticks): self.d('wait', ticks=ticks) - def script(self, lines): - """Keep tick-sensitive input in one ctl process, through the normal control commands.""" + def script(self, lines, every=False): + """Keep tick-sensitive input in one ctl process, through the normal control commands. Returns the last + reply, or with every=True all of them in order.""" result = subprocess.run(self.d.base + ['script'], input='\n'.join(lines) + '\n', cwd=self.work, env=self.d.env, capture_output=True, text=True, timeout=600) @@ -348,7 +349,7 @@ class Game: ticks = [r['ticks'] for r in replies if 'ticks' in r] print(f'[{self.name}] script {lines} -> {replies[-1] if replies else result.stderr[:400]}; ticks={ticks}') assert result.returncode == 0 and replies and not any('error' in r for r in replies), result.stdout - return replies[-1] + return replies if every else replies[-1] def until(self, observe, predicate, ticks=100): """Wait for an observable answer, within the existing tick budget.""" diff --git a/tools/test_remote_fireball_parity.py b/tools/test_remote_fireball_parity.py index 7e60ff30a..d227ed3f7 100644 --- a/tools/test_remote_fireball_parity.py +++ b/tools/test_remote_fireball_parity.py @@ -18,15 +18,19 @@ def check_motion(host, guest, out): for game in (host, guest): game('command', command='gamemode creative') tag = 'remote_fireball_parity' - host('command', command=f'fill {x + 2} {y + 18} {z - 1} {x + 48} {y + 23} {z + 1} minecraft:air') + # A slow fireball (acceleration_power 0.02: under 0.4 blocks a tick, against ~2 by default) stays in the cleared + # corridor and in the guest's tracking range for every sample, however many ticks a loaded host takes to answer. + host('command', command=f'fill {x + 2} {y + 18} {z - 1} {x + 96} {y + 23} {z + 1} minecraft:air') host('command', command=f'summon minecraft:fireball {x + 3.5} {y + 20} {z + 0.5} ' - f'{{Tags:["{tag}"],Motion:[0.1d,0.0d,0.0d]}}') + f'{{Tags:["{tag}"],Motion:[0.1d,0.0d,0.0d],acceleration_power:0.02d}}') samples = [] try: - for _ in range(8): - balls = [p for p in guest('status')['projectiles'] if p['kind'] == 'LargeFireball'] + # One ctl process for all samples: a ctl process per sample took 10-16 ticks on a loaded runner, by + # which time the fireball had left the cleared corridor and exploded. + replies = guest.script(['status', 'wait 1'] * 16, every=True) + for status in (reply for reply in replies if 'projectiles' in reply): + balls = [p for p in status['projectiles'] if p['kind'] == 'LargeFireball'] samples.append(balls[0]['position'][0] if balls else None) - guest.wait(2) finally: host('command', command=f'kill @e[tag={tag}]') seen = [p for p in samples if p is not None] From 8f02822a9aa71d5e008d5f18b68345db7a61abc5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:09:44 +0100 Subject: [PATCH 21/26] remote.sh: stop the command on the host with its caller, and what it leaves running The command ran on after remote.sh was stopped or its connection dropped, holding the name's lock, and a game or daemon it left behind outlived the run. It now runs in a scope named after the run (remote-.scope), stopped when the command ends, when the ssh session ends first, and before the next run under that name. Co-Authored-By: Claude Opus 5.5 --- docs/parity-workflow.md | 2 +- tools/remote.sh | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/parity-workflow.md b/docs/parity-workflow.md index 7419eef26..50b67c571 100644 --- a/docs/parity-workflow.md +++ b/docs/parity-workflow.md @@ -46,7 +46,7 @@ Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip ### Any command on playhorizon: `tools/remote.sh` -For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh ` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 4-7 (`--cpus`; 0-3 stay free for benchmarks), with scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Logs stay in `~/remote-work/.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones. +For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh ` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 4-7 (`--cpus`; 0-3 stay free for benchmarks), with scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Whatever the command leaves running (a game, a daemon) is stopped when it ends, and stopping `remote.sh` (or losing the connection) stops the command on the host. Logs stay in `~/remote-work/.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones. ```sh tools/remote.sh .claude/worktrees/my-fix cargo test -p horizon-world --lib # 33 s, warm diff --git a/tools/remote.sh b/tools/remote.sh index 1df21c560..0955666cb 100755 --- a/tools/remote.sh +++ b/tools/remote.sh @@ -15,6 +15,7 @@ # toolchains, a virtual screen and scratch homes for the game and launcher. .local/ links to the host's vanilla # data; a command that runs harnesses (it names parity, lan_e2e or horizon-prepare) also gets .local/horizon-client, # prepared as CI prepares it (tools/ci/vanilla-data.sh). Output streams back and is kept in ~/remote-work/.logs/. +# What the command leaves running is stopped when it ends; stopping this script stops the command on the host. # The exit status is the command's. # # The host is lent by a partner who keeps hypervisor access: never send secrets, tokens or credentials there, @@ -96,9 +97,22 @@ export HORIZON_HOME=$scratch/game HORIZON_LAUNCHER_HOME=$scratch/launcher XDG_DA mkdir -p "$HORIZON_HOME" "$HORIZON_LAUNCHER_HOME" "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" ../"$name.logs" log=../"$name.logs/$(date -u +%Y%m%dT%H%M%SZ)-${sha:0:9}.log" echo "remote.sh: $(hostname) at ${sha:0:9} in ~/remote-work/$name, CPUs $cpus, -j $jobs; log ~/remote-work/${log#../}" >&2 +# The command runs in a scope of its own, stopped with everything in it once the command is done (a game or a +# daemon it left behind), when the caller goes away first (the ssh session ends), and before the next run here. +unit=remote-$name.scope +systemctl --user stop "$unit" 2>/dev/null || true +session=$PPID +( while kill -0 "$session" 2>/dev/null; do sleep 2; done; systemctl --user stop "$unit" 2>/dev/null ) \ + < /dev/null > /dev/null 2>&1 9>&- & +watcher=$! status=0 -systemd-run --user --scope --quiet --slice=horizon-ci.slice --unit="remote-$name-$$" \ +systemd-run --user --scope --quiet --slice=horizon-ci.slice --unit="$unit" \ "${pin[@]}" nice -n 10 bash -c "$command" 2>&1 < /dev/null 9>&- | tee "$log" || status=${PIPESTATUS[0]} +kill "$watcher" 2>/dev/null || true +systemctl --user stop "$unit" 2>/dev/null || true exit "$status" REMOTE -} | ssh -o BatchMode=yes "ludovic@$host" bash -s +} | ssh -o BatchMode=yes "ludovic@$host" bash -s & +# Stopping this script ends the session, and the host then stops the command (above). +trap 'kill $! 2>/dev/null' INT TERM HUP +wait $! From cc58083574d857b72c3abc6578254b364e256937 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:14:56 +0100 Subject: [PATCH 22/26] Give the dropped preload's memory back to the OS glibc keeps the pages the preload thread freed in that thread's arena, and macOS keeps them in its zones until memory runs short, so dropping the preload didn't lower the footprint: horizon-threads now asks the allocator to return free pages (malloc_trim, malloc_zone_pressure_relief). Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/world_loading.rs | 1 + crates/horizon-threads/src/lib.rs | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index b16bcffb6..73f1820f2 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -259,6 +259,7 @@ pub(super) fn preload_world_data( horizon_client::vanillagen::forget_shared_data(); horizon_client::structure_blocks::forget_loot_data(); super::lan::forget_sync_data(); + horizon_threads::release_free_memory(); *preload = Preload::Idle; log::info!("Horizon: preloaded world data dropped, no world opened"); } diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index 90c8d128e..917c3b502 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -126,6 +126,38 @@ fn idle() -> bool { false } +/// Give back to the OS the memory the allocator holds free, after something +/// large was dropped: glibc keeps freed pages in the arena of the thread that +/// allocated them, and macOS in its zones until memory runs short. Nothing +/// elsewhere. +pub fn release_free_memory() { + release(); +} + +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn release() { + // SAFETY: returns free heap pages of every arena to the OS; no live + // allocation moves. + unsafe { + libc::malloc_trim(0); + } +} + +#[cfg(target_vendor = "apple")] +fn release() { + unsafe extern "C" { + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; + } + // SAFETY: a null zone means every zone and a goal of 0 as much as it can; + // it frees only pages no allocation uses. + unsafe { + malloc_zone_pressure_relief(std::ptr::null_mut(), 0); + } +} + +#[cfg(not(any(all(target_os = "linux", target_env = "gnu"), target_vendor = "apple")))] +fn release() {} + #[cfg(test)] mod tests { /// The hint holds on the platforms that take it. From f8ba6505597ad363b18e8bc0076ccb3610b3ba96 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:25:11 +0100 Subject: [PATCH 23/26] ci: full-parity label runs a pull request's parity job on the desktop A crate split reaches every harness (#2160: 352 harnesses, 71 min on a Mac mini runner) and held the Mac mini's runners while other pull requests' parity jobs queued for over an hour. With the full-parity label, the job runs on the desktop's runner only (8 at a time) and waits for it while the desktop is in use; its timeout is 300 min, since a job under way when someone comes back goes on with 2 threads. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/parity.yml | 13 +++++++++++-- .github/workflows/run-parity.yml | 1 + docs/release-flow.md | 7 +++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39ea920da..b2fd9fa48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,7 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} + full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }} # Horizon never downloads the game. These jobs need a self-hosted Linux runner # (label horizon-game) holding the maintainer's own copy: the official launcher's # game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 9f678b43b..b90ad9968 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -13,6 +13,13 @@ on: description: The commit to check out and test type: string required: true + full: + description: >- + The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it + runs on the desktop's runner only, so the Mac mini's runners stay free for the others. While the desktop is + in use the job waits for it. + type: boolean + default: false permissions: contents: read env: @@ -21,8 +28,10 @@ env: jobs: parity: - runs-on: [self-hosted, horizon-game] - timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini + runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "ludodesktop"]') || fromJSON('["self-hosted", "horizon-game"]') }} + # A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini, ~35 min on the desktop, longer if + # someone comes back to the desktop meanwhile (the job then goes on with 2 threads). + timeout-minutes: ${{ inputs.full && 300 || 120 }} steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/run-parity.yml b/.github/workflows/run-parity.yml index a2b8e3291..9089a9a09 100644 --- a/.github/workflows/run-parity.yml +++ b/.github/workflows/run-parity.yml @@ -22,3 +22,4 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} + full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }} diff --git a/docs/release-flow.md b/docs/release-flow.md index b0d435c29..8373f2bf6 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -27,6 +27,12 @@ quick steps and finishes in seconds. The job is `.github/workflows/parity.yml`, a reusable workflow: `checks` calls it for pull requests from this repository, `run-parity.yml` for a fork's pull request a maintainer labels `run-parity`. +A pull request every harness reaches (a crate split moves files every probe links: #2160 ran all 352 harnesses, 71 +minutes on a Mac mini runner, while five other pull requests' jobs queued behind it) carries the `full-parity` label: +its job runs on the desktop's runner only (8 harnesses at a time), and waits for it while the desktop is in use +instead of taking a Mac mini runner. Labels don't start `checks`: add it when opening the pull request +(`gh pr create --label full-parity`), or before the next push. + Horizon never downloads the game, so harnesses that compare with vanilla run only where the maintainer's own copy is: the Mac mini, his Linux desktop and playhorizon (below). Their runners run as the maintainer's account and keep their checkout between runs (`clean: false`), so the probes build incrementally into `.local/parity-target` and earlier passes @@ -212,6 +218,7 @@ How releases are numbered, and how installs from before 0.2 reach it: [versionin | `release-now` | Releases as soon as main's checks pass, without waiting for merges to settle | | `skip-release` | Doesn't trigger a release and isn't listed in the notes | | `run-parity` | Runs the parity job once on a fork's pull request | +| `full-parity` | Runs the pull request's parity job on the desktop's runner only (a change every harness reaches) | | `parity-regression` | Opened by the nightly audit for a harness that fails on main | | `release-blocker` | On an issue: no game release is published while it is open ([One channel](#one-channel)) | From b0783dac38d9fa238ecd2a38aadc0049936506a2 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:54:33 +0100 Subject: [PATCH 24/26] launcher: Sculk's banner at the foot of the sidebar, seen without scrolling On Home it sat under every row, so nobody saw it without scrolling. It now sits small (228 px wide) above the Discord and Website links on every page but Servers, which keeps the large one. Co-Authored-By: Claude Opus 5.5 --- launcher/src/main.rs | 8 ++++++-- launcher/src/partner.rs | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 50e62756d..9438f1af0 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1749,6 +1749,11 @@ impl Launcher { } _ => {} } + // Sculk's banner, small, above the links: seen without scrolling, on every page but Servers, which + // shows it large. + if self.page != Page::Servers { + foot = foot.child(partner::banner("partner-side", theme::SIDEBAR - 20., &t).mb(px(8.))); + } if !self.links.discord.is_empty() { foot = foot.child(link( "discord", @@ -2131,8 +2136,7 @@ impl Launcher { if let Some(crash) = self.crash.clone() { page = page.child(self.crash_notice(crash, cx)); } - // Below everything the player needs to start, so it never moves Play. - page.child(partner::banner("partner-home", 560., &t)) + page } /// "Horizon crashed": the report and the logs, and where to report it. diff --git a/launcher/src/partner.rs b/launcher/src/partner.rs index eea1a3524..5e1cdb695 100644 --- a/launcher/src/partner.rs +++ b/launcher/src/partner.rs @@ -1,5 +1,5 @@ -//! Sculk's banner: a server host that sponsors Horizon (Ludovic, 2026-10-26), on the Servers page -//! and under Home's rows. The picture is in the binary, never fetched; a click opens Sculk's page in +//! Sculk's banner: a server host that sponsors Horizon (Ludovic, 2026-10-26), large on the Servers page +//! and small at the foot of the sidebar on the other pages, where it shows without scrolling. The picture is in the binary, never fetched; a click opens Sculk's page in //! the browser. A "Partner" label above it says it is an ad. use crate::theme::Theme; use gpui::{div, img, prelude::*, px, ClickEvent, Div, Image, ImageFormat, Stateful}; @@ -19,7 +19,7 @@ static BANNER: LazyLock> = LazyLock::new(|| { }); /// The banner `width` logical pixels wide, under its label. Hovering lifts it a little and turns the -/// label to ink. The narrowest window (960) leaves the page 648 pixels, so it always fits. +/// label to ink. The narrowest window (960) leaves the page 648 pixels, so the Servers one always fits. pub fn banner(id: &'static str, width: f32, t: &Theme) -> Stateful
{ let t = *t; let el = div() From 12bf15cfd7658e118b29a1f695f64554b140168f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:58:09 +0100 Subject: [PATCH 25/26] launcher: the sidebar's partner banner can be hidden once it has been seen A "Hide" shows beside its label after the banner has been on screen for 3 minutes, counted over every start (settings: partner_seen_secs, saved each minute). Hiding it is kept (partner_hidden); the Servers page keeps its own banner. Co-Authored-By: Claude Opus 5.5 --- launcher/src/backend.rs | 7 +++++ launcher/src/main.rs | 57 ++++++++++++++++++++++++++++++++++++++--- launcher/src/partner.rs | 4 +++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 85818a67c..55a78193a 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2545,6 +2545,13 @@ pub struct Settings { /// first-run question, or on the Play page once if they answered that before it existed. #[serde(default)] pub ping_notice_seen: bool, + /// Seconds the sidebar's partner banner has been on screen, over every start, up to + /// `partner::SEEN_BEFORE_HIDE`: only then can the player hide it. + #[serde(default)] + pub partner_seen_secs: u64, + /// The player hid the sidebar's partner banner. The Servers page keeps its own. + #[serde(default)] + pub partner_hidden: bool, } impl Settings { diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 9438f1af0..7cc6d0ccd 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -248,6 +248,32 @@ impl Launcher { } }) .detach(); + // Counts the time the sidebar's partner banner is on screen, until it may be hidden. + cx.spawn(async move |this, cx| loop { + cx.background_executor() + .timer(Duration::from_secs(10)) + .await; + let counting = this.update(cx, |this, cx| { + let s = &mut this.settings; + if s.partner_hidden || s.partner_seen_secs >= partner::SEEN_BEFORE_HIDE { + return false; + } + if this.page != Page::Servers { + s.partner_seen_secs += 10; + if s.partner_seen_secs % 60 == 0 + || s.partner_seen_secs >= partner::SEEN_BEFORE_HIDE + { + s.save(); + cx.notify(); + } + } + true + }); + if !matches!(counting, Ok(true)) { + break; + } + }) + .detach(); let mut settings = Settings::load(); if settings.minecraft_dir.is_none() { settings.minecraft_dir = backend::default_minecraft_dir(); @@ -1750,9 +1776,34 @@ impl Launcher { _ => {} } // Sculk's banner, small, above the links: seen without scrolling, on every page but Servers, which - // shows it large. - if self.page != Page::Servers { - foot = foot.child(partner::banner("partner-side", theme::SIDEBAR - 20., &t).mb(px(8.))); + // shows it large. After a while on screen a "Hide" beside its label puts it away for good. + if self.page != Page::Servers && !self.settings.partner_hidden { + let mut side = div().relative().mb(px(8.)).child(partner::banner( + "partner-side", + theme::SIDEBAR - 20., + &t, + )); + if self.settings.partner_seen_secs >= partner::SEEN_BEFORE_HIDE { + let hide = div() + .id("partner-hide") + .absolute() + .top(px(0.)) + .right(px(0.)) + .cursor_pointer() + .child( + text(theme::MICRO) + .text_color(t.muted) + .hover(move |s| s.text_color(t.ink)) + .child("HIDE"), + ) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.settings.partner_hidden = true; + this.settings.save(); + cx.notify(); + })); + side = side.child(keyboard(hide, &t)); + } + foot = foot.child(side); } if !self.links.discord.is_empty() { foot = foot.child(link( diff --git a/launcher/src/partner.rs b/launcher/src/partner.rs index 5e1cdb695..c4feac7b8 100644 --- a/launcher/src/partner.rs +++ b/launcher/src/partner.rs @@ -7,6 +7,10 @@ use std::sync::{Arc, LazyLock}; pub const URL: &str = "https://my.sculkhosting.com/ref/horizon"; +/// How long the sidebar's banner stays on screen, counted over every start, before a "Hide" shows +/// beside its label: long enough to be seen, then the player may put it away for good. +pub const SEEN_BEFORE_HIDE: u64 = 180; + /// 1200×441: twice the widest it is shown at, for Retina screens. Its transparent edges let the /// characters stand out of the card. const WIDTH: f32 = 1200.; From 111db2827b9b797baf1ce83548ffaa5e38c1ffbd Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:46:29 +0100 Subject: [PATCH 26/26] ci: full-parity and PR parity jobs on playhorizon, not the desktop The maintainer asked that CI stop using his desktop (2026-10-26). playhorizon's runner now carries horizon-game and the desktop's runner none of the job labels; full-parity jobs go to playhorizon only, with a 6 h timeout on its 4 CPUs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/parity-main.yml | 4 ++-- .github/workflows/parity.yml | 16 ++++++++-------- docs/release-flow.md | 25 +++++++++++++++---------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.github/workflows/parity-main.yml b/.github/workflows/parity-main.yml index 70fb33796..4840b98be 100644 --- a/.github/workflows/parity-main.yml +++ b/.github/workflows/parity-main.yml @@ -25,8 +25,8 @@ jobs: parity-main: # A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs # this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited - # behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down - # because the maintainer came back could time out, and a failure here bisects and comments on merges. + # behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs + # make a core-crate run several times longer, and a failure here bisects and comments on merges. runs-on: [self-hosted, macOS, horizon-parity-main] timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures steps: diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index b90ad9968..c850c17aa 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -1,6 +1,6 @@ name: parity -# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's -# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository +# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the +# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository # and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md. on: workflow_call: @@ -16,8 +16,8 @@ on: full: description: >- The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it - runs on the desktop's runner only, so the Mac mini's runners stay free for the others. While the desktop is - in use the job waits for it. + runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is + busy the job waits for it. type: boolean default: false permissions: @@ -28,10 +28,10 @@ env: jobs: parity: - runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "ludodesktop"]') || fromJSON('["self-hosted", "horizon-game"]') }} - # A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini, ~35 min on the desktop, longer if - # someone comes back to the desktop meanwhile (the job then goes on with 2 threads). - timeout-minutes: ${{ inputs.full && 300 || 120 }} + runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }} + # A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4 + # harnesses at a time) up to a few hours, and any pull request's job may land there. + timeout-minutes: ${{ inputs.full && 360 || 180 }} steps: - uses: actions/checkout@v4 with: diff --git a/docs/release-flow.md b/docs/release-flow.md index 8373f2bf6..ed7be2418 100644 --- a/docs/release-flow.md +++ b/docs/release-flow.md @@ -13,7 +13,7 @@ reopened; labels never start it): | Job | Runs on | What | Time | | --- | --- | --- | --- | | `verify` | GitHub `ubuntu-latest` | Public-tree check, Python tests, rustfmt, clippy with warnings denied, Rust tests. Dependencies and the workspace build come from a cache saved by main's runs | ~4 min cold, less warm | -| `parity / parity` | A runner with the label `horizon-game`: the Mac mini's two, and the desktop's while nobody uses it (below) | Only for a change to a harness, an oracle, a probe or the runner (or the `run-parity` label): the vanilla harnesses the change can affect (`tools/parity.py --changed `). Other pull requests skip it: [parity-main](#parity-on-main) checks them on main | seconds when nothing is affected; minutes per affected harness | +| `parity / parity` | A runner with the label `horizon-game`: the Mac mini's and playhorizon's (below) | Only for a change to a harness, an oracle, a probe or the runner (or the `run-parity` label): the vanilla harnesses the change can affect (`tools/parity.py --changed `). Other pull requests skip it: [parity-main](#parity-on-main) checks them on main | seconds when nothing is affected; minutes per affected harness | Pushes to main run `verify` only; the nightly audit covers main's harnesses. @@ -29,12 +29,12 @@ repository, `run-parity.yml` for a fork's pull request a maintainer labels `run- A pull request every harness reaches (a crate split moves files every probe links: #2160 ran all 352 harnesses, 71 minutes on a Mac mini runner, while five other pull requests' jobs queued behind it) carries the `full-parity` label: -its job runs on the desktop's runner only (8 harnesses at a time), and waits for it while the desktop is in use -instead of taking a Mac mini runner. Labels don't start `checks`: add it when opening the pull request +its job runs on playhorizon's runner only (4 harnesses at a time), and waits for it while that runner is busy +instead of taking the Mac mini's. Labels don't start `checks`: add it when opening the pull request (`gh pr create --label full-parity`), or before the next push. Horizon never downloads the game, so harnesses that compare with vanilla run only where the maintainer's own -copy is: the Mac mini, his Linux desktop and playhorizon (below). Their runners run as the maintainer's account and keep their checkout +copy is: the Mac mini and playhorizon (below). Their runners run as the maintainer's account and keep their checkout between runs (`clean: false`), so the probes build incrementally into `.local/parity-target` and earlier passes stand. @@ -83,8 +83,9 @@ release's macOS build back. Each keeps its own checkout, `target/` and Rust inst `_work/horizon/shared-local` links to the first's, so they share the vanilla data, the preparations (one at a time, under a lock) and the parity passes and slots. -**The desktop's runner.** `ludodesktop-horizon` (labels `self-hosted`, `Linux`, `X64`, `ludodesktop`, and -`horizon-game` once a full trial has passed there) runs on the maintainer's Linux desktop while nobody uses it: its +**The desktop's runner (retired).** On 2026-10-26 the maintainer asked that CI stop using his desktop: its services are +stopped and `ludodesktop-horizon` has no label a job asks for (only `self-hosted`, `Linux`, `X64`), so nothing waits for +it. What follows describes it as it ran. It ran on the maintainer's Linux desktop while nobody used it: its screen locked, or untouched for 15 minutes (his rule, 2026-10-26). `tools/ci/linux-runner.sh install`, run there as him, registers it and installs two systemd user services: `horizon-runner` (the runner, in `~/.actions-runners/horizon`, nice 19, in `horizon-ci.slice` with CPU and I/O weight 20, half the threads (on all of @@ -105,12 +106,16 @@ and its failures bisect and comment on merges. **playhorizon's runner.** `playhorizon-horizon` runs on a server a hosting partner lends the project (Ubuntu, 8 AMD EPYC threads, 30 GB, in Detroit; reachable only through the maintainer's tailnet). Nobody sits at it, so the -watcher never pauses it except by hand, and it gets the machine: `RUNNER_CPUS=7 RUNNER_MEMORY=24 RUNNER_JOBS=6`. +watcher never pauses it except by hand. It gets half the machine: `RUNNER_MEMORY=24`, `CARGO_BUILD_JOBS` and the +harness slots at 4, and a drop-in (`~/.config/systemd/user/horizon-runner.service.d/cpus.conf`) that starts it under +`taskset -c 4-7`, so CPUs 0-3 stay free for benchmarks pinned there (the user slice can't set `AllowedCPUs`: systemd +doesn't delegate the cpuset controller to it). Without Nix, `linux-runner.sh` takes the build libraries, the JDK, sccache, Xvfb and Mesa's software Vulkan (lavapipe; there is no GPU) from apt, and Python 3.13 from uv. The partner can reach the machine, so gh is not logged in there: the registration token comes from another machine (`RUNNER_TOKEN`), and no job with secrets may -run there (parity has none). It is also a parity host (`playhorizon` in `tools/parity_hosts.json`, 6 jobs). Like -the desktop's, it gets the `horizon-game` label only once a full trial has passed there. +run there (parity has none). It is also a parity host (`playhorizon` in `tools/parity_hosts.json`, 6 jobs). Since +2026-10-26 it has the `horizon-game` label: pull requests' parity jobs run there or on the Mac mini, and `full-parity` +ones only there. ## Merging @@ -218,7 +223,7 @@ How releases are numbered, and how installs from before 0.2 reach it: [versionin | `release-now` | Releases as soon as main's checks pass, without waiting for merges to settle | | `skip-release` | Doesn't trigger a release and isn't listed in the notes | | `run-parity` | Runs the parity job once on a fork's pull request | -| `full-parity` | Runs the pull request's parity job on the desktop's runner only (a change every harness reaches) | +| `full-parity` | Runs the pull request's parity job on playhorizon's runner only (a change every harness reaches) | | `parity-regression` | Opened by the nightly audit for a harness that fails on main | | `release-blocker` | On an issue: no game release is published while it is open ([One channel](#one-channel)) |