From 3e788469756ab71a5208394bbc0f4c888c290fb4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 14:31:28 +0100 Subject: [PATCH 01/24] 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 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 02/24] 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 409a8281283d7776dbb25e8c2f42863cc083971e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:11:29 +0100 Subject: [PATCH 03/24] mods: capability recipes (API level 4), the game's items and recipes for recipe viewers recipes.items() lists every item in registry order, with its name in the player's language and its tags. recipes.all() lists every recipe: crafting and furnace ones from the recipe book's own entries (vanilla's displays and display ids), then campfire, stonecutting, smithing and brewing (display only). Stacks point into the item table by index. recipes.known() gives the book ids the player's recipe book holds, from the client book in every mode, so on a server it follows the server's book. The game builds the tables once, the first time a running mod reads them. Experimental, new functions only. Smithing::Transform now keeps its recipe id. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/mod_recipes.rs | 252 ++++++++++++++++++ crates/horizon-client/src/game/mods.rs | 11 + crates/horizon-client/src/game/recipe_book.rs | 19 ++ crates/horizon-client/src/stations.rs | 3 + crates/horizon-mod-sdk/src/lib.rs | 2 +- crates/horizon-mod-sdk/wit/horizon.wit | 76 ++++++ crates/horizon-mods/src/api_level.rs | 7 +- crates/horizon-mods/src/manifest.rs | 11 +- crates/horizon-mods/src/runtime.rs | 54 ++++ crates/horizon-mods/tests/sandbox.rs | 90 ++++++- crates/horizon-mods/tests/ui_guest/src/lib.rs | 40 ++- docs/api/mods-reference.md | 72 ++++- docs/api/stability.md | 2 +- docs/mods.md | 2 +- 15 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 crates/horizon-client/src/game/mod_recipes.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9bb6602fd..826ea73c1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -100,6 +100,8 @@ mod minecarts; mod mob_blocks; mod mob_gear; mod mod_input; +#[cfg(feature = "mods")] +mod mod_recipes; mod mod_render; mod mods; mod mount_menu; diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs new file mode 100644 index 000000000..0d57e1f94 --- /dev/null +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -0,0 +1,252 @@ +//! Capability `recipes` (docs/mods.md): every item and recipe the game +//! knows, made once for the mods that read them, and the recipe book's +//! known displays. The recipes are the window's own data: the world's in +//! single player, vanilla's on a server (a client is sent no others), while +//! the known displays follow the server's book as the recipe book does. +use super::Game; +use horizon_client::{ + crafting::Ingredient, + items::ItemStack, + recipe_book::{Category, Display}, + stations::Smithing, +}; +use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; +use std::{collections::HashMap, sync::Arc}; + +/// Item id -> its index in [`Recipes::items`]. +struct Index(HashMap, u32>); +impl Index { + fn stack(&self, item: &str, count: u32, components: String) -> Option { + Some(RecipeStack { + item: *self.0.get(item)?, + count, + components, + }) + } + fn of(&self, s: &ItemStack) -> Option { + self.stack(&s.item, s.count, components(s)) + } + fn any(&self, i: &Ingredient) -> Vec { + i.1.iter() + .filter_map(|id| self.stack(id, 1, String::new())) + .collect() + } +} + +/// A stack's components beyond its item's, as vanilla's item syntax writes +/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "". +fn components(s: &ItemStack) -> String { + use horizon_world::nbt::Tag; + let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else { + return String::new(); + }; + let parts: Vec = entries + .iter() + .map(|(k, v)| { + format!( + "{}={}", + k.to_string_lossy(), + horizon_client::snbt::to_snbt(v) + ) + }) + .collect(); + format!("[{}]", parts.join(",")) +} + +fn recipe(id: String, kind: RecipeKind) -> Recipe { + Recipe { + id, + kind, + category: String::new(), + group: String::new(), + width: 0, + height: 0, + slots: Vec::new(), + results: Vec::new(), + cooking_time: 0, + experience: 0.0, + book_id: -1, + } +} + +impl Game<'_> { + /// Capability `recipes`'s tables and known displays for this tick's + /// snapshot; nothing while no running mod reads them. + pub(super) fn mod_recipes(&mut self) -> (Option>, Arc<[i32]>) { + if !self.mods.mods.wants_recipes() { + return (None, Arc::from([])); + } + if self.mods.recipes.is_none() { + self.mods.recipes = Some(Arc::new(self.make_mod_recipes())); + } + let (version, known) = self.recipe_book_displays(); + if self.mods.known_recipes.0 != Some(version) { + self.mods.known_recipes = (Some(version), known.into()); + } + (self.mods.recipes.clone(), self.mods.known_recipes.1.clone()) + } + + fn make_mod_recipes(&mut self) -> Recipes { + let names = &self.pack.names; + let items = self.res.items(); + let mut tags: HashMap<&str, Vec> = HashMap::new(); + for (tag, members) in &items.item_tags { + for m in members { + tags.entry(m).or_default().push(tag.clone()); + } + } + let table: Vec = items + .registry_order + .iter() + .map(|id| RecipeItem { + id: id.to_string(), + name: names.display_item(id), + tags: tags.remove(&**id).map_or_else(Vec::new, |mut t| { + t.sort(); + t + }), + }) + .collect(); + let index = Index( + items + .registry_order + .iter() + .enumerate() + .map(|(i, id)| (id.clone(), i as u32)) + .collect(), + ); + let mut out = Vec::new(); + let cooking: HashMap = self + .res + .recipes() + .cooking + .iter() + .map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience))) + .collect(); + for e in self.recipe_book_entries() { + let furnace = |c: Category| match c.name().split('_').next() { + Some("blast") => RecipeKind::Blasting, + Some("smoker") => RecipeKind::Smoking, + _ => RecipeKind::Smelting, + }; + let kind = match &e.display { + Display::Shaped { .. } => RecipeKind::CraftingShaped, + Display::Shapeless(_) => RecipeKind::CraftingShapeless, + Display::Furnace(_) => furnace(e.category), + }; + let mut r = recipe(e.id.clone(), kind); + r.category = e.category.name().to_owned(); + r.group = e.group.clone().unwrap_or_default(); + r.book_id = e.display_id; + r.slots = match &e.display { + Display::Shaped { + width, + height, + cells, + } => { + (r.width, r.height) = (*width as u32, *height as u32); + cells + .iter() + .map(|c| c.as_ref().map_or_else(Vec::new, |i| index.any(i))) + .collect() + } + Display::Shapeless(list) => list.iter().map(|i| index.any(i)).collect(), + Display::Furnace(i) => vec![index.any(i)], + }; + // Requirements shown as other stacks (an arrow's potions): the + // requirement's index counts the filled slots only. + let filled: Vec = (0..r.slots.len()) + .filter( + |&k| !matches!(&e.display, Display::Shaped { cells, .. } if cells[k].is_none()), + ) + .collect(); + for (req, stacks) in &e.stacks { + if let Some(&k) = filled.get(*req) { + r.slots[k] = stacks.iter().filter_map(|s| index.of(s)).collect(); + } + } + r.results = e.results().iter().filter_map(|s| index.of(s)).collect(); + if let Some(&(time, xp)) = cooking.get(&e.id) { + (r.cooking_time, r.experience) = (time, xp); + } + out.push(r); + } + for c in &self.res.recipes().cooking { + if c.kind == "minecraft:campfire_cooking" { + let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire); + r.group = c.group.clone(); + r.slots = vec![index.any(&c.ingredient)]; + r.results = index.of(&c.result).into_iter().collect(); + (r.cooking_time, r.experience) = (c.time, c.experience); + out.push(r); + } + } + let stations = self.res.stations(); + for s in &stations.recipes.stonecutting { + let mut r = recipe(s.id.clone(), RecipeKind::Stonecutting); + r.slots = vec![index.any(&s.input)]; + r.results = index.of(&s.result).into_iter().collect(); + out.push(r); + } + for s in &stations.recipes.smithing { + let (id, template, base, addition, results) = match s { + Smithing::Transform { + id, + template, + base, + addition, + result, + } => ( + id, + template, + base, + addition, + index.stack(result, 1, String::new()).into_iter().collect(), + ), + Smithing::Trim { + id, + template, + base, + addition, + .. + } => (id, template, base, addition, index.any(base)), + }; + let mut r = recipe(id.clone(), RecipeKind::Smithing); + r.slots = vec![index.any(template), index.any(base), index.any(addition)]; + r.results = results; + out.push(r); + } + for b in &stations.brewing.recipes { + let potion = |p: &horizon_client::brewing::PotionIngredient| -> Vec { + let mut item_ids: Vec<&String> = p.items.iter().collect(); + item_ids.sort(); + let mut potions: Vec<&String> = p.potions.iter().flatten().collect(); + potions.sort(); + item_ids + .into_iter() + .flat_map(|item| { + let one = |c: String| index.stack(item, 1, c); + if potions.is_empty() { + one(String::new()).into_iter().collect::>() + } else { + potions + .iter() + .filter_map(|p| { + one(format!("[minecraft:potion_contents={{potion:\"{p}\"}}]")) + }) + .collect() + } + }) + .collect() + }; + let mut r = recipe(b.id.clone(), RecipeKind::Brewing); + r.slots = vec![potion(&b.input), potion(&b.reagent)]; + r.results = index.of(&b.output).into_iter().collect(); + out.push(r); + } + Recipes { + items: table, + recipes: out, + } + } +} diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 12deb61fc..156467e7a 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -70,6 +70,12 @@ pub(super) struct ModsState { /// The HUD font's advances for `render.text-width`, made once. #[cfg(feature = "mods")] font_widths: std::sync::Arc<[f32]>, + /// Capability `recipes`'s tables, made once a mod reads them. + #[cfg(feature = "mods")] + pub(super) recipes: Option>, + /// The recipe book's known displays, and the book's version they are of. + #[cfg(feature = "mods")] + pub(super) known_recipes: (Option, std::sync::Arc<[i32]>), /// Extras that are official mods now, being installed. #[cfg(feature = "mods")] pub(super) moved: super::moved_extras::MovedState, @@ -282,6 +288,8 @@ impl Game<'_> { self.mods.mods.set_bindings(|id| chosen.keys_of(id)); // Entries were replaced wholesale: a screen's owner index is stale. self.mods.screen = None; + // Content mods may have changed the items: remade on the next read. + self.mods.recipes = None; self.list_mods(); self.sync_mod_commands(); self.show_mod_output(); @@ -409,6 +417,7 @@ impl Game<'_> { .map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1)) .collect(); } + let (recipes, known_recipes) = self.mod_recipes(); Snapshot { player, clock, @@ -420,6 +429,8 @@ impl Game<'_> { font_widths: self.mods.font_widths.clone(), players: Vec::new(), view_hit: self.mod_view_hit(), + recipes, + known_recipes, } } /// The first block along the player's view within diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 96e12d79b..045abeb6f 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -338,6 +338,25 @@ impl Game<'_> { ui.client_known = Some(known.clone()); ui.client_version += 1; } + /// Every entry of the book, in its order (capability `recipes`). + #[cfg(feature = "mods")] + pub(super) fn recipe_book_entries(&mut self) -> Vec { + self.ensure_book(); + self.recipe_ui + .book + .as_ref() + .map_or_else(Vec::new, |b| b.entries.clone()) + } + /// The display ids the player's book holds, and a number that changes + /// whenever they do (capability `recipes`). + #[cfg(feature = "mods")] + pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec) { + self.sync_client_book(); + ( + self.recipe_ui.client_version, + self.recipe_ui.client.displays(), + ) + } fn ensure_book(&mut self) { if self.recipe_ui.book.is_none() { let mut book = Book::build(self.res.recipes(), self.res.items()); diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index 715c2663e..bd157843a 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -445,6 +445,7 @@ pub fn grindstone_experience( #[derive(Clone, Debug)] pub enum Smithing { Transform { + id: String, template: Ingredient, base: Ingredient, addition: Ingredient, @@ -524,6 +525,7 @@ impl StationRecipes { ); match v["type"].as_str().unwrap_or("") { "minecraft:smithing_transform" => smithing.push(Smithing::Transform { + id, template: ingredient(&v["template"], items), base: ingredient(&v["base"], items), addition: ingredient(&v["addition"], items), @@ -627,6 +629,7 @@ impl StationRecipes { base: b, addition: a, result, + .. } => { if t.test(template) && b.test(base) && a.test(addition) { // SmithingTransformRecipe: transmute the base keeping its components. diff --git a/crates/horizon-mod-sdk/src/lib.rs b/crates/horizon-mod-sdk/src/lib.rs index 679fdc7b8..b00d06ecf 100644 --- a/crates/horizon-mod-sdk/src/lib.rs +++ b/crates/horizon-mod-sdk/src/lib.rs @@ -39,7 +39,7 @@ wit_bindgen::generate!({ pub use exports::horizon::mods::events::{Guest as __Guest, Registration}; pub use exports::horizon::mods::ui_events::Guest as __UiEventsGuest; -pub use horizon::mods::{chat, command, game, hud, input, log, render, storage, ui}; +pub use horizon::mods::{chat, command, game, hud, input, log, recipes, render, storage, ui}; /// The game's calls into a mod. Only the hooks [`Registration`] asks for /// are called; the others may stay as their defaults. diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index d9bb9f5c2..7d7adaa0d 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -439,6 +439,76 @@ interface events { chat: func(text: string); } +/// Capability `recipes` (experimental): the recipes and items the game +/// knows, for recipe viewers. In single player and on Horizon's servers +/// they are the world's; on any other server they are vanilla 26.3's (a +/// client is sent no others), while `known` follows that server's book. +interface recipes { + /// An item of `items`, by its index there, with a count and the + /// components it has beyond the item's own, in vanilla's item syntax + /// (`[potion_contents={potion:"minecraft:swiftness"}]`), or "". + record stack { + item: u32, + count: u32, + components: string, + } + /// The station a recipe is made at. + enum kind { + crafting-shaped, + crafting-shapeless, + smelting, + blasting, + smoking, + campfire, + stonecutting, + smithing, + brewing, + } + record recipe { + /// `minecraft:oak_planks`. + id: string, + kind: kind, + /// The recipe book's tab (`crafting_building_blocks`, + /// `furnace_food`), or "". + category: string, + /// Recipes the book shows on one button share a group, or "". + group: string, + /// A shaped recipe's grid: `slots` holds its cells row by row. + /// 0 for the other kinds. + width: u32, + height: u32, + /// Each slot's stacks, any one of which fits; an empty list is an + /// empty cell. Cooking and stonecutting: the input. Smithing: the + /// template, the base, the addition. Brewing: the bottle, the + /// reagent. + slots: list>, + /// What it makes; several when that varies (a trim on any armour). + results: list, + /// Cooking: game ticks and experience; 0 for the other kinds. + cooking-time: u32, + experience: f32, + /// The recipe book's id for it (one per way the book shows it), as + /// `known` lists them; -1 for the kinds the book doesn't show + /// (campfire, stonecutting, smithing, brewing). + book-id: s32, + } + record item { + /// `minecraft:diamond`, `:`. + id: string, + /// In the player's language. + name: string, + /// Its item tags, without the `#`. + tags: list, + } + /// Every item, in the registry's order: `stack.item` indexes this list. + items: func() -> list; + /// Every recipe: the book's in its order, then campfire, stonecutting, + /// smithing and brewing. They don't change while a world is open. + all: func() -> list; + /// The `book-id`s of the recipes the player's recipe book holds now. + known: func() -> list; +} + /// Capability `players`, server mods only (manifest `api = "0.3"`, /// experimental): the players on the server, and messages to them. interface players { @@ -545,6 +615,7 @@ world horizon-ui-mod { import ui; import render; import input; + import recipes; export ui-events; } @@ -559,3 +630,8 @@ world render-host { import render; import input; } + +/// The game's side of capability `recipes` (imports only). +world recipes-host { + import recipes; +} diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index bf87d0cfa..ff06a94ae 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -7,13 +7,13 @@ //! version's level so the launcher can tell before it installs. /// The newest level this Horizon serves. -pub const LEVEL: u32 = 3; +pub const LEVEL: u32 = 4; /// Every function the game offers mods, as `interface.function`, with its /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 34] = [ +pub const FUNCTIONS: [(&str, u32); 37] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -48,6 +48,9 @@ pub const FUNCTIONS: [(&str, u32); 34] = [ ("players.broadcast", 1), ("game.raycast", 2), ("command.run-quiet", 3), + ("recipes.items", 4), + ("recipes.all", 4), + ("recipes.known", 4), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/manifest.rs b/crates/horizon-mods/src/manifest.rs index 5ad7823aa..05cf96447 100644 --- a/crates/horizon-mods/src/manifest.rs +++ b/crates/horizon-mods/src/manifest.rs @@ -97,9 +97,12 @@ pub enum Capability { Input, /// The players on the server and messages to them (server mods). Players, + /// The recipes and items the game knows, for recipe viewers + /// (interface `recipes`). + Recipes, } impl Capability { - pub const ALL: [Capability; 10] = [ + pub const ALL: [Capability; 11] = [ Capability::GameRead, Capability::Chat, Capability::Hud, @@ -110,6 +113,7 @@ impl Capability { Capability::Render, Capability::Input, Capability::Players, + Capability::Recipes, ]; /// Whether a server mod may have it: the others act on a local player. pub fn on_server(self) -> bool { @@ -128,6 +132,7 @@ impl Capability { Capability::Render => "render", Capability::Input => "input", Capability::Players => "players", + Capability::Recipes => "recipes", } } /// May change in any release: a mod uses it only with @@ -135,7 +140,7 @@ impl Capability { pub fn is_experimental(self) -> bool { matches!( self, - Capability::Ui | Capability::Render | Capability::Input + Capability::Ui | Capability::Render | Capability::Input | Capability::Recipes ) } /// What the player reads before turning the mod on: "May ". @@ -153,6 +158,7 @@ impl Capability { "see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys" } Capability::Players => "see who is on the server and message them", + Capability::Recipes => "read the game's recipes and items, and which recipes you know", } } /// The capability a component import needs, `Ok(None)` for one every @@ -188,6 +194,7 @@ impl fmt::Display for Capability { Capability::Render => "render", Capability::Input => "input", Capability::Players => "players", + Capability::Recipes => "recipes", }; f.write_str(name) } diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index ce48f2317..ebce03944 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -78,6 +78,18 @@ pub use render_bindings::horizon::mods::render::{ Zoom as ModZoom, }; use render_bindings::{RenderHost, horizon::mods::render}; +/// Capability `recipes`'s imports (docs/mods.md): the game's recipes and items. +mod recipes_bindings { + wasmtime::component::bindgen!({ + path: "../horizon-mod-sdk/wit", + world: "recipes-host", + }); +} +/// What capability `recipes` reads (WIT `interface recipes`). +pub use recipes_bindings::horizon::mods::recipes::{ + Item as RecipeItem, Kind as RecipeKind, Recipe, Stack as RecipeStack, +}; +use recipes_bindings::{RecipesHost, horizon::mods::recipes}; /// A mod's linear memory cap. const MEMORY: usize = 64 << 20; @@ -133,6 +145,19 @@ pub struct Snapshot { /// its distance from the eye, for `game.raycast`; the game works it out /// only while a mod calls it ([`Mods::wants_raycast`]). pub view_hit: Option<(f32, Hit)>, + /// Every item and recipe, for capability `recipes`; the game makes them + /// once, and only while a mod reads them ([`Mods::wants_recipes`]). + pub recipes: Option>, + /// The `book-id`s of the recipes the player's book holds. + pub known_recipes: Arc<[i32]>, +} + +/// Capability `recipes`'s tables: `items` in the registry's order, which +/// each [`RecipeStack::item`] indexes. +#[derive(Clone, Debug, Default)] +pub struct Recipes { + pub items: Vec, + pub recipes: Vec, } /// Which mods a game starts: the window's game the client mods, the game @@ -241,6 +266,8 @@ struct Running { hooks: Hooks, /// It imports `game.raycast`. raycasts: bool, + /// It imports capability `recipes`. + reads_recipes: bool, } /// A running mod's exports and the hooks its `init` asked for. @@ -392,6 +419,8 @@ impl Mods { .map_err(|e| e.to_string())?; RenderHost::add_to_linker::>(&mut linker, |s| s) .map_err(|e| e.to_string())?; + RecipesHost::add_to_linker::>(&mut linker, |s| s) + .map_err(|e| e.to_string())?; self.engine = Some((engine, linker)); } Ok(self.engine.as_ref().expect("made above")) @@ -509,6 +538,7 @@ impl Mods { ui, hooks, raycasts: functions.iter().any(|f| f == "game.raycast"), + reads_recipes: functions.iter().any(|f| f.starts_with("recipes.")), })) } @@ -769,6 +799,13 @@ impl Mods { .iter() .any(|e| e.run.as_ref().is_some_and(|r| r.raycasts)) } + /// Some running mod reads capability `recipes`: the snapshot needs + /// [`Snapshot::recipes`] and [`Snapshot::known_recipes`]. + pub fn wants_recipes(&self) -> bool { + self.entries + .iter() + .any(|e| e.run.as_ref().is_some_and(|r| r.reads_recipes)) + } /// Some running mod has a `frame` hook: the game refreshes the snapshot /// every frame for it. pub fn wants_frames(&self) -> bool { @@ -1125,6 +1162,23 @@ impl game::Host for State { (*at <= distance.min(RAYCAST_MAX) && !distance.is_nan()).then(|| hit.clone()) } } +impl recipes::Host for State { + fn items(&mut self) -> Vec { + self.snapshot + .recipes + .as_ref() + .map_or_else(Vec::new, |r| r.items.clone()) + } + fn all(&mut self) -> Vec { + self.snapshot + .recipes + .as_ref() + .map_or_else(Vec::new, |r| r.recipes.clone()) + } + fn known(&mut self) -> Vec { + self.snapshot.known_recipes.to_vec() + } +} impl State { /// Queues a chat line unless the call already sent [`CHAT_PER_CALL`]. fn say(&mut self, line: impl FnOnce(String) -> Output) -> bool { diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index c6b835f10..06a8e6aa2 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -26,8 +26,8 @@ id = "test_ui_guest" name = "Test UI Guest" version = "0.1.0" api = "0.1" -capabilities = ["game-read", "chat", "command", "ui", "render", "input"] -experimental = ["ui", "render", "input"] +capabilities = ["game-read", "chat", "command", "ui", "render", "input", "recipes"] +experimental = ["ui", "render", "input", "recipes"] "#; /// Builds the mods once and writes their `.hmod` files; returns the folder. @@ -238,8 +238,8 @@ id = "too_new" name = "Too New" version = "0.1.0" api = "0.1" -capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input"] -experimental = ["ui", "render", "input"] +capabilities = ["game-read", "chat", "hud", "storage", "command", "ui", "render", "input", "recipes"] +experimental = ["ui", "render", "input", "recipes"] "#; std::fs::write(dir.join("horizon-mod.toml"), manifest).unwrap(); std::fs::write(dir.join("mod.wasm"), wasm).unwrap(); @@ -1016,3 +1016,85 @@ fn a_server_mod_runs_only_where_the_world_does() { "a server command isn't the window's" ); } + +/// Capability `recipes`: the guest reads tables as big as vanilla's (1,500 +/// items, 1,600 recipes of nine five-item slots) within one command's fuel. +#[test] +fn recipes_reads_the_tables() { + use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + let stack = |item| RecipeStack { + item, + count: 1, + components: String::new(), + }; + let items = (0..1500) + .map(|i| RecipeItem { + id: format!("minecraft:item_{i}"), + name: format!("Item {i}"), + tags: vec!["planks".into(), "logs".into()], + }) + .collect(); + let mut recipes: Vec = (0..1600) + .map(|i| Recipe { + id: format!("minecraft:recipe_{i}"), + kind: RecipeKind::CraftingShaped, + category: "crafting_misc".into(), + group: String::new(), + width: 3, + height: 3, + slots: (0..9) + .map(|k| (0..5).map(|j| stack((i + k + j) % 1500)).collect()) + .collect(), + results: vec![RecipeStack { + item: 7, + count: 4, + components: String::new(), + }], + cooking_time: 0, + experience: 0.0, + book_id: i as i32, + }) + .collect(); + recipes[1].kind = RecipeKind::Brewing; + recipes[1].results[0].components = + "[minecraft:potion_contents={potion:\"minecraft:swiftness\"}]".into(); + mods.set_snapshot(Snapshot { + recipes: Some(std::sync::Arc::new(Recipes { items, recipes })), + known_recipes: vec![1, 5, 9].into(), + ..Snapshot::default() + }); + let said = |mods: &mut Mods, command: &str| { + assert!(mods.command(command)); + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::Chat { text, .. } => Some(text), + _ => None, + }) + .collect::>() + }; + assert_eq!( + said(&mut mods, "recipes"), + ["1500 items, 1600 recipes, 3 known, 1 brewing"] + ); + assert_eq!( + said(&mut mods, "recipes minecraft:recipe_1"), + [ + "minecraft:recipe_1 Kind::Brewing 3x3 0t slots 9, the first 1 minecraft:item_1 -> \ + 4 minecraft:item_7[minecraft:potion_contents={potion:\"minecraft:swiftness\"}] known true" + ] + ); + assert!( + mods.entries() + .iter() + .all(|e| e.status == horizon_mods::Status::Running), + "it ran out of fuel" + ); + // Without the game's tables (no mod read them yet): empty, not a trap. + mods.set_snapshot(Snapshot::default()); + assert_eq!( + said(&mut mods, "recipes"), + ["0 items, 0 recipes, 0 known, 0 brewing"] + ); +} diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 74fb64350..2fd835d3c 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -6,10 +6,12 @@ //! `input`; `/zoom FACTOR SMOOTH TURN` tests `render.set-zoom`; //! `/corner` and `/readings` the calls the official extras mods use; //! `/raycast DISTANCE` says what `game.raycast` sees; `/quiet A;B` runs -//! lines with `command.run-quiet` (`/quiet_many N`: N of them). +//! lines with `command.run-quiet` (`/quiet_many N`: N of them). `/recipes` +//! counts what `recipes` reads; `/recipes ID` describes that recipe. use horizon_mod_sdk::{ Mod, Registration, chat, command, game, input::{self, Event}, + recipes, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget}, }; @@ -102,6 +104,7 @@ impl Mod for Guest { "raycast", "quiet", "quiet_many", + "recipes", "corner", "readings", ] @@ -241,6 +244,41 @@ impl Mod for Guest { None => "no hit".into(), }); } + "recipes" => { + let items = recipes::items(); + let all = recipes::all(); + let known = recipes::known(); + let name = |s: &recipes::Stack| { + let id = items.get(s.item as usize).map_or("?", |i| i.id.as_str()); + format!("{} {id}{}", s.count, s.components) + }; + chat::show(&match all.iter().find(|r| r.id == args) { + Some(r) => format!( + "{} {:?} {}x{} {}t slots {}, the first {} -> {} known {}", + r.id, + r.kind, + r.width, + r.height, + r.cooking_time, + r.slots.len(), + r.slots + .first() + .and_then(|s| s.first()) + .map_or("-".into(), name), + r.results.first().map_or("-".into(), name), + known.contains(&r.book_id) + ), + None => format!( + "{} items, {} recipes, {} known, {} brewing", + items.len(), + all.len(), + known.len(), + all.iter() + .filter(|r| r.kind == recipes::Kind::Brewing) + .count() + ), + }); + } // Clamped to (1, 10); the two after it shake nothing. "render_shake" => { render::shake(2.0, 99.0); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index c44f27576..1ee54e70f 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -17,6 +17,7 @@ The contract between Horizon and its mods: the WIT package `horizon:mods@0.1.0`, | `render` | [`render`](#render) | draw on your screen, shake and zoom the camera | | `input` | [`input`](#input) | see the keys you hold and your clicks, take over the use button on its own items and the mouse wheel, and add keys | | `players` | [`players`](#players) | see who is on the server and message them | +| `recipes` | [`recipes`](#recipes) | read the game's recipes and items, and which recipes you know | ## World `horizon-mod` @@ -36,7 +37,7 @@ A server mod (manifest `side = "server"`, `api = "0.3"`, experimental): runs whe A mod with widgets (capability `ui`): the SDK builds every mod against this world. Mods built for `horizon-mod` alone (no `ui-events` export) still load and simply hear nothing of their widgets. -- Imports (what the game offers): `ui`, `render`, `input`, `log`, `game`, `chat`, `hud`, `storage`, `command` +- Imports (what the game offers): `ui`, `render`, `input`, `recipes`, `log`, `game`, `chat`, `hud`, `storage`, `command` - Exports (what the mod provides): `ui-events`, `events` ## World `ui-host` @@ -53,6 +54,13 @@ The game's side of capability `render` (imports only). - Imports (what the game offers): `render`, `input` - Exports (what the mod provides): +## World `recipes-host` + +The game's side of capability `recipes` (imports only). + +- Imports (what the game offers): `recipes` +- Exports (what the mod provides): + ## `log` Access: always granted. @@ -461,6 +469,68 @@ What the game calls. Only the hooks `init` asks for are ever called. | `command` | `func(name: string, args: string) -> result, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. | | `chat` | `func(text: string)` | Each chat line the player receives, as plain text. | +## `recipes` + +Access: capability `recipes`. + +Capability `recipes` (experimental): the recipes and items the game knows, for recipe viewers. In single player and on Horizon's servers they are the world's; on any other server they are vanilla 26.3's (a client is sent no others), while `known` follows that server's book. + +### record `stack` + +An item of `items`, by its index there, with a count and the components it has beyond the item's own, in vanilla's item syntax (`[potion_contents={potion:"minecraft:swiftness"}]`), or "". + +| Field | Type | | +| --- | --- | --- | +| `item` | `u32` | | +| `count` | `u32` | | +| `components` | `string` | | + +### enum `kind` + +The station a recipe is made at. + +| Case | | +| --- | --- | +| `crafting-shaped` | | +| `crafting-shapeless` | | +| `smelting` | | +| `blasting` | | +| `smoking` | | +| `campfire` | | +| `stonecutting` | | +| `smithing` | | +| `brewing` | | + +### record `recipe` + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | `minecraft:oak_planks`. | +| `kind` | `kind` | | +| `category` | `string` | The recipe book's tab (`crafting_building_blocks`, `furnace_food`), or "". | +| `group` | `string` | Recipes the book shows on one button share a group, or "". | +| `width` | `u32` | A shaped recipe's grid: `slots` holds its cells row by row. 0 for the other kinds. | +| `height` | `u32` | | +| `slots` | `list>` | Each slot's stacks, any one of which fits; an empty list is an empty cell. Cooking and stonecutting: the input. Smithing: the template, the base, the addition. Brewing: the bottle, the reagent. | +| `results` | `list` | What it makes; several when that varies (a trim on any armour). | +| `cooking-time` | `u32` | Cooking: game ticks and experience; 0 for the other kinds. | +| `experience` | `f32` | | +| `book-id` | `s32` | The recipe book's id for it (one per way the book shows it), as `known` lists them; -1 for the kinds the book doesn't show (campfire, stonecutting, smithing, brewing). | + +### record `item` + +| Field | Type | | +| --- | --- | --- | +| `id` | `string` | `minecraft:diamond`, `:`. | +| `name` | `string` | In the player's language. | +| `tags` | `list` | Its item tags, without the `#`. | + +| Function | Signature | What it does | +| --- | --- | --- | +| `items` | `func() -> list` | Every item, in the registry's order: `stack.item` indexes this list. | +| `all` | `func() -> list` | Every recipe: the book's in its order, then campfire, stonecutting, smithing and brewing. They don't change while a world is open. | +| `known` | `func() -> list` | The `book-id`s of the recipes the player's recipe book holds now. | + ## `players` Access: capability `players`. diff --git a/docs/api/stability.md b/docs/api/stability.md index df2c1aeb3..00b5541a9 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -38,7 +38,7 @@ don't import it); faster, or fixing behaviour to what the reference already says A new WIT function doesn't break older mods, but a mod that calls it can't run on a Horizon from before it. So every function the game offers mods has an **API level**, the one it arrived at (`crates/horizon-mods/src/api_level.rs`): -level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`. A mod needs the level of +level 0 is the contract of Horizon 0.2.67 to 0.2.69, level 1 adds `render.set-minimap` and server mods' `players`, level 2 `game.raycast`, level 3 `command.run-quiet`, level 4 `recipes` (`items`, `all`, `known`). A mod needs the level of the newest function it imports (a component imports only the functions it calls). The level is read from the file, never declared: diff --git a/docs/mods.md b/docs/mods.md index 28415d28f..73ecfb9a5 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -91,6 +91,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); `set-minimap(minimap)`: a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | +| `recipes` | `recipes` | `items()`: every item in the registry's order, with its name in the player's language and its tags; `all()`: every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; `known()`: the book ids the player's recipe book holds. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while `known` follows that server's book. The game makes the tables once, when a mod first reads them. **Experimental:** `experimental = ["recipes"]` | | Hook (export) | When | |---|---| @@ -116,7 +117,6 @@ loading. Ordered by what the first native ports need: | `keys` | keybinds | registered binds appear in Controls; press/release events | | `target` | the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips | | `inventory-read` | the player's inventory, held item, open container | AppleSkin, recipe viewers | -| `recipes` | the recipe registry and lookups | JEI/EMI-style viewers | | `world-read` | blocks and biomes around the player, within the render distance | minimaps, schematics | | `render` (world) | world-space lines, boxes and billboards, beside the HUD shapes `render` has | Litematica-style placement, waypoints | | `registries` | new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the `.hmod`'s `assets/` and `data/` | server side; see [Multiplayer](#multiplayer) | From 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 04/24] 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 05/24] 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 06/24] 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 07/24] 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 08/24] 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 09/24] 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 10/24] 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 11/24] 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 12/24] 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 13/24] 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 14/24] 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 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 15/24] 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 67c27701e97559473e133ffe5bcaee4836c0cdb2 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:16:43 +0100 Subject: [PATCH 16/24] Server: new players join around the spawn, not all on one spot The server put every player new to the world exactly on the world spawn. On the server thread the host's own player joins that way too, so the host and each new guest stood inside each other: the guest's client targets the host's player (its eye inside that box, as vanilla's getEntityHitResult does), so it could open no crafting table, chest or furnace, and Entity.push never parts two players with the same centre. A dedicated server stacked its new players the same way. Vanilla places each new player with ServerPlayer.adjustSpawnLocation: PlayerSpawnFinder.findSpawn, a free column at a random offset within the respawn radius. Game::server_player_spawn does it for a server's new players with the window's own finder, passing over the candidates whose chunk isn't loaded yet (vanilla waits for them; a server tick can't). lan_e2e's new check `spawned` asserts that the host and the guest join on different spots, then has the guest open a crafting table, a chest, a furnace and a stonecutter where it joined. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/initial_spawn.rs | 75 +++++++++++++------ crates/horizon-client/src/game/lan.rs | 13 +++- tools/lan_e2e.py | 40 +++++++++- 3 files changed, 103 insertions(+), 25 deletions(-) diff --git a/crates/horizon-client/src/game/initial_spawn.rs b/crates/horizon-client/src/game/initial_spawn.rs index 3f7a071bb..7ddb9cb90 100644 --- a/crates/horizon-client/src/game/initial_spawn.rs +++ b/crates/horizon-client/src/game/initial_spawn.rs @@ -126,28 +126,7 @@ impl Game<'_> { pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result> { let mut finder = match self.player_spawn { Some(finder) => finder, - None if self.level.game_mode == 2 => { - // Adventure mode: the suggestion itself. - let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0); - finder.next_candidate(); - finder - } - None => { - let mut radius = self.ui.rules.int("respawn_radius").max(0); - let to_border = self - .border - .distance(f64::from(suggestion[0]), f64::from(suggestion[2])) - .floor() as i32; - if to_border < radius { - radius = to_border; - } - if to_border <= 1 { - radius = 1; - } - PlayerSpawnFinder::new(suggestion, radius, |n| { - entity_random().next_int_bound(n).unwrap_or(0) - }) - } + None => self.spawn_finder(suggestion), }; let found = loop { let Some(candidate) = finder.next_candidate() else { @@ -176,6 +155,58 @@ impl Game<'_> { self.player_spawn = found.is_none().then_some(finder); Ok(found) } + /// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut + /// by the world border; the suggestion itself in adventure mode. + fn spawn_finder(&self, suggestion: [i32; 3]) -> PlayerSpawnFinder { + if self.level.game_mode == 2 { + let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0); + finder.next_candidate(); + return finder; + } + let mut radius = self.ui.rules.int("respawn_radius").max(0); + let to_border = self + .border + .distance(f64::from(suggestion[0]), f64::from(suggestion[2])) + .floor() as i32; + if to_border < radius { + radius = to_border; + } + if to_border <= 1 { + radius = 1; + } + PlayerSpawnFinder::new(suggestion, radius, |n| { + entity_random().next_int_bound(n).unwrap_or(0) + }) + } + /// ServerPlayer.adjustSpawnLocation for a server's new player: where + /// PlayerSpawnFinder.findSpawn puts their feet around the world spawn + /// (`spawn`, the feet there), so that players new to a world don't all + /// stand in one spot. Vanilla waits for each candidate's chunk; a server + /// tick can't, so a candidate in a chunk not loaded yet is passed over, + /// and `spawn` stays when the search never reaches a loaded column. + pub(super) fn server_player_spawn(&self, spawn: [f64; 3]) -> [f64; 3] { + let suggestion = spawn.map(|v| v.floor() as i32); + let mut finder = self.spawn_finder(suggestion); + while let Some(candidate) = finder.next_candidate() { + let (x, z) = match candidate { + SpawnCandidate::Column(x, z) => (x, z), + SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]), + }; + if !self.streaming.is_lit(x >> 4, z >> 4) { + continue; + } + let feet = match candidate { + SpawnCandidate::Column(x, z) => level_respawn_pos(&self.spawn_columns(), x, z) + .filter(|&pos| self.player_fits(pos)) + .map(bottom_center), + SpawnCandidate::Suggestion => Some(self.fixup_spawn_height(suggestion)), + }; + if let Some(feet) = feet { + return [feet.x, feet.y, feet.z]; + } + } + spawn + } /// PlayerSpawnFinder.noCollisionNoLiquid: a player's box standing at the /// block's bottom centre touches no block or liquid. fn player_fits(&self, pos: [i32; 3]) -> bool { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..ce65c52bc 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1128,8 +1128,17 @@ impl Game<'_> { g.game_mode = default_mode; } if !known { - g.position = spawn; - g.previous_position = spawn; + // ServerPlayer.adjustSpawnLocation: a player new to the world + // stands where PlayerSpawnFinder puts them, not all on one spot + // (it reads the loaded level, which holds the spawn when it is + // the overworld). + let feet = if self.dim == super::Dim::Overworld { + self.server_player_spawn(spawn) + } else { + spawn + }; + g.position = feet; + g.previous_position = feet; g.health = 20.0; g.food = 20; g.dimension = super::Dim::Overworld.id().to_owned(); diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index ab3fcde1b..012e8f021 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -14,6 +14,8 @@ address. The script then checks, from both sides, what a guest's game used to ge closes on both when the guest closes it; the host keeps playing with its mouse look (#687); - crafting: the guest crafts planks in the inventory's 2x2 grid and opens a crafting table, which leaves the host's screen and mouse look alone; +- spawned: the host and a guest new to the world join on different spots (PlayerSpawnFinder), and the + guest opens a crafting table, a chest, a furnace and a stonecutter where it joined; - sign: a sign the guest places doesn't open a sign editor on the host; - damage: a guest in survival takes fall damage; - mobs: with the host in creative, a zombie and a skeleton go for the guest and hurt them; @@ -292,7 +294,7 @@ ROOT = Path(__file__).resolve().parents[1] GUEST_NAME = 'Guest' # Match normal single player; --in-frame is the comparison path. SERVER_THREAD = True -CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', @@ -554,6 +556,39 @@ def check_crafting(host, guest, out): return {'planks': planks, 'table': counts} +def check_spawned(host, guest, out): + """Players new to a world don't share one spot: ServerPlayer.adjustSpawnLocation spreads them over the + respawn radius (PlayerSpawnFinder). Two players standing in each other target each other, so a guest + left in the host could open nothing it clicked. At its own spot, the guest opens a crafting table, a + chest, a furnace and a stonecutter.""" + joined = {'host': [round(v, 2) for v in host.joined_at], 'guest': [round(v, 2) for v in guest.joined_at]} + apart = abs(joined['host'][0] - joined['guest'][0]) + abs(joined['host'][2] - joined['guest'][2]) >= 0.6 + guest('command', command='gamemode survival') + for game in (host, guest): + x, y, z = game.joined_at + game('command', command=f'tp @s {x} {y} {z}') + guest.wait(20) + x, y, z = (int(v // 1) for v in pos_of(guest)) + at = (x, y, z + 2) + grab(host) + opened = {} + for block in ('crafting_table', 'chest', 'furnace', 'stonecutter'): + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:{block}') + guest.wait(10) + guest.raw('interact', *at) + guest.wait(10) + opened[block] = guest('state').get('screen') + guest.shot(out, f'spawned-{block}') + back_to_game(guest) + guest('command', command=f'setblock {at[0]} {at[1]} {at[2]} minecraft:air') + host_unaffected(host, "the guest's stations") + face_each_other(host, guest) + assert apart, f'the host and the guest joined on one spot: {joined}' + want = {'crafting_table': 'inventory', 'chest': 'container', 'furnace': 'container', 'stonecutter': 'station'} + assert opened == want, f'where it joined, the guest opened {opened}' + return {'joined': joined, 'opened': opened} + + def check_sign(host, guest, out): x, y, z = (int(v // 1) for v in pos_of(guest)) spot = (x + 2, y, z) @@ -4669,6 +4704,7 @@ def check_worn(host, guest, out): CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'nametags': check_nametags, 'chest': check_chest, 'crafting': check_crafting, + 'spawned': check_spawned, 'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs, 'hazards': check_hazards, 'pvp': check_pvp, 'respawn': check_respawn, 'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles, @@ -4727,6 +4763,8 @@ def run(ctl, out, resources, checks): guest.raw('connect', f'127.0.0.1:{port}', GUEST_NAME) guest.raw('wait-for', 'loaded', '--timeout', 300) guest.wait(40) + # Where each joined, before anything moves them (check_spawned). + host.joined_at, guest.joined_at = pos_of(host), pos_of(guest) face_each_other(host, guest) host.shot(out, 'joined') guest.shot(out, 'joined') 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 17/24] 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 331e3a7202a9f8c667ce46e3d53cba966a55b964 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:49:24 +0100 Subject: [PATCH 18/24] dedicated_server: steps wait for the server's reply, not a 1.5 s timer The parity client's chat, villager and furnace steps went on 1.5 s after sending, so a busy server's late reply landed in the next step (or a click carried a stale state id). Each step now waits until the server answered: its chat line, both slot packets of a shift-click, and the furnace totals the click changes (up to the same 30 s patience). The respawn window keeps only the player's own entity packets: a squid's swim event or a mob coming into view falls into it as each server's ticks fall. Off the known-flaky list: it passes under 2.5 s server stalls every 5 s, where main's client fails. Co-Authored-By: Claude Opus 5.5 --- .../parity/VanillaProtocolClient.java | 64 +++++++++++++++++-- tools/parity_hosts.json | 1 - 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java index e37e898a3..fbb8ed2b0 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolClient.java @@ -487,6 +487,17 @@ public final class VanillaProtocolClient { long deadCommandAt = 0; // When the client last sent where it is. long positionAt = 0; + // Chat lines and furnace slot changes received, and how many there were when the current step sent + // its command or click: a step goes on once the server answered, not after a fixed time (a busy + // server answers seconds late, and a click sent before the last one's reply carries a stale state id). + int replies = 0; + int repliesBefore = 0; + // A shift-click changes two slots, each its own packet, which a busy server can send ticks apart: a furnace + // step waits for both. A full resend of the menu counts as both. + int furnaceReplies = 0; + int furnaceBefore = 0; + // The furnace's lit and cooking totals received: the server sends them a tick after what changed them. + int furnaceData = 0; while (System.currentTimeMillis() < deadline) { Received r; try { @@ -501,7 +512,7 @@ public final class VanillaProtocolClient { if (entities != null && !entities.acceptSelf(packet) && step >= ENTITIES) entities.accept(packet); if (r.type.equals("minecraft:chunk_batch_start")) ordered = false; if (respawning && !respawned) { - String line = respawnLine(r); + String line = respawnLine(r, playerId); // PlayerList.respawn ends with initInventoryMenu: what follows is the // next ticks' (health, entity data, chunks), in each server's own order. respawned = r.type.equals("minecraft:container_set_content") && respawnAt != null; @@ -558,15 +569,18 @@ public final class VanillaProtocolClient { } else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket p && furnaceWindow > 0 && p.containerId() == furnaceWindow) { furnaceState = p.stateId(); + furnaceReplies += 2; record("play-furnace", "content " + p.items() + " carried " + p.carriedItem()); } else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket p && furnaceWindow > 0 && p.getContainerId() == furnaceWindow) { furnaceState = p.getStateId(); + furnaceReplies++; record("play-furnace", "slot " + p.getSlot() + " " + p.getItem()); if (p.getSlot() == 2 && !p.getItem().isEmpty() && smeltedAt == 0) smeltedAt = now; } else if (packet instanceof net.minecraft.network.protocol.game.ClientboundContainerSetDataPacket p && furnaceWindow > 0 && p.getContainerId() == furnaceWindow && (p.getId() == 1 || p.getId() == 3)) { // Lit total and cooking total; the time left and the cooking timer follow the ticks. + furnaceData++; record("play-furnace", "data " + p.getId() + " " + p.getValue()); } else if (packet instanceof net.minecraft.network.protocol.game.ClientboundEntityEventPacket p && step == 5 + REPLIES.length && (int) (byte) field(p, "eventId") == 28) { @@ -583,13 +597,16 @@ public final class VanillaProtocolClient { record("play-death", "command reply " + json(p.content())); respawning = respawn(); } else if (packet instanceof ClientboundSystemChatPacket p && step > 0 && step < 5 + REPLIES.length) { + replies++; record("play-chat", "system " + p.overlay() + " " + json(p.content())); } else if (packet instanceof ClientboundPlayerChatPacket p && step > 0 && step < 5 + REPLIES.length) { + replies++; record("play-chat", "player " + p.sender() + " " + p.index() + " " + p.body().content() + " " + p.signature().isPresent() + " " + p.unsignedContent().map(VanillaProtocolClient::json) + " " + text(p.filterMask()) + " " + p.chatType().chatType().getRegisteredName() + " " + json(p.chatType().name()) + " " + p.chatType().targetName().map(VanillaProtocolClient::json)); } else if (packet instanceof ClientboundDisguisedChatPacket p && step > 0 && step < 5 + REPLIES.length) { + replies++; record("play-chat", "disguised " + json(p.message()) + " " + p.chatType().chatType().getRegisteredName()); } else if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p && p.getType() == net.minecraft.world.entity.EntityTypes.SNOWBALL) { @@ -679,20 +696,24 @@ public final class VanillaProtocolClient { record("play-light-layers", inner.size() + " " + inner); step = 1; stepAt = now; + repliesBefore = replies; send(new ServerboundChatPacket("hello from the parity client", Instant.now(), 0L, Optional.empty(), new LastSeenMessages.Update(0, new BitSet(), (byte) 0))); - } else if (step == 1 && now - stepAt > 1500) { + } else if (step == 1 && answered(replies, repliesBefore, stepAt, now)) { step = 2; stepAt = now; + repliesBefore = replies; send(new ServerboundChatCommandPacket("list")); - } else if (step >= 2 && step < 2 + REPLIES.length && now - stepAt > 1500) { + } else if (step >= 2 && step < 2 + REPLIES.length && answered(replies, repliesBefore, stepAt, now)) { // Commands a player without operator rights may run, whose // replies (a failure, a syntax error with its context, a // failure with an argument) are compared component by component. send(new ServerboundChatCommandPacket(REPLIES[step - 2])); step++; stepAt = now; - } else if (step == 2 + REPLIES.length && now - stepAt > 1500 && (villager >= 0 || now - stepAt > PATIENCE)) { + repliesBefore = replies; + } else if (step == 2 + REPLIES.length && answered(replies, repliesBefore, stepAt, now) + && (villager >= 0 || now - stepAt > PATIENCE)) { // Right-click the villager (once the server sent it): the trading screen and its offers. record("play-trade", "villager " + (villager >= 0)); if (villager >= 0) { @@ -739,9 +760,13 @@ public final class VanillaProtocolClient { send(new net.minecraft.network.protocol.game.ServerboundUseItemOnPacket(net.minecraft.world.InteractionHand.MAIN_HAND, new net.minecraft.world.phys.BlockHitResult(net.minecraft.world.phys.Vec3.atCenterOf(furnace), net.minecraft.core.Direction.NORTH, furnace, false), 1)); + furnaceBefore = furnaceReplies; step++; stepAt = now; - } else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0 && now - stepAt > 1500 + } else if (step >= 8 + REPLIES.length && step <= 10 + REPLIES.length && furnaceWindow > 0 + && answered(furnaceReplies, furnaceBefore + 1, stepAt, now) + // Both totals with the window, then the cooking total the ore set, then the lit total the coal set. + && (furnaceData >= step - 6 - REPLIES.length || now - stepAt > PATIENCE) // The ingot out once the furnace made it (200 ticks), and its tick's other changes came. && (step < 10 + REPLIES.length || smeltedAt > 0 && now - smeltedAt > 1000 || now - stepAt > 10_000 + PATIENCE)) { // Shift-click the ore (hotbar 0) into the input, the coal (hotbar 1) into the @@ -752,9 +777,10 @@ public final class VanillaProtocolClient { send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(furnaceWindow, furnaceState, (short) slot, (byte) 0, net.minecraft.world.inventory.ContainerInput.QUICK_MOVE, new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY)); + furnaceBefore = furnaceReplies; step++; stepAt = now; - } else if (step == 11 + REPLIES.length && now - stepAt > 1500) { + } else if (step == 11 + REPLIES.length && answered(furnaceReplies, furnaceBefore + 1, stepAt, now)) { send(new net.minecraft.network.protocol.game.ServerboundContainerClosePacket(furnaceWindow)); record("play-furnace", "closed"); step++; @@ -816,6 +842,11 @@ public final class VanillaProtocolClient { record("error", "timed out in play, step " + step + ", chunks " + chunks.size()); } + /** The server answered the step's command or click (at least 1.5 s after it, as before), or had all the time. */ + private static boolean answered(int count, int before, long sent, long now) { + return now - sent > 1500 && (count > before || now - sent > PATIENCE); + } + /** * A right click's snowballs are counted: once the first came and a second could have come with it * (the server spawns each in the tick it handles the click), or once the server had all the time. @@ -857,8 +888,12 @@ public final class VanillaProtocolClient { * packets (time, keep-alive, chunk data and light, whose batching follows each server's * timing) left out; null to skip it. */ - private String respawnLine(Received r) { + private String respawnLine(Received r, int playerId) { String t = r.type; + // Other entities (a squid's swim event, a mob coming into view) land in the window as each server's + // ticks fall: only the player's own are PlayerList.respawn's. + Integer entity = entityOf(r.packet); + if (entity != null && entity != playerId) return null; // Entities moving, blocks the world's ticks change (a lava fall flows again once the game // runs) and the dead player's health land in the window between the death and the respawn // as each server's ticks fall: left out. @@ -891,6 +926,21 @@ public final class VanillaProtocolClient { return t; } + /** The entity a packet is about, for the packets that name one; null for the rest. */ + private static Integer entityOf(Packet packet) { + if (packet instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket p) return p.getId(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundUpdateAttributesPacket p) return p.getEntityId(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundEntityEventPacket p) return (int) field(p, "entityId"); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundSetEquipmentPacket p) return p.getEntity(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundAnimatePacket p) return p.getId(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundHurtAnimationPacket p) return p.id(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundDamageEventPacket p) return p.entityId(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundUpdateMobEffectPacket p) return p.getEntityId(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundRemoveMobEffectPacket p) return p.entityId(); + if (packet instanceof net.minecraft.network.protocol.game.ClientboundSoundEntityPacket p) return p.getId(); + return null; + } + /** * A chunk's blocks, biomes, heightmaps and block entities (its * ClientboundLevelChunkPacketData bytes). The light is left out: both servers relight around chunks diff --git a/tools/parity_hosts.json b/tools/parity_hosts.json index 42b9a46fd..29027f87f 100644 --- a/tools/parity_hosts.json +++ b/tools/parity_hosts.json @@ -44,7 +44,6 @@ "lan_integration": "the whole lan_e2e suite: ~40 min a run on a loaded runner, and single checks flake under load; run it by name, or tools/lan_e2e.py" }, "known_flaky": { - "dedicated_server": "its Java client moves to the next step on a 1.5 s timer, not on the server's reply: under load a respawn's add_entity/update_attributes or entity_event 19 lands in the wrong step (failed, then passed at the same head, 10-26)", "remote_fireball": "samples the fireball on the wall clock, so a slow host sees it in too few samples (desktop runner, 3 times on 10-26)", "world_reopen": "counted 4 minecarts instead of 3 once on a loaded Mac mini runner, then passed at the same head (10-26)" } 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 19/24] 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 20/24] 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 21/24] 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 22/24] 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 23/24] 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 24/24] 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.;