diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7da71e2cf4..103d85567e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -91,3 +91,27 @@ licensed under the MIT license: > NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, > DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## FusionExamples + +The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting +texture templates (`templates/template .png`) of +[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is +licensed under the MIT license: + +> Copyright (c) 2024 Martijn van den Brand +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software +> and associated documentation files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or +> substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/horizon-client-core/src/lang.rs b/crates/horizon-client-core/src/lang.rs index 7fa3a96d1a..16be4e537e 100644 --- a/crates/horizon-client-core/src/lang.rs +++ b/crates/horizon-client-core/src/lang.rs @@ -3,6 +3,7 @@ //! from `pack.mcmeta` `language` sections, and a process-wide current //! language used by every screen. use std::{ + cell::RefCell, collections::{BTreeMap, HashMap}, sync::{ Arc, RwLock, @@ -34,6 +35,15 @@ pub struct Language { pub const DEFAULT: &str = "en_us"; static CURRENT: RwLock>> = RwLock::new(None); +/// A language that only the calling thread sees, and the keys it was asked for and did not have +/// (`scoped`). +struct Scope { + language: Arc, + missed: Vec, +} +thread_local! { + static SCOPE: RefCell> = const { RefCell::new(None) }; +} /// The current language's `bidirectional`, read for every drawn string. static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false); @@ -254,8 +264,50 @@ pub fn set(language: Language) { *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language)); } pub fn current() -> Option> { + if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) { + return Some(language); + } CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone() } +/// Runs `f` with `language` as the current language of this thread only, and returns what it +/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order +/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another +/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer +/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide +/// language's. +pub fn scoped(language: Language, f: impl FnOnce() -> R) -> (R, Vec) { + /// Puts the outer scope back, also when `f` panics. + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + SCOPE.with(|s| *s.borrow_mut() = self.0.take()); + } + } + let outer = SCOPE.with(|s| { + s.borrow_mut().replace(Scope { + language: Arc::new(language), + missed: Vec::new(), + }) + }); + let _restore = Restore(outer); + let result = f(); + let missed = SCOPE.with(|s| { + s.borrow_mut() + .as_mut() + .map(|s| std::mem::take(&mut s.missed)) + }); + (result, missed.unwrap_or_default()) +} +/// `key` was asked for in a `scoped` language that has no text for it. +fn missed(key: &str) { + SCOPE.with(|s| { + if let Some(scope) = s.borrow_mut().as_mut() + && !scope.missed.iter().any(|m| m == key) + { + scope.missed.push(key.to_owned()); + } + }); +} /// `ClientLanguage.defaultRightToLeft` of the current language. pub fn bidirectional() -> bool { BIDIRECTIONAL.load(Ordering::Relaxed) @@ -267,13 +319,19 @@ pub fn code() -> String { pub fn get(key: &str) -> String { current() .and_then(|l| l.get(key).map(str::to_owned)) - .unwrap_or_else(|| key.to_owned()) + .unwrap_or_else(|| { + missed(key); + key.to_owned() + }) } /// The translation, or `fallback` when the key is missing. pub fn get_or(key: &str, fallback: &str) -> String { current() .and_then(|l| l.get(key).map(str::to_owned)) - .unwrap_or_else(|| fallback.to_owned()) + .unwrap_or_else(|| { + missed(key); + fallback.to_owned() + }) } pub fn has(key: &str) -> bool { current().is_some_and(|l| l.get(key).is_some()) @@ -350,6 +408,52 @@ mod tests { ); } + #[test] + fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() { + let empty = Mem(HashMap::new()); + let outside = code(); + let ((german, english_only, fallback, elsewhere), missed) = + scoped(Language::load(&[&empty], "de_de"), || { + ( + get("horizon.osk.close"), + get("horizon.no_such_key"), + get_or("horizon.no_fallback", "English"), + // Another thread keeps the process-wide language. + std::thread::spawn(code).join().unwrap(), + ) + }); + assert_eq!(german, "Schließen"); + assert_eq!(english_only, "horizon.no_such_key"); + assert_eq!(fallback, "English"); + assert_eq!(elsewhere, outside); + assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]); + // The scope ends with its closure. + assert_eq!(code(), outside); + } + + #[test] + fn scopes_nest_and_end_when_their_closure_panics() { + let empty = Mem(HashMap::new()); + let outside = code(); + let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || { + let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || { + (code(), get("horizon.inner_miss")) + }); + assert_eq!(inner_missed, ["horizon.inner_miss"]); + assert_eq!(code(), "fr_fr"); + inner.0 + }); + assert_eq!(inner, "pl_pl"); + assert!(missed.is_empty()); + let panicked = std::panic::catch_unwind(|| { + scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || { + panic!("a test failed inside a scope") + }) + }); + assert!(panicked.is_err()); + assert_eq!(code(), outside); + } + /// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live. fn bundled_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/horizon-client-input/src/interaction_input.rs b/crates/horizon-client-input/src/interaction_input.rs index 06d1c8c3b7..9457726434 100644 --- a/crates/horizon-client-input/src/interaction_input.rs +++ b/crates/horizon-client-input/src/interaction_input.rs @@ -51,6 +51,10 @@ impl InteractionInput { pub fn press(&mut self, button: Button) { self.clicks[button as usize] += 1; } + /// Whether a click waits for the next tick. + pub fn has_clicks(&self) -> bool { + self.clicks.iter().any(|&c| c > 0) + } pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) { let [attack, use_item, pick] = std::mem::take(&mut self.clicks); diff --git a/crates/horizon-client-render/src/block_entity_cem.rs b/crates/horizon-client-render/src/block_entity_cem.rs new file mode 100644 index 0000000000..7048c97bea --- /dev/null +++ b/crates/horizon-client-render/src/block_entity_cem.rs @@ -0,0 +1,630 @@ +//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s +//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the +//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file +//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry +//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations +//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the +//! atlas has it. +//! +//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and +//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest. +//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are +//! block models here, so their files are not read; copper golem statues have no CEM name. + +use crate::block_entity_render::ModelPart; +use horizon_optifine::cem::{ + files::{atlas_name, jem_files, load_jem}, + model::{Bone, EntityModel}, +}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + path::Path, +}; + +/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's +/// names for its vanilla parts. +struct Target { + key: &'static str, + /// The vanilla model it starts from: `key` itself, or the one a block the renderer tells + /// apart shares (a trapped chest's is the chest's). + base: &'static str, + names: &'static [&'static str], + parts: &'static [(&'static str, &'static str)], +} + +const CHEST: &[(&str, &str)] = &[ + ("root", "root"), + ("base", "bottom"), + ("lid", "lid"), + ("knob", "lock"), +]; +const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")]; +const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")]; +const FLAG: &[(&str, &str)] = &[("slate", "flag")]; +const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")]; +const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")]; +const PIGLIN: &[(&str, &str)] = &[ + ("root", "root"), + ("head", "head"), + ("left_ear", "left_ear"), + ("right_ear", "right_ear"), +]; +const BELL: &[(&str, &str)] = &[ + ("root", "root"), + ("body", "bell_body"), + ("base", "bell_base"), +]; +const BOOK: &[(&str, &str)] = &[ + ("root", "root"), + ("cover_right", "right_lid"), + ("cover_left", "left_lid"), + ("pages_right", "right_pages"), + ("pages_left", "left_pages"), + ("flipping_page_right", "flip_page1"), + ("flipping_page_left", "flip_page2"), + ("book_spine", "seam"), +]; +const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")]; +const POT_SIDES: &[(&str, &str)] = &[ + ("front", "front"), + ("back", "back"), + ("left", "left"), + ("right", "right"), +]; + +const fn model( + key: &'static str, + names: &'static [&'static str], + parts: &'static [(&'static str, &'static str)], +) -> Target { + Target { + key, + base: key, + names, + parts, + } +} +const fn copy( + key: &'static str, + base: &'static str, + names: &'static [&'static str], + parts: &'static [(&'static str, &'static str)], +) -> Target { + Target { + key, + base, + names, + parts, + } +} + +/// In order: a copy comes after its base. +const TARGETS: &[Target] = &[ + model("chest", &["chest"], CHEST), + model("double_chest_left", &["chest_left"], CHEST), + model("double_chest_right", &["chest_right"], CHEST), + copy("trapped_chest", "chest", &["trapped_chest"], CHEST), + copy( + "trapped_chest_left", + "double_chest_left", + &["trapped_chest_left"], + CHEST, + ), + copy( + "trapped_chest_right", + "double_chest_right", + &["trapped_chest_right"], + CHEST, + ), + copy("ender_chest", "chest", &["ender_chest"], CHEST), + model("shulker_box", &["shulker_box"], SHULKER), + model("standing_banner", &["banner_base", "banner"], BANNER), + model("wall_banner", &["banner_base", "banner"], BANNER), + model("standing_banner_flag", &["banner_flag", "banner"], FLAG), + model("wall_banner_flag", &["banner_flag", "banner"], FLAG), + model("skeleton_skull", &["head_skeleton"], HEAD), + model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD), + model("zombie_head", &["head_zombie"], HEAD), + model("creeper_head", &["head_creeper"], HEAD), + model("dragon_skull", &["head_dragon"], DRAGON), + model("piglin_head", &["head_piglin"], PIGLIN), + model("player_head", &["head_player"], HEAD), + model("bell", &["bell"], BELL), + model("book", &["enchanting_book", "book"], BOOK), + copy("lectern_book", "book", &["lectern_book", "book"], BOOK), + model("conduit_shell", &["conduit"], &[("base", "shell")]), + model("conduit_cage", &["conduit"], &[("cage", "shell")]), + model("conduit_wind", &["conduit"], &[("wind", "wind")]), + model("conduit_eye", &["conduit"], &[("eye", "eye")]), + model("decorated_pot_base", &["decorated_pot"], POT_BASE), + model("decorated_pot_sides", &["decorated_pot"], POT_SIDES), +]; + +/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model. +pub type Reshaped = ( + HashMap<&'static str, ModelPart>, + HashMap<&'static str, String>, +); + +/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has +/// a `.jem` there. +pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option { + let (reshaped, unused) = reshape_all(root, vanilla)?; + for (path, part) in unused { + log::warn!("Horizon: custom entity model {path}: no part {part}"); + } + Some(reshaped) +} + +/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has. +fn reshape_all( + root: &Path, + vanilla: &HashMap<&'static str, ModelPart>, +) -> Option<(Reshaped, Vec<(String, String)>)> { + let files = jem_files(root); + let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n)); + if !TARGETS.iter().any(named) { + return None; + } + let mut models = vanilla.clone(); + let mut textures = HashMap::new(); + let mut reshaped = HashSet::new(); + // Each file parsed once; which of its entries found a part in some model. + let mut parsed: HashMap<&str, Option> = HashMap::new(); + let mut used: BTreeMap<&str, Vec> = BTreeMap::new(); + for t in TARGETS { + let Some(model) = vanilla.get(t.base) else { + continue; + }; + let found = t + .names + .iter() + .flat_map(|name| files.get(*name).into_iter().flatten()) + .map(String::as_str) + .find(|&path| { + parsed + .entry(path) + .or_insert_with(|| load_jem(root, path)) + .is_some() + }); + let Some(path) = found else { + // A copy of a reshaped base that has no file of its own stays vanilla. + if t.key != t.base && reshaped.contains(t.base) { + models.insert(t.key, model.clone()); + } + continue; + }; + let Some(Some(jem)) = parsed.get(path) else { + continue; + }; + let mut out = model.clone(); + let hits = apply(&mut out, jem, t.parts); + // A file shared by several models (the conduit's, the pot's) paints only the models + // it reshapes: vanilla geometry keeps its own texture's layout. + let reshapes = hits.iter().any(|hit| *hit); + let marks = used + .entry(path) + .or_insert_with(|| vec![false; jem.parts.len()]); + for (mark, hit) in marks.iter_mut().zip(hits) { + *mark |= hit; + } + models.insert(t.key, out); + reshaped.insert(t.key); + if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) { + textures.insert(t.key, texture); + } + } + let mut unused = Vec::new(); + for (path, marks) in &used { + let Some(Some(jem)) = parsed.get(path) else { + continue; + }; + for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) { + unused.push(((*path).to_owned(), p.part.clone())); + } + } + Some(((models, textures), unused)) +} + +/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla +/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child +/// `cem#`. Which entries found their part. +fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec { + let mut count = 0; + let mut hits = Vec::with_capacity(jem.parts.len()); + for p in &jem.parts { + let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v); + let target = match mapped { + Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla, + _ => p.part.as_str(), + }; + let Some(node) = find_mut(model, target) else { + hits.push(false); + continue; + }; + if !p.attach { + node.polygons.clear(); + } + let (name, mut bone) = part(&p.bone, &mut count); + bone.pose[6..9].fill(p.scale); + node.children.push((name, bone)); + hits.push(true); + } + hits +} + +/// The model itself for `root`, else its first part named `name`, depth first. +fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> { + fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> { + part.children.iter_mut().find_map(|(n, c)| { + if n.as_str() == name { + Some(c) + } else { + child(c, name) + } + }) + } + if name == "root" { + return Some(model); + } + child(model, name) +} + +/// A baked bone as a model part, named `cem#`: unique in the model, so no vanilla pose +/// change by name reaches it. +fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) { + let name = format!("cem#{count}"); + *count += 1; + let [x, y, z] = bone.pivot; + let [rx, ry, rz] = bone.rotation; + let out = ModelPart { + pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0], + polygons: bone.quads.iter().map(|q| q.vertices).collect(), + children: bone.children.iter().map(|c| part(c, count)).collect(), + }; + (name, out) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::{collections::BTreeSet, fs}; + + /// A part with one face and `children`. + pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart { + ModelPart { + pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + polygons: vec![[[0.0; 5]; 4]], + children: children + .into_iter() + .map(|(n, c)| (n.to_owned(), c)) + .collect(), + } + } + pub(crate) fn chest() -> ModelPart { + let mut root = plane(vec![ + ("bottom", plane(vec![])), + ("lid", plane(vec![])), + ("lock", plane(vec![])), + ]); + root.polygons.clear(); + root + } + pub(crate) const BOX: &str = + r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#; + /// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`. + pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf { + let root = + std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for (path, text) in files { + let path = root.join("assets/minecraft").join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + root + } + /// A `.jem` replacing each of `parts` with one box. + fn jem(parts: &[&str]) -> String { + let entries: Vec<_> = parts + .iter() + .map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#)) + .collect(); + format!(r#"{{"models": [{}]}}"#, entries.join(", ")) + } + /// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels + /// deep, so a part that took another entry's box shows. + fn jem_sized(parts: &[&str]) -> String { + let entries: Vec<_> = parts + .iter() + .enumerate() + .map(|(i, p)| { + let depth = i + 1; + format!( + r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"# + ) + }) + .collect(); + format!(r#"{{"models": [{}]}}"#, entries.join(", ")) + } + /// How deep (z) the custom box under `model`'s part `name` is. + fn depth(model: &ModelPart, name: &str) -> f32 { + let part = model.child(name).unwrap(); + let zs = part + .children + .iter() + .flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2])); + let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z))); + hi - lo + } + /// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one + /// child of a box's six faces. + fn replaced(model: &ModelPart, name: &str) -> bool { + let part = model.child(name).unwrap(); + match part.children.as_slice() { + [(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6, + _ => false, + } + } + + #[test] + fn every_optifine_part_name_lands_on_its_vanilla_part() { + // File, OptiFine's part, the model it reshapes, vanilla's part there. + let cases = [ + ("chest", "base", "chest", "bottom"), + ("chest", "lid", "chest", "lid"), + ("chest", "knob", "chest", "lock"), + ("chest_left", "base", "double_chest_left", "bottom"), + ("chest_left", "lid", "double_chest_left", "lid"), + ("chest_left", "knob", "double_chest_left", "lock"), + ("chest_right", "base", "double_chest_right", "bottom"), + ("chest_right", "lid", "double_chest_right", "lid"), + ("chest_right", "knob", "double_chest_right", "lock"), + ("trapped_chest", "base", "trapped_chest", "bottom"), + ("trapped_chest", "knob", "trapped_chest", "lock"), + ("trapped_chest_left", "base", "trapped_chest_left", "bottom"), + ("trapped_chest_left", "knob", "trapped_chest_left", "lock"), + ( + "trapped_chest_right", + "base", + "trapped_chest_right", + "bottom", + ), + ("trapped_chest_right", "knob", "trapped_chest_right", "lock"), + ("ender_chest", "base", "ender_chest", "bottom"), + ("ender_chest", "knob", "ender_chest", "lock"), + ("shulker_box", "base", "shulker_box", "base"), + ("shulker_box", "lid", "shulker_box", "lid"), + ("banner", "stand", "standing_banner", "pole"), + ("banner", "top", "standing_banner", "bar"), + ("banner", "top", "wall_banner", "bar"), + ("banner", "slate", "standing_banner_flag", "flag"), + ("banner", "slate", "wall_banner_flag", "flag"), + ("head_skeleton", "head", "skeleton_skull", "head"), + ( + "head_wither_skeleton", + "head", + "wither_skeleton_skull", + "head", + ), + ("head_zombie", "head", "zombie_head", "head"), + ("head_creeper", "head", "creeper_head", "head"), + ("head_dragon", "head", "dragon_skull", "head"), + ("head_dragon", "jaw", "dragon_skull", "jaw"), + ("head_piglin", "head", "piglin_head", "head"), + ("head_piglin", "left_ear", "piglin_head", "left_ear"), + ("head_piglin", "right_ear", "piglin_head", "right_ear"), + ("head_player", "head", "player_head", "head"), + ("bell", "body", "bell", "bell_body"), + ("bell", "base", "bell", "bell_base"), + ("enchanting_book", "cover_right", "book", "right_lid"), + ("enchanting_book", "cover_left", "book", "left_lid"), + ("enchanting_book", "pages_right", "book", "right_pages"), + ("enchanting_book", "pages_left", "book", "left_pages"), + ( + "enchanting_book", + "flipping_page_right", + "book", + "flip_page1", + ), + ( + "enchanting_book", + "flipping_page_left", + "book", + "flip_page2", + ), + ("enchanting_book", "book_spine", "book", "seam"), + ("lectern_book", "cover_right", "lectern_book", "right_lid"), + ("lectern_book", "pages_left", "lectern_book", "left_pages"), + ( + "lectern_book", + "flipping_page_right", + "lectern_book", + "flip_page1", + ), + ("lectern_book", "book_spine", "lectern_book", "seam"), + ("conduit", "base", "conduit_shell", "shell"), + ("conduit", "cage", "conduit_cage", "shell"), + ("conduit", "wind", "conduit_wind", "wind"), + ("conduit", "eye", "conduit_eye", "eye"), + ("decorated_pot", "neck", "decorated_pot_base", "neck"), + ("decorated_pot", "top", "decorated_pot_base", "top"), + ("decorated_pot", "bottom", "decorated_pot_base", "bottom"), + ("decorated_pot", "front", "decorated_pot_sides", "front"), + ("decorated_pot", "back", "decorated_pot_sides", "back"), + ("decorated_pot", "left", "decorated_pot_sides", "left"), + ("decorated_pot", "right", "decorated_pot_sides", "right"), + ]; + // One file per name with each of its parts, and a stub of each model holding its parts + // (chest models all of theirs, so a misplaced entry shows). + let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for (file, part, key, vanilla) in cases { + files.entry(file).or_default().insert(part); + trees.entry(key).or_default().insert(vanilla); + } + for key in ["chest", "double_chest_left", "double_chest_right"] { + trees + .entry(key) + .or_default() + .extend(["bottom", "lid", "lock"]); + } + let files_by_name = files.clone(); + let files: Vec<_> = files + .iter() + .map(|(file, parts)| { + let parts: Vec<_> = parts.iter().copied().collect(); + (format!("optifine/cem/{file}.jem"), jem_sized(&parts)) + }) + .collect(); + let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect(); + let root = pack("parts", &files); + let vanilla: HashMap<&'static str, ModelPart> = trees + .iter() + .map(|(key, parts)| { + let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect(); + (*key, plane(parts)) + }) + .collect(); + let result = reshape_all(&root, &vanilla); + fs::remove_dir_all(&root).unwrap(); + let ((models, _), unused) = result.unwrap(); + for (file, part, key, vanilla) in cases { + assert!( + replaced(&models[key], vanilla), + "{file}.jem {part}: {key} {vanilla}" + ); + // The box is this entry's own: swapped names (left and right, body and base) show. + let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1; + assert_eq!( + depth(&models[key], vanilla), + own as f32, + "{file}.jem {part}: {key} {vanilla}" + ); + } + assert_eq!(unused, Vec::new()); + } + + #[test] + fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() { + let root = pack( + "chest", + &[( + "optifine/cem/chest.jem", + format!( + r#"{{"texture": "textures/entity/chest/fancy.png", "models": [ + {{"part": "lid", {BOX}}}, + {{"part": "knob", "attach": true, "scale": 2, {BOX}}}, + {{"part": "bottom", {BOX}}}, + {{"part": "tail", {BOX}}}]}}"# + ), + )], + ); + let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into(); + let result = reshape_all(&root, &vanilla); + fs::remove_dir_all(&root).unwrap(); + let ((models, textures), unused) = result.unwrap(); + let chest = &models["chest"]; + // Replaced: the lid's own face goes and the custom box (6 faces) joins it. + assert!(replaced(chest, "lid")); + // `bottom` is no OptiFine name but vanilla's own: it reaches the part as well. + assert!(replaced(chest, "bottom")); + // Attached: the lock keeps its face beside the scaled box. + let lock = chest.child("lock").unwrap(); + assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1)); + assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]); + // `tail` names no chest part: the one warning. + assert_eq!( + unused, + [( + "assets/minecraft/optifine/cem/chest.jem".to_owned(), + "tail".to_owned() + )] + ); + // The double chest has no file, nor do trapped and ender chests: vanilla's own, and + // only the chest takes the texture. + for key in ["double_chest_left", "trapped_chest", "ender_chest"] { + let lid = models[key].child("lid").unwrap(); + assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}"); + } + assert_eq!( + textures, + HashMap::from([("chest", "entity/chest/fancy".to_owned())]) + ); + } + + #[test] + fn banners_split_one_file_and_books_fall_back() { + let root = pack( + "banner", + &[ + ("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])), + ("emf/cem/enchanting_book.jem", jem(&["cover_left"])), + ], + ); + let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]); + let wall = plane(vec![("bar", plane(vec![]))]); + let flag = plane(vec![("flag", plane(vec![]))]); + let book = plane(vec![("left_lid", plane(vec![]))]); + let vanilla: HashMap<_, _> = [ + ("standing_banner", banner), + ("wall_banner", wall), + ("wall_banner_flag", flag), + ("book", book), + ] + .into(); + let result = reshape_all(&root, &vanilla); + fs::remove_dir_all(&root).unwrap(); + let ((models, textures), unused) = result.unwrap(); + assert!(replaced(&models["standing_banner"], "pole")); + assert!(replaced(&models["standing_banner"], "bar")); + assert!(replaced(&models["wall_banner"], "bar")); + assert!(replaced(&models["wall_banner_flag"], "flag")); + assert!(replaced(&models["book"], "left_lid")); + // The lectern's book has no file of its own nor `book.jem`: vanilla's. + let lectern = models["lectern_book"].child("left_lid").unwrap(); + assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0)); + assert!(textures.is_empty()); + // Each entry found its part in one of the models: no warning. + assert_eq!(unused, Vec::new()); + } + + #[test] + fn book_jem_reshapes_the_lectern_book_without_its_own_file() { + let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]); + let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into(); + let result = reshape(&root, &vanilla); + fs::remove_dir_all(&root).unwrap(); + let (models, _) = result.unwrap(); + assert!(replaced(&models["book"], "left_lid")); + assert!(replaced(&models["lectern_book"], "left_lid")); + } + + #[test] + fn a_shared_file_paints_only_the_models_it_reshapes() { + // The conduit's file reshapes its eye alone: the shell, cage and wind keep their + // vanilla geometry and so their own textures. + let file = format!( + r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"# + ); + let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]); + let vanilla: HashMap<_, _> = [ + "conduit_shell", + "conduit_cage", + "conduit_wind", + "conduit_eye", + ] + .map(|key| { + let part = if key == "conduit_eye" { "eye" } else { "shell" }; + (key, plane(vec![(part, plane(vec![]))])) + }) + .into(); + let result = reshape(&root, &vanilla); + fs::remove_dir_all(&root).unwrap(); + let (models, textures) = result.unwrap(); + assert!(replaced(&models["conduit_eye"], "eye")); + let painted: BTreeSet<_> = textures.keys().copied().collect(); + assert_eq!(painted, BTreeSet::from(["conduit_eye"])); + assert_eq!(textures["conduit_eye"], "entity/conduit/custom"); + } +} diff --git a/crates/horizon-client-render/src/block_entity_render.rs b/crates/horizon-client-render/src/block_entity_render.rs index 709ae8557a..5ab00586c1 100644 --- a/crates/horizon-client-render/src/block_entity_render.rs +++ b/crates/horizon-client-render/src/block_entity_render.rs @@ -20,7 +20,12 @@ use horizon_world::{ registry::{BlockRegistry, BlockStateId}, }; use serde_json::Value; -use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex}; +use std::{ + collections::HashMap, + f32::consts::PI, + path::Path, + sync::{Arc, Mutex, PoisonError, RwLock}, +}; /// One baked `ModelPart`: initial pose, textured polygons (pixel units, /// normalized UVs) and named children. @@ -802,16 +807,102 @@ pub struct Scene<'a> { /// Empty by default (a dedicated server): no state has a block entity model. #[derive(Default)] pub struct BlockEntityRenderer { - models: HashMap<&'static str, ModelPart>, + vanilla: Arc, + /// What draws: `vanilla`, or the packs' custom entity models over it. + shapes: RwLock>, kinds: Vec, - // Closed chest geometry: dimension lighting, chest part, horizontal facing. - closed_chests: [[[Vec; 4]; 3]; 2], /// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`). pot_patterns: HashMap, /// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock). visibility: Option>, sign_texts: Mutex, } +/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity +/// models over them ([`crate::block_entity_cem`]). +#[derive(Default)] +pub struct BlockEntityShapes { + models: HashMap<&'static str, ModelPart>, + /// Closed chest geometry by chest model: dimension lighting, horizontal facing. + closed_chests: HashMap<&'static str, [[Vec; 4]; 2]>, + /// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's. + textures: HashMap<&'static str, String>, +} +/// The chest models, each baked with its lid closed. +const CHEST_MODELS: [&str; 7] = [ + "chest", + "double_chest_left", + "double_chest_right", + "trapped_chest", + "trapped_chest_left", + "trapped_chest_right", + "ender_chest", +]; +impl BlockEntityShapes { + fn new( + models: HashMap<&'static str, ModelPart>, + textures: HashMap<&'static str, String>, + ) -> Self { + let closed_chests = CHEST_MODELS + .iter() + .filter_map(|key| Some((*key, closed_chest(models.get(key)?)))) + .collect(); + Self { + models, + closed_chests, + textures, + } + } + /// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`. + fn texture<'a>( + &'a self, + key: &str, + vanilla: &'a str, + sprites: &HashMap, + ) -> &'a str { + match self.textures.get(key) { + Some(texture) if sprites.contains_key(texture) => texture, + _ => vanilla, + } + } +} +/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs +/// over the whole texture. +fn closed_chest(model: &ModelPart) -> [[Vec; 4]; 2] { + [false, true].map(|nether| { + [0.0f32, 90.0, 180.0, 270.0].map(|y_rot| { + let mut vertices = Vec::new(); + draw_part( + model, + "root", + around( + Mat4::from_rotation_y((-y_rot).to_radians()), + Vec3::new(0.5, 0.0, 0.5), + ), + &|name, p| { + if name == "lid" || name == "lock" { + p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0); + } + }, + Paint { + sprite: Rect { + u0: 0.0, + v0: 0.0, + u1: 1.0, + v1: 1.0, + w: 16, + h: 16, + }, + tint: 0xFFFFFF, + light: 0, + lights: diffuse_lights(nether), + glow: None, + }, + &mut vertices, + ); + vertices + }) + }) +} /// Laid-out sign text by position. `AbstractSignRenderer` lays the text out /// again each frame, glyph by glyph; its vertices only change with what is /// in the key, so they are kept relative to the block and moved to the camera. @@ -894,6 +985,8 @@ impl BlockEntityRenderer { .collect(); let mut renderer = Self::new(models, registry); renderer.visibility = Some(Mutex::new(visibility)); + // The packs' custom entity models (`.jem` files), before anything draws. + renderer.set_shapes(renderer.shapes_for(root)); // Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id. let items = match horizon_world::item_defaults::read(root) { Ok(items) => items, @@ -920,44 +1013,7 @@ impl BlockEntityRenderer { Ok(renderer) } fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self { - let closed_chests = [false, true].map(|nether| { - ["chest", "double_chest_left", "double_chest_right"].map(|name| { - [0.0f32, 90.0, 180.0, 270.0].map(|y_rot| { - let mut vertices = Vec::new(); - if let Some(model) = models.get(name) { - draw_part( - model, - "root", - around( - Mat4::from_rotation_y((-y_rot).to_radians()), - Vec3::new(0.5, 0.0, 0.5), - ), - &|name, p| { - if name == "lid" || name == "lock" { - p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0); - } - }, - Paint { - sprite: Rect { - u0: 0.0, - v0: 0.0, - u1: 1.0, - v1: 1.0, - w: 16, - h: 16, - }, - tint: 0xFFFFFF, - light: 0, - lights: diffuse_lights(nether), - glow: None, - }, - &mut vertices, - ); - } - vertices - }) - }) - }); + let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new())); let kinds = (0..registry.len() as u32) .map(|id| { registry.state(BlockStateId(id)).map_or(Kind::None, |s| { @@ -970,14 +1026,31 @@ impl BlockEntityRenderer { }) .collect(); Self { - models, + shapes: RwLock::new(vanilla.clone()), + vanilla, kinds, - closed_chests, pot_patterns: HashMap::new(), visibility: None, sign_texts: Mutex::default(), } } + /// The models the packs under `root` give, their `.jem` files over vanilla's, for + /// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any. + pub fn shapes_for(&self, root: &Path) -> Arc { + match crate::block_entity_cem::reshape(root, &self.vanilla.models) { + Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)), + None => self.vanilla.clone(), + } + } + pub fn set_shapes(&self, shapes: Arc) { + *self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes; + } + fn shapes(&self) -> Arc { + self.shapes + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } fn kind(&self, state: u32) -> &Kind { self.kinds.get(state as usize).unwrap_or(&Kind::None) } @@ -1271,6 +1344,7 @@ impl BlockEntityRenderer { world_chests: bool, out: &mut Vec, ) { + let shapes = self.shapes(); let sprite = |name: &str| scene.sprites.get(name).copied(); let paint = |name: &str, tint: u32, packed: u8| { let rect = if crumbling { @@ -1363,12 +1437,34 @@ impl BlockEntityRenderer { } else { *texture }; - let model = match part { - ChestPart::Single => "chest", - ChestPart::Left => "double_chest_left", - ChestPart::Right => "double_chest_right", + // Trapped and ender chests have models of their own when a `.jem` reshapes + // them; copper chests have no CEM name and draw vanilla's chest. + let shapes: &BlockEntityShapes = + if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") { + &shapes + } else { + &self.vanilla + }; + let (own, chest) = match (block.as_str(), part) { + ("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"), + ("trapped_chest", ChestPart::Left) => { + ("trapped_chest_left", "double_chest_left") + } + ("trapped_chest", ChestPart::Right) => { + ("trapped_chest_right", "double_chest_right") + } + ("ender_chest", _) => ("ender_chest", "chest"), + (_, ChestPart::Single) => ("chest", "chest"), + (_, ChestPart::Left) => ("double_chest_left", "double_chest_left"), + (_, ChestPart::Right) => ("double_chest_right", "double_chest_right"), }; - let Some(model) = self.models.get(model) else { + let key = if shapes.models.contains_key(own) { + own + } else { + chest + }; + let texture = shapes.texture(key, texture, scene.sprites); + let Some(model) = shapes.models.get(key) else { return; }; let (open, other) = self.chest_openness( @@ -1384,9 +1480,11 @@ impl BlockEntityRenderer { packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15); } if world_chests && open == 0.0 { - if let Some(paint) = paint(texture, 0xFFFFFF, packed) { - let vertices = &self.closed_chests[usize::from(scene.nether)] - [*part as usize][*y_rot as usize / 90]; + if let (Some(paint), Some(closed)) = ( + paint(texture, 0xFFFFFF, packed), + shapes.closed_chests.get(key), + ) { + let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90]; let origin = base.w_axis.truncate(); let passes = std::iter::once((paint.sprite, paint.light)) .chain(paint.glow.map(|g| (g, FULL_BRIGHT))); @@ -1421,8 +1519,12 @@ impl BlockEntityRenderer { } Kind::Shulker { texture, facing } => { let (Some(model), Some(paint)) = ( - self.models.get("shulker_box"), - paint(texture, 0xFFFFFF, packed), + shapes.models.get("shulker_box"), + paint( + shapes.texture("shulker_box", texture, scene.sprites), + 0xFFFFFF, + packed, + ), ) else { return; }; @@ -1446,12 +1548,13 @@ impl BlockEntityRenderer { draw_part(model, "root", m, &lid, paint, out); } Kind::Banner { color, wall, angle } => { - let (main, flag) = if *wall { + let (main_key, flag_key) = if *wall { ("wall_banner", "wall_banner_flag") } else { ("standing_banner", "standing_banner_flag") }; - let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag)) + let (Some(main), Some(flag)) = + (shapes.models.get(main_key), shapes.models.get(flag_key)) else { return; }; @@ -1472,8 +1575,13 @@ impl BlockEntityRenderer { p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI; } }; - if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) { + let banner_base = "entity/banner/banner_base"; + let main_texture = shapes.texture(main_key, banner_base, scene.sprites); + if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) { draw_part(main, "root", m, &none, paint, out); + } + let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites); + if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) { draw_part(flag, "root", m, &wave, paint, out); } // Pattern passes must not multiply the crumbling decal. @@ -1515,9 +1623,12 @@ impl BlockEntityRenderer { None }; let has_profile = skin.is_some(); - let texture = skin.as_deref().unwrap_or(texture); + // A `.jem` texture stands in for the default skin, not a profile's. + let texture = skin + .as_deref() + .unwrap_or_else(|| shapes.texture(model, texture, scene.sprites)); let (Some(model), Some(paint)) = - (self.models.get(model), paint(texture, 0xFFFFFF, packed)) + (shapes.models.get(model), paint(texture, 0xFFFFFF, packed)) else { return; }; @@ -1559,8 +1670,12 @@ impl BlockEntityRenderer { } Kind::Bell => { let (Some(model), Some(paint)) = ( - self.models.get("bell"), - paint("entity/bell/bell_body", 0xFFFFFF, packed), + shapes.models.get("bell"), + paint( + shapes.texture("bell", "entity/bell/bell_body", scene.sprites), + 0xFFFFFF, + packed, + ), ) else { return; }; @@ -1588,8 +1703,8 @@ impl BlockEntityRenderer { } Kind::DecoratedPot { y_rot } => { let (Some(base_model), Some(sides)) = ( - self.models.get("decorated_pot_base"), - self.models.get("decorated_pot_sides"), + shapes.models.get("decorated_pot_base"), + shapes.models.get("decorated_pot_sides"), ) else { return; }; @@ -1617,17 +1732,20 @@ impl BlockEntityRenderer { } } let first = out.len(); - if let Some(paint) = - paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed) - { + let pot_base = "entity/decorated_pot/decorated_pot_base"; + let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites); + if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) { draw_part(base_model, "root", m, &none, paint, out); } - // PotDecorations (back, left, right, front); items without a pattern use the blank side. + // PotDecorations (back, left, right, front); items without a pattern use the blank + // side (a `.jem`'s texture, if any). + let blank = "entity/decorated_pot/decorated_pot_side"; + let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites); for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) { let texture = item .as_ref() .and_then(|i| self.pot_patterns.get(i)) - .map_or("entity/decorated_pot/decorated_pot_side", String::as_str); + .map_or(blank, String::as_str); if let (Some(part), Some(paint)) = (sides.child(side), paint(texture, 0xFFFFFF, packed)) { @@ -1677,11 +1795,14 @@ impl BlockEntityRenderer { let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit; // ConduitRenderer.extractRenderState. let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375; - let tex = |name: &str| paint(name, 0xFFFFFF, packed); + let tex = |key: &str, name: &str| { + paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed) + }; if !active { - let (Some(model), Some(paint)) = - (self.models.get("conduit_shell"), tex("entity/conduit/base")) - else { + let (Some(model), Some(paint)) = ( + shapes.models.get("conduit_shell"), + tex("conduit_shell", "entity/conduit/base"), + ) else { return; }; let m = base @@ -1696,9 +1817,10 @@ impl BlockEntityRenderer { let hh = (anim_time * 0.1).msin() / 2.0 + 0.5; let hh = hh * hh + hh; let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5); - if let (Some(model), Some(paint)) = - (self.models.get("conduit_cage"), tex("entity/conduit/cage")) - { + if let (Some(model), Some(paint)) = ( + shapes.models.get("conduit_cage"), + tex("conduit_cage", "entity/conduit/cage"), + ) { let axis = Vec3::new(0.5, 1.0, 0.5).normalize(); let m = base * Mat4::from_translation(lift) @@ -1714,7 +1836,9 @@ impl BlockEntityRenderer { } else { "entity/conduit/wind" }; - if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) { + if let (Some(model), Some(paint)) = + (shapes.models.get("conduit_wind"), tex("conduit_wind", wind)) + { let turn = match phase { 1 => Mat4::from_rotation_x(PI / 2.0), 2 => Mat4::from_rotation_z(PI / 2.0), @@ -1732,7 +1856,9 @@ impl BlockEntityRenderer { } else { "entity/conduit/closed_eye" }; - if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) { + if let (Some(model), Some(paint)) = + (shapes.models.get("conduit_eye"), tex("conduit_eye", eye)) + { let m = base * Mat4::from_translation(lift) * Mat4::from_scale(Vec3::splat(0.5)) @@ -1743,9 +1869,14 @@ impl BlockEntityRenderer { } } Kind::EnchantingTable => { + let book = "entity/enchantment/enchanting_table_book"; let (Some(model), Some(paint)) = ( - self.models.get("book"), - paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed), + shapes.models.get("book"), + paint( + shapes.texture("book", book, scene.sprites), + 0xFFFFFF, + packed, + ), ) else { return; }; @@ -1764,9 +1895,16 @@ impl BlockEntityRenderer { draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out); } Kind::Lectern { y_rot } => { + // Its own `.jem`'s book, else the enchanting table's. + let key = if shapes.models.contains_key("lectern_book") { + "lectern_book" + } else { + "book" + }; + let book = "entity/enchantment/enchanting_table_book"; let (Some(model), Some(paint)) = ( - self.models.get("book"), - paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed), + shapes.models.get(key), + paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed), ) else { return; }; @@ -1797,7 +1935,7 @@ impl BlockEntityRenderer { _ => "copper_golem", }; let (Some(model), Some(paint)) = - (self.models.get(key), paint(texture, 0xFFFFFF, packed)) + (shapes.models.get(key), paint(texture, 0xFFFFFF, packed)) else { return; }; @@ -2449,7 +2587,8 @@ mod tests { #[test] fn cached_sign_text_matches_and_follows_changes() { let renderer = BlockEntityRenderer { - models: HashMap::new(), + vanilla: Default::default(), + shapes: Default::default(), kinds: vec![ Kind::Sign { hanging: false, @@ -2462,7 +2601,6 @@ mod tests { angle: 90.0, }, ], - closed_chests: Default::default(), pot_patterns: HashMap::new(), visibility: None, sign_texts: Mutex::default(), @@ -2646,4 +2784,131 @@ mod tests { "entitySolid culls the pot's back faces" ); } + + #[test] + fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() { + use crate::block_entity_cem::tests::{BOX, chest, pack}; + let vanilla = Arc::new(BlockEntityShapes::new( + HashMap::from([("chest", chest())]), + HashMap::new(), + )); + let renderer = BlockEntityRenderer { + shapes: RwLock::new(vanilla.clone()), + vanilla, + kinds: Vec::new(), + pot_patterns: HashMap::new(), + visibility: None, + sign_texts: Mutex::default(), + }; + // No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy. + let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]); + assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla)); + let root = pack( + "render-chest", + &[( + "optifine/cem/chest.jem", + format!( + r#"{{"texture": "textures/entity/chest/fancy.png", + "models": [{{"part": "lid", {BOX}}}]}}"# + ), + )], + ); + renderer.set_shapes(renderer.shapes_for(&root)); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&empty); + let rect = |u0: f32| Rect { + u0, + v0: 0.0, + u1: u0 + 0.2, + v1: 0.2, + w: 16, + h: 16, + }; + let sprites: HashMap = [ + ("entity/chest/normal", rect(0.0)), + ("entity/chest/ender", rect(0.2)), + ("entity/chest/fancy", rect(0.4)), + ("entity/chest/copper", rect(0.6)), + ] + .map(|(name, r)| (name.to_owned(), r)) + .into(); + let world = World::new(BlockStateId(0), 1); + let scene = Scene { + camera: DVec3::ZERO, + orientation: Quat::IDENTITY, + anim: &|_| BlockAnim::default(), + texts: None, + partial: 0.0, + game_time: 0, + max_y: 320, + world: &world, + sprites: &sprites, + emissive: &Default::default(), + nether: false, + lid: &|_| 0.0, + sign: &|_| None, + banner: &|_| Vec::new(), + skull: &|_| None, + glyph: &|_| (None, 0.0), + glyphs: 0, + }; + // A closed single chest of `block` drawn by `renderer`: positions and UVs. + let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| { + let kind = Kind::Chest { + block: block.into(), + texture, + part: ChestPart::Single, + y_rot: 0.0, + double_with: None, + }; + let mut out = Vec::new(); + renderer.render_one( + &scene, + [0; 3], + &kind, + Mat4::IDENTITY, + 0xF0, + diffuse_lights(false), + false, + true, + &mut out, + ); + out.iter().map(|v| (v.pos, v.uv)).collect::>() + }; + // The chest's three faces become the bottom's, the lock's and the custom lid's six, in + // the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's + // three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their + // texture turns festive in late December.) + for (block, texture, u0, faces) in [ + ("chest", "entity/chest/normal", 0.4, 8), + ("ender_chest", "entity/chest/ender", 0.2, 3), + ("copper_chest", "entity/chest/copper", 0.6, 3), + ] { + let out = draw(&renderer, block, texture); + assert_eq!(out.len(), faces * 4, "{block}"); + assert!( + out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])), + "{block}" + ); + } + // A copper chest draws exactly what it draws without any pack. + let plain = BlockEntityRenderer { + shapes: RwLock::new(renderer.vanilla.clone()), + vanilla: renderer.vanilla.clone(), + kinds: Vec::new(), + pot_patterns: HashMap::new(), + visibility: None, + sign_texts: Mutex::default(), + }; + assert_eq!( + draw(&renderer, "copper_chest", "entity/chest/copper"), + draw(&plain, "copper_chest", "entity/chest/copper") + ); + // The atlas has no such texture: vanilla's. + let shapes = renderer.shapes(); + assert_eq!( + shapes.texture("chest", "entity/chest/normal", &HashMap::new()), + "entity/chest/normal" + ); + } } diff --git a/crates/horizon-client-render/src/connected.rs b/crates/horizon-client-render/src/connected.rs index eec6572df1..4318125045 100644 --- a/crates/horizon-client-render/src/connected.rs +++ b/crates/horizon-client-render/src/connected.rs @@ -387,7 +387,8 @@ impl ConnectedTextures { /// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`. /// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only - /// by rules that test one. + /// by rules that test one; `texture` the quad's own texture directions, asked only by rules + /// with `orient=texture`. pub fn pick<'b>( &self, state: u32, @@ -396,6 +397,7 @@ impl ConnectedTextures { pos: [i32; 3], neighbour: impl Fn([i32; 3]) -> u32, biome: impl Fn() -> Option<&'b str>, + texture: impl Fn() -> Option, mut overlay: impl FnMut(Overlay), ) -> Pick { let (mut by_state, mut by_layer) = ( @@ -427,9 +429,58 @@ impl ConnectedTextures { let r = &self.rules[usize::from(i)]; if (block || r.rule.match_blocks.is_empty()) && (tile || r.rule.match_tiles.is_empty()) - && let Some(pick) = - self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay) + && let Some(pick) = self.apply( + r, + state, + layer, + face, + pos, + &neighbour, + &biome, + &texture, + &mut overlay, + ) { + // "Tiles output by CTM rules can also be matched by another rule": once, by the + // tile rules naming the picked tile (and this block, if they name blocks). + // A tile on the face's own sprite (`tiles=stone`) is no new tile. + let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else { + return pick; + }; + if picked == layer { + return pick; + } + let own = self.list(&self.by_state, state as usize); + // Rules tried on the face's sprite already (those before this one) are not tried + // again: an overlay would draw twice. + let first = self.list(&self.by_layer, layer as usize); + let again = self + .list(&self.by_layer, picked as usize) + .iter() + .map(|&j| (j, &self.rules[usize::from(j)])) + .filter(|&(j, r)| { + j != i + && !(j < i && first.contains(&j)) + && (r.rule.match_blocks.is_empty() || own.contains(&j)) + }); + for (_, r) in again { + // Joins by tile still compare the face's own sprite with its neighbours'. + match self.apply( + r, + state, + layer, + face, + pos, + &neighbour, + &biome, + &texture, + &mut overlay, + ) { + Some(Pick::Keep) => return pick, + Some(chained) => return chained, + None => {} + } + } return pick; } } @@ -447,6 +498,7 @@ impl ConnectedTextures { pos: [i32; 3], neighbour: &impl Fn([i32; 3]) -> u32, biome: &impl Fn() -> Option<&'b str>, + texture: &impl Fn() -> Option, overlay: &mut impl FnMut(Overlay), ) -> Option { let rule = &r.rule; @@ -487,7 +539,12 @@ impl ConnectedTextures { }; let axes = |face| match (rule.orient, info.axis) { (Orient::StateAxis, Some(axis)) => Axes::column(face, axis), - // `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on. + // The quad's own texture directions, whatever face `symmetry` stands it for. + // Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face + // the standard way. + (Orient::Texture, _) if rule.method != Method::Overlay => { + texture().unwrap_or(Axes::cube(face)) + } _ => Axes::cube(face), }; let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins); @@ -649,6 +706,10 @@ mod tests { |name| match name { "minecraft:block/stone" => Some(7), "minecraft:block/glass" => Some(3), + // The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`). + "assets/minecraft/optifine/ctm/b0.png" => Some(2000), + // And `a0` of the first rule. + "assets/minecraft/optifine/ctm/a0.png" => Some(1000), _ => None, }, ) @@ -671,7 +732,7 @@ mod tests { blocks: &[([i32; 3], u32)], ) -> Pick { let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1); - ctm.pick(state, layer, face, [0, 64, 0], at, || None, |_| {}) + ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {}) } const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46"; @@ -794,8 +855,26 @@ mod tests { )]); let mut tiles = std::collections::HashSet::new(); for x in 0..20 { - let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None, |_| {}); - let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {}); + let lower = ctm.pick( + 5, + 3, + Face::North, + [x, 64, 0], + |_| 0, + || None, + || None, + |_| {}, + ); + let upper = ctm.pick( + 6, + 3, + Face::North, + [x, 65, 0], + |_| 0, + || None, + || None, + |_| {}, + ); assert_eq!(lower, upper, "{x}"); tiles.insert(format!("{lower:?}")); } @@ -809,7 +888,16 @@ mod tests { "matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert", )]); let at = |y, biome: &'static str| { - ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {}) + ctm.pick( + 1, + 3, + Face::Up, + [0, y, 0], + |_| 0, + || Some(biome), + || None, + |_| {}, + ) }; assert_eq!(at(10, "minecraft:desert"), tile(1000)); assert_eq!(at(64, "minecraft:desert"), Pick::Keep); @@ -844,7 +932,7 @@ mod tests { "glass.properties", "matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3", )]); - let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, |_| {}); + let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {}); assert_eq!(at([0, 64, 0]), tile(1000)); // The north face's right is west; rows run down. assert_eq!(at([-1, 64, 0]), tile(1001)); @@ -925,6 +1013,7 @@ mod tests { [0, 64, 0], at, || None, + || None, |o| out.push(o.layer), ); (out, pick) @@ -962,6 +1051,7 @@ mod tests { [0, 64, 0], |o| if o == [0, 0, -1] { 1 } else { 0 }, || None, + || None, |o| { tint = Some((o.tint, o.translucent)); }, @@ -999,6 +1089,7 @@ mod tests { [0, 64, 0], door_north, || None, + || None, |o| got.push(o), ); assert_eq!( @@ -1039,6 +1130,99 @@ mod tests { assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep)); } + #[test] + fn a_picked_tile_can_be_picked_again() { + // b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first) + // names that tile, so it draws in b0's place. + let chain = |t: &str| { + world(&[ + ("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"), + ("t.properties", t), + ]) + }; + let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0"); + assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000)); + // A chained rule keeping the tile () leaves b's. + let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles="); + assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000)); + // One that names another block doesn't chain on glass. + let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0"); + assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000)); + // An overlay tried on the face's own sprite and on the picked tile draws once. + let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0"); + let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]); + assert_eq!((overlays, pick), (vec![1000], tile(2000))); + // A rule after the one that picked, naming both the face's sprite and the tile, wasn't + // tried: it chains. + let ctm = world(&[ + ("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"), + ( + "c.properties", + "matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0", + ), + ]); + assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000)); + } + + #[test] + fn orient_texture_follows_the_quads_uvs() { + let rule = |orient: &str| { + world(&[( + "glass.properties", + &*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"), + )]) + }; + // A mirrored north face (a vanilla pane's): its texture's right is east. + let mirrored = || { + Some(Axes { + right: [1, 0, 0], + up: [0, 1, 0], + }) + }; + let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 }; + let at = |ctm: &ConnectedTextures| { + ctm.pick( + 1, + 3, + Face::North, + [0, 64, 0], + east, + || None, + mirrored, + |_| {}, + ) + }; + // Glass to the east is to the texture's right: the row's left end. + assert_eq!(at(&rule("texture")), tile(1000)); + // By the cube's directions it is to the left: the row's right end. + assert_eq!(at(&rule("none")), tile(1002)); + // A plain overlay keeps the standard directions: glass north of a top face is above it + // (tile 15) whatever the quad's texture says. + let overlay = world(&[( + "a.properties", + "matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture", + )]); + let turned = || { + Some(Axes { + right: [0, 0, 1], + up: [1, 0, 0], + }) + }; + let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 }; + let mut got = Vec::new(); + overlay.pick( + 2, + 7, + Face::Up, + [0, 64, 0], + north, + || None, + turned, + |o| got.push(o.layer), + ); + assert_eq!(got, [1015]); + } + #[test] fn stacked_panes_hide_the_strips_they_share() { let props = |pairs: &[(&str, &str)]| -> BlockProperties { diff --git a/crates/horizon-client-render/src/entity.wgsl b/crates/horizon-client-render/src/entity.wgsl index 6dab91ac1a..a5e4107de3 100644 --- a/crates/horizon-client-render/src/entity.wgsl +++ b/crates/horizon-client-render/src/entity.wgsl @@ -31,6 +31,9 @@ struct Globals { // HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright, // [0] where the lift starts, [1] where it is full. hdr_windows: array, 2>, + // HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`), + // y what the sky and fog get: the same, but none under water. + hdr_light: vec4, } @group(0) @binding(0) var globals: Globals; @group(0) @binding(1) var atlas: texture_2d; @@ -69,6 +72,8 @@ struct Out { // HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's // high bits (particles.rs): fs_hdr_mark marks the frame with it. @location(13) @interpolate(flat) tier: u32, + // HDR lighting (`hdr_scene`), 0 for what isn't lit. + @location(14) scene: f32, } // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. fn entity_light(normal: vec3, nether: bool) -> f32 { @@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3) -> vec3 { } return min(rgb, vec3(1.0)); } +// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's. +fn hdr_scene(block: f32, sky: f32) -> f32 { + let b = smoothstep(11.0, 15.0, block); + let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x; + return max(b, s); +} +// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75. +fn hdr_scene_alpha(scene: f32) -> f32 { + return 1.0 - 0.24 * scene; +} fn entity_vertex(position: vec3, uv: vec2, overlay: u32, color: vec4, light: vec4, normal: vec4) -> Out { var out: Out; @@ -120,6 +135,7 @@ fn entity_vertex(position: vec3, uv: vec2, let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u || overlay == 11u || overlay == 12u; out.light_color = select(lm, vec4(1.0), unlit); + out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit); if globals.colored_count.x > 0u && !unlit { out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a); } @@ -328,8 +344,14 @@ fn shadow_color(in: Out) -> vec4 { var uv = in.uv; if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; } let texel = textureSample(atlas, atlas_sampler, uv); - if in.tier == 0u || texel.a < 0.1 { discard; } - return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb))); + if texel.a < 0.1 { discard; } + // Fog hides a light source and the light on a surface as it hides everything else. + let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), + linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a; + if in.tier == 0u { + return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged))); + } + return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged))); } fn brightness(c: vec3) -> f32 { let l = pow(max(c, vec3(0.0)), vec3(2.2)); @@ -348,7 +370,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 { // no longer shines; fs_hdr_mark then marks the light sources it holds. @fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4 { if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; } - return vec4(0.0, 0.0, 0.0, 1.0); + return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene)); } fn celestial_color(in: Out) -> vec4 { @@ -375,8 +397,12 @@ fn celestial_color(in: Out) -> vec4 { let added = color.rgb * color.a; // The sun at the brightest tier, the moon at its own (in.tier); geometry that sets // none (the End sky, the End flash) stays brightest. - let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added)); - return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b)))); + let tier = select(in.tier, 4u, in.tier == 0u); + let y = brightness(added); + // Only what's past the tier's window takes the mark: a soft edge keeps the sky's light, + // or it would come out darker than the sky around it. + if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); } + return vec4(added, 1.0 - hdr_mark(tier, y)); } // RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by diff --git a/crates/horizon-client-render/src/font.rs b/crates/horizon-client-render/src/font.rs index 01ede34bbc..9169456609 100644 --- a/crates/horizon-client-render/src/font.rs +++ b/crates/horizon-client-render/src/font.rs @@ -947,6 +947,16 @@ impl Font { .sum::() .ceil() } + /// `Font.width(FormattedCharSequence)` of a sequence a component built + /// with `FormattedCharSequence.forward`: every character is a glyph, a + /// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`). + pub fn width_drawn(&self, t: &Text) -> f32 { + crate::bidi::visual(t) + .chars() + .map(|(c, s)| self.advance(c, s)) + .sum::() + .ceil() + } /// `StringSplitter.splitLines`: each line and whether it continues a /// wrapped one. A line breaks at a `\n`, or once a character overflows /// `max_width` (never the first visible one) at the last space so far, or diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index f70c6a07a6..f03ee772d3 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -329,6 +329,7 @@ struct Globals { /// HDR output's tier windows, low to very bright: where the lift starts, where it is /// full (`hdr::Hdr::windows`); zero without HDR. hdr_windows: [[f32; 4]; 2], + hdr_light: [f32; 4], } pub type SectionKey = (i32, i32, i32); /// The water mirror's targets: the world as a camera mirrored about the water plane sees @@ -4009,6 +4010,10 @@ impl Renderer { pub fn section_count(&self) -> usize { self.sections.len() } + /// The running native pack's `[[particle_lights]]`. + pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> { + self.native.as_ref().map(|n| n.particle_lights.as_slice()) + } /// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the /// frame's `shadow_only` quads. pub fn native_shadow(&self) -> bool { @@ -4883,6 +4888,11 @@ impl Renderer { let world = pack_post::World { atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())), lightmap: self.lightmap_view.clone(), + gui_size: self + .hud + .atlas_texture + .as_ref() + .map_or([1, 1], |t| [t.width(), t.height()]), terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry { buffers: if flat { &buffers[..1] } else { &buffers }, table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout), @@ -5355,6 +5365,8 @@ impl Renderer { } /// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written. fn draw_frame(&mut self, frame: &Frame, checks: Option) -> Result { + // Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended. + let mut laps = crate::spans::laps(); let landing = Instant::now(); self.land_frame(); // Waiting for the previous frame (offscreen: at most two on the GPU), not work. @@ -5410,6 +5422,7 @@ impl Renderer { } self.surface_unconfigured = false; } + laps.lap("r_prepare"); // Frame generation: its present thread acquires and presents, this frame draws // into frames of its own. Not over menus that blur the frame, a shader pack's // own post passes, HDR or the title screen. @@ -5536,6 +5549,7 @@ impl Renderer { let views = self.views(frame); let pose = frame.bob * frame.spin; let view_proj = views.primary().view_proj(pose); + laps.lap("r_acquire"); // TAA runs where the world post pass does, not under a shader pack's own. let taa = !frame.title && self.pack_post.as_ref().is_none_or(|p| p.1.is_none()) @@ -5675,9 +5689,21 @@ impl Renderer { 0.0, ], hdr_windows: self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows), + hdr_light: [ + frame.sky.sky_light_factor, + // The underwater overlay: the sky and fog are the water's own colour there. + if frame.screen.iter().any(|q| q.world_tint) { + 0.0 + } else { + frame.sky.sky_light_factor + }, + 0.0, + 0.0, + ], }; self.uploads .buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals)); + laps.lap("r_targets"); // GameRenderer.render3dHud: the hand's projection at hudFov, with the // bob in its pose rather than the projection (same product). let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection( @@ -5883,6 +5909,7 @@ impl Renderer { } self.origins.clear(); let (sections, retiring) = (&self.sections, &self.lod_retiring); + laps.lap("r_cameras"); // `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once // nothing is fading (the usual frame), every section is whole without a lookup. let fade_ms = fade_millis(frame.chunk_fade); @@ -6064,6 +6091,7 @@ impl Renderer { self.draw_pages.extend_from_slice(&self.phase2_pages); let culling = cull.is_some(); let solid_draws = self.draws.len() as u32; + laps.lap("r_section_groups"); // LevelRenderer: nearby sections each frame, plus a rotating eighth // (at least 15) of the visible list. Cached views avoid needless sorts. self.translucent_draws.clear(); @@ -6261,6 +6289,7 @@ impl Renderer { (shadow_first + shadow_solid) as usize, ); let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid; + laps.lap("r_nearby"); // Metal replays a multi-draw as one draw call per entry. self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal { (0..self.section_draws) @@ -6346,12 +6375,19 @@ impl Renderer { &self.queue, frame.entities, frame.entity_spans, + frame.crumbling_sprite, &mut self.pack_entity_rest, ); entities = &self.pack_entity_rest; } // GameRenderer.useImprovedTransparency. A shader pack keeps its own // path. + let hdr_lighting = self.native.is_none() + && self.pack_post.as_ref().is_none_or(|p| p.1.is_none()) + && std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0"); + if let Some(hdr) = &mut self.hdr { + hdr.lighting = hdr_lighting; + } let oit_on = self.improved_transparency && !frame.title && self.pack_post.as_ref().is_none_or(|p| p.1.is_none()) @@ -6387,6 +6423,7 @@ impl Renderer { + translucent_particle_quads + translucent_item_quads + oit_entity_quads; + laps.lap("r_draw_lists"); // Where the tail of materials the classic and the improved passes // both draw ends: the shadows, particles and items count back from it. let base_quads = world_quads - oit_entity_quads; @@ -6532,6 +6569,7 @@ impl Renderer { bytemuck::bytes_of(&uniform), ); } + laps.lap("r_materials"); // Every upload before the frame's first pass: a pass ends wgpu-core's // command buffer, so copies between passes would need one more. if !frame.screen.is_empty() { @@ -6658,6 +6696,7 @@ impl Renderer { .flatten(); } let pack_reads_world = pack_post || native_post; + laps.lap("r_uploads"); // Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it. let keep_depth = pack_reads_world || (taa && self.world_post.taa) @@ -6789,6 +6828,7 @@ impl Renderer { } }; let sky_visible = sky_open(frame); + laps.lap("r_world_pass"); // One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a // pass of its own, so that it has a time of its own. let mut native_sky_pass = false; @@ -6927,6 +6967,7 @@ impl Renderer { pass.set_bind_group(0, &self.group, &[]); sky.draw(&mut pass, &native.frame); } + laps.lap("r_pack_inputs"); // The water mirror, for one of Horizon's own packs that reads it: the world as a // camera mirrored about the water plane sees it, into a half-resolution target the // material stage samples as `reflection`. @@ -7064,6 +7105,7 @@ impl Renderer { }; native.frame.fit_surface(&self.device, size); } + laps.lap("r_mirror"); // One of Horizon's own packs with a material stage: terrain draws with its pipelines, // which read the pack's frame at group 3. let material = self @@ -8054,6 +8096,7 @@ impl Renderer { (&view_target, self.config.format.remove_srgb_suffix()), ); } + laps.lap("r_passes"); // Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame. let shown_texture = match &fg_frames { Some((_, generated)) if fg_made && frame_gen::generated_only() => generated, diff --git a/crates/horizon-client-render/src/gfx/hdr.rs b/crates/horizon-client-render/src/gfx/hdr.rs index 283ab8fb70..f3c28c6078 100644 --- a/crates/horizon-client-render/src/gfx/hdr.rs +++ b/crates/horizon-client-render/src/gfx/hdr.rs @@ -149,6 +149,9 @@ pub struct Hdr { pub ui: f32, /// Which pixels of a light source shine (`Masks`). pub masks: Masks, + /// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and + /// with `HORIZON_HDR_LIGHTING=0`, to compare. + pub lighting: bool, /// Calibration's test picture while it runs. pub calibration: Option, } @@ -293,6 +296,7 @@ impl Hdr { tiers: TIERS, ui: 1.0, masks: MASKS, + lighting: true, calibration: None, } } @@ -539,6 +543,11 @@ impl Hdr { ]); params[5] = self.black.clamp(0.0, paper); params[6] = self.ui.clamp(0.25, 1.0); + params[7] = if self.lighting { + scene_gain(self.peak, self.paper_white) + } else { + 1.0 + }; for (i, t) in self.tiers.iter().enumerate() { params[8 + i] = t.share; } @@ -618,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView { .create_view(&Default::default()) } +/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right +/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`). +fn scene_gain(peak: f32, paper_white: f32) -> f32 { + (peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0) +} + #[cfg(test)] mod tests { use super::{Output, pick}; @@ -670,3 +685,18 @@ mod tests { assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None); } } + +#[cfg(test)] +mod lighting_tests { + use super::scene_gain; + + #[test] + fn the_lighting_scales_with_the_headroom() { + // Enough headroom: the full strength, whatever the paper white. + assert_eq!(scene_gain(1000.0, 430.0), 4.0); + assert_eq!(scene_gain(1060.0, 203.0), 4.0); + // Little headroom, and none. + assert_eq!(scene_gain(600.0, 400.0), 2.625); + assert_eq!(scene_gain(200.0, 480.0), 1.0); + } +} diff --git a/crates/horizon-client-render/src/gfx/native_material.rs b/crates/horizon-client-render/src/gfx/native_material.rs index 4d8a6d50d4..8f8527bc0e 100644 --- a/crates/horizon-client-render/src/gfx/native_material.rs +++ b/crates/horizon-client-render/src/gfx/native_material.rs @@ -39,6 +39,8 @@ struct HorizonMaterialOut { @location(8) @interpolate(flat) face: u32, @location(9) @interpolate(flat) fluid_quad: u32, @location(10) @interpolate(flat) emission: f32, + // terrain.wgsl's `emission`, for HDR output's mark. + @location(11) @interpolate(flat) hdr_code: u32, } fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color: vec4, light: vec2, origin: vec3) -> HorizonMaterialOut { @@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color out.fluid_quad = u32(fluid); // The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the // face shines: a redstone torch 7/15, a torch 14/15, glowstone 1. - let tier = 15u - (flags & 15u); + out.hdr_code = 15u - (flags & 15u); + // Codes above 4 are the same tiers, shining all over rather than in their bright parts. + let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u); out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u); return out; } @@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4 horizon_material_face_normal: vec3; struct HorizonMaterialSolid { @@ -154,15 +159,33 @@ struct HorizonMaterialSolid { // stage's `surface_at` compares with the final depth. @location(1) surface: vec4, } +// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's +// material: the pack decides the colour, Horizon still knows which pixels are light sources. +fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4) -> f32 { + if in.hdr_code > 0u { + let whole = in.hdr_code > 4u; + let tier = select(in.hdr_code, in.hdr_code - 4u, whole); + let l = in.layer & 0x7fffu; + let t = textureLoad(horizon_terrain_emission_thresholds, + vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0); + let y = horizon_terrain_brightness(texel.rgb); + if whole || y >= t.r { + return horizon_terrain_hdr_mark(tier, y); + } + } + return 1.0; +} @fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid { let color = horizon_material_color(in, false); + let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu); // Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout. if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } if horizon_surface.w == 0.0 { set_surface(horizon_material_face_normal, 0.0, 0.0); } var out: HorizonMaterialSolid; - out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical); + let mark = horizon_material_mark(in, texel); + out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical); out.surface = vec4(horizon_surface.xyz, in.clip.z); return out; } diff --git a/crates/horizon-client-render/src/gfx/native_pack.rs b/crates/horizon-client-render/src/gfx/native_pack.rs index dad08003aa..c0f3fe4c2b 100644 --- a/crates/horizon-client-render/src/gfx/native_pack.rs +++ b/crates/horizon-client-render/src/gfx/native_pack.rs @@ -21,6 +21,8 @@ pub struct NativePack { pub weather: Option, /// The draw of the shadow map the stages read, if the pack asks for one. pub shadow: Option, + /// `[[particle_lights]]`. + pub particle_lights: Vec, /// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`). pub timing: Option, } @@ -133,6 +135,7 @@ impl NativePack { labels.extend(post.labels().into_iter().map(str::to_owned)); } let pack = Self { + particle_lights: report.manifest.particle_lights.clone(), frame, sky, material, diff --git a/crates/horizon-client-render/src/gfx/pack_entities.rs b/crates/horizon-client-render/src/gfx/pack_entities.rs index f4ec2db431..f83f1733d4 100644 --- a/crates/horizon-client-render/src/gfx/pack_entities.rs +++ b/crates/horizon-client-render/src/gfx/pack_entities.rs @@ -319,6 +319,7 @@ impl PackEntities { entities: &[Vertex], spans: &[EntitySpan], drawn: [bool; PROGRAMS], + crack: ([f32; 4], [u32; 2]), shadow: Casters, rest: &mut Vec, ) { @@ -330,7 +331,9 @@ impl PackEntities { rest.extend_from_slice(&entities[..first.min(entities.len())]); for (span, range) in runs(entities.len(), spans) { let run = &entities[range]; - let program = program_of(&span).filter(|_| plain(run)); + let program = program_of(&span).filter(|&p| { + plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12))) + }); let pack = program.is_some_and(|p| drawn[p]) && !span.hidden; let casts = program.is_some_and(|p| shadow.cast(&span, p)); if !pack && !span.hidden { @@ -341,6 +344,9 @@ impl PackEntities { }; let start = self.scratch.len() as u32; self.scratch.extend_from_slice(run); + if program == DESTROY { + project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1); + } let (entity_id, block_id) = match program { BLOCK => (0, span.id), _ => (span.id, 0), @@ -419,7 +425,8 @@ pub const ENTITY: usize = 0; pub const BLOCK: usize = 1; pub const TRANSLUCENT: usize = 2; pub const PARTICLE: usize = 3; -pub const PROGRAMS: usize = 4; +pub const DESTROY: usize = 4; +pub const PROGRAMS: usize = 5; /// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has; /// `None`: Horizon draws it. @@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option { BLOCK } else if span.stage == render_stage("PARTICLES") { PARTICLE + } else if span.stage == render_stage("DESTROY") { + DESTROY } else { return None; }) @@ -461,6 +470,16 @@ pub fn runs( mod tests { use super::*; + #[test] + fn destroy_stage_uses_the_pack_only_when_a_program_is_available() { + let destroy = EntitySpan { + stage: render_stage("DESTROY"), + ..Default::default() + }; + assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some()); + assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None); + } + #[test] fn runs_split_the_list_between_pack_and_horizon() { let v = |layer| Vertex { @@ -483,15 +502,21 @@ mod tests { let all = [true; PROGRAMS]; assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY)); assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE)); - assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None); - assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None); + assert_eq!( + drawn_by_pack(&spans[1], [true, true, true, false, true]), + None + ); + assert_eq!( + drawn_by_pack(&spans[2], [true, false, true, true, true]), + None + ); assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK)); let shadow = EntitySpan { translucent: true, ..spans[0] }; assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT)); - assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None); + assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY)); // A beacon beam's quads stay Horizon's. assert!(plain(&[v(0), v(0), v(0), v(0), v(1)])); assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)])); @@ -523,3 +548,53 @@ mod tests { assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK)); } } + +/// Decode the destroy-stage origin stored in the light bytes and project one continuous +/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes. +fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) { + let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32]; + for quad in vertices.as_chunks_mut::<4>().0 { + let origin = [ + (quad[0].light & 65535) as f32 / 65535.0, + (quad[0].light >> 16) as f32 / 65535.0, + ]; + let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]); + let min = [0, 1] + .map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor()); + for v in quad { + v.uv = + [0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]); + v.layer = 0; + v.light = 0x0000_f0f0; + } + } +} + +#[cfg(test)] +mod crack_projection_tests { + use super::*; + #[test] + fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() { + let mut vertices: Vec = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]] + .into_iter() + .map(|uv| Vertex { + uv, + layer: 11, + light: crate::breaking::stage_light([0.25, 0.5]), + ..bytemuck::Zeroable::zeroed() + }) + .collect(); + project_cracks( + &mut vertices, + [0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0], + [4096, 9216], + ); + assert!( + vertices + .iter() + .all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0) + ); + assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0); + assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0); + } +} diff --git a/crates/horizon-client-render/src/gfx/pack_post.rs b/crates/horizon-client-render/src/gfx/pack_post.rs index d205f855c8..db3306e30b 100644 --- a/crates/horizon-client-render/src/gfx/pack_post.rs +++ b/crates/horizon-client-render/src/gfx/pack_post.rs @@ -408,6 +408,7 @@ pub(super) struct World<'a> { pub block_ids: &'a [i32], /// The atlas of the sun and moon's sprites. pub celestial: Option, + pub gui_size: [u32; 2], /// Set 1 of the clouds' draw: their offset and palette. pub clouds: Option<&'a wgpu::BindGroupLayout>, /// The game can hand its rain and snow to the pack. @@ -510,6 +511,7 @@ pub struct PackPost { /// with the page buffer it was made for. block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>, page_groups: HashMap, + gui_size: [u32; 2], /// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`). pub timing: Option, /// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`. @@ -1341,11 +1343,16 @@ fn build_pass( } else if let Some(&buffer) = p.buffers.get(l) { let format = formats[buffer as usize]; let mut state = target(format); - if let (Some(t), Some(b)) = (&mut state, p.blend) - && format - .guaranteed_format_features(features) - .flags - .contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) + if let (Some(t), Some(b)) = ( + &mut state, + p.buffer_blends + .iter() + .find(|(n, _)| *n == buffer) + .map_or(p.blend, |(_, b)| *b), + ) && format + .guaranteed_format_features(features) + .flags + .contains(wgpu::TextureFormatFeatureFlags::BLENDABLE) { t.blend = Some(blend_state(b)); } @@ -1433,7 +1440,15 @@ fn build_pass( depth_write_enabled: Some(g.depth_write), depth_compare: Some(wgpu::CompareFunction::LessEqual), stencil: Default::default(), - bias: Default::default(), + bias: if p.name == "gbuffers_damagedblock" { + wgpu::DepthBiasState { + constant: -10, + slope_scale: -1.0, + clamp: 0.0, + } + } else { + Default::default() + }, }), multisample: Default::default(), fragment: Some(wgpu::FragmentState { @@ -1893,8 +1908,15 @@ impl PackPost { post.block, post.entities_translucent, post.particles, + post.damaged_block, + ]; + let late = [ + false, + false, + post.translucent_late, + post.particles_late, + false, ]; - let late = [false, false, post.translucent_late, post.particles_late]; let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks); let shadow_programs = [post.shadow_entities, post.shadow_block]; let entity_vertices = (programs.iter().chain(&shadow_programs)) @@ -1910,8 +1932,21 @@ impl PackPost { cull: false, }); let entity_passes = programs.map(|p| { - p.zip(entity_geometry.as_ref()) - .and_then(|(p, g)| build(p, Some(g))) + p.zip(entity_geometry.as_ref()).and_then(|(p, g)| { + if p.name == "gbuffers_damagedblock" { + let decal = Geometry { + buffers: g.buffers, + table: g.table, + depth_write: false, + atlas: g.atlas.clone(), + ids: g.ids, + cull: false, + }; + build(p, Some(&decal)) + } else { + build(p, Some(g)) + } + }) }); let shadow_entities = shadow_programs.map(|p| { p.zip(entity_geometry.as_ref()) @@ -2243,6 +2278,7 @@ impl PackPost { draws, block_ids, page_groups: HashMap::new(), + gui_size: world.gui_size, timing: None, center_depth: None, custom, @@ -2868,6 +2904,7 @@ impl PackPost { queue: &wgpu::Queue, entities: &[super::Vertex], spans: &[super::pack_draw::EntitySpan], + crumbling: [f32; 4], rest: &mut Vec, ) { let shadow = self.shadow_casters(); @@ -2884,6 +2921,7 @@ impl PackPost { entities, spans, drawn, + (crumbling, self.gui_size), shadow, rest, ); diff --git a/crates/horizon-client-render/src/gfx/pack_uniforms.rs b/crates/horizon-client-render/src/gfx/pack_uniforms.rs index 9539efb27f..88e63065a2 100644 --- a/crates/horizon-client-render/src/gfx/pack_uniforms.rs +++ b/crates/horizon-client-render/src/gfx/pack_uniforms.rs @@ -404,9 +404,9 @@ impl FrameUniforms { frame.depth_far, ); let camera = frame.camera.as_vec3(); + let first_frame = self.previous.is_none(); let (previous_exact, previous_view, previous_projection) = - self.previous - .unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO)); + self.previous.unwrap_or((frame.camera, view, projection)); let previous_camera = previous_exact.as_vec3(); self.previous = Some((frame.camera, view, projection)); // The camera's whole blocks and the rest, exact however far from the origin. @@ -436,6 +436,10 @@ impl FrameUniforms { }; self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt); let eye = inputs.eye_light.map(|l| (l * 16) as f32); + if first_frame { + self.wetness = inputs.rain; + self.eye_smooth = eye; + } for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) { *smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt); } @@ -849,3 +853,147 @@ mod tests { ); } } + +#[cfg(test)] +mod initial_history_tests { + use super::*; + use crate::hud::HudBatch; + fn float(u: &FrameUniforms, name: &str) -> [f32; 16] { + match u.values[index(name).unwrap()] { + Value::F(v) => v, + _ => panic!("float uniform expected"), + } + } + #[test] + fn first_history_is_current_then_tracks_the_actual_previous_frame() { + let hud = HudBatch::new([800, 450]); + let mut f = frame(&hud); + let mut u = FrameUniforms::default(); + f.camera = DVec3::new(1024.25, -3.5, 11.75); + f.yaw = 21.0; + f.pitch = -16.0; + u.update(&f, &PackInputs::default(), [800, 450]); + assert_eq!( + float(&u, "previousCameraPosition"), + float(&u, "cameraPosition") + ); + for (prior, current) in [ + ("gbufferPreviousProjection", "gbufferProjection"), + ("gbufferPreviousModelView", "gbufferModelView"), + ] { + assert_eq!(float(&u, prior), float(&u, current)); + assert!(float(&u, prior).iter().any(|v| *v != 0.0)); + } + let camera = float(&u, "cameraPosition"); + let view = float(&u, "gbufferModelView"); + let projection = float(&u, "gbufferProjection"); + f.camera = DVec3::new(-100.0, 20.0, 40.0); + f.yaw = 90.0; + f.pitch = 50.0; + f.fov_degrees = 100.0; + u.update(&f, &PackInputs::default(), [1000, 500]); + assert_eq!(float(&u, "previousCameraPosition"), camera); + assert_eq!(float(&u, "gbufferPreviousModelView"), view); + assert_eq!(float(&u, "gbufferPreviousProjection"), projection); + assert_ne!( + float(&u, "previousCameraPosition"), + float(&u, "cameraPosition") + ); + } + #[test] + fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() { + let hud = HudBatch::new([800, 450]); + let f = frame(&hud); + let mut u = FrameUniforms::default(); + let mut input = PackInputs { + rain: 0.75, + eye_light: [3, 14], + ..Default::default() + }; + u.update(&f, &input, [800, 450]); + assert_eq!(float(&u, "wetness")[0], 0.75); + match u.values[index("eyeBrightnessSmooth").unwrap()] { + Value::I(v) => assert_eq!(&v[..2], &[48, 224]), + _ => panic!("integer uniform expected"), + } + input.rain = 0.0; + input.eye_light = [0, 0]; + u.update(&f, &input, [800, 450]); + assert!(float(&u, "wetness")[0] > 0.0); + assert!(u.eye_smooth.iter().all(|v| *v > 0.0)); + } + fn frame(hud: &HudBatch) -> Frame<'_> { + Frame { + eye: glam::DVec3::ZERO, + eye_rotation: [0.0; 2], + first_person: true, + shadow_scene_revision: (0, 0), + shadow_cache: false, + water_plane: None, + weather: &[], + weather_time: crate::weather::Time::new(0, 0.0), + camera: glam::DVec3::new(0.5, 100.0, 0.5), + yaw: 0.0, + pitch: 10.0, + fov_degrees: 85.0, + bob: glam::Mat4::IDENTITY, + spin: glam::Mat4::IDENTITY, + sky: crate::environment::Sky::default(), + distance: 12.0, + detail_distance: 12.0, + depth_far: 1000.0, + underwater: false, + cave_culling: false, + title: true, + sky_blocked: false, + skybox: false, + end_sky: false, + end_sky_sprite: None, + environment_fog: None, + water_fog: [0.0; 3], + fog: None, + dark_disc: false, + outline: Vec::new(), + outline_after_terrain: false, + outline_high_contrast: false, + debug_lines: &[], + debug_lines_on_top: &[], + debug_crosshair: &[], + hud, + glint_alpha: 0.75, + glint_ticks: 0.0, + nether_lighting: false, + entities: &[], + entity_motion: &[], + colored_lights: &[], + emissive_layers: &[], + shadow_only: &[], + entity_spans: &[], + glowing: &[], + dynamic_lights: &[], + clouds: None, + lightning: Default::default(), + additive: &[], + see_through: &[], + celestial: &[], + stars: None, + hand: &[], + hand_fov_degrees: 70.0, + screen: &[], + pack: Default::default(), + border: None, + game_time: 0.0, + shader_game_time: 0.0, + end_portal_sprites: [[0.0; 4]; 2], + crumbling_sprite: [0.0; 4], + ticks: 0, + darkness: (0.0, 0.0), + night_vision: 0.0, + block_flicker: 0.0, + boss_darken: 0.0, + menu_blur: None, + chunk_fade: 0.0, + post_effects: crate::post_chain::Request::none(), + } + } +} diff --git a/crates/horizon-client-render/src/gfx/world_post.rs b/crates/horizon-client-render/src/gfx/world_post.rs index 91cf3d588f..4eede65a7e 100644 --- a/crates/horizon-client-render/src/gfx/world_post.rs +++ b/crates/horizon-client-render/src/gfx/world_post.rs @@ -10,6 +10,8 @@ use glam::{DMat4, DVec3, Mat4, Vec2}; +/// The least a world is scaled to when memory runs out. +const MIN_SCALE: f32 = 0.25; /// The TAA history format: 8 bits would band as frames accumulate. const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; @@ -322,12 +324,65 @@ impl Post { self.target = None; return None; } - let (size, output) = self.sizes(window); - let history_size = self.taa_on.then_some(output); - if self - .target + // The old target goes first: a slider drag would otherwise hold the old and the new + // targets together, and the sum is what runs a small GPU out of memory. + loop { + let (size, output) = self.sizes(window); + let history_size = self.taa_on.then_some(output); + if self + .target + .as_ref() + .is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size) + { + break; + } + self.target = None; + // Errors raised are fatal, so catch an out-of-memory one and scale down. A texture + // that failed is invalid, so its view fails validation too: catch that as well. + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory); + let target = self.build_target(device, size, history_size); + let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop())); + if error.is_none() { + // New textures: no history yet. + self.taa_this_frame.jitter[2] = 1.0; + self.target = Some(target); + break; + } + drop(target); + if self.scale <= MIN_SCALE { + log::warn!( + "Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size", + error.map_or_else(String::new, |e| e.to_string()) + ); + self.scale = 1.0; + self.target = None; + return None; + } + let reduced = (self.scale * 0.75).max(MIN_SCALE); + log::warn!( + "Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})", + self.scale, + error.map_or_else(String::new, |e| e.to_string()) + ); + self.scale = reduced; + if self.scale == 1.0 && !self.antialias && !self.taa_on && !always { + return None; + } + } + self.target .as_ref() - .is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size) + .map(|t| (t.color.clone(), t.depth.clone())) + } + + /// The world's colour and depth targets at `size`, and TAA's history when `history_size` + /// is set. + fn build_target( + &self, + device: &wgpu::Device, + size: [u32; 2], + history_size: Option<[u32; 2]>, + ) -> WorldTarget { { let make_tex = |label, size: [u32; 2], format, usage| { device.create_texture(&wgpu::TextureDescriptor { @@ -403,19 +458,14 @@ impl Post { show, }) }); - // New textures: no history yet. - self.taa_this_frame.jitter[2] = 1.0; - self.target = Some(WorldTarget { + WorldTarget { size, color, depth, group, taa, - }); + } } - self.target - .as_ref() - .map(|t| (t.color.clone(), t.depth.clone())) } /// The size of the world's own target, when it has one. diff --git a/crates/horizon-client-render/src/graphics_ui.rs b/crates/horizon-client-render/src/graphics_ui.rs index 1847533eb7..02c10f4625 100644 --- a/crates/horizon-client-render/src/graphics_ui.rs +++ b/crates/horizon-client-render/src/graphics_ui.rs @@ -2226,4 +2226,112 @@ mod tests { } } } + /// Horizon's graphics captions are English sentences that the language table keys by + /// themselves, so a caption, description or value that is not in `en_us.json` shows in + /// English in every language. Every text the three pages show, with each setting cycled + /// through its values (#1927). + #[test] + fn every_graphics_text_is_in_the_language_table() { + let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || { + for hdr in [false, true] { + let mut s = Settings::default(); + s.horizon.hdr = hdr; + s.horizon.taa = true; + for level in [Level::Simple, Level::Advanced, Level::Hdr] { + for _ in 0..8 { + for row in extra_rows(level, &s) { + match row { + Row::Header(name) => { + lang::get(name); + } + Row::Setting(row) => { + row.item.label(&s); + let text = description(row.item, &s, row.text); + lang::get(text); + lang::get(row.cost.label()); + if let Some(why) = row.item.blocked(&s) { + lang::get(why); + } + } + } + } + for row in extra_rows(level, &s) { + if let Row::Setting(Setting { + item: Item::Horizon(h), + .. + }) = row + { + h.cycle(&mut s, false); + } + } + } + } + } + for button in ["Advanced...", "Simple...", "Done"] { + lang::get(button); + } + }); + // `labelled` also looks up values that are already words in the language ("An") or + // numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is + // a name. + let missed: Vec<_> = missed + .into_iter() + .filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto (")) + .filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str())) + .collect(); + assert_eq!(missed, Vec::::new()); + } + /// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927). + #[test] + fn newer_graphics_settings_are_translated() { + let mut s = Settings::default(); + s.horizon.leaf_culling = LeafCullingMode::Hollow; + s.horizon.entity_culling = EntityCullingMode::Asynchronous; + for (code, leaf, entity, leaf_text) in [ + ( + "de_de", + "Laub-Culling: Hohl", + "Objekt-Culling: Asynchron", + "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.", + ), + ( + "pl_pl", + "Pomijanie liści: Puste", + "Pomijanie bytów: Asynchroniczne", + "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.", + ), + ( + "uk_ua", + "Відсікання листя: Порожнє", + "Відсік. сутностей: Асинхронно", + "Прибирає грані між листям: швидше, але прогалини прозорі.", + ), + ( + "zh_cn", + "树叶剔除: 镂空", + "实体剔除: 异步", + "省略树叶之间的面:更快,但缝隙会透视。", + ), + ( + "fr_fr", + "Occlusion feuillage: Creux", + "Occlusion des entités: Asynchrone", + "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.", + ), + ] { + let (shown, _) = lang::scoped(lang::Language::load(&[], code), || { + let item = |h| Item::Horizon(h); + ( + item(H::LeafCulling).label(&s).plain(), + item(H::EntityCulling).label(&s).plain(), + lang::get(description(item(H::LeafCulling), &s, "")), + ) + }); + assert_eq!( + shown, + (leaf.into(), entity.into(), leaf_text.into()), + "{code}" + ); + } + } } diff --git a/crates/horizon-client-render/src/hdr.wgsl b/crates/horizon-client-render/src/hdr.wgsl index b053817c62..ee800c8b63 100644 --- a/crates/horizon-client-render/src/hdr.wgsl +++ b/crates/horizon-client-render/src/hdr.wgsl @@ -20,6 +20,8 @@ struct Params { black: f32, // The HUD and menus' brightness as a share of paper white. ui: f32, + // HDR lighting: what the strongest light multiplies a surface by, 1 for none. + scene: f32, // Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom // its fully lifted pixels get. share: vec4, @@ -53,7 +55,7 @@ fn shows_world(c: vec3, w: vec3) -> bool { return true; } let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0); - return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0); + return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0); } // Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first. @@ -168,8 +170,23 @@ fn calibration_nits(p: vec2, size: vec2) -> f32 { // lower the brighter, and where in the band how much (hdr_mark). The hand // writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds. var lifted = false; - if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 - && (hand || (!covered && shows_world(c, w.rgb))) { + let shows = params.world > 0.5 && params.peak > 1.0 + && (hand || (!covered && shows_world(c, w.rgb))); + // HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's + // light is albedo times lightmap, so this is the lightmap going past 1. It eases into the + // peak from 60% of it, so bright textures keep their detail. + if shows && w.a >= 0.75 { + let top = max(light.r, max(light.g, light.b)); + let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24; + let v = top * gain; + let knee = max(0.6 * params.peak, 1.0); + var out = v; + if v > knee && params.peak > knee { + out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee))); + } + light *= select(gain, out / max(top, 1e-4), v > knee); + } + if shows && w.a < 0.75 { let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0)); let tier = 3u - band; let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0); diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index 077e2070c6..d0586102f9 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -11,6 +11,7 @@ pub mod biome; pub mod block_edit; pub mod block_entities; pub mod block_entity_anim; +pub mod block_entity_cem; pub mod block_entity_render; pub mod border_render; pub mod breaking; diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index 8761776369..4bf689ec09 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection; use horizon_core::{ biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed, }; -use horizon_optifine::ctm::connect::Face as CtmFace; +use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace}; use horizon_world::{ block_render::FaceVisibility, fluid::{FluidKind, FluidState}, @@ -1379,6 +1379,14 @@ impl Mesher<'_> { [world.x, world.y, world.z], |[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)], || Some(tints.biome(pos[0], pos[1], pos[2])), + // A pane's tiles are drawn with the cube's UVs (below), so that is its texture. + || { + if state.pane { + Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize])) + } else { + CtmAxes::texture(&q.positions, &q.uv) + } + }, |overlay| { if let Some(slot) = overlays.get_mut(count) { *slot = Some(overlay); diff --git a/crates/horizon-client-render/src/native_emissive.rs b/crates/horizon-client-render/src/native_emissive.rs index 4a684a3526..ce60644fb7 100644 --- a/crates/horizon-client-render/src/native_emissive.rs +++ b/crates/horizon-client-render/src/native_emissive.rs @@ -28,10 +28,18 @@ pub fn load(root: &Path, sprites: &mut Sprites) { } } +/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by +/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id. +fn tile_file(name: &str) -> Option<&str> { + name.strip_prefix("assets/")?; + name.strip_suffix(".png") +} + /// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws; /// items draw from the HUD atlas): for each -/// `:block/` whose `.png` sits beside it, the overlay is loaded too and -/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of +/// `:block/`, or connected-texture tile `assets//.../.png`, whose +/// `.png` sits beside it, the overlay is loaded too and paired with it, blocks +/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of /// the table). A pack without such files adds nothing. pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> { let suffix = horizon_optifine::emissive::suffix(root); @@ -41,21 +49,34 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String) for layer in sprites.layers.values() { *sharing.entry(*layer).or_default() += 1; } - let bases: Vec = sprites + let mut bases: Vec = sprites .layers .iter() .filter(|(name, layer)| { sharing[layer] == 1 - && name + && (name .split_once(':') .is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix)) + || tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix))) }) .map(|(name, _)| name.clone()) .collect(); + // Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out. + bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone())); let mut pairs = Vec::new(); for base in bases { - let overlay = format!("{base}{suffix}"); - let file = crate::atlas_sources::texture_path(root, &overlay, "textures"); + // A tile is named by its file, a block sprite by its id. + let (overlay, file) = match tile_file(&base) { + Some(stem) => { + let overlay = format!("{stem}{suffix}.png"); + (overlay.clone(), root.join(overlay)) + } + None => { + let overlay = format!("{base}{suffix}"); + let file = crate::atlas_sources::texture_path(root, &overlay, "textures"); + (overlay, file) + } + }; if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) { continue; } @@ -71,7 +92,12 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String) continue; } // A file that doesn't load would glow as the missing texture: no pair instead. - match sprites.try_insert(root, &overlay) { + let loaded = if overlay.starts_with("assets/") { + sprites.insert_file(root, &overlay) + } else { + sprites.try_insert(root, &overlay) + }; + match loaded { Ok(_) => pairs.push((base, overlay)), Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"), } @@ -192,13 +218,40 @@ mod tests { let props = root.join("assets/minecraft/optifine/emissive.properties"); std::fs::create_dir_all(props.parent().unwrap()).unwrap(); std::fs::write(&props, "suffix.emissive=_glow").unwrap(); + // Connected-texture tiles pair with the overlay beside them. + let tiles = root.join("assets/minecraft/optifine/ctm/lamp"); + png(&tiles.join("0.png"), 230); + png(&tiles.join("0_glow.png"), 240); + png(&tiles.join("1.png"), 250); + // A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same. + png(&root.join("assets/minecraft/optifine/glass/2.png"), 245); + png( + &root.join("assets/minecraft/optifine/glass/2_glow.png"), + 246, + ); + for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] { + sprites + .insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png")) + .unwrap(); + } let pairs = load_optifine(&root, &mut sprites); assert_eq!( pairs, - [( - "minecraft:block/deepslate".into(), - "minecraft:block/deepslate_glow".into() - )] + // Blocks first, then tiles. + [ + ( + "minecraft:block/deepslate".into(), + "minecraft:block/deepslate_glow".into() + ), + ( + "assets/minecraft/optifine/ctm/lamp/0.png".into(), + "assets/minecraft/optifine/ctm/lamp/0_glow.png".into() + ), + ( + "assets/minecraft/optifine/glass/2.png".into(), + "assets/minecraft/optifine/glass/2_glow.png".into() + ), + ] ); std::fs::remove_dir_all(&root).unwrap(); } diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index c6b96c3926..767dc465c1 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -5368,6 +5368,61 @@ mod tests { assert!(ui.resource_error.is_none()); } + /// The texts of an options page, as laid out in `code`'s language, and the keys it lacked. + fn page_texts(code: &str, pages: &[Page]) -> (Vec, Vec) { + let s = Settings::default(); + lang::scoped(lang::Language::load(&[], code), || { + let mut texts = Vec::new(); + for page in pages { + let mut ui = OptionsUi::default(); + ui.show(*page); + for w in ui.layout(&s, 480.0, 4000.0).0 { + match w.kind { + WKind::Button { label: t, .. } + | WKind::Slider { label: t, .. } + | WKind::Text { text: t, .. } => texts.push(t.plain()), + _ => {} + } + } + } + texts + }) + } + + /// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and + /// "Hotbar: Previous Slot" in every language (#1927). + #[test] + fn horizon_option_pages_follow_the_language() { + let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]); + for want in [ + "Splitscreen …", + "Partikel", + "Blockpartikel weg: Aus", + "Weicher Chat: Aus", + "Nachrichtenmarkierung: An", + "Kanäle für UI-Töne: Aus", + "Schnellzugriffsleiste: Vorheriger Platz", + "Schnellzugriffsleiste: Nächster Platz", + ] { + assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}"); + } + // Every key these pages ask for is in the table; vanilla's own keys (`options.*`, + // `key.*`: dotted ids without spaces) come from the prepared resources, which this + // language does not have. + let missed: Vec<_> = missed + .into_iter() + .filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.')) + .collect(); + assert_eq!(missed, Vec::::new()); + let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]); + for want in [ + "Ukryj cząstki bloków: Wył.", + "Pasek szybkiego wyboru: poprzednie pole", + ] { + assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}"); + } + } + #[test] fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() { let atlas = atlas(); diff --git a/crates/horizon-client-render/src/particles.rs b/crates/horizon-client-render/src/particles.rs index fc4a08fb2e..4fbb240e99 100644 --- a/crates/horizon-client-render/src/particles.rs +++ b/crates/horizon-client-render/src/particles.rs @@ -4093,6 +4093,47 @@ impl ParticleEngine { } } + /// The particles that `entries` name, as lights: where each is this frame, its entry's colour + /// as RGB and reach, and its alpha as the intensity. + pub fn lights( + &self, + entries: &[horizon_shader_api::manifest::ParticleLight], + partial: f32, + ) -> Vec<([f64; 3], u32, f32, f32)> { + let shining: Vec<(ParticleType, u32, f32)> = entries + .iter() + .flat_map(|e| { + let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32); + e.particles.iter().filter_map(move |name| { + let name = if name.contains(':') { + name.clone() + } else { + format!("minecraft:{name}") + }; + Some(( + ParticleType::from_name(&name)?, + r << 16 | g << 8 | b, + e.reach, + )) + }) + }) + .collect(); + if shining.is_empty() { + return Vec::new(); + } + let f = f64::from(partial); + self.particles + .iter() + .filter(|p| p.rgba[3] > 0.0) + .filter_map(|p| { + let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?; + let at = + std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f); + Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0))) + }) + .collect() + } + /// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`). pub fn render( &self, diff --git a/crates/horizon-client-render/src/screen_draw.rs b/crates/horizon-client-render/src/screen_draw.rs index 697b0e6a7c..f2acb0e08e 100644 --- a/crates/horizon-client-render/src/screen_draw.rs +++ b/crates/horizon-client-render/src/screen_draw.rs @@ -133,6 +133,21 @@ pub fn text(out: &mut Vec, text: Text, x: i32, y: i32, color: i32, shadow: }); } } +/// `graphics.text` on text the caller already styled, drawn as it is: a `§` +/// in it stays a character, as `FormattedCharSequence.forward` +/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box +/// builds its own sequence that way, so a `§` in its value shows. +pub fn text_as_typed(out: &mut Vec, text: Text, x: i32, y: i32, color: i32, shadow: bool) { + if (color as u32) >> 24 != 0 { + out.push(Draw::Text { + text, + x, + y, + color, + shadow, + }); + } +} /// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`, /// with their index in it and their style; legacy `§` codes restyle what @@ -186,6 +201,11 @@ impl Measure<'_> { pub fn width(&self, t: &Text) -> i32 { self.0.width_text(&t.resolve_legacy()) as i32 } + /// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a + /// `§` in the text counts as the glyph it is drawn as. + pub fn width_drawn(&self, t: &Text) -> i32 { + self.0.width_drawn(t) as i32 + } pub fn width_str(&self, s: &str) -> i32 { self.0.width(s) as i32 } @@ -864,8 +884,8 @@ impl TextField { displayed.clone() }; let t = styled(half, self.display); - let w = m.width(&t); - text(out, t, draw_x, ty, color, self.text_shadow); + let w = m.width_drawn(&t); + text_as_typed(out, t, draw_x, ty, color, self.text_shadow); draw_x += w + 1; } let insert = self.cursor < len || len >= self.max_length; @@ -878,7 +898,7 @@ impl TextField { } if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len { let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor); - text(out, t, draw_x, ty, color, self.text_shadow); + text_as_typed(out, t, draw_x, ty, color, self.text_shadow); } if let Some(hint) = &self.hint && displayed.is_empty() diff --git a/crates/horizon-client-render/src/spans.rs b/crates/horizon-client-render/src/spans.rs index 7027b3fa16..0b0e8bf2f8 100644 --- a/crates/horizon-client-render/src/spans.rs +++ b/crates/horizon-client-render/src/spans.rs @@ -46,6 +46,30 @@ pub fn time(name: &'static str, f: impl FnOnce() -> T) -> T { SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms })); out } +/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since +/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off. +pub struct Laps(Option<(Instant, Option)>); +/// Start timing the steps of a function (see [`Laps`]). +#[inline] +pub fn laps() -> Laps { + Laps(enabled().then(|| (Instant::now(), thread_cpu()))) +} +impl Laps { + /// Record the step that just ended under `name`, and start the next. + #[inline] + pub fn lap(&mut self, name: &'static str) { + let Some((started, cpu)) = &mut self.0 else { + return; + }; + let (now, now_cpu) = (Instant::now(), thread_cpu()); + let ms = (now - *started).as_secs_f32() * 1000.0; + let cpu_ms = now_cpu + .zip(*cpu) + .map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0); + SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms })); + (*started, *cpu) = (now, now_cpu); + } +} /// The CPU time this thread has used, where the platform tells it: a tick /// with more wall time than CPU time waited (for a lock, or for a core on a /// busy machine). @@ -293,6 +317,25 @@ mod tests { assert!(super::take().is_empty()); } + /// Laps record the steps between them while spans are on, and nothing while off. + #[test] + fn laps_time_consecutive_steps() { + let _ = super::take(); + let mut off = super::laps(); + off.lap("unseen"); + super::enable(true); + let mut laps = super::laps(); + std::thread::sleep(std::time::Duration::from_millis(2)); + laps.lap("first"); + laps.lap("second"); + super::enable(false); + off.lap("still unseen"); + let spans = super::take(); + let names: Vec<_> = spans.iter().map(|s| s.name).collect(); + assert_eq!(names, ["first", "second"]); + assert!(spans[0].ms >= 2.0); + } + /// The thread's CPU clock moves with work done on it, not with time /// spent asleep. #[cfg(any(unix, all(windows, target_arch = "x86_64")))] diff --git a/crates/horizon-client-render/src/sprites.rs b/crates/horizon-client-render/src/sprites.rs index 48521e5b7b..5acfe686c9 100644 --- a/crates/horizon-client-render/src/sprites.rs +++ b/crates/horizon-client-render/src/sprites.rs @@ -413,6 +413,119 @@ impl Sprites { }; self.register(root, resource, decoded) } + /// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by + /// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer + /// (named `fusion//#`); a fully transparent one is `None`, as Fusion skips + /// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole + /// grids one below the other, or with `per_tile` each cell is a column of its tile's frames. + /// The sheet's own layer, if models already show it, shows its first tile from then on. + pub fn insert_tiles( + &mut self, + root: &Path, + name: &str, + (columns, rows): (u32, u32), + per_tile: bool, + ) -> Result>> { + let path = crate::atlas_sources::texture_path(root, name, "textures"); + let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) { + Ok(text) => Some(serde_json::from_str::< + BTreeMap>, + >(&text)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let animation = metadata + .as_ref() + .and_then(|m| m.get("animation")) + .map(|v| v.get().to_owned()); + // The sheet's `texture` section is each tile's. + let texture = texture_section(metadata.as_ref()); + let strategy = texture + .as_ref() + .and_then(|t| t["mipmap_strategy"].as_str()) + .and_then(crate::mipmap::Strategy::parse) + .unwrap_or_default(); + let alpha_cutoff_bias = texture + .as_ref() + .and_then(|t| t["alpha_cutoff_bias"].as_f64()) + .unwrap_or(0.0) as f32; + let (width, height, pixels) = read_rgba(&path)?; + let side = width / columns.max(1); + // How far down the next frame of a tile is, and the next row of tiles. + let (frame_step, row_step) = match per_tile { + true => (side, height / rows.max(1)), + false => (rows * side, side), + }; + let frames = match per_tile { + true => row_step / side.max(1), + false => height / frame_step.max(1), + }; + let whole = match per_tile { + true => height % rows.max(1) == 0 && row_step % side.max(1) == 0, + false => height % frame_step.max(1) == 0, + }; + if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole { + return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into()); + } + let tiles = (0..columns * rows) + .map(|tile| { + let frames: Vec> = (0..frames) + .map(|f| { + let (x, y) = ( + tile % columns * side, + tile / columns * row_step + f * frame_step, + ); + (y..y + side) + .flat_map(|row| { + let start = ((row * width + x) * 4) as usize; + pixels[start..start + side as usize * 4].iter().copied() + }) + .collect() + }) + .collect(); + let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4)); + if alphas().all(|&a| a == 0) { + return Ok(None); + } + let decoded = crate::sprite_cache::Decoded { + side, + animation: animation.clone().filter(|_| frames.len() > 1), + strategy, + alpha_cutoff_bias, + has_transparent: alphas().any(|&a| a == 0), + opaque: alphas().all(|&a| a == 255), + translucent: alphas().any(|&a| a != 0 && a != 255), + frames, + }; + let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/")); + self.register(root, &tile_name, decoded).map(Some) + }) + .collect::>>()?; + // Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than + // the whole sheet squeezed square, whose size would set every layer's. + if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) { + self.show_as(own, first); + } + Ok(tiles) + } + /// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels. + fn show_as(&mut self, layer: u32, source: u32) { + self.still.retain(|_, l| *l != layer); + let (l, s) = (layer as usize, source as usize); + self.native[l] = self.native[s].clone(); + self.opaque[l] = self.opaque[s]; + self.translucent[l] = self.translucent[s]; + self.mips[l] = self.mips[s]; + self.pbr[l] = self.pbr[s].clone(); + self.animations.retain(|a| a.layer != layer); + if let Some(animation) = self.animations.iter().find(|a| a.layer == source) { + let animation = SpriteAnimation { + layer, + ..animation.clone() + }; + self.animations.push(animation); + } + } /// Read and decode one texture and its `.mcmeta`. fn decode(root: &Path, name: &str) -> Result { if !name.contains(':') { @@ -762,6 +875,75 @@ mod tests { fs::remove_dir_all(&root).unwrap(); } #[test] + fn tile_sheets_cut_into_a_layer_per_tile() { + let root = + std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id())); + let file = root.join("assets/minecraft/textures/block/sheet.png"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + // 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green + // y; the middle column is transparent, and the top-right tile half see-through. + let (w, h) = (6u32, 4u32); + let pixels: Vec = (0..w * h) + .flat_map(|i| { + let (x, y) = (i % w, i / w); + let alpha = match x / 2 { + 1 => 0, + 2 if y < 2 => 128, + _ => 255, + }; + [x as u8, y as u8, 0, alpha] + }) + .collect(); + let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + // Red and green of each pixel in a layer's frame. + let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> { + sprites.native[layer as usize].1[frame] + .chunks(4) + .map(|p| (p[0], p[1])) + .collect() + }; + let mut sprites = Sprites::default(); + let name = "minecraft:block/sheet"; + let own = sprites.insert(&root, name).unwrap(); + // Still, as a 3 x 2 grid: the empty middle column draws nothing. + let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap(); + // The sheet's own layer now shows the top-left tile, at a tile's size. + assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]); + assert_eq!( + tiles.iter().map(Option::is_some).collect::>(), + [true, false, true, true, false, true] + ); + let bottom_left = tiles[3].unwrap(); + assert_eq!( + texels(&sprites, bottom_left, 0), + [(0, 2), (1, 2), (0, 3), (1, 3)] + ); + assert!(sprites.translucent[tiles[2].unwrap() as usize]); + assert!(sprites.opaque[bottom_left as usize]); + // Animated, as one row of tiles: two frames, the grid's top and then its bottom half; + // per tile, each cell is the column of a tile's frames, which here is the same. + fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap(); + for per_tile in [false, true] { + let mut sprites = Sprites::default(); + let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap(); + let first = tiles[0].unwrap(); + assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]); + assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]); + assert_eq!(sprites.animations.len(), 2, "two non-empty tiles"); + } + // Not a grid of square tiles. + assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err()); + assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err()); + fs::remove_dir_all(&root).unwrap(); + } + #[test] fn custom_item_texture_ids_load_with_their_animation() { let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id())); let file = root.join("assets/minecraft/optifine/cit/a.png"); diff --git a/crates/horizon-client-render/src/terrain.wgsl b/crates/horizon-client-render/src/terrain.wgsl index 6b990664e4..158bb30992 100644 --- a/crates/horizon-client-render/src/terrain.wgsl +++ b/crates/horizon-client-render/src/terrain.wgsl @@ -37,6 +37,9 @@ struct Globals { // HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright, // [0] where the lift starts, [1] where it is full. hdr_windows: array, 2>, + // HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`), + // y what the sky and fog get: the same, but none under water. + hdr_light: vec4, } @group(0) @binding(0) var globals: Globals; @group(0) @binding(1) var sprites: texture_2d_array; @@ -171,6 +174,8 @@ struct TerrainOut { // How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not // at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over. @location(5) @interpolate(flat) emission: u32, + // HDR lighting: block and sky light, as levels 0 to 15. + @location(11) scene: vec2, @location(6) @interpolate(flat) fade: u32, // A distant tile's tile-relative position, for its fade (`faded`). @location(7) local: vec3, @@ -361,6 +366,22 @@ fn left_out() -> TerrainOut { return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz, origin.w); } +// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the +// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun. +fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 { + let b = smoothstep(11.0, 15.0, block); + let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x * facing; + return max(b, s); +} +// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75. +fn hdr_scene_alpha(scene: f32) -> f32 { + return 1.0 - 0.24 * scene; +} +// The face's normal, from the fragment's derivatives, for how it faces the sun. +var hdr_normal: vec3; +fn hdr_facing() -> f32 { + return mix(0.45, 1.0, max(dot(hdr_normal, globals.sun.xyz), 0.0)); +} fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4, light: vec2, origin: vec3, w: f32) -> TerrainOut { let pos = position + origin; @@ -384,6 +405,7 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4 0u { out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a); } @@ -426,6 +448,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 { } // `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard. @fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4 { + hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local))); let texel = sprite(in.uv, in.layer & 0x7fffu); var color = texel * in.color; color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility); @@ -440,6 +463,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 { fn solid_color(in: TerrainOut) -> vec4 { let dx = dpdx(in.local); let dy = dpdy(in.local); + hdr_normal = normalize(cross(dy, dx)); if faded(in.fade, in.local, dx, dy) { discard; } // Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout. let texel = sprite(in.uv, in.layer & 0x7fffu); @@ -456,15 +480,24 @@ fn solid_finish(in: TerrainOut, texel: vec4, color: vec4) -> vec4 // texel's own brightness, so its light level and face shading don't change // it. Only pixels as bright as their sprite's threshold may: its bright // parts, or all of it. Nothing else reads the frame's alpha. - var emissive = 1.0; + // Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a + // fully fogged mountain would stand out from the fog behind it. + let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), + linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a; + var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), + globals.hdr_light.y, fogged)); if in.emission > 0u { + // A light source's own face shines as one or stays as drawn, without the lighting. + emissive = 1.0; let whole = in.emission > 4u; let tier = select(in.emission, in.emission - 4u, whole); let l = in.layer & 0x7fffu; let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0); let y = brightness(texel.rgb); - if y >= select(t.r, t.g, whole) { - emissive = hdr_mark(tier, y); + // All over means all over: the sprite's range is one frame's, and an animated sprite's + // other frames dip below it. Fog hides a light source as it hides everything else. + if whole || y >= t.r { + emissive = hdr_mark(tier, y * (1.0 - fogged)); } } if in.emissive_layer != 0xffffffffu { @@ -612,7 +645,8 @@ fn sky_along_ray(d: vec3) -> vec3 { return color; } @fragment fn fs_sky(in: SkyOut) -> @location(0) vec4 { - return vec4(sky_along_ray(view_ray(in.ndc)), 1.0); + // `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies. + return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y); } // The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the // far plane, written over the whole depth target with no colour. diff --git a/crates/horizon-client-render/src/ui.rs b/crates/horizon-client-render/src/ui.rs index 46be0cfaee..006a93b595 100644 --- a/crates/horizon-client-render/src/ui.rs +++ b/crates/horizon-client-render/src/ui.rs @@ -384,19 +384,42 @@ pub fn scrolling_text_at( y: f32, now_ms: i64, color: u32, +) { + // `acceptScrollingWithDefaultCenter`: `centerX = (left + right) / 2`. + let center_x = left + ((right - left) / 2.0).floor(); + scrolling_text_around_at( + batch, atlas, text, center_x, left, right, top, bottom, y, now_ms, color, + ); +} + +/// [`scrolling_text_at`] around an explicit `center_x` +/// (`ActiveTextCollector.acceptScrolling`, which `SpriteIconButton.TextAndIcon` +/// calls with the button's middle): a label that fits is centred on `center_x` +/// moved as far as it must to stay between `left` and `right` +/// (`Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)`), so +/// beside an icon it ends where the icon's room begins. A wider one is drawn +/// from the left and slid inside the box. +#[allow(clippy::too_many_arguments)] +pub fn scrolling_text_around_at( + batch: &mut HudBatch, + atlas: &HudAtlas, + text: &Text, + center_x: f32, + left: f32, + right: f32, + top: f32, + bottom: f32, + y: f32, + now_ms: i64, + color: u32, ) { let text = text.resolved(); let width = atlas.font.width_text(&text); let room = right - left; if width <= room { - batch.text_styled( - atlas, - &text, - centered_left(left, room, width), - y, - color, - true, - ); + let half = (width / 2.0).floor(); + let text_x = center_x.max(left + half).min(right - half); + batch.text_styled(atlas, &text, text_x - half, y, color, true); return; } // Scissored to the widget: what does not fit is read as the label @@ -440,6 +463,37 @@ pub fn scrolling_text( ); } +/// `MenuTabBar.MenuTabButton.renderLabel` for the tab at `x, y`: its label +/// inside the tab less 1 px on each side, from the tab's top when selected +/// and 3 px lower when not (`acceptScrollingWithDefaultCenter`, so one wider +/// than the tab slides instead of running into the next tab). White, gray +/// for an inactive tab (`WithInactiveMessage`). +pub fn menu_tab_label( + batch: &mut HudBatch, + atlas: &HudAtlas, + label: &Text, + (x, y, width, height): (f32, f32, f32, f32), + selected: bool, + active: bool, + now_ms: i64, +) { + let top = y + if selected { 0.0 } else { 3.0 }; + let bottom = y + height; + scrolling_text_at( + batch, + atlas, + label, + x + 1.0, + x + width - 1.0, + top, + bottom, + // defaultScrollingHelper: `(top + bottom - lineHeight) / 2 + 1`. + ((top + bottom - 9.0) / 2.0).floor() + 1.0, + now_ms, + if active { u32::MAX } else { 0xFFA0_A0A0 }, + ); +} + /// Button (AbstractButton): nine-slice sprite plus the centred label. pub fn button( batch: &mut HudBatch, @@ -1642,6 +1696,51 @@ mod tests { ); } + /// `MenuTabButton.renderLabel` on a 24 px tab: centred on the tab, its + /// text top at `(0 + 24 - 9) / 2 + 1 = 8` when selected and at + /// `(3 + 24 - 9) / 2 + 1 = 10` when not; white on an active tab, selected + /// or not, `0xA0A0A0` on an inactive one (`WithInactiveMessage`). The + /// Statistics tabs drew every label at 8 and an active unselected one gray. + #[test] + fn a_tab_label_sits_lower_on_an_unselected_tab_and_is_gray_only_when_inactive() { + let atlas = atlas(); + let label = Text::literal("Items"); + let half = (atlas.font.width_text(&label.resolved()) / 2.0).floor(); + // A 124 px tab at x = 100: `(left + right) / 2 = (101 + 223) / 2 = 162`. + let left = 162.0 - half; + for (selected, active, y, color) in [ + (true, true, 8.0, u32::MAX), + (false, true, 10.0, u32::MAX), + (false, false, 10.0, 0xFFA0_A0A0), + ] { + let mut tab = HudBatch::new([640, 480]); + menu_tab_label( + &mut tab, + &atlas, + &label, + (100.0, 0.0, 124.0, 24.0), + selected, + active, + 0, + ); + let mut want = HudBatch::new([640, 480]); + want.text_styled(&atlas, &label.resolved(), left, y, color, true); + let glyphs = |b: &HudBatch| -> Vec<([u32; 2], u32)> { + b.vertices + .iter() + .filter(|v| v.text == 1) + .map(|v| ([v.pos[0].to_bits(), v.pos[1].to_bits()], v.color)) + .collect() + }; + assert!(!glyphs(&want).is_empty()); + assert_eq!( + glyphs(&tab), + glyphs(&want), + "selected {selected}, active {active}" + ); + } + } + #[test] fn slider_track_is_outlined_only_while_dragged() { let (track, handle) = slider_sprites(true, true, false); diff --git a/crates/horizon-client/assets/horizon/lang/de_de.json b/crates/horizon-client/assets/horizon/lang/de_de.json index 0b55e90235..3f4ba7762e 100644 --- a/crates/horizon-client/assets/horizon/lang/de_de.json +++ b/crates/horizon-client/assets/horizon/lang/de_de.json @@ -95,6 +95,7 @@ "UI Brightness": "GUI-Helligkeit", "Battery Mode": "Akkumodus", "Graphics API": "Grafik-API", + "Translucent Batching": "Transparenz-Batching", "Performance Overlay": "Leistungsanzeige", "Full Detail Distance": "Detailweite", "On": "An", @@ -142,6 +143,7 @@ "This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.", "Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.", "Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.", + "Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.", "%s chunks": "%s Chunks", "%s per frame": "%s pro Bild", "%s MiB/frame": "%s MiB/Bild", @@ -164,7 +166,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.", "How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.", "Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.", "Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.", @@ -198,6 +199,7 @@ "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.", "Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.", "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.", + "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Wasser und Glas in einem Puffer: weniger Draw-Calls, etwas mehr VRAM. Nach Neustart.", "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.", "Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.", "horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …", @@ -286,5 +288,53 @@ "horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.", "horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s", "horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …", - "horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …" + "horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …", + "Culling": "Culling", + "Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.", + "Leaf Culling": "Laub-Culling", + "Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.", + "Distant Plant Dither": "Pflanzen-Dithering", + "Keeps fine plants in high-res packs from fading far away.": "Verhindert, dass feine Pflanzen in hochauflösenden Paketen in der Ferne verblassen.", + "Screenshot Quality": "Screenshot-Qualität", + "F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2-Screenshots in diesem Vielfachen der Spielauflösung. Höher dauert länger.", + "Frame Generation": "Bildgenerierung", + "Shows a made-up frame between each two real ones: smoother, a little more lag.": "Zeigt zwischen zwei echten Bildern ein errechnetes: flüssiger, etwas mehr Verzögerung.", + "Shadow Cache": "Schatten-Cache", + "Reuses the pack shadow map when sun, view and world match. Off by default.": "Verwendet die Schattenkarte des Pakets erneut, wenn Sonne, Blick und Welt gleich bleiben. Standardmäßig aus.", + "Synchronous": "Synchron", + "Asynchronous": "Asynchron", + "Solid": "Massiv", + "Solid Aggressive": "Massiv, aggressiv", + "Hollow": "Hohl", + "Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blätter mitten im Blätterdach werden massiv gezeichnet: weniger durchsichtige Schichten.", + "Leaves with one open side draw solid too: faster, chunkier edges.": "Auch Blätter mit einer offenen Seite werden massiv gezeichnet: schneller, gröbere Kanten.", + "Drops faces between leaves: faster, but gaps look through.": "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.", + "Hotbar: Previous Slot": "Schnellzugriffsleiste: Vorheriger Platz", + "Hotbar: Next Slot": "Schnellzugriffsleiste: Nächster Platz", + "horizon.agent.connected.one": "Verbunden: 1 Agentenverbindung", + "horizon.agent.connected.many": "Verbunden: %s Agentenverbindungen", + "horizon.agent.connected.one_ago": "Verbunden: 1 Agentenverbindung, letzter Befehl vor %s s", + "horizon.agent.connected.many_ago": "Verbunden: %s Agentenverbindungen, letzter Befehl vor %s s", + "horizon.agent.waiting": "Wartet auf einen Agenten.", + "horizon.agent.off": "Aus: Agenten können dieses Spiel nicht erreichen.", + "horizon.agent.session_opened": "Für diese Sitzung von dem Programm geöffnet, das das Spiel gestartet hat.", + "horizon.agent.socket": "Socket: %s (Verbindungsdaten kopieren für den vollständigen Pfad)", + "horizon.agent.local_only": "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.", + "horizon.extras.chat_animation": "Weicher Chat", + "horizon.extras.chat_animation.tooltip": "Eine neue Chatzeile gleitet von links herein und blendet dabei ein, statt sofort ganz zu erscheinen.", + "horizon.extras.chat_indicator": "Nachrichtenmarkierung", + "horizon.extras.chat_indicator.tooltip": "Der farbige Balken links neben einer Chatzeile.", + "horizon.extras.header.chat": "Chat", + "horizon.extras.header.particles": "Partikel", + "horizon.extras.header.sound": "Ton", + "horizon.extras.hide_block_particles": "Blockpartikel weg", + "horizon.extras.hide_block_particles.tooltip": "Blendet die Trümmerpartikel aus, die aus einem zerstörten Block sprühen und von dem abbröckeln, den du abbaust. Andere Partikel bleiben.", + "horizon.extras.splitscreen": "Splitscreen", + "horizon.extras.splitscreen.tooltip": "In einer Welt kann ein weiterer Controller als neuer Spieler beitreten: A drücken, wenn gefragt, dann Profil und Spielort wählen.", + "horizon.extras.ui_sound_reserve": "Kanäle für UI-Töne", + "horizon.extras.ui_sound_reserve.tooltip": "Weltgeräusche lassen 16 der 247 Tonkanäle frei, damit eine Flut davon (eine große Explosion) Menü-Klicks nicht verstummen lässt.", + "horizon.pages.open.split_setup": "Splitscreen …", + "horizon.pages.split_settings": "Splitscreen-Einstellungen", + "horizon.pages.split_setup": "Splitscreen", + "horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten." } diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index 271cf30190..95368b7365 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -170,7 +170,6 @@ "How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.", "How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.", "Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.", "Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.", @@ -355,5 +354,36 @@ "horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.", "horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.", "horizon.splitscreen.setup.help.apply": "Play the way the map shows.", - "horizon.splitscreen.setup.help.unchanged": "Nothing changed yet." + "horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.", + "Culling": "Culling", + "Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.", + "Leaf Culling": "Leaf Culling", + "Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.", + "Distant Plant Dither": "Distant Plant Dither", + "Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.", + "Screenshot Quality": "Screenshot Quality", + "F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshots at this many times the game's resolution. Higher takes longer.", + "Frame Generation": "Frame Generation", + "Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.", + "Shadow Cache": "Shadow Cache", + "Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.", + "Synchronous": "Synchronous", + "Asynchronous": "Asynchronous", + "Solid": "Solid", + "Solid Aggressive": "Solid Aggressive", + "Hollow": "Hollow", + "Leaves fully inside a canopy draw solid: fewer see-through layers.": "Leaves fully inside a canopy draw solid: fewer see-through layers.", + "Leaves with one open side draw solid too: faster, chunkier edges.": "Leaves with one open side draw solid too: faster, chunkier edges.", + "Drops faces between leaves: faster, but gaps look through.": "Drops faces between leaves: faster, but gaps look through.", + "Hotbar: Previous Slot": "Hotbar: Previous Slot", + "Hotbar: Next Slot": "Hotbar: Next Slot", + "horizon.agent.connected.one": "Connected: 1 agent connection", + "horizon.agent.connected.many": "Connected: %s agent connections", + "horizon.agent.connected.one_ago": "Connected: 1 agent connection, last command %s s ago", + "horizon.agent.connected.many_ago": "Connected: %s agent connections, last command %s s ago", + "horizon.agent.waiting": "Waiting for an agent.", + "horizon.agent.off": "Off: agents can't reach this game.", + "horizon.agent.session_opened": "Opened for this session by the program that started the game.", + "horizon.agent.socket": "Socket: %s (copy the details for its full path)", + "horizon.agent.local_only": "Local only: agents on this computer, never the network." } diff --git a/crates/horizon-client/assets/horizon/lang/es_es.json b/crates/horizon-client/assets/horizon/lang/es_es.json index 1f6201972c..d404c192a5 100644 --- a/crates/horizon-client/assets/horizon/lang/es_es.json +++ b/crates/horizon-client/assets/horizon/lang/es_es.json @@ -158,7 +158,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.", "How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.", "Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.", "Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.", diff --git a/crates/horizon-client/assets/horizon/lang/fr_fr.json b/crates/horizon-client/assets/horizon/lang/fr_fr.json index 927df43af7..129603d8c6 100644 --- a/crates/horizon-client/assets/horizon/lang/fr_fr.json +++ b/crates/horizon-client/assets/horizon/lang/fr_fr.json @@ -95,6 +95,7 @@ "UI Brightness": "Luminosité UI", "Battery Mode": "Mode batterie", "Graphics API": "API graphique", + "Translucent Batching": "Regroupement translucide", "Performance Overlay": "Infos de perfs", "Full Detail Distance": "Dist. détail", "On": "Oui", @@ -142,6 +143,7 @@ "This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.", "Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.", "Turn HDR on to use it.": "Activez le HDR pour l'utiliser.", + "Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.", "%s chunks": "%s chunks", "%s per frame": "%s par image", "%s MiB/frame": "%s MiB/image", @@ -164,7 +166,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.", "How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.", "Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.", "Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.", @@ -198,6 +199,7 @@ "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.", "Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.", "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.", + "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Eau et verre dans un seul tampon : moins d'appels de dessin, un peu plus de VRAM. Au redémarrage.", "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.", "Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.", "horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...", @@ -340,5 +342,48 @@ "horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.", "horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.", "horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.", - "horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé." + "horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.", + "Culling": "Occlusion", + "Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.", + "Leaf Culling": "Occlusion feuillage", + "Every leaf face. Vanilla's fancy leaves.": "Toutes les faces de feuillage, comme les feuilles détaillées de Minecraft.", + "Distant Plant Dither": "Tramage des plantes", + "Keeps fine plants in high-res packs from fading far away.": "Empêche les plantes fines des packs haute résolution de s'estomper au loin.", + "Screenshot Quality": "Qualité des captures", + "F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures F2 à ce multiple de la résolution du jeu. Plus c'est haut, plus c'est long.", + "Frame Generation": "Génération d'images", + "Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.", + "Shadow Cache": "Cache des ombres", + "Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.", + "Synchronous": "Synchrone", + "Asynchronous": "Asynchrone", + "Solid": "Opaque", + "Solid Aggressive": "Opaque agressif", + "Hollow": "Creux", + "Leaves fully inside a canopy draw solid: fewer see-through layers.": "Les feuilles au cœur d'un feuillage sont opaques : moins de couches transparentes.", + "Leaves with one open side draw solid too: faster, chunkier edges.": "Les feuilles avec un côté ouvert sont aussi opaques : plus rapide, bords plus grossiers.", + "Drops faces between leaves: faster, but gaps look through.": "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.", + "Hotbar: Previous Slot": "Barre d'action : case précédente", + "Hotbar: Next Slot": "Barre d'action : case suivante", + "horizon.agent.connected.one": "Connecté : 1 connexion d'agent", + "horizon.agent.connected.many": "Connecté : %s connexions d'agent", + "horizon.agent.connected.one_ago": "Connecté : 1 connexion d'agent, dernière commande il y a %s s", + "horizon.agent.connected.many_ago": "Connecté : %s connexions d'agent, dernière commande il y a %s s", + "horizon.agent.waiting": "En attente d'un agent.", + "horizon.agent.off": "Désactivé : les agents ne peuvent pas joindre ce jeu.", + "horizon.agent.session_opened": "Ouvert pour cette session par le programme qui a lancé le jeu.", + "horizon.agent.socket": "Socket : %s (copiez les détails pour le chemin complet)", + "horizon.agent.local_only": "Local uniquement : agents sur cet ordinateur, jamais via le réseau.", + "horizon.extras.chat_animation": "Chat fluide", + "horizon.extras.chat_animation.tooltip": "Une nouvelle ligne de chat glisse depuis la gauche en apparaissant progressivement, au lieu d'apparaître d'un coup.", + "horizon.extras.chat_indicator": "Marqueur de message", + "horizon.extras.chat_indicator.tooltip": "La barre colorée à gauche d'une ligne de chat.", + "horizon.extras.header.chat": "Chat", + "horizon.extras.header.particles": "Particules", + "horizon.extras.header.sound": "Son", + "horizon.extras.hide_block_particles": "Masquer particules", + "horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.", + "horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.", + "horizon.extras.ui_sound_reserve": "Canaux pour sons UI", + "horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu." } diff --git a/crates/horizon-client/assets/horizon/lang/pl_pl.json b/crates/horizon-client/assets/horizon/lang/pl_pl.json index 041dc0945c..2f9587145f 100644 --- a/crates/horizon-client/assets/horizon/lang/pl_pl.json +++ b/crates/horizon-client/assets/horizon/lang/pl_pl.json @@ -95,6 +95,7 @@ "UI Brightness": "Jasność interfejsu", "Battery Mode": "Tryb baterii", "Graphics API": "API grafiki", + "Translucent Batching": "Grupowanie półprzezroczystych", "Performance Overlay": "Nakładka wydajności", "Full Detail Distance": "Zasięg detali", "On": "Wł.", @@ -142,6 +143,7 @@ "This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.", "Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.", "Turn HDR on to use it.": "Włącz HDR, aby tego użyć.", + "Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.", "%s chunks": "%s chunków", "%s per frame": "%s na klatkę", "%s MiB/frame": "%s MiB/klatka", @@ -164,7 +166,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.", "How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.", "Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.", "Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.", @@ -198,6 +199,7 @@ "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.", "Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.", "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.", + "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Woda i szkło w jednym buforze: mniej wywołań rysowania, trochę więcej VRAM. Po restarcie.", "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.", "Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.", "horizon.multiplayer.finding_friend": "Szukanie świata znajomego…", @@ -286,5 +288,53 @@ "horizon.osk.delete": "Usuń", "horizon.osk.type": "Wpisz", "horizon.osk.symbols": "Symbole", - "horizon.osk.close": "Zamknij" + "horizon.osk.close": "Zamknij", + "Culling": "Pomijanie", + "Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.", + "Leaf Culling": "Pomijanie liści", + "Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.", + "Distant Plant Dither": "Dithering roślin", + "Keeps fine plants in high-res packs from fading far away.": "Zapobiega zanikaniu drobnych roślin w paczkach wysokiej rozdzielczości w oddali.", + "Screenshot Quality": "Jakość zrzutów ekranu", + "F2 screenshots at this many times the game's resolution. Higher takes longer.": "Zrzuty F2 w tylokrotności rozdzielczości gry. Więcej trwa dłużej.", + "Frame Generation": "Generowanie klatek", + "Shows a made-up frame between each two real ones: smoother, a little more lag.": "Pokazuje wygenerowaną klatkę między każdymi dwiema prawdziwymi: płynniej, trochę większe opóźnienie.", + "Shadow Cache": "Pamięć cieni", + "Reuses the pack shadow map when sun, view and world match. Off by default.": "Używa ponownie mapy cieni paczki, gdy słońce, widok i świat się zgadzają. Domyślnie wyłączone.", + "Synchronous": "Synchroniczne", + "Asynchronous": "Asynchroniczne", + "Solid": "Pełne", + "Solid Aggressive": "Pełne, agresywnie", + "Hollow": "Puste", + "Leaves fully inside a canopy draw solid: fewer see-through layers.": "Liście w środku korony rysują się jako pełne: mniej przezroczystych warstw.", + "Leaves with one open side draw solid too: faster, chunkier edges.": "Liście z jedną otwartą stroną też rysują się jako pełne: szybciej, grubsze krawędzie.", + "Drops faces between leaves: faster, but gaps look through.": "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.", + "Hotbar: Previous Slot": "Pasek szybkiego wyboru: poprzednie pole", + "Hotbar: Next Slot": "Pasek szybkiego wyboru: następne pole", + "horizon.agent.connected.one": "Połączono: 1 połączenie agenta", + "horizon.agent.connected.many": "Połączenia agenta: %s", + "horizon.agent.connected.one_ago": "Połączono: 1 połączenie agenta, ostatnie polecenie %s s temu", + "horizon.agent.connected.many_ago": "Połączenia agenta: %s, ostatnie polecenie %s s temu", + "horizon.agent.waiting": "Czeka na agenta.", + "horizon.agent.off": "Wyłączone: agenci nie mają dostępu do tej gry.", + "horizon.agent.session_opened": "Otwarte na tę sesję przez program, który uruchomił grę.", + "horizon.agent.socket": "Gniazdo: %s (skopiuj dane, aby poznać pełną ścieżkę)", + "horizon.agent.local_only": "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.", + "horizon.extras.chat_animation": "Płynny czat", + "horizon.extras.chat_animation.tooltip": "Nowa linia czatu wsuwa się z lewej i pojawia stopniowo, zamiast wyświetlać się od razu w całości.", + "horizon.extras.chat_indicator": "Znacznik wiadomości", + "horizon.extras.chat_indicator.tooltip": "Kolorowy pasek po lewej stronie linii czatu.", + "horizon.extras.header.chat": "Czat", + "horizon.extras.header.particles": "Cząsteczki", + "horizon.extras.header.sound": "Dźwięk", + "horizon.extras.hide_block_particles": "Ukryj cząstki bloków", + "horizon.extras.hide_block_particles.tooltip": "Ukrywa cząsteczki odłamków, które tryskają ze zniszczonego bloku i kruszą się z tego, który kopiesz. Inne cząsteczki zostają.", + "horizon.extras.splitscreen": "Podzielony ekran", + "horizon.extras.splitscreen.tooltip": "W świecie może dołączyć kolejny kontroler jako nowy gracz: naciśnij A, gdy zapyta, potem wybierz profil i miejsce gry.", + "horizon.extras.ui_sound_reserve": "Kanały dla dźwięków UI", + "horizon.extras.ui_sound_reserve.tooltip": "Dźwięki świata zostawiają wolnych 16 z 247 kanałów, by ich nawał (duży wybuch) nie wyciszył kliknięć w menu.", + "horizon.pages.open.split_setup": "Podzielony ekran…", + "horizon.pages.split_settings": "Ustawienia podzielonego ekranu", + "horizon.pages.split_setup": "Podzielony ekran", + "horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata." } diff --git a/crates/horizon-client/assets/horizon/lang/ru_ru.json b/crates/horizon-client/assets/horizon/lang/ru_ru.json index d17ff3ba26..66b98e28e9 100644 --- a/crates/horizon-client/assets/horizon/lang/ru_ru.json +++ b/crates/horizon-client/assets/horizon/lang/ru_ru.json @@ -157,7 +157,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.", "How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.", "Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.", "Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.", diff --git a/crates/horizon-client/assets/horizon/lang/tr_tr.json b/crates/horizon-client/assets/horizon/lang/tr_tr.json index 21aac401c4..388f119da8 100644 --- a/crates/horizon-client/assets/horizon/lang/tr_tr.json +++ b/crates/horizon-client/assets/horizon/lang/tr_tr.json @@ -158,7 +158,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.", "How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Opak blokların arkasında kalan modelleri gizler. Varlıkların güncellemeleri devam eder. Özel paketler kullanırken kapatın.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.", "Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.", "Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.", diff --git a/crates/horizon-client/assets/horizon/lang/uk_ua.json b/crates/horizon-client/assets/horizon/lang/uk_ua.json index 00e340db65..ab01c4d058 100644 --- a/crates/horizon-client/assets/horizon/lang/uk_ua.json +++ b/crates/horizon-client/assets/horizon/lang/uk_ua.json @@ -95,6 +95,7 @@ "UI Brightness": "Яскравість UI", "Battery Mode": "Режим батареї", "Graphics API": "API графіки", + "Translucent Batching": "Групування напівпрозорого", "Performance Overlay": "Накладка швидкодії", "Full Detail Distance": "Дальність деталей", "On": "Увімк.", @@ -142,6 +143,7 @@ "This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.", "Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.", "Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.", + "Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.", "%s chunks": "%s чанків", "%s per frame": "%s на кадр", "%s MiB/frame": "%s MiB/кадр", @@ -164,7 +166,6 @@ "How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.", "How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.", "Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.", "Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.", "Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.", "Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.", @@ -198,6 +199,7 @@ "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.", "Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.", "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.", + "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вода і скло в одному буфері: менше викликів малювання, трохи більше VRAM. Після перезапуску.", "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.", "Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.", "horizon.multiplayer.finding_friend": "Шукаємо світ друга…", @@ -286,5 +288,53 @@ "horizon.osk.delete": "Видалити", "horizon.osk.type": "Ввести", "horizon.osk.symbols": "Символи", - "horizon.osk.close": "Закрити" + "horizon.osk.close": "Закрити", + "Culling": "Відсікання", + "Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.", + "Leaf Culling": "Відсікання листя", + "Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.", + "Distant Plant Dither": "Дизеринг рослин", + "Keeps fine plants in high-res packs from fading far away.": "Не дає дрібним рослинам у пакетах високої роздільності зникати здалеку.", + "Screenshot Quality": "Якість знімків екрана", + "F2 screenshots at this many times the game's resolution. Higher takes longer.": "Знімки F2 у стільки разів більшій роздільності, ніж гра. Більше значить довше.", + "Frame Generation": "Генерація кадрів", + "Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показує згенерований кадр між кожними двома справжніми: плавніше, трохи більша затримка.", + "Shadow Cache": "Кеш тіней", + "Reuses the pack shadow map when sun, view and world match. Off by default.": "Повторно використовує карту тіней пакета, коли сонце, вигляд і світ збігаються. Типово вимкнено.", + "Synchronous": "Синхронно", + "Asynchronous": "Асинхронно", + "Solid": "Суцільне", + "Solid Aggressive": "Суцільне, агресивно", + "Hollow": "Порожнє", + "Leaves fully inside a canopy draw solid: fewer see-through layers.": "Листя всередині крони малюється суцільним: менше прозорих шарів.", + "Leaves with one open side draw solid too: faster, chunkier edges.": "Листя з одним відкритим боком теж малюється суцільним: швидше, грубіші краї.", + "Drops faces between leaves: faster, but gaps look through.": "Прибирає грані між листям: швидше, але прогалини прозорі.", + "Hotbar: Previous Slot": "Панель швидкого доступу: попередній слот", + "Hotbar: Next Slot": "Панель швидкого доступу: наступний слот", + "horizon.agent.connected.one": "Підключено: 1 з’єднання агента", + "horizon.agent.connected.many": "Підключено з’єднань агента: %s", + "horizon.agent.connected.one_ago": "Підключено: 1 з’єднання агента, остання команда %s с тому", + "horizon.agent.connected.many_ago": "Підключено з’єднань агента: %s, остання команда %s с тому", + "horizon.agent.waiting": "Очікування агента.", + "horizon.agent.off": "Вимкнено: агенти не можуть дістатися цієї гри.", + "horizon.agent.session_opened": "Відкрито на цей сеанс програмою, що запустила гру.", + "horizon.agent.socket": "Сокет: %s (скопіюйте дані, щоб побачити повний шлях)", + "horizon.agent.local_only": "Лише локально: агенти на цьому комп’ютері, ніколи через мережу.", + "horizon.extras.chat_animation": "Плавний чат", + "horizon.extras.chat_animation.tooltip": "Новий рядок чату виїжджає зліва, поступово з’являючись, замість того щоб з’явитися одразу.", + "horizon.extras.chat_indicator": "Позначка повідомлення", + "horizon.extras.chat_indicator.tooltip": "Кольорова смужка ліворуч від рядка чату.", + "horizon.extras.header.chat": "Чат", + "horizon.extras.header.particles": "Частинки", + "horizon.extras.header.sound": "Звук", + "horizon.extras.hide_block_particles": "Без частинок блоків", + "horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.", + "horizon.extras.splitscreen": "Розділений екран", + "horizon.extras.splitscreen.tooltip": "У світі може долучитися ще один геймпад як новий гравець: натисніть A, коли запитає, потім виберіть профіль і де грати.", + "horizon.extras.ui_sound_reserve": "Канали для звуків UI", + "horizon.extras.ui_sound_reserve.tooltip": "Звуки у світі залишають вільними 16 із 247 звукових каналів, щоб їхній потік (великий вибух) не заглушив кліки в меню.", + "horizon.pages.open.split_setup": "Розділений екран…", + "horizon.pages.split_settings": "Налаштування розділеного екрана", + "horizon.pages.split_setup": "Розділений екран", + "horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу." } diff --git a/crates/horizon-client/assets/horizon/lang/zh_cn.json b/crates/horizon-client/assets/horizon/lang/zh_cn.json index 20a557a438..13133d4489 100644 --- a/crates/horizon-client/assets/horizon/lang/zh_cn.json +++ b/crates/horizon-client/assets/horizon/lang/zh_cn.json @@ -89,6 +89,7 @@ "UI Brightness": "界面亮度", "Battery Mode": "电池模式", "Graphics API": "图形 API", + "Translucent Batching": "半透明批处理", "Performance Overlay": "性能叠加层", "Full Detail Distance": "全细节距离", "On": "开", @@ -136,6 +137,7 @@ "This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。", "Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。", "Turn HDR on to use it.": "开启 HDR 后才能使用。", + "Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。", "%s chunks": "%s 个区块", "%s per frame": "每帧 %s", "%s MiB/frame": "每帧 %s MiB", @@ -158,7 +160,6 @@ "How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。", "How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。", "Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。", - "Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。", "Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。", "Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。", "Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。", @@ -192,6 +193,7 @@ "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。", "Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。", "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。", + "Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "水和玻璃放在同一个缓冲区:绘制调用更少,显存略多。下次启动生效。", "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。", "Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。", "horizon.multiplayer.finding_friend": "正在查找好友的世界…", @@ -303,5 +305,36 @@ "horizon.osk.close": "关闭", "horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。", "horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。", - "horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。" + "horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。", + "Culling": "剔除", + "Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。", + "Leaf Culling": "树叶剔除", + "Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。", + "Distant Plant Dither": "远处植物抖动", + "Keeps fine plants in high-res packs from fading far away.": "避免高分辨率资源包中的细小植物在远处逐渐消失。", + "Screenshot Quality": "截图质量", + "F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 截图的分辨率为游戏分辨率的这个倍数。越高耗时越长。", + "Frame Generation": "帧生成", + "Shows a made-up frame between each two real ones: smoother, a little more lag.": "在每两个真实帧之间插入一个生成帧:更流畅,延迟略增。", + "Shadow Cache": "阴影缓存", + "Reuses the pack shadow map when sun, view and world match. Off by default.": "太阳、视角和世界不变时重复使用资源包的阴影贴图。默认关闭。", + "Synchronous": "同步", + "Asynchronous": "异步", + "Solid": "实心", + "Solid Aggressive": "激进实心", + "Hollow": "镂空", + "Leaves fully inside a canopy draw solid: fewer see-through layers.": "树冠内部的树叶绘制为实心:透明层更少。", + "Leaves with one open side draw solid too: faster, chunkier edges.": "只有一面外露的树叶也绘制为实心:更快,边缘更粗糙。", + "Drops faces between leaves: faster, but gaps look through.": "省略树叶之间的面:更快,但缝隙会透视。", + "Hotbar: Previous Slot": "快捷栏:上一个栏位", + "Hotbar: Next Slot": "快捷栏:下一个栏位", + "horizon.agent.connected.one": "已连接:1 个代理连接", + "horizon.agent.connected.many": "已连接:%s 个代理连接", + "horizon.agent.connected.one_ago": "已连接:1 个代理连接,上次命令在 %s 秒前", + "horizon.agent.connected.many_ago": "已连接:%s 个代理连接,上次命令在 %s 秒前", + "horizon.agent.waiting": "正在等待代理。", + "horizon.agent.off": "关闭:代理无法访问此游戏。", + "horizon.agent.session_opened": "由启动游戏的程序为本次会话开启。", + "horizon.agent.socket": "套接字:%s(复制详细信息以获取完整路径)", + "horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。" } diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 66856858fd..43564d3bcf 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -536,6 +536,9 @@ pub fn main() -> Result<()> { let flying = op["value"].as_bool().unwrap(); world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying); world.player.flying = flying; + if let Some(speed) = op.get("speed") { + world.player.flying_speed = single(speed); + } } "trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32, "delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32, diff --git a/crates/horizon-client/src/bin/horizon-ctl/commands.rs b/crates/horizon-client/src/bin/horizon-ctl/commands.rs index 39318b25cb..26db7f4f4a 100644 --- a/crates/horizon-client/src/bin/horizon-ctl/commands.rs +++ b/crates/horizon-client/src/bin/horizon-ctl/commands.rs @@ -214,6 +214,9 @@ pub const COMMANDS: &[Cmd] = &[ mcp: true, local: true), cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)", "Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false), + cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves", + "Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).", + ["horizon-ctl hello"], &[], mcp: true, local: false), cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version", "folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false), cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)", @@ -444,7 +447,7 @@ pub const COMMANDS: &[Cmd] = &[ "Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.", ["horizon-ctl mcp"], &[], mcp: false, local: true), cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false), - cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false), + cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false), cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false), cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary", "The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).", diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs index 808c08613f..cbf2247dc2 100644 --- a/crates/horizon-client/src/crafting.rs +++ b/crates/horizon-client/src/crafting.rs @@ -19,6 +19,30 @@ use std::{ sync::Arc, }; +/// A recipe ingredient of a mod's own kind: the entry named a type in a +/// namespace outside `minecraft:`, and the loader kept it for this call. +/// The kind and the JSON the file wrote are here; fill `items` with the +/// item ids the ingredient matches (item ids only: components are not +/// checked here; a match-time hook follows separately). Duplicates are +/// removed keeping first-seen order. The hook may be called more than +/// once per recipe; the answer must be the same. An unclaimed kind +/// leaves the list empty and the recipe fails, as an unknown kind does. +/// API @API:ingredient@. +#[inline(never)] +pub fn native_ingredient( + kind: &str, + data: &serde_json::Map, + items: &mut Vec, +) { + let _ = (kind, data, items); +} + +/// Keep first-seen order, drop the rest. +fn dedup_keep_order(items: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + items.retain(|i| seen.insert(i.clone())); +} + /// A set of accepted items (expanded from an item, list or tag), and the /// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`). #[derive(Clone, Debug)] @@ -107,6 +131,14 @@ impl Ingredient { .cloned() .collect() } + // A mod's own kind: the hook answers with the item list it + // matches; a bare type name is minecraft's own, not a mod's. + k if k.contains(':') && !k.starts_with("minecraft:") => { + let mut list = Vec::new(); + native_ingredient(k, o, &mut list); + dedup_keep_order(&mut list); + return (!list.is_empty()).then(|| Self::of(list)); + } _ => return None, }; (!kept.is_empty()).then(|| Self::of(kept)) @@ -1913,6 +1945,23 @@ mod tests { /// NeoForge's standard ingredient shapes resolve to their flat item /// lists; a kind the loader doesn't know still fails the file. + #[test] + fn an_unclaimed_ingredient_kind_fails_and_dedup_keeps_order() { + let items = Items::for_tests(HashMap::new()); + let json = |s: &str| serde_json::from_str::(s).unwrap(); + assert!( + Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items,) + .is_none() + ); + assert!( + Ingredient::parse(&json(r#"{"type":"minecraft:future_shape"}"#), &items,).is_none() + ); + assert!(Ingredient::parse(&json(r#"{"type":"aspect"}"#), &items,).is_none()); + let mut list = vec!["b".to_string(), "a".to_string(), "b".to_string()]; + dedup_keep_order(&mut list); + assert_eq!(list, vec!["b".to_string(), "a".to_string()]); + } + #[test] fn neoforge_ingredient_shapes_parse() { let mut tags = HashMap::new(); diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 40c98b84c5..c93e086924 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -2343,6 +2343,20 @@ pub fn fireball_to_nbt( if let Some(power) = power { put(&mut tag, "ExplosionPower", Tag::Byte(power as i8)); } + // Fireball.addAdditionalSaveData: the item it is drawn as, one of the loaded + // item or else a fire charge (`setItem` refuses an empty stack and keeps one). + if matches!( + p.kind, + ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball + ) { + let mut item = tag + .get("Item") + .and_then(item_from_nbt) + .filter(|i| i.count > 0 && &*i.item != "minecraft:air") + .unwrap_or_else(|| crate::items::ItemStack::new("minecraft:fire_charge", 1)); + item.count = 1; + put(&mut tag, "Item", item_to_nbt(&item)); + } Some(tag) } @@ -4673,6 +4687,61 @@ mod fireball_nbt_tests { } } + /// `Fireball.addAdditionalSaveData` / `readAdditionalSaveData`: both fireballs save + /// `Item`, a fire charge by default, and a loaded one stays, as one item. + #[test] + fn a_fireball_saves_its_item() { + let item_of = |tag: &Tag| { + let item = tag.get("Item").and_then(item_from_nbt).unwrap(); + (item.item.to_string(), item.count) + }; + for id in ["minecraft:fireball", "minecraft:small_fireball"] { + let plain = Tag::compound([ + ("id", Tag::String(id.into())), + ("Pos", doubles(&[0.0, 0.0, 0.0])), + ]); + let p = fireball_from_nbt(&plain, None).unwrap(); + let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&plain)).unwrap(); + assert_eq!( + item_of(&back), + ("minecraft:fire_charge".to_owned(), 1), + "{id}" + ); + // A loaded item stays, as a single one. + let mut loaded = plain.clone(); + put( + &mut loaded, + "Item", + item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 5)), + ); + let p = fireball_from_nbt(&loaded, None).unwrap(); + let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&loaded)).unwrap(); + assert_eq!(item_of(&back), ("minecraft:diamond".to_owned(), 1), "{id}"); + // An empty one falls back to the fire charge. + let mut empty = plain.clone(); + put( + &mut empty, + "Item", + item_to_nbt(&crate::items::ItemStack::new("minecraft:air", 1)), + ); + let p = fireball_from_nbt(&empty, None).unwrap(); + let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&empty)).unwrap(); + assert_eq!( + item_of(&back), + ("minecraft:fire_charge".to_owned(), 1), + "{id}" + ); + } + // The wind charges are not `Fireball`s: they save no item. + let wind = Tag::compound([ + ("id", Tag::String("minecraft:wind_charge".into())), + ("Pos", doubles(&[0.0, 0.0, 0.0])), + ]); + let p = fireball_from_nbt(&wind, None).unwrap(); + let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&wind)).unwrap(); + assert!(back.get("Item").is_none()); + } + #[test] fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() { let player = Some([5, 6, 7, 8]); diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 52ceb46e9b..078dd52c19 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -541,6 +541,8 @@ pub struct HandSway { pub y_bob: f32, pub x_bob_o: f32, pub y_bob_o: f32, + /// The yaw `tick_wrapped` has followed so far, unwrapped like vanilla's. + y_rot: f32, } impl HandSway { /// `LocalPlayer.applyInput` with the player's `getXRot` and `getYRot`. @@ -548,9 +550,14 @@ impl HandSway { self.y_bob_o = self.y_bob; self.x_bob_o = self.x_bob; self.x_bob += (x_rot - self.x_bob) * 0.5; - // Vanilla's yaw runs on without wrapping, so `yRot - yBob` is the short way round. Ours - // is kept in -180..180 (a boat turns its rider through it), so take the short way here. - self.y_bob += wrap_degrees(y_rot - self.y_bob) * 0.5; + self.y_bob += (y_rot - self.y_bob) * 0.5; + } + /// `tick` for a yaw kept in -180..180 (a boat turns its rider through the wrap). Vanilla's + /// yaw runs on without wrapping, so `yRot - yBob` is always the short way round; this + /// follows the wrapped yaw by its short steps to give `tick` the same unwrapped yaw. + pub fn tick_wrapped(&mut self, x_rot: f32, y_rot: f32) { + self.y_rot += wrap_degrees(y_rot - self.y_rot); + self.tick(x_rot, self.y_rot); } /// The degrees the hands turn about X, then Y, before either is drawn: /// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs @@ -1406,12 +1413,12 @@ mod action_result_tests { #[test] fn hand_sway_takes_the_short_way_over_the_yaw_wrap() { let mut sway = HandSway::default(); - sway.tick(0.0, 179.0); + sway.tick_wrapped(0.0, 179.0); for _ in 0..20 { - sway.tick(0.0, 179.0); + sway.tick_wrapped(0.0, 179.0); } // The view is still at 179; one tick later it reads -179 (2 degrees on). - sway.tick(0.0, -179.0); + sway.tick_wrapped(0.0, -179.0); let [_, y] = sway.rotation(1.0, 0.0, -179.0); assert!( y.abs() < 0.5, diff --git a/crates/horizon-client/src/furnace.rs b/crates/horizon-client/src/furnace.rs index 9d8ce4392d..dd111e709f 100644 --- a/crates/horizon-client/src/furnace.rs +++ b/crates/horizon-client/src/furnace.rs @@ -6,7 +6,6 @@ use crate::{ items::{ItemStack, Items}, providers::Providers, }; -use std::sync::Arc; /// The furnace methods that read the recipes. pub trait FurnaceCooking { @@ -22,15 +21,15 @@ pub trait FurnaceCooking { /// (`Slot.setByPlayer`; partial takes and merges keep the stack). fn input_clicked(&mut self, recipes: &Recipes, before: Option); /// One server tick. Returns `Some(lit)` when the LIT block state must change. - fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option; -} -fn remainder(item: &str) -> Option<&'static str> { - match item { - "minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => { - Some("minecraft:bucket") - } - _ => None, - } + /// `drops` collects what the furnace lets fall at its position + /// (`Containers.dropItemStack`): the remainder of a fuel when more of it is left. + fn tick( + &mut self, + recipes: &Recipes, + items: &Items, + providers: &Providers, + drops: &mut Vec, + ) -> Option; } impl FurnaceCooking for Furnace { fn set_input(&mut self, recipes: &Recipes, stack: Option) { @@ -59,7 +58,13 @@ impl FurnaceCooking for Furnace { self.set_input(recipes, after); } } - fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option { + fn tick( + &mut self, + recipes: &Recipes, + items: &Items, + providers: &Providers, + drops: &mut Vec, + ) -> Option { let was_lit = self.lit_remaining > 0; let mut lit = false; if self.lit_remaining > 0 { @@ -101,16 +106,19 @@ impl FurnaceCooking for Furnace { (ratio * self.cooking_total as f32).ceil() as i32; } if time > 0 { + // AbstractFurnaceBlockEntity.consumeFuel: the fuel's crafting + // remainder takes the slot once the stack is used up, else + // it is dropped. if let Some(f) = self.items[1].as_mut() { - let item = f.item.clone(); + let remainder = items + .get(&f.item) + .and_then(|i| i.crafting_remainder.as_deref()) + .map(|r| ItemStack::new(r, 1)); f.count -= 1; if f.count == 0 { - self.items[1] = remainder(&item).map(|r| ItemStack { - item: Arc::from(r), - count: 1, - damage: 0, - extra: None, - }); + self.items[1] = remainder; + } else if let Some(remainder) = remainder { + drops.push(remainder); } } lit = true; @@ -171,3 +179,98 @@ fn total_time(time: u32, speed: f32) -> i32 { time as i32 } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{containers::FurnaceKind, items::Items}; + use horizon_world::loot::PackOverlay; + use std::{collections::HashMap, fs}; + + /// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a + /// data root holding that one recipe and an item table with two fuels: `test:coal` + /// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`). + fn burn(fuel: &str, count: u32) -> (Furnace, Vec) { + let root = std::env::temp_dir().join(format!( + "horizon-furnace-remainder-{}-{}-{count}", + std::process::id(), + fuel.replace(':', "-") + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap(); + fs::write( + root.join("data/minecraft/recipe/test_ingot.json"), + br#"{"type":"minecraft:smelting","ingredient":"test:ore","result":{"id":"test:ingot"}}"#, + ) + .unwrap(); + fs::create_dir_all(root.join("data/minecraft/loot_table")).unwrap(); + let fuel_component = + r#"{"minecraft:cooking_fuel": {"burn_time": 200, "speed_multiplier": 1.0}}"#; + fs::write( + root.join("items.json"), + format!( + r#"{{"test:ore": {{}}, "test:ingot": {{}}, "test:coal": {fuel_component}, "test:cell": {fuel_component}, "test:husk": {{}}}}"# + ), + ) + .unwrap(); + fs::write( + root.join(horizon_items::items::MOD_CRAFT_REMAINDERS), + r#"{"test:cell": "test:husk"}"#, + ) + .unwrap(); + let items = Items::catalog(&root, &PackOverlay::NONE).unwrap(); + let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap(); + let providers = Providers::load(&root); + let mut furnace = Furnace { + kind: FurnaceKind::Furnace, + items: [ + Some(ItemStack::new("test:ore", 1)), + Some(ItemStack::new(fuel, count)), + None, + ], + lit_remaining: 0, + lit_total: 0, + cooking_timer: 0, + cooking_total: 0, + speed: 1.0, + recipes_used: HashMap::new(), + }; + let mut drops = Vec::new(); + let lit = furnace.tick(&recipes, &items, &providers, &mut drops); + let _ = fs::remove_dir_all(&root); + assert_eq!(lit, Some(true), "the fuel lit the furnace"); + (furnace, drops) + } + + fn fuel_slot(furnace: &Furnace) -> Option<(&str, u32)> { + furnace.items[1].as_ref().map(|s| (&*s.item, s.count)) + } + + /// `AbstractFurnaceBlockEntity.consumeFuel`: the last of a fuel with a remainder + /// leaves it in the fuel slot. + #[test] + fn the_last_fuel_leaves_its_remainder_in_the_slot() { + let (furnace, drops) = burn("test:cell", 1); + assert_eq!(fuel_slot(&furnace), Some(("test:husk", 1))); + assert!(drops.is_empty()); + } + + /// With more of the fuel left, the remainder is dropped at the furnace. + #[test] + fn a_fuel_still_in_the_stack_drops_its_remainder() { + let (furnace, drops) = burn("test:cell", 3); + assert_eq!(fuel_slot(&furnace), Some(("test:cell", 2))); + assert_eq!(drops, vec![ItemStack::new("test:husk", 1)]); + } + + /// A fuel without a remainder leaves nothing. + #[test] + fn a_fuel_without_a_remainder_leaves_nothing() { + let (furnace, drops) = burn("test:coal", 1); + assert_eq!(fuel_slot(&furnace), None); + assert!(drops.is_empty()); + let (furnace, drops) = burn("test:coal", 2); + assert_eq!(fuel_slot(&furnace), Some(("test:coal", 1))); + assert!(drops.is_empty()); + } +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index fce62c8a1e..9a020c6688 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -115,6 +115,7 @@ mod dragon; mod dynamic_lights; mod enchant_effects; mod end; +mod entity; mod entity_culling; mod event_loop; mod events; @@ -2677,6 +2678,10 @@ struct Game<'a> { cart_models: minecarts::CartModels, /// The boat the player rides. riding: Option, + /// The mount the last `position_rider` put the player on, to tell the tick + /// a seat is taken (`Entity.moveTo`, no camera sweep) from the ones that + /// follow it (`positionRider`, interpolated as usual). + posed_rider: Option, /// The vehicle the player's file names, waiting for it to be in the world /// (`ServerPlayer.loadAndSpawnParentVehicle`). pending_vehicle: Option<[i32; 4]>, @@ -2849,6 +2854,13 @@ struct Game<'a> { /// `remote_handle_input`): picked once, so a creative break does not /// move the use that follows it past the broken block. tick_hit: Option>, + /// The mob of that `hitResult`, picked with the item held at the pick + /// (`LocalPlayer.raycastHitResult`: a spear's `attack_range`), before the + /// tick's number keys change it. + tick_mob: Option>, + /// Number keys since the last tick (`keyHotbarSlots[i].consumeClick`): + /// `Minecraft.handleKeybinds` selects them after the tick's pick. + hotbar_presses: Vec, /// `Player.entityInteractionRange` and `blockInteractionRange`, refreshed /// each tick from the attribute (a server's reach modifier, creative's /// +2): the pick helpers' `&self` reach. @@ -7150,6 +7162,11 @@ impl Game<'_> { self.tick_riding(keys)?; } let riding = self.riding.is_some(); + // A seat that is given up is taken again as a new one: the next mount + // snaps the camera even when it is the same vehicle. + if !riding { + self.posed_rider = None; + } let entity_collisions = self.player_entity_collisions(); let spin_targets = self.spin_targets(); let sprint_window = self.settings.sprint_window; @@ -8554,7 +8571,7 @@ impl Game<'_> { batch.blur_at = batch.vertices.len() as u32; // PackConfirmScreen takes the open screen's place (it comes back once // the prompt is answered): the screen under the prompt isn't drawn. - let screen = if self.server_packs.prompt.is_some() { + let screen = if self.server_packs.prompt.is_some() || self.book.is_some() { Screen::Playing } else { self.screen @@ -8627,7 +8644,7 @@ impl Game<'_> { /// read-only: what a mod draws is its own. fn fire_screen_render_post(&self, batch: &mut HudBatch) { let screen = self.screen; - if screen == Screen::Playing || self.server_packs.prompt.is_some() { + if screen == Screen::Playing || self.server_packs.prompt.is_some() || self.book.is_some() { return; } // slot_count mirrors slots() without allocating; keep them in step. @@ -10456,6 +10473,7 @@ impl Game<'_> { let mut crafted = 0; while let Some((recipe, result)) = self.craft_recipe() { let made = (result.item.clone(), result.count); + let taken = result.clone(); let cells = self.craft_size * self.craft_size; let ingredients = self.craft_grid[..cells].to_vec(); let remainders = world_recipes!(self).remainders( @@ -10496,6 +10514,10 @@ impl Game<'_> { } } crafted += 1; + // `ResultSlot.onTake`: the recipe and the stack taken + // (native_item_crafted). + let player = self.native_acting_id(); + self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken); self.stats.add("crafted", &made.0, made.1 as i32); self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]); if !shift || crafted >= 64 { @@ -10589,6 +10611,8 @@ impl Game<'_> { alpha: f64, mut out: Vec, ) -> Vec { + // Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended. + let mut laps = horizon_client::spans::laps(); self.entity_spans.borrow_mut().clear(); self.entity_motion.borrow_mut().clear(); // LevelExtractor.extract: the transient blocks queued since the last frame. @@ -10604,6 +10628,7 @@ impl Game<'_> { } else { self.yaw }; + laps.lap("eg_prep"); for m in &self.mobs { // Blaze.isOnFire: a charged blaze burns. let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag; @@ -10648,6 +10673,7 @@ impl Game<'_> { &mut out, ); } + laps.lap("eg_fire_mobs"); for item in &self.dropped { if item.fire_ticks > 0 && !self @@ -10672,6 +10698,7 @@ impl Game<'_> { ); } } + laps.lap("eg_fire_items"); for stand in &self.armor_stands { if stand.fire_ticks > 0 && !stand.marker { let scale = if stand.small { 0.5 } else { 1.0 }; @@ -10692,6 +10719,7 @@ impl Game<'_> { ); } } + laps.lap("eg_fire_stands"); // `ItemEntityRenderer`. self.mark_entity(out.len(), "minecraft:item"); self.dropped_item_geometry(camera, alpha, &mut out); @@ -10706,9 +10734,11 @@ impl Game<'_> { self.shelf_geometry(camera, &mut out); self.mark_entity(out.len(), "minecraft:ominous_item_spawner"); self.ominous_item_spawner_geometry(camera, alpha, &mut out); + laps.lap("eg_items_misc"); // Mobs and their projectiles (a span each). self.mob_geometry(camera, alpha, &mut out); self.mark_motion(out.len(), 0); + laps.lap("eg_mobs"); // In first person only a shader pack's shadow pass draws the player. self.mark_player(out.len(), !self.own_body_in_view()); let player_start = out.len(); @@ -10725,6 +10755,7 @@ impl Game<'_> { self.mark_entity(out.len(), "minecraft:evoker_fangs"); self.fangs_geometry(camera, alpha, &mut out); self.block_item_display_geometry(camera, alpha, &mut out); + laps.lap("eg_players"); let mut see_through = std::mem::take(&mut self.see_through_vertices); see_through.clear(); self.name_tag_geometry(camera, alpha, &mut out, &mut see_through); @@ -10732,6 +10763,7 @@ impl Game<'_> { self.debug_renderer_geometry(camera, &mut out); self.text_display_geometry(camera, alpha, &mut out, &mut see_through); self.see_through_vertices = see_through; + laps.lap("eg_name_tags"); // Entity shadows (EntityRenderer.extractShadow). self.mark_translucent_entity(out.len(), "minecraft:entity_shadow"); // A native shader pack's sun shadows already fall under the entities in the Overworld: @@ -10797,6 +10829,7 @@ impl Game<'_> { ); } } + laps.lap("eg_shadows"); // Block entities (BlockEntityRenderers): chests, shulker boxes, banners, skulls... if let Some(r) = &self.renderer { let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32); @@ -10893,6 +10926,7 @@ impl Game<'_> { .render_breaking(&scene, pos, state.0, stage, &mut out); } } + laps.lap("eg_block_entities"); // Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet. self.mark_entity(out.len(), "minecraft:experience_orb"); if let Some(r) = self @@ -10962,6 +10996,7 @@ impl Game<'_> { } } } + laps.lap("eg_orbs"); // Falling blocks use their baked model, including non-cubic shapes. self.mark_entity(out.len(), "minecraft:falling_block"); for f in &self.falling { @@ -11026,6 +11061,7 @@ impl Game<'_> { self.rocket_geometry(camera, alpha, &mut out); self.mark_entity(out.len(), "minecraft:splash_potion"); self.thrown_potion_geometry(camera, alpha, &mut out); + laps.lap("eg_objects"); // Particles (ParticleEngine.extract): camera-facing quads. self.mark_stage(out.len(), "PARTICLES"); let world = &self.world; @@ -11037,6 +11073,7 @@ impl Game<'_> { &|b| world.light(b[0], b[1], b[2]), &mut out, ); + laps.lap("eg_particles"); // Breaking progress overlays (destroy_stage_0..9): ours and the other players'. self.mark_stage(out.len(), "DESTROY"); for &(pos, stage) in &cracks { @@ -11067,7 +11104,9 @@ impl Game<'_> { } } } + laps.lap("eg_cracks"); self.finish_async_entity_culling(); + laps.lap("eg_culling_finish"); out } @@ -11163,6 +11202,9 @@ impl Game<'_> { darken } fn mob_hit(&self) -> Option<(usize, f64)> { + if let Some(hit) = self.tick_mob { + return hit; + } let (eye, dir) = self.aim()?; let reach: f64 = self.entity_reach; let mut best: Option<(usize, f64)> = None; @@ -11386,7 +11428,7 @@ impl Game<'_> { let Some((index, distance)) = self.mob_hit() else { return false; }; - if !self.before_block(distance) { + if !self.before_block(distance) || !self.held_range_allows(distance) { return false; } self.attack_mob(index); @@ -15945,6 +15987,8 @@ impl Game<'_> { KeyCode::KeyE => match self.screen { Screen::Playing if self.spectator() => {} Screen::Playing => { + // handleKeybinds: the number keys select before key.inventory. + self.select_queued_hotbar(); // key.inventory while riding a tamed mount opens its inventory. if !self.open_ridden_mount_menu() { if self.tutorial.open_inventory() { @@ -15985,6 +16029,8 @@ impl Game<'_> { } } KeyCode::KeyF if self.screen == Screen::Playing => { + // handleKeybinds: the number keys select before key.swapOffhand. + self.select_queued_hotbar(); // handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for // the off hand, then the main hand. std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]); @@ -15996,6 +16042,8 @@ impl Game<'_> { self.using = None; } KeyCode::KeyQ if self.screen == Screen::Playing => { + // handleKeybinds: the number keys select before key.drop. + self.select_queued_hotbar(); let all = drop_whole_stack(self.modifiers); self.drop_held(all); } @@ -16033,7 +16081,7 @@ impl Game<'_> { { self.hotbar_load_or_save(slot, load); } else { - self.selected = slot; + self.hotbar_presses.push(slot); } } _ if self.screen == Screen::Paused && self.pause_menu => { @@ -16084,6 +16132,8 @@ impl Game<'_> { } } MouseButton::Middle if pressed => { + // handleKeybinds: the number keys select before key.pickItem. + self.select_queued_hotbar(); // Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock: // the block's clone item, with its block entity's data on Ctrl in // creative, picked from the inventory (`pick_item`). @@ -18418,6 +18468,7 @@ impl<'a> Game<'a> { trim_slots: Vec::new(), cart_models: prepared.cart_models, riding: None, + posed_rider: None, pending_vehicle: None, ender_items: vec![None; 27], ender_open: None, @@ -18513,6 +18564,8 @@ impl<'a> Game<'a> { world_icon: None, forced_hit: None, tick_hit: None, + tick_mob: None, + hotbar_presses: Vec::new(), entity_reach: 3.0, block_reach: 4.5, guest_use: None, @@ -20645,6 +20698,15 @@ pub fn run_with() -> Result<()> { horizon_threads::catch_stop_signals(); } let started = Instant::now(); + // A dedicated server writes its slow ticks to the log with the spans + // they spent their time in (`HORIZON_SPIKES`'s line; the variable still + // sets another threshold), so a lag spike players felt can be traced + // from the log alone. Timing the spans costs microseconds a tick. + if options.spikes.is_none() + && options.execution_role == execution_role::ExecutionRole::DedicatedServer + { + options.spikes = Some(dedicated::SLOW_TICK_MS); + } horizon_client::spans::enable(options.spikes.is_some()); let vanilla_root = options.resources.clone(); if let Some(error) = missing_resources(&vanilla_root) { diff --git a/crates/horizon-client/src/game/agent_settings.rs b/crates/horizon-client/src/game/agent_settings.rs index 5045f1c49b..cd41625e6e 100644 --- a/crates/horizon-client/src/game/agent_settings.rs +++ b/crates/horizon-client/src/game/agent_settings.rs @@ -86,34 +86,21 @@ impl Game<'_> { let server = self .control .as_ref() - .filter(|c| !c.path().as_os_str().is_empty()); - let mut lines = vec![match server { - Some(s) if s.clients() > 0 => { - let ago = self - .agent_last - .map(|t| format!(", last command {} s ago", t.elapsed().as_secs())) - .unwrap_or_default(); - format!( - "Connected: {} agent connection{}{ago}", - s.clients(), - if s.clients() == 1 { "" } else { "s" } - ) - } - Some(_) => "Waiting for an agent.".into(), - None => "Off: agents can't reach this game.".into(), - }]; - if let Some(s) = server { - if self.options.control.is_some() && !self.agent_control_on { - lines.push("Opened for this session by the program that started the game.".into()); - } - // The full path is too wide for the page: Copy Connection Details has it. - let name = s.path().file_name().unwrap_or_default().to_string_lossy(); - lines.push(format!( - "Socket: {name} (copy the details for its full path)" - )); - } - lines.push("Local only: agents on this computer, never the network.".into()); - lines + .filter(|c| !c.path().as_os_str().is_empty()) + .map(|s| Socket { + clients: s.clients(), + name: s + .path() + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + }); + status_lines( + server, + self.agent_last.map(|t| t.elapsed().as_secs()), + self.options.control.is_some() && !self.agent_control_on, + ) } /// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try, @@ -138,3 +125,127 @@ impl Game<'_> { ) } } + +/// The control socket as the AI Agent page tells of it. +struct Socket { + /// Agents connected now. + clients: usize, + /// The socket's file name; the full path is too wide for the page (Copy Connection Details + /// has it). + name: String, +} + +/// The AI Agent page's lines in the selected language: the state, then where agents connect. +/// `last_command` is the seconds since an agent's last command, `session_only` whether the +/// program that started the game opened the socket and the player's switch did not. +fn status_lines( + server: Option, + last_command: Option, + session_only: bool, +) -> Vec { + use horizon_client::lang::{get, tr}; + let mut lines = vec![match &server { + Some(s) if s.clients > 0 => { + let (one, many) = match last_command { + Some(_) => ("one_ago", "many_ago"), + None => ("one", "many"), + }; + let key = format!( + "horizon.agent.connected.{}", + if s.clients == 1 { one } else { many } + ); + let mut args: Vec<&dyn std::fmt::Display> = Vec::new(); + if s.clients != 1 { + args.push(&s.clients); + } + if let Some(ago) = &last_command { + args.push(ago); + } + tr(&key, &args) + } + Some(_) => get("horizon.agent.waiting"), + None => get("horizon.agent.off"), + }]; + if let Some(s) = &server { + if session_only { + lines.push(get("horizon.agent.session_opened")); + } + lines.push(tr("horizon.agent.socket", &[&s.name])); + } + lines.push(get("horizon.agent.local_only")); + lines +} + +#[cfg(test)] +mod tests { + use super::{Socket, status_lines}; + use horizon_client::lang::{Language, scoped}; + + fn lines(code: &str, clients: usize, ago: Option, session_only: bool) -> Vec { + let socket = Socket { + clients, + name: "ctl.sock".into(), + }; + let (lines, missed) = scoped(Language::load(&[], code), || { + status_lines(Some(socket), ago, session_only) + }); + assert!(missed.is_empty(), "{code}: {missed:?}"); + lines + } + + /// The AI Agent page told its state in English in every language (#1927). + #[test] + fn agent_page_lines_follow_the_language() { + assert_eq!( + lines("de_de", 2, Some(5), false), + [ + "Verbunden: 2 Agentenverbindungen, letzter Befehl vor 5 s", + "Socket: ctl.sock (Verbindungsdaten kopieren für den vollständigen Pfad)", + "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.", + ] + ); + assert_eq!( + lines("pl_pl", 1, None, true), + [ + "Połączono: 1 połączenie agenta", + "Otwarte na tę sesję przez program, który uruchomił grę.", + "Gniazdo: ctl.sock (skopiuj dane, aby poznać pełną ścieżkę)", + "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.", + ] + ); + let (off, missed) = scoped(Language::load(&[], "fr_fr"), || { + status_lines(None, None, false) + }); + assert!(missed.is_empty(), "{missed:?}"); + assert_eq!( + off, + [ + "Désactivé : les agents ne peuvent pas joindre ce jeu.", + "Local uniquement : agents sur cet ordinateur, jamais via le réseau.", + ] + ); + } + + /// English keeps the sentences it always had: the count, its plural, the time since the last + /// command, and the wait for a first agent. + #[test] + fn agent_page_lines_in_english() { + assert_eq!( + lines("en_us", 2, Some(5), false)[0], + "Connected: 2 agent connections, last command 5 s ago" + ); + assert_eq!( + lines("en_us", 1, None, false)[0], + "Connected: 1 agent connection" + ); + assert_eq!( + lines("en_us", 1, Some(0), false)[0], + "Connected: 1 agent connection, last command 0 s ago" + ); + assert_eq!( + lines("en_us", 3, None, false)[0], + "Connected: 3 agent connections" + ); + assert_eq!(lines("en_us", 0, None, true)[0], "Waiting for an agent."); + } +} diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index 6c78a2a707..5323f7466b 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -218,11 +218,17 @@ impl Game<'_> { let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else { return Ok(()); }; - let Some(lit) = f.tick( + let mut drops = Vec::new(); + let lit = f.tick( world_recipes!(self), world_items!(self), self.res.providers(), - ) else { + &mut drops, + ); + for stack in drops { + self.campfire_drop(pos, stack); + } + let Some(lit) = lit else { return Ok(()); }; let state = self.world.block(pos.0, pos.1, pos.2); diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 867ceb71d6..0f47f17ff0 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -258,7 +258,7 @@ impl Game<'_> { self.remote_close_container(); } self.book = None; - self.capture(true); + self.capture(self.screen == super::Screen::Playing && !self.screen_over_playing()); } /// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close: /// trailing empty pages dropped, the book in hand updated here and, on a diff --git a/crates/horizon-client/src/game/campfire.rs b/crates/horizon-client/src/game/campfire.rs index 5d98521ecd..a36cac471d 100644 --- a/crates/horizon-client/src/game/campfire.rs +++ b/crates/horizon-client/src/game/campfire.rs @@ -171,7 +171,7 @@ impl Game<'_> { /// `Containers.dropItemStack(level, x, y, z, stack)` at the block's /// integer position. - fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) { + pub(super) fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) { let at = BlockPos::new(pos.0, pos.1, pos.2); for piece in horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index a972146bf7..c3604e5e48 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -574,6 +574,8 @@ impl Game<'_> { /// last time, when this method can restore it (a command draft only for /// /), else with its prefix. pub(super) fn open_chat_method(&mut self, prefix: &str) { + // handleKeybinds: the number keys select before key.chat and key.command. + self.select_queued_hotbar(); let command = prefix == "/"; let draft = self .ui diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index bf4947272e..8574062925 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -3000,6 +3000,7 @@ impl Game<'_> { } /// `MinecraftServer.setDifficulty(difficulty, true)`. pub(super) fn set_difficulty_forced(&mut self, i: u8) { + let from = self.ui.difficulty; self.ui.difficulty = i; // The level data holds the world's difficulty: it survives the // reopen, so it follows the command. @@ -3018,6 +3019,10 @@ impl Game<'_> { locked: self.level.difficulty_locked, })); } + if from != i { + let (from, to) = difficulty_names(from, i); + self.native_difficulty_changed(from, to); + } } fn cmd_spawnpoint(&mut self, ctx: &Context) -> R { let targets = if ctx.get("targets").is_some() { @@ -3744,6 +3749,30 @@ pub(super) fn block_input_matches( .all(|(k, v)| s.properties.get(k) == Some(v)) } +/// The names `native_difficulty_changed` hears for a change from `from` to +/// `to`: the four difficulties of `options.difficulty.*`, the index kept in +/// range as the command and the options keep it. +fn difficulty_names(from: u8, to: u8) -> (&'static str, &'static str) { + ( + DIFFICULTIES[usize::from(from) & 3], + DIFFICULTIES[usize::from(to) & 3], + ) +} + +#[cfg(test)] +mod difficulty_tests { + use super::difficulty_names; + + #[test] + fn a_difficulty_change_names_both_ends() { + assert_eq!(difficulty_names(0, 3), ("peaceful", "hard")); + assert_eq!(difficulty_names(2, 1), ("normal", "easy")); + // The four difficulties are the only indexes the command and the + // options keep; an index outside them still names one. + assert_eq!(difficulty_names(7, 4), ("hard", "peaceful")); + } +} + #[cfg(test)] mod summon_tests { #[test] diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 27517b5457..729fd02f24 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -68,7 +68,12 @@ impl Game<'_> { .flatten() } fn entity_nbt_in_level(&mut self, t: Target) -> Option { - let player_uuid = self.level.player_uuid; + // The acting player's own UUID: a guest's turn (the window's player on + // the server thread) keeps the world owner's in `level`. + let player_uuid = self + .lan_acting_uuid + .map(horizon_client::lan::playerdata::uuid_ints) + .or(self.level.player_uuid); Some(match t { Target::Guest(_) => { return self diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index fe75e8890a..9b20ac3294 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -10,7 +10,27 @@ use std::{ }; use winit::{event::MouseButton, keyboard::KeyCode}; +/// The control protocol's version (`docs/api/stability.md`): before 1.0 an +/// addition makes the next patch and a breaking change the next line. +const PROTOCOL: &str = "0.1.0"; + +/// The command groups the game serves, as `help` chains them: what `hello` reports as its features. +const FEATURES: &[&str] = &[ + "game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options", + "events", "saves", "pad", +]; + +/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves. +fn hello() -> Value { + json!({ + "protocol": PROTOCOL, + "release": horizon_core::release::VERSION, + "features": FEATURES, + }) +} + const HELP: &[&str] = &[ + "hello the control protocol's version, the game's release and the command groups it serves", "status game, player, streaming and renderer state", "simulation authoritative projectiles in the owner's level", "server-hold on|off hold simulation and player packets at a control-fixture barrier", @@ -36,7 +56,7 @@ const HELP: &[&str] = &[ "type TEXT type into the open chat box (key enter tap sends)", "chat-log [N] the newest N chat messages (default 10)", "options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)", - "stats [reset] frame-time summary since the last reset", + "stats [reset] [raw] frame-time summary since the last reset (raw: every frame time too)", "narration the narrator's status and the last messages it was asked to say", "bench SECONDS reset stats, wait, then reply with the summary", "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)", @@ -355,6 +375,7 @@ impl Game<'_> { Ok(Some(json!({ "ok": true, "sides": r.section_sides() }))) } "sculk" => Ok(Some(self.sculk_command(&words)?)), + "hello" => Ok(Some(hello())), "status" => Ok(Some(self.control_status())), "stall" => { let millis: u64 = arg(1)?.parse()?; @@ -859,8 +880,13 @@ impl Game<'_> { }))) } "stats" => { - let summary = self.summary_with_passes(); - if words.get(1) == Some(&"reset") { + let mut summary = self.summary_with_passes(); + if words[1..].contains(&"raw") { + summary["frame_ms"] = self.frame_stats.frame_millis().into(); + summary["cpu_ms"] = self.frame_stats.cpu_millis().into(); + summary["gpu_ms"] = self.frame_stats.gpu_millis().into(); + } + if words[1..].contains(&"reset") { self.frame_stats.reset(); } Ok(Some(summary)) @@ -1403,6 +1429,25 @@ fn key_code(name: &str) -> Option { mod tests { use super::*; + #[test] + fn hello_names_the_protocol_the_release_and_the_command_groups() { + let reply = hello(); + assert_eq!(reply["release"], horizon_core::release::VERSION); + let protocol = reply["protocol"].as_str().unwrap(); + let parts: Vec<_> = protocol.split('.').collect(); + assert!( + parts.len() == 3 && parts.iter().all(|p| p.parse::().is_ok()), + "{protocol}" + ); + let features = reply["features"].as_array().unwrap(); + assert!(!features.is_empty()); + let mut names: Vec<_> = features.iter().filter_map(Value::as_str).collect(); + assert_eq!(names.len(), features.len()); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), features.len(), "a group is named twice"); + } + #[test] fn taps_and_ups_always_release_their_key() { for line in ["key shift tap", "key shift up", "key Shift up"] { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 52f4935a2c..7a0bdcd017 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -488,14 +488,15 @@ impl Game<'_> { } } /// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit - /// `MobEvent::ArmorHurt` reported: every worn piece that wears when its - /// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking - /// applied, and at its maximum the slot empties and the equipment-break - /// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52 - /// for the armour, 65 for the body). A fire hit leaves a fire-resistant - /// piece alone (`ItemStack.canBeHurtBy`). + /// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a + /// mob, and its override asks for the BODY slot alone, so the body piece is + /// the only one this wears: it loses `max(1, damage / 4)` durability, + /// Unbreaking applied, and at its maximum the slot empties and the break + /// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 65 for the + /// body — 49 to 52 are the four armour slots, which only a player's own + /// pieces reach). A fire hit leaves a fire-resistant piece alone + /// (`ItemStack.canBeHurtBy`). fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) { - use horizon_client::mobs::drops; use horizon_world::player_inventory::EquipmentSlot; if damage <= 0.0 { return; @@ -504,7 +505,6 @@ impl Game<'_> { return; }; let at = [m.position.x, m.position.y, m.position.z]; - let body_armour = drops::head_is_body(m.species()); let amount = ((damage / 4.0) as u32).max(1); let name = damage_type(kind, false); let fire = self @@ -514,9 +514,15 @@ impl Game<'_> { .id(name) .is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire")); // A piece's maximum, or none when it may not lose durability at all. + // `doHurtEquipment` starts from the item's `equippable` component + // (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a + // damageable item with no such component — a sword put in a horse's body + // slot by `/item replace` — never wears; `equip_damage_on_hurt` alone + // cannot say that, since it defaults to true when the component is absent. fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option { let info = me.res.items().get(&stack.item)?; - if !info.equip_damage_on_hurt + if info.equip_slot.is_none() + || !info.equip_damage_on_hurt || info.max_damage == 0 || !horizon_client::stations::is_damageable(me.res.items(), stack) || (fire && me.res.items().resists_fire(stack, name)) @@ -525,66 +531,39 @@ impl Game<'_> { } Some(info.max_damage) } - let mut broke: Vec = Vec::new(); - // The four armour slots, then the body slot (`extra.head`). - let event_of = horizon_client::mobs::item_break::equipment_break_event; - for (index, slot) in [ - (0usize, EquipmentSlot::Head), - (1, EquipmentSlot::Chest), - (2, EquipmentSlot::Legs), - (3, EquipmentSlot::Feet), - ] { - let Some(m) = self.mobs.iter().find(|m| m.id == id) else { - return; - }; - let Some(stack) = m.extra.armor[index].clone() else { - continue; - }; - let Some(max) = wearable(self, &stack, fire, name) else { - continue; - }; - let loss = self.durability_loss(&stack, amount); - if loss == 0 { - continue; - } - let lost = stack.damage + loss; + let Some(body) = self + .mobs + .iter() + .find(|m| m.id == id) + .and_then(|m| m.item_by_slot(EquipmentSlot::Body)) + else { + return; + }; + let Some(max) = wearable(self, &body, fire, name) else { + return; + }; + let loss = self.durability_loss(&body, amount); + if loss == 0 { + return; + } + let lost = body.damage + loss; + let broke = { let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { return; }; if lost >= max { - m.extra.armor[index] = None; - broke.push(event_of(slot)); - } else if let Some(worn) = m.extra.armor[index].as_mut() { - worn.damage = lost; + m.extra.head = None; + m.extra.body_damage = 0; + true + } else { + m.extra.body_damage = lost as i32; + false } - } - if body_armour { - let body = self - .mobs - .iter() - .find(|m| m.id == id) - .and_then(|m| m.item_by_slot(EquipmentSlot::Body)); - if let Some(body) = body - && let Some(max) = wearable(self, &body, fire, name) - { - let loss = self.durability_loss(&body, amount); - if loss > 0 { - let lost = body.damage + loss; - let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { - return; - }; - if lost >= max { - m.extra.head = None; - m.extra.body_damage = 0; - broke.push(event_of(EquipmentSlot::Body)); - } else { - m.extra.body_damage = lost as i32; - } - } - } - } - for event in broke { + }; + if broke { // LivingEntity.onEquippedItemBroken through the level's broadcast. + let event = + horizon_client::mobs::item_break::equipment_break_event(EquipmentSlot::Body); self.lan_entity_event(id as i32, event, at); } } diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index f3ca01e5d5..f654f8e1fb 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -257,6 +257,16 @@ fn forwarding_secret(online: bool) -> Result>> { Ok(Some(secret)) } +/// `MinecraftServer.runServer`'s overload warning for a server `behind` its +/// schedule, in milliseconds and in ticks of `tick`. +fn overload_warning(behind: Duration, tick: Duration) -> String { + format!( + "Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind", + behind.as_millis(), + behind.as_nanos() / tick.as_nanos().max(1) + ) +} + /// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`. pub(super) fn log(level: &str, message: &str) { log_from("Server thread", level, message); @@ -502,6 +512,13 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [ /// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it). const FAR_PROPERTY: &str = "horizon-max-view-distance"; +/// A dedicated server logs a tick slower than this, in milliseconds, with +/// its spans, unless `HORIZON_SPIKES` names another threshold: five ticks' +/// worth, a stall players feel as rubber-banding. +pub(super) const SLOW_TICK_MS: f32 = 250.0; +/// `MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS` plus 100 ticks: the +/// "Can't keep up!" warning comes at most this often. +const OVERLOAD_WARNING_INTERVAL: Duration = Duration::from_secs(15); /// server.properties' line for `server.toml`'s `admin_feedback_to_ops` (horizon-server writes it). const ADMIN_FEEDBACK_PROPERTY: &str = "horizon-admin-feedback-to-ops"; @@ -618,6 +635,8 @@ struct ServerState { /// View and simulation distance, and the player limit, for the report. view: (u32, u32), max_players: usize, + /// The mutable `gamemode` server property (`DedicatedServer.gameMode`). + game_mode: i32, } /// The dedicated-only commands' replies vanilla sends with `broadcast` @@ -985,6 +1004,7 @@ impl Game<'_> { broadcast_console: props.broadcast_console_to_ops, broadcast_rcon: props.broadcast_rcon_to_ops, op_level: props.op_permission_level, + game_mode: props.game_mode, report: None, view: ( props.view_distance.clamp(2, 32) as u32, @@ -1000,6 +1020,7 @@ impl Game<'_> { self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8); let watchdog = Watchdog::start(props.max_tick_time); let mut braked = Instant::now(); + let mut overload_warned: Option = None; while !state.stop { let tick_start = Instant::now(); if let Some(watchdog) = &watchdog { @@ -1089,7 +1110,17 @@ impl Game<'_> { far.brake(mspt, host.hub.config.view_distance); } state.ticked(Instant::now()); - self.wait_for_next_server_tick(&mut next, paused); + // MinecraftServer.runServer: a server more than a second's ticks + // behind skips them and says so, at most every 15 seconds. + if let Some(behind) = self.wait_for_next_server_tick(&mut next, paused) + && overload_warned.is_none_or(|at| at.elapsed() >= OVERLOAD_WARNING_INTERVAL) + { + overload_warned = Some(Instant::now()); + log( + "WARN", + &overload_warning(behind, self.clock.tick_duration()), + ); + } } if let Some(watchdog) = &watchdog { watchdog.running.store(false, Ordering::Relaxed); @@ -1975,7 +2006,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { #[cfg(test)] mod tests { - use super::{capture_log, log, log_from, panic_message}; + use super::{capture_log, log, log_from, overload_warning, panic_message}; use std::{ fs, io::{self, BufRead, BufReader, Read, Write}, @@ -1983,6 +2014,16 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + /// Vanilla's words, with how far behind the server is in milliseconds and in whole ticks. + #[test] + fn the_overload_warning_reads_as_vanilla_s() { + let tick = std::time::Duration::from_millis(50); + assert_eq!( + overload_warning(std::time::Duration::from_millis(13_027), tick), + "Can't keep up! Is the server overloaded? Running 13027ms or 260 ticks behind" + ); + } + /// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is /// named as unknown, so the crash line is never empty. #[test] diff --git a/crates/horizon-client/src/game/dedicated/management.rs b/crates/horizon-client/src/game/dedicated/management.rs index b4139250c9..4f3128b485 100644 --- a/crates/horizon-client/src/game/dedicated/management.rs +++ b/crates/horizon-client/src/game/dedicated/management.rs @@ -3,7 +3,7 @@ //! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as //! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the //! players a change shuts out are disconnected. The supervisor answers with the lists, from their files. -use super::{ServerState, log}; +use super::{GAME_MODES, ServerState, log}; use crate::game::Game; use horizon_client::{ command::vanilla::DIFFICULTIES, @@ -70,6 +70,25 @@ impl Game<'_> { self.store_property("motd", value.to_owned()); return json!({ "value": value }); } + "serversettings/game_mode" => { + return json!({ "value": GAME_MODES[state.game_mode as usize] }); + } + "serversettings/game_mode/set" => { + let Some(value) = body["value"].as_str() else { + return json!({ "error": "game mode must be a string" }); + }; + let Some(index) = GAME_MODES.iter().position(|name| *name == value) else { + return json!({ "error": format!("unknown game mode {value:?}") }); + }; + rpc(format!( + "Update game mode from '{}' to '{}'", + GAME_MODES[state.game_mode as usize], value + )); + self.store_property("gamemode", value.to_owned()); + state.game_mode = index as i32; + self.set_default_game_mode(state.game_mode); + return json!({ "value": GAME_MODES[state.game_mode as usize] }); + } "serversettings/difficulty" => { return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] }); } diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 1828ff9ce1..019ce3d648 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -89,7 +89,17 @@ impl Game<'_> { /// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking /// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`). pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 { - let loss = self.durability_loss(stack, amount); + let mut loss = self.durability_loss(stack, amount); + // The mods hear a server player's wear once Unbreaking has had its say, + // and may change it or stop it. + if loss > 0 + && let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) + { + let mut points = loss as i32; + let mut cancel = false; + self.native_item_damaged(uuid, &name, &stack.item, &mut points, &mut cancel); + loss = if cancel { 0 } else { points.max(0) as u32 }; + } if loss > 0 { self.trigger_item_durability(stack, stack.damage + loss); } diff --git a/crates/horizon-client/src/game/entity.rs b/crates/horizon-client/src/game/entity.rs new file mode 100644 index 0000000000..f94248cd0d --- /dev/null +++ b/crates/horizon-client/src/game/entity.rs @@ -0,0 +1,308 @@ +//! `Entity`: what every kind in the game's entity lists answers to, so a system +//! can treat them alike and only write down where vanilla's kinds differ. +//! +//! Default methods follow vanilla's `Entity`; a kind overrides one only where +//! its vanilla class does. [`EntityHandle`] names one entity by list and index, +//! so a system can walk handles and reach each entity after other borrows of +//! `Game` end. The walk allocates nothing and dispatch is static: hot paths pay +//! no vtable. A new kind is one line in `entity_lists!` and one `impl Entity`. + +use super::{FallingBlock, Game}; +use super::{ + boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident, +}; +use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity}; +use horizon_client::mobs::Mob; +use horizon_client::projectiles::Projectile; +use horizon_core::geometry::{Aabb, Direction, Vec3}; +use horizon_core::position::BlockPos; +use horizon_world::{level::data::PushReaction, piston_push}; + +pub(super) trait Entity { + fn position(&self) -> Vec3; + fn position_mut(&mut self) -> &mut Vec3; + fn velocity_mut(&mut self) -> &mut Vec3; + fn bounds(&self) -> Aabb; + + fn on_ground(&self) -> bool { + false + } + /// `Entity.maxUpStep`. + fn get_step_height(&self) -> f32 { + 0.0 + } + fn is_alive(&self) -> bool { + true + } + /// `Entity.isSupportedBy`. + fn is_supported_by(&self, _pos: BlockPos) -> bool { + false + } + /// `Entity.getPistonPushReaction`. + fn get_piston_push_reaction(&self) -> PushReaction { + PushReaction::PushPull + } + /// `Entity.move(MoverType.PISTON, …)` once the movement is resolved. + fn move_by_piston(&mut self, movement: Vec3) { + let position = self.position_mut(); + *position = position.add_vector(movement); + } + /// `PistonMovingBlockEntity.causeBounce`: a slime block sets the velocity + /// along the movement axis. + fn piston_slime_bounce(&mut self, movement: Direction) { + let velocity = self.velocity_mut(); + *velocity = piston_push::bounce_velocity(movement, *velocity); + } +} + +/// The fields every kind stores under the same names. +macro_rules! stored_motion { + () => { + fn position(&self) -> Vec3 { + self.position + } + fn position_mut(&mut self) -> &mut Vec3 { + &mut self.position + } + fn velocity_mut(&mut self) -> &mut Vec3 { + &mut self.velocity + } + }; +} + +/// The game's lists of entities, in the order systems walk them. Hands them to +/// `$then`, so one list drives the handles and every typed dispatch. +macro_rules! entity_lists { + ($then:ident, $($args:tt)*) => { + $crate::game::entity::$then! { + ($($args)*) + Mob: mobs, + Item: dropped, + Boat: boats, + Cart: carts, + ArmorStand: armor_stands, + Tnt: tnts, + Falling: falling, + Orb: orbs, + Projectile: creatures.projectiles, + Rocket: rockets, + Trident: tridents, + } + }; +} +pub(crate) use entity_lists; + +macro_rules! define_entity_handles { + (() $($kind:ident: $($list:ident).+,)*) => { + /// One entity, by list and index. + #[derive(Clone, Copy, Hash, PartialEq, Eq)] + pub(super) enum EntityHandle { + $($kind(usize),)* + } + + impl Game<'_> { + /// Every entity's handle: lengths read now, nothing allocated, and + /// no borrow of `Game` held, so the caller can change it mid-walk. + pub(super) fn entity_handles(&self) -> impl Iterator + use<> { + std::iter::empty() + $(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))* + } + } + }; +} +pub(crate) use define_entity_handles; + +entity_lists!(define_entity_handles,); + +macro_rules! entity_borrow { + (shared, $place:expr) => { + &$place + }; + (exclusive, $place:expr) => { + &mut $place + }; +} +pub(crate) use entity_borrow; + +macro_rules! match_entity { + (($borrow:ident, $entity:ident, $game:expr, $handle:expr, $body:expr) + $($kind:ident: $($list:ident).+,)*) => { + match $handle { + $($crate::game::entity::EntityHandle::$kind(i) => { + let $entity = $crate::game::entity::entity_borrow!($borrow, $game$(.$list)+[i]); + $body + })* + } + }; +} +pub(crate) use match_entity; + +/// Runs `$body` with `$entity` bound to the handle's entity, as its own type: +/// one match, each arm compiled for its kind, so nothing goes through a vtable. +macro_rules! with_entity { + ($game:expr, $handle:expr, $entity:ident => $body:expr) => { + $crate::game::entity::entity_lists!(match_entity, shared, $entity, $game, $handle, $body) + }; +} +pub(super) use with_entity; + +/// [`with_entity`], with the entity mutable. +macro_rules! with_entity_mut { + ($game:expr, $handle:expr, $entity:ident => $body:expr) => { + $crate::game::entity::entity_lists!(match_entity, exclusive, $entity, $game, $handle, $body) + }; +} +pub(super) use with_entity_mut; + +impl Entity for Mob { + stored_motion!(); + fn bounds(&self) -> Aabb { + Mob::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } + fn get_step_height(&self) -> f32 { + self.kind.step_height as f32 + } + fn is_alive(&self) -> bool { + !self.removed && self.alive() + } + fn is_supported_by(&self, pos: BlockPos) -> bool { + self.supporting == Some([pos.x, pos.y, pos.z]) + } +} + +impl Entity for ItemEntity { + stored_motion!(); + fn bounds(&self) -> Aabb { + ItemEntity::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } + fn move_by_piston(&mut self, movement: Vec3) { + self.position = self.position.add_vector(movement); + // Entity.move(MoverType.PISTON): onGround only if the push was down. + if movement.y != 0.0 { + self.on_ground = movement.y < 0.0; + } + } + fn piston_slime_bounce(&mut self, movement: Direction) { + ItemEntity::piston_slime_bounce(self, movement); + } +} + +impl Entity for ExperienceOrb { + stored_motion!(); + fn bounds(&self) -> Aabb { + ExperienceOrb::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } + fn move_by_piston(&mut self, movement: Vec3) { + self.position = self.position.add_vector(movement); + if movement.y != 0.0 { + self.on_ground = movement.y < 0.0; + } + } + fn piston_slime_bounce(&mut self, movement: Direction) { + self.velocity = piston_push::bounce_velocity(movement, self.velocity); + self.on_ground = false; + } +} + +impl Entity for Boat { + stored_motion!(); + fn bounds(&self) -> Aabb { + Boat::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } +} + +impl Entity for Minecart { + stored_motion!(); + fn bounds(&self) -> Aabb { + Minecart::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } +} + +impl Entity for ArmorStand { + stored_motion!(); + fn bounds(&self) -> Aabb { + ArmorStand::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } + /// `ArmorStand.getPistonPushReaction`: a marker is ignored. + fn get_piston_push_reaction(&self) -> PushReaction { + if self.marker { + PushReaction::IgnoreEntity + } else { + PushReaction::PushPull + } + } +} + +impl Entity for PrimedTnt { + stored_motion!(); + fn bounds(&self) -> Aabb { + PrimedTnt::bounds(self) + } + fn on_ground(&self) -> bool { + self.on_ground + } + fn piston_slime_bounce(&mut self, movement: Direction) { + self.velocity = piston_push::bounce_velocity(movement, self.velocity); + // Leave the ground so the next tick does not apply the on-ground + // Y * -0.5 friction that cancels the launch. + self.on_ground = false; + } +} + +impl Entity for FallingBlock { + stored_motion!(); + fn bounds(&self) -> Aabb { + horizon_client::falling_block::bounds(self.position) + } +} + +impl Entity for Projectile { + stored_motion!(); + fn bounds(&self) -> Aabb { + Projectile::bounds(self) + } + fn move_by_piston(&mut self, movement: Vec3) { + self.position = self.position.add_vector(movement); + self.needs_sync = true; + } + fn piston_slime_bounce(&mut self, movement: Direction) { + self.velocity = piston_push::bounce_velocity(movement, self.velocity); + self.needs_sync = true; + } +} + +impl Entity for Rocket { + stored_motion!(); + fn bounds(&self) -> Aabb { + Rocket::bounds(self) + } +} + +impl Entity for ThrownTrident { + stored_motion!(); + fn bounds(&self) -> Aabb { + let p = self.position; + Aabb::new( + Vec3::new(p.x - 0.25, p.y, p.z - 0.25), + Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25), + ) + } +} diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index 344f13edb7..af3456f6df 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -98,6 +98,22 @@ impl Game<'_> { if &*stack.item != "minecraft:fishing_rod" { return false; } + // The mods hear a server player's cast or reel-in first. + if self.player.is_some() + && let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) + { + let state = match &self.hook { + None => 0, + Some(h) if matches!(h.state, State::Hooked(_)) => 4, + Some(h) if h.nibble > 0 => 2, + Some(_) => 3, + }; + let mut cancel = false; + self.native_player_fish(uuid, &name, state, &mut cancel); + if cancel { + return true; + } + } let Some(p) = self.player.as_ref() else { return false; }; @@ -419,6 +435,19 @@ impl Game<'_> { } } } else { + // The mods hear a server player's fish bite, and may call it + // off: the wait starts over. + if let (Some(uuid), Some(name)) = + (self.lan_acting_uuid, self.lan_acting_name.clone()) + { + let mut cancel = false; + self.native_player_fish(uuid, &name, 1, &mut cancel); + if cancel { + h.time_until_lured = 0; + h.time_until_hooked = 0; + return; + } + } let pitch = 1.0 + (self.world.random.next_float() - self.world.random.next_float()) * 0.4; self.audio.play( diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c949e84a07..cb63e37ea1 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -205,6 +205,94 @@ impl Game<'_> { self.attack_ticks = 0; self.hand_strength.reset(); } + /// `Minecraft.tick`'s `pick`, then `handleKeybinds`' number keys: the + /// tick's clicks act on what the item held before the keys picked (a + /// spear's 4.5 blocks), with the item the keys select (attribute + /// swapping a spear's reach onto a mace). + pub(super) fn pick_tick_targets(&mut self) { + self.tick_hit = Some(self.pick()); + self.tick_mob = None; + let mob = match self.active_attack_range() { + Some(range) => self.attack_range_pick(&range), + None => None, + }; + self.tick_mob = Some(mob.unwrap_or_else(|| self.mob_hit())); + self.select_queued_hotbar(); + } + /// `handleKeybinds`' number keys (`keyHotbarSlots[i].consumeClick`). + pub(super) fn select_queued_hotbar(&mut self) { + for slot in std::mem::take(&mut self.hotbar_presses) { + self.selected = slot; + } + } + /// The tick's clicks are done: later picks follow the crosshair again. + pub(super) fn release_tick_targets(&mut self) { + self.tick_hit = None; + self.tick_mob = None; + } + /// `Minecraft.startAttack` on an entity: a main-hand item with an + /// `attack_range` attacks only a hit inside it. + pub(super) fn held_range_allows(&self, distance: f64) -> bool { + let range = self.held().and_then(|stack| { + self.pack + .item_renderer + .models + .components(&horizon_client::item_render::stack_view(stack)) + .get("minecraft:attack_range") + .cloned() + }); + range.is_none_or(|r| attack_range_contains(&r, distance, self.creative)) + } + /// `LocalPlayer.raycastHitResult`'s item (`getActiveItem`: the one in + /// use, else the main hand's) `attack_range` component. + fn active_attack_range(&self) -> Option { + let active = self + .using + .and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref)) + .or_else(|| self.held())?; + self.pack + .item_renderer + .models + .components(&horizon_client::item_render::stack_view(active)) + .get("minecraft:attack_range") + .cloned() + } + /// `AttackRange.getClosesetHit` against the mobs `mob_hit` can pick + /// (`EntitySelector.CAN_BE_PICKED`), as `raycastHitResult` reads it: the + /// nearest mob hit within the item's reach; no mob when the clip met a + /// block within the block reach (`getHitEntitiesAlong`'s block result, + /// kept by `filterHitResult`); `None` on a miss, where the plain pick + /// decides. + fn attack_range_pick(&self, range: &serde_json::Value) -> Option> { + let eye = v3(self.eye_position()?); + let look = v3(super::view_vector(self.yaw, self.pitch)); + let reach = attack_reach(range, self.creative); + let camera = self.remote_camera_id(); + let boxes: Vec<(usize, Aabb)> = (self.mobs.iter().enumerate()) + .filter(|(_, m)| !m.removed && Some(m.id) != self.riding && Some(m.id) != camera) + .map(|(i, m)| (i, m.bounds())) + .collect(); + let clip = |from: Vec3, to: Vec3| { + let length = from.distance_squared(to).sqrt(); + let d = to.subtract(from).scale(1.0 / length); + (length > 0.0) + .then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length)) + .flatten() + .map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2])) + }; + let movement = self.known_player_movement(); + let nearest = super::spear::hits_in_reach(&boxes, eye, look, movement, reach, clip) + .into_iter() + .map(|(i, at)| (i, eye.distance_squared(at).sqrt())) + .reduce(|a, b| if b.1 < a.1 { b } else { a }); + if nearest.is_some() { + return Some(nearest); + } + let to = eye.add_vector(look.scale(f64::from(reach.1) + movement.dot(look).max(0.0))); + clip(eye, to) + .is_some_and(|block| eye.distance_squared(block) <= self.block_reach.powi(2)) + .then_some(None) + } /// The attack and use part of `Minecraft.tick` for the local player: /// attack clicks since the last tick and the held attack button. pub(super) fn tick_local_hand(&mut self) -> Result<()> { @@ -216,11 +304,10 @@ impl Game<'_> { // (scripts under --control hold buttons without grabbing it). let down = self.left; let ticks = self.clicks.destroying.ticks; - // Minecraft.hitResult is picked once per frame: a creative break - // does not move the use that follows it past the broken block. - if use_clicks > 0 && attack_clicks > 0 { - self.tick_hit = Some(self.pick()); - } + // Minecraft.hitResult is picked once per tick, before the number + // keys: a creative break does not move the use that follows it + // past the broken block. + self.pick_tick_targets(); let mut world = GameHands { game: self, error: None, @@ -229,7 +316,7 @@ impl Game<'_> { let error = world.error.take(); self.clicks = clicks; if let Some(e) = error { - self.tick_hit = None; + self.release_tick_targets(); return Err(e); } // Use clicks follow the attack clicks within the tick, against @@ -245,7 +332,7 @@ impl Game<'_> { break; } } - self.tick_hit = None; + self.release_tick_targets(); used?; // A tick of breaking: the hit sound every fourth tick and the // cracking particles. @@ -267,8 +354,10 @@ impl Game<'_> { } } } else { - // Minecraft.tick: a screen keeps the attack from starting. + // Minecraft.tick: a screen keeps the attack from starting, and + // opening it released the keys (`KeyMapping.releaseAll`). self.clicks.miss_time = 10000; + self.hotbar_presses.clear(); } let d = &self.clicks.destroying; self.mining = d.active.then_some(Mining { @@ -385,7 +474,7 @@ impl Game<'_> { pub(super) fn tick_hand(&mut self) { // LocalPlayer.applyInput (the camera is the player's own). if self.player.is_some() { - self.sway.tick(self.pitch, self.yaw); + self.sway.tick_wrapped(self.pitch, self.yaw); } self.swing.tick(); let main = self.inventory[self.selected].clone(); diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 069eb70a27..8813970a28 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -383,6 +383,15 @@ impl Game<'_> { let _ = m.apply_saved_attributes(g.saved.as_ref().and_then(|t| t.get("attributes"))); } + // ServerPlayer.restoreFrom: a body that follows another takes its + // attributes (a scale of 0.1 stays 0.1 after a respawn or a trip + // to another level). + let g = &mut host.hub.guests[i]; + if let Some(carried) = g.carried_attributes.take() + && let Some(m) = g.motion.as_mut() + { + let _ = m.apply_saved_attributes(Some(&carried)); + } // The saved golden hearts go on the first body of a player who // has been here before. let saved = host.hub.guests[i].saved_absorption.take(); @@ -2516,8 +2525,9 @@ impl Game<'_> { } let g = &mut host.hub.guests[gi]; (g.yaw, g.pitch) = (yaw, pitch); - // A new body there: no fall distance or motion carried over. - g.motion = None; + // A new body there: no fall distance or motion carried over, but the + // player's attributes (ServerPlayer.teleport keeps the entity). + g.new_body(true); host.hub.teleport(gi, Some(at)); } fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) { @@ -3248,14 +3258,16 @@ impl Game<'_> { g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT; g.loading = true; g.death_time = 0; - g.health = 20.0; g.food = 20; g.saturation = 5.0; g.sent_health = None; - if let Some(m) = g.motion.take() { + // ServerPlayer.restoreFrom(old, false): the base values come back, + // and the new player has the max health they give. + if let Some(m) = g.new_body(false) { let m = *m; g.logs_kept = Some((m.hurt_log, m.attack_log)); } + g.health = g.respawn_health(self.motion); let mut xp = Experience { level: g.xp_level, progress: g.xp_progress, @@ -3847,6 +3859,12 @@ impl Game<'_> { w.player.fall_distance = 0.0; } let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect(); + // The tick's whole movement, for the delta movement a glide's wall + // hit reads (`handleMovePlayer` gives the server copy the client's + // own movement). + let tick_movement = travelled.iter().fold(Vec3::ZERO, |sum, d| { + sum.add_vector(Vec3::new(d[0], d[1], d[2])) + }); // Entity.move's movementThisTick: each move, from where the // tick's first one started. let end = w.player.position; @@ -3957,6 +3975,7 @@ impl Game<'_> { let _ = player.peaceful_regeneration(rules); let _ = player.food_tick(health, rules); events.append(&mut player.events); + player.carry_client_movement(tick_movement); let _ = MotionWorld { entities: &[], player, diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index 8a867abba5..55e5943df4 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -130,6 +130,7 @@ impl Game<'_> { let equipped = std::mem::take(&mut menu.equipped); let brewed = std::mem::take(&mut menu.brewed); let crafted = std::mem::take(&mut menu.crafted); + let item_crafted = std::mem::take(&mut menu.item_crafted); menu.drops.clear(); store_player(menu, &mut host.hub.guests[gi]); if !brewed.is_empty() { @@ -148,6 +149,15 @@ impl Game<'_> { } }); } + if !item_crafted.is_empty() { + let conn = host.hub.guests[gi].conn; + self.as_guest(host, conn, |g| { + let player = g.native_acting_id(); + for (recipe, stack) in &item_crafted { + g.native_item_crafted(player, recipe, stack); + } + }); + } if !equipped.is_empty() { let conn = host.hub.guests[gi].conn; self.as_guest(host, conn, |g| { diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index ed21ae194a..25abf03d73 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -520,8 +520,14 @@ impl Game<'_> { /// The end of `MinecraftServer.runServer`'s loop for a server thread: a /// finished `/tick sprint` reports to the operators as the server, then /// the next tick starts one tick length after this one, or straight away - /// while the sprint runs (`checkShouldSprintThisTick`). - pub(super) fn wait_for_next_server_tick(&mut self, next: &mut Instant, paused: bool) { + /// while the sprint runs (`checkShouldSprintThisTick`). A server more + /// than two seconds behind skips the ticks it missed: that returns how far + /// behind it was, for the overload warning. + pub(super) fn wait_for_next_server_tick( + &mut self, + next: &mut Instant, + paused: bool, + ) -> Option { if let Some(report) = self.clock.take_sprint_report() { // ServerTickRateManager.finishTickSprint: the server's sendSuccess. self.console_broadcast = Some(true); @@ -536,7 +542,7 @@ impl Game<'_> { let now = Instant::now(); if !paused && self.clock.sprinting() { *next = now; - return; + return None; } *next += self.clock.tick_duration(); if *next > now { @@ -548,8 +554,11 @@ impl Game<'_> { std::thread::sleep(*next - now); } } else if now - *next > Duration::from_secs(2) { + let behind = now - *next; *next = now; + return Some(behind); } + None } /// The levels this server tick runs, in `MinecraftServer.levels` order diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index f241f46054..a5fee133a9 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -477,12 +477,6 @@ impl Game<'_> { Vec3::new(p[0] + w, p[1] + h, p[2] + w), )) }; - let inside = move |p: [f64; 3]| { - (p[0] - at[0]).abs() <= 3.0 - && (p[2] - at[2]).abs() <= 3.0 - && p[1] >= at[1] - 3.0 - && p[1] <= at[1] + 9.0 - }; let struck: Vec<(u32, EntityView)> = self .mobs .iter() @@ -575,12 +569,15 @@ impl Game<'_> { if !events.is_empty() { let _ = self.handle_mob_events(events); } - // Cushion.thunderHit: a cushion in the box breaks and drops its item + // Cushion.thunderHit: a cushion whose box meets the strike box breaks and drops its item // (the bolt is no player, so no creative spare). Last first, since // break_cushion removes the entry. for i in (0..self.cushions.len()).rev() { - let p = self.cushions[i].position; - if inside([p.x, p.y, p.z]) { + let (lo, hi) = self.cushions[i].bounds(); + if strike.intersects(Aabb::new( + Vec3::new(lo[0], lo[1], lo[2]), + Vec3::new(hi[0], hi[1], hi[2]), + )) { let before = self.dropped.len(); self.break_cushion(i, false); b.spared.extend(self.dropped[before..].iter().map(|e| e.id)); diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index ac21ac000b..37593c04bf 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -536,8 +536,14 @@ impl Game<'_> { let rear = Vec3::new(rear[0], rear[1], rear[2]) .rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32); at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z); + // The tick a rider takes its seat is a teleport (`Entity.moveTo`), which + // sets the old position too: the camera must not sweep from where the + // player stood to the seat. The ticks after it move with the mount and + // are interpolated as usual. + let first = self.posed_rider != self.riding; + self.posed_rider = self.riding; if let Some(p) = self.player.as_mut() { - self.previous_position = p.position; + self.previous_position = if first { at } else { p.position }; p.position = at; p.velocity = Vec3::ZERO; p.fall_distance = 0.0; diff --git a/crates/horizon-client/src/game/native_block_entity_draw.rs b/crates/horizon-client/src/game/native_block_entity_draw.rs index 0748da0d16..2e5ba6e309 100644 --- a/crates/horizon-client/src/game/native_block_entity_draw.rs +++ b/crates/horizon-client/src/game/native_block_entity_draw.rs @@ -559,6 +559,11 @@ impl Game<'_> { .get(key) .map(String::as_str) } + /// The block `state` is a state of: its name, as `GameLevel`'s helper of + /// the same name gives it for the hooks that get a state in the world. + pub(super) fn native_state_block(&self, state: BlockStateId) -> Option<&str> { + self.pack.assets.registry.state(state).map(|s| &*s.name) + } } #[cfg(test)] diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 4ce1c7f3bd..307b71d8cb 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -869,7 +869,8 @@ impl GameLevel<'_> { /// the clicked position, whether the clicked block is being replaced, the /// item in hand) and `fluid` the fluid in the position the block would go /// in, which a waterlogged block reads. Build the state from `state` with - /// `native_state_with_property`. It is asked for every placer that goes + /// `native_state_with_property`, and know whether `state` is yours with + /// `native_state_block`. It is asked for every placer that goes /// through vanilla's placement (a player's `BlockItem.place`, a /// dispenser's `DirectionalPlaceContext`), and the answer still takes its /// survival and entity obstruction checks. A trait mod returns @@ -1287,6 +1288,21 @@ impl Game<'_> { pub(super) fn native_frame(&mut self, elapsed: Duration) { mod_api::dispatch_frame(self, mod_api::mods(), elapsed); } + /// The frame rate this window is about to be held to: Max Framerate after the + /// inactivity throttle and Battery Mode (260 is unlimited). `focused` is whether the + /// window has focus, `minimized` whether it is minimised and `in_world` whether a world + /// is open. A mod may lower `limit`, as Dynamic FPS does for a window in the background: + /// Horizon keeps the lower of its own limit and the mod's, and ignores values below 1. + /// API @API:framerate_limit@. + pub(super) fn native_framerate_limit( + &self, + focused: bool, + minimized: bool, + in_world: bool, + limit: &mut i32, + ) { + let _ = (focused, minimized, in_world, limit); + } /// Render-thread notification after a resource-pack reload has applied successfully. /// Atlas, models, renderer and client caches already refer to the new resources. /// Selection/F3+T and server-pack reloads share this point; initial startup does not. @@ -2717,6 +2733,28 @@ impl Game<'_> { pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) { let _ = (command, ran); } + /// A server player's fishing rod, with `state`: 0 the cast + /// (`FishingRodItem.use` throwing the bobber), 1 a fish biting + /// (`FishingHook.catchingFish`), 2 the reel-in of a bite, which gives the + /// catch, 3 the reel-in with nothing on the line, 4 the reel-in of a + /// bobber hooked to an entity or item (like Bukkit's `PlayerFishEvent`; + /// NeoForge's `ItemFishedEvent` is later, in `FishingHook.retrieve`). Set `cancel` on the cast and nothing + /// is thrown; on a bite the fish gets away and the wait starts over; on + /// a reel-in (2 to 4) nothing happens, the bobber stays out and the rod + /// takes no wear. The bobber ending by itself (too far, the rod put + /// away, the owner dying), nor the host of a LAN world's rod. The + /// player's state is swapped in, so `native_acting_player`, `native_tell` + /// and the item helpers reach them. Once for each cast, bite and reel-in, + /// on a server. @API:native_player_fish@. + pub(super) fn native_player_fish( + &mut self, + player: u128, + name: &str, + state: i32, + cancel: &mut bool, + ) { + let _ = (player, name, state, cancel); + } /// A click in a menu a mod opened (`native_open_menu`), which moved /// nothing: the menu's `slot` (its own first, then the player's /// inventory, `native_menu_inventory_slot`; -999 outside), the `button` @@ -3115,6 +3153,16 @@ impl Game<'_> { pub(super) fn native_level_unload(&mut self, dimension: &str) { let _ = dimension; } + /// The server's difficulty changed, from `from` to `to` (`peaceful`, + /// `easy`, `normal`, `hard`; NeoForge's `DifficultyChangeEvent`). It is + /// vanilla's `MinecraftServer.setDifficulty`, which `/difficulty`, a + /// dedicated server's management and a test environment's difficulty all + /// go through, and it is told only when the difficulty is another one. It + /// only hears: the change has already happened. Server-side. API + /// @API:difficulty_changed@. + pub(super) fn native_difficulty_changed(&mut self, from: &str, to: &str) { + let _ = (from, to); + } /// Chunk `chunk` (`[x, z]`, in chunks) of `dimension` came into the world /// (NeoForge's `ChunkEvent.Load`): `new` when it was just generated, false /// when read from the save. A flat world's chunk that leaves before it's @@ -3494,6 +3542,27 @@ impl Game<'_> { ) { mod_api::dispatch_loot_drops(self, mod_api::mods(), context, drops); } + /// The item `item` of a server player is about to lose `amount` points of + /// durability (`ItemStack.hurtAndBreak`; Bukkit's `PlayerItemDamageEvent`): + /// the tool that mines or hits, the armour that takes a hit, a shield, a + /// rod, an elytra. `amount` is what is left after Unbreaking; change it + /// (0 or less leaves the item whole) or set `cancel` for no wear at all. It + /// is not heard for a creative player's tools (they take no wear) nor when + /// Unbreaking already saved the item, nor for the wear of a mob's armour or + /// of a tool a dispenser uses, nor the host of a LAN world. The player's + /// state is swapped in, so `native_acting_player`, `native_tell` and the + /// item helpers reach them. Once for each wear, on a server. + /// @API:native_item_damaged@. + pub(super) fn native_item_damaged( + &mut self, + player: u128, + name: &str, + item: &str, + amount: &mut i32, + cancel: &mut bool, + ) { + let _ = (player, name, item, amount, cancel); + } /// `player` threw `count` of `item` (`Player.drop`: the drop key, a /// menu's throw...), not what scatters when they die. It only hears. /// Server-side. API 40. @@ -3512,6 +3581,17 @@ impl Game<'_> { ) { mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled); } + /// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's + /// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe` + /// the recipe the grid matched (`minecraft:oak_planks`) and `stack` the + /// result it took, with its components. It only hears: the take has + /// already happened. It fires for the crafting grid of an inventory or a + /// crafting table, on the integrated server and a dedicated server, guests + /// included, and not for a stonecutter's, a smithing table's or a + /// furnace's result. Server-side. API @API:item_crafted@. + pub(super) fn native_item_crafted(&mut self, player: u128, recipe: &str, stack: &ItemStack) { + let _ = (player, recipe, stack); + } /// Whether a mod hears `native_experience_orb_pickup`: set `listened` /// (a HEAD injection), or the server skips swapping the player in for it, /// and with this empty the pickup costs nothing more without mods. @@ -4190,6 +4270,14 @@ impl GameLevel<'_> { ) -> Option { with_property(self.registry, state, key, value) } + /// The block `state` is a state of (`BlockState.getBlock()`): its name, + /// `mymod:cable`. `native_state_for_placement` is asked for every block + /// placed and given the block's default state: compare this with your + /// block to answer only for it. None for an unknown state. API + /// @API:state_block@. + pub(super) fn native_state_block(&self, state: BlockStateId) -> Option<&str> { + self.registry.state(state).map(|s| &*s.name) + } /// This level's own random (vanilla's `Level.random`): the stream /// `native_level_random(dimension)` hands a server hook, for a world hook /// that draws as a vanilla block does (`isBonemealSuccess`), so the mod's @@ -4807,6 +4895,12 @@ impl Game<'_> { ) -> Option { self.insert_stack_through(pos, face, stack) } + /// The window's width and height in physical pixels, independent of GUI + /// scale; none on a server or when no window exists. API @API:physical_window_size@. + pub(super) fn native_physical_window_size(&self) -> Option<[u32; 2]> { + let size = self.window.as_ref()?.inner_size(); + Some([size.width, size.height]) + } /// Where the mouse is, in GUI pixels. API 20. pub(super) fn native_cursor(&self) -> [f32; 2] { [self.cursor.0, self.cursor.1] @@ -6357,7 +6451,7 @@ mod tests { #[test] fn native_breathing_stays_hookable() { - let source = include_str!("../breathing.rs"); + let source = include_str!("../breathing.rs").replace("\r\n", "\n"); let hook = source.find("pub fn native_living_breathe(").unwrap(); // From its doc comment's first line, so an attribute above it counts. let start = source[..hook].find("/// An entity's breathing").unwrap(); @@ -6373,7 +6467,7 @@ mod tests { #[test] fn native_loot_kinds_stay_hookable() { - let source = include_str!("../../../horizon-world/src/loot.rs"); + let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n"); for hook in [ "pub fn native_loot_function(", "pub fn native_loot_condition(", @@ -6392,6 +6486,23 @@ mod tests { } } + #[test] + fn native_ingredient_stays_hookable() { + let source = include_str!("../crafting.rs"); + let at = source.find("pub fn native_ingredient(").unwrap(); + let start = source[..at].rfind("///").unwrap(); + let body = &source[start..start + source[start..].find("\n}\n").unwrap()]; + for line in body.lines() { + assert!( + !line.contains("cfg"), + "a cfg in the ingredient hook: {line}" + ); + for mac in ["!(", "![", "! {", "!{"] { + assert!(!line.contains(mac), "a macro: {line}"); + } + } + } + #[test] fn loot_table_load_stays_hookable() { let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n"); @@ -6494,6 +6605,8 @@ mod tests { let menu_hooks = free_hooks(include_str!("../menu.rs")); // The air supply's (`horizon_client::breathing`). let breath_hooks = free_hooks(include_str!("../breathing.rs")); + // The crafting loaders' (`horizon_client_lib::crafting`). + let crafting_hooks = free_hooks(include_str!("../crafting.rs")); let declared: BTreeSet<_> = section .lines() .chain(level_section.lines()) @@ -6508,6 +6621,7 @@ mod tests { .chain(loot_hooks.iter().copied()) .chain(menu_hooks.iter().copied()) .chain(breath_hooks.iter().copied()) + .chain(crafting_hooks.iter().copied()) .collect(); let mut names = BTreeSet::new(); let mut contracts = BTreeSet::new(); @@ -6525,6 +6639,10 @@ mod tests { format!("horizon_world::item_defaults::native_{name}") } else if loot_hooks.contains(name) { format!("horizon_world::loot::native_{name}") + } else if crafting_hooks.contains(name) { + // crafting.rs compiles into horizon-client-lib (#5446's + // split): horizon-mixins resolves the crate by its package. + format!("horizon_client_lib::crafting::native_{name}") } else if menu_hooks.contains(name) { format!("horizon_client::menu::native_{name}") } else if breath_hooks.contains(name) { diff --git a/crates/horizon-client/src/game/native_server.rs b/crates/horizon-client/src/game/native_server.rs index 7aa369677e..69b26784de 100644 --- a/crates/horizon-client/src/game/native_server.rs +++ b/crates/horizon-client/src/game/native_server.rs @@ -390,6 +390,18 @@ impl Game<'_> { host.hub.send_to(g.conn, &packet); } } + /// Posts a line to the dedicated server's event stream (`GET /api/v1/events`) + /// as an `announce` event, which horizon-server's Discord bridge relays when + /// `[discord] events` lists `announce`: `name` and `uuid` say who it is + /// about (empty and 0 for no one), `message` is the line (`§` codes colour it). + /// Only on a dedicated server; false elsewhere and for an empty message. + pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool { + if self.options.dedicated.is_none() || message.trim().is_empty() { + return false; + } + super::dedicated::events::record("announce", name, uuid, json!({ "message": message })); + true + } /// A line of the command's answer, inside `native_perform_command`: /// sent to the player whose command it is after the call, as vanilla /// commands' feedback is. diff --git a/crates/horizon-client/src/game/native_visuals.rs b/crates/horizon-client/src/game/native_visuals.rs index 65ce22c2ca..b57427fdd8 100644 --- a/crates/horizon-client/src/game/native_visuals.rs +++ b/crates/horizon-client/src/game/native_visuals.rs @@ -16,8 +16,16 @@ impl Game<'_> { camera: glam::DVec3, lines: &mut Vec, ) -> Vec { + let particle_lights = self + .renderer + .as_ref() + .and_then(|r| r.particle_lights()) + .map_or_else(Vec::new, |entries| { + self.particles + .lights(entries, self.clock.camera_fraction() as f32) + }); let Some(player) = self.native_player() else { - return Vec::new(); + return dynamic_light::select_colored(particle_lights.into_iter(), camera); }; let mut rims = Vec::new(); let mut lights = Vec::new(); @@ -98,7 +106,9 @@ impl Game<'_> { )) }); dynamic_light::select_colored( - sources.map(|l| (l.at, l.rgb, l.radius, l.intensity)), + sources + .map(|l| (l.at, l.rgb, l.radius, l.intensity)) + .chain(particle_lights), camera, ) } diff --git a/crates/horizon-client/src/game/nether_portal.rs b/crates/horizon-client/src/game/nether_portal.rs index f7bd12f5f7..386ea49051 100644 --- a/crates/horizon-client/src/game/nether_portal.rs +++ b/crates/horizon-client/src/game/nether_portal.rs @@ -734,6 +734,16 @@ impl Game<'_> { ids.push((o.id, true)); } } + for t in &self.tridents { + if tracked.contains_key(&t.id) { + ids.push((t.id, true)); + } + } + for r in &self.rockets { + if tracked.contains_key(&r.id) && r.attached_to.is_none() && !r.attached { + ids.push((r.id, true)); + } + } } let present: std::collections::HashSet = ids.iter().map(|(id, _)| *id).collect(); self.portals.entities.retain(|id, _| present.contains(id)); @@ -921,6 +931,30 @@ impl Game<'_> { inside.push((p.id, pos)); } } + // A trident (0.5 across) or rocket (0.25) in flight, or one that moved + // this tick; a rocket that carries its owner stays. + let mut flyers: Vec<(u32, Vec3, Vec3, f32)> = Vec::new(); + for t in self + .tridents + .iter_mut() + .filter(|t| !t.in_ground || t.previous != t.position) + { + let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id); + flyers.push((id, t.previous, t.position, 0.5)); + } + for r in self + .rockets + .iter_mut() + .filter(|r| r.attached_to.is_none() && !r.attached) + { + let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id); + flyers.push((id, r.previous, r.position, 0.25)); + } + for (id, from, to, size) in flyers { + if let Some(pos) = self.swept_portal(from, to, size, size, &mut portal_sections) { + inside.push((id, pos)); + } + } self.portals.sections = portal_sections; for (id, pos) in inside { self.portals @@ -1058,6 +1092,28 @@ impl Game<'_> { self.forget_entity(id); self.portals.arrivals.push(Arrival { plan, tag }); self.portals.dirty = true; + } else if let Some(i) = self.tridents.iter().position(|t| t.id == id) { + let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false); + if !self.may_enter(plan.dim) { + return; + } + let uuid = self.uuid_for(id); + let tag = self.tridents[i].to_nbt(uuid, self.persist_raw(id)); + self.tridents.remove(i); + self.forget_entity(id); + self.portals.arrivals.push(Arrival { plan, tag }); + self.portals.dirty = true; + } else if let Some(i) = self.rockets.iter().position(|r| r.id == id) { + let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false); + if !self.may_enter(plan.dim) { + return; + } + let uuid = self.uuid_for(id); + let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id)); + self.rockets.remove(i); + self.forget_entity(id); + self.portals.arrivals.push(Arrival { plan, tag }); + self.portals.dirty = true; } else if let Some(i) = self.orbs.iter().position(|o| o.id == id) { let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false); if !self.may_enter(plan.dim) { diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 1d3f9ec52a..e1fb95d378 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -574,9 +574,23 @@ impl Game<'_> { has_overlay: false, }; let limit = framerate_limit(self.settings.max_fps as i32, context); - self.settings + let limit = self + .settings .horizon - .battery_limit(limit, self.title.is_none(), self.focused) + .battery_limit(limit, self.title.is_none(), self.focused); + // A native mod may only lower it (`native_framerate_limit`). + let mut hooked = limit; + self.native_framerate_limit( + self.focused, + context.iconified, + self.title.is_none(), + &mut hooked, + ); + if hooked >= 1 { + hooked.min(limit) + } else { + limit + } } /// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS` /// below 260 frames per second, sleeping then spin-waiting until the diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 812ab558e1..6a9b66f32b 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -299,6 +299,7 @@ impl Game<'_> { pack, textures, cem, + block_entity_shapes, } = staged; // Keep the working GPU resources, and hand its window surface over. if let Some(renderer) = &mut self.renderer { @@ -372,6 +373,9 @@ impl Game<'_> { .cem .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = cem; + self.res + .block_entity_render() + .set_shapes(block_entity_shapes); // The random entity textures and emissive overlays the new atlas packs. A new scan is // a new generation: // every mob (every splitscreen player's too) chooses again on its next tick. @@ -479,6 +483,7 @@ pub(super) fn stage_pack( &root, res.mob_types(), )); + let block_entity_shapes = res.block_entity_render().shapes_for(&root); progress.set(3); Ok(StagedPack { root, @@ -486,6 +491,7 @@ pub(super) fn stage_pack( pack, textures, cem, + block_entity_shapes, }) } @@ -498,6 +504,8 @@ pub(super) struct StagedPack { pub textures: PreparedTextures, /// The packs' custom entity models (OptiFine CEM). pub cem: Arc, + /// The block entities' models, the packs' custom entity models over vanilla's. + pub block_entity_shapes: Arc, } /// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 92a9cefbfd..fa9b5a63f9 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -493,8 +493,11 @@ impl Game<'_> { } "minecraft:trident" => { if let Some(n) = entity_nbt::trident_from_nbt(&tag) { - self.tridents - .push(super::trident::ThrownTrident::from_nbt(n)); + let mut t = super::trident::ThrownTrident::from_nbt(n); + // One that came through a portal keeps its cooldown. + super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id); + self.restore_portal_cooldown(t.id, &tag); + self.tridents.push(t); } } "minecraft:firework_rocket" => { @@ -503,6 +506,8 @@ impl Game<'_> { let id = self.next_entity; let mut rocket = super::fireworks::Rocket::from_nbt(r); rocket.id = id; + // One that came through a portal keeps its cooldown. + self.restore_portal_cooldown(id, &tag); self.rockets.push(rocket); self.remember(id, uuid, tag); } @@ -1085,11 +1090,17 @@ impl Game<'_> { } /// An entity Horizon keeps no compound for (orbs, projectiles, the /// crystals and the dragon) still keeps its `Tags`, by entity id, so - /// `@e[tag=]`, `/tag` and `/data get` see them. + /// `@e[tag=]`, `/tag` and `/data get` see them, and the `Item` a + /// fireball or a thrown item projectile was loaded with. pub(super) fn remember_tags(&mut self, id: u32, tag: &Tag) { + let mut map = horizon_world::nbt::Compound::new(); if let Some(tags @ Tag::List(_)) = tag.get("Tags") { - let mut map = horizon_world::nbt::Compound::new(); map.insert("Tags".into(), tags.clone()); + } + if let Some(item @ Tag::Compound(_)) = tag.get("Item") { + map.insert("Item".into(), item.clone()); + } + if !map.is_empty() { self.persist.raw.insert(id, Tag::Compound(map)); } } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 8be25bf0c9..77004163b4 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -427,6 +427,7 @@ impl Game<'_> { let visually_swimming = p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying); let in_water = p.fluids.was_touching_water; + let in_lava = p.is_in_lava(); let (yaw, pitch) = (self.yaw, self.pitch); let main = self.inventory[self.selected].clone(); let off = self.offhand.as_ref().map(|s| s.item.clone()); @@ -497,6 +498,9 @@ impl Game<'_> { m.extra.crouching = crouching; // Guests and server players take their pose from DATA_POSE, not here. m.extra.avatar_pose = pose; + // What custom entity model animations read of the fluids (`is_in_water`, crawling). + m.fluids.in_water = in_water; + m.fluids.in_lava = in_lava; m.extra.passenger = riding; m.extra.previous_velocity = m.velocity; m.velocity = velocity; diff --git a/crates/horizon-client/src/game/piston_push.rs b/crates/horizon-client/src/game/piston_push.rs index 98eb5cee2e..fc15b2fe0a 100644 --- a/crates/horizon-client/src/game/piston_push.rs +++ b/crates/horizon-client/src/game/piston_push.rs @@ -9,13 +9,17 @@ //! then run through the block collision scan, so a pushed entity stops at a //! wall instead of passing through it. //! -use super::Game; +use super::{ + Game, + entity::{Entity, EntityHandle, with_entity, with_entity_mut}, +}; use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip}; use horizon_core::geometry::{Aabb, Axis, Direction, ShapeOp, Vec3, border::BorderGeometry}; use horizon_core::position::BlockPos; use horizon_world::{ - block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston, - piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld, + block_collision_scan::BlockCollisionWorld, level::data::PushReaction::IgnoreEntity, + level::pistons::MovingPiston, piston_movement::PistonMovement, piston_push, + world_movement::EntityMovementWorld, }; /// What one piston asks for this tick: where it is, its state, and the progress @@ -33,20 +37,7 @@ pub(super) type ShulkerPush = ([i32; 3], u8, [f64; 6]); enum Target { Player, Guest(u32), - Mob(usize), - Item(usize), - Boat(usize), - Cart(usize), - ArmorStand(usize), - /// Primed TNT (`PrimedTnt`): slime piston bounce (#4550). - Tnt(usize), - /// Every other entity `getEntities` returns is pushed too: only - /// `PushReaction.IGNORE_ENTITY` (a marker armor stand) is spared. - Falling(usize), - Orb(usize), - Projectile(usize), - Rocket(usize), - Trident(usize), + Entity(EntityHandle), } /// `Entity.getStepHeight` of each body, read before the push. @@ -103,12 +94,12 @@ impl Game<'_> { Vec3::new(lid[3], lid[4], lid[5]), ); let at = BlockPos::new(pos[0], pos[1], pos[2]); - for target in self.push_targets() { + self.push_targets().for_each(|target| { let Some(body) = self.push_body(target, at, steps) else { - continue; + return; }; if !body.bounds.intersects(area) { - continue; + return; } let Ok(moved) = pushed_movement( SceneCollisions { @@ -123,12 +114,12 @@ impl Game<'_> { body.step_height, body.on_ground, ) else { - continue; + return; }; if moved.length_squared() > 0.0 { self.apply_push(target, moved); } - } + }); } fn push_for_piston( @@ -189,10 +180,11 @@ impl Game<'_> { delta_progress, ) }); - let targets = self.push_targets(); - for &target in &targets { + // `for_each`: a chain folds list by list, where `next` would re-check every + // link for each entity. + self.push_targets().for_each(|target| { let Some(body) = self.push_body(target, pos, &steps) else { - continue; + return; }; let amount = piston_push::collided_amount( &boxes, @@ -243,11 +235,11 @@ impl Game<'_> { ); } } - } + }); // Vanilla selects honey passengers after all collided entities have moved. if honey && movement.normal()[1] == 0 { let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top); - for target in targets { + for target in self.push_targets() { let Some(body) = self.push_body(target, pos, &steps) else { continue; }; @@ -347,38 +339,26 @@ impl Game<'_> { /// The entities a moving block can run into: the window's player, the LAN /// guests in the level, then the mobs. - fn push_targets(&self) -> Vec { - let mut targets = Vec::new(); - if self.player.is_some() { - targets.push(Target::Player); - } - if let Some(host) = self.lan.as_ref() { - targets.extend( - host.hub - .guests - .iter() - .filter(|g| host.hub.here(g) && g.loaded && !g.dead) - .map(|g| Target::Guest(g.conn)), - ); - } - targets.extend((0..self.mobs.len()).map(Target::Mob)); - targets.extend((0..self.dropped.len()).map(Target::Item)); - targets.extend((0..self.boats.len()).map(Target::Boat)); - targets.extend((0..self.carts.len()).map(Target::Cart)); - targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand)); - targets.extend((0..self.tnts.len()).map(Target::Tnt)); - targets.extend((0..self.falling.len()).map(Target::Falling)); - targets.extend((0..self.orbs.len()).map(Target::Orb)); - targets.extend((0..self.creatures.projectiles.len()).map(Target::Projectile)); - targets.extend((0..self.rockets.len()).map(Target::Rocket)); - targets.extend((0..self.tridents.len()).map(Target::Trident)); - targets + fn push_targets(&self) -> impl Iterator + use<> { + let player = self.player.is_some().then_some(Target::Player); + // Empty, and so unallocated, unless guests are here. + let guests: Vec = self.lan.as_ref().map_or_else(Vec::new, |host| { + host.hub + .guests + .iter() + .filter(|g| host.hub.here(g) && g.loaded && !g.dead) + .map(|g| Target::Guest(g.conn)) + .collect() + }); + player + .into_iter() + .chain(guests) + .chain(self.entity_handles().map(Target::Entity)) } /// What the push needs from one entity, or `None` when it is not pushed: /// removed, dead, a spectator, or an armor-stand marker. fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option { - let at = [pos.x, pos.y, pos.z]; match target { Target::Player => { let p = self.player.as_ref()?; @@ -414,133 +394,18 @@ impl Game<'_> { .is_some_and(|m| m.ground.supporting_block == Some(pos)), }) } - Target::Mob(i) => { - let m = &self.mobs[i]; - if m.removed || !m.alive() { + Target::Entity(handle) => with_entity!(self, handle, entity => { + if !entity.is_alive() || entity.get_piston_push_reaction() == IgnoreEntity { return None; } Some(Body { - bounds: m.bounds(), - position: m.position, - on_ground: m.on_ground, - step_height: m.kind.step_height as f32, - supported: m.supporting == Some(at), + bounds: Entity::bounds(entity), + position: entity.position(), + on_ground: entity.on_ground(), + step_height: entity.get_step_height(), + supported: entity.is_supported_by(pos), }) - } - Target::Item(i) => { - let entity = &self.dropped[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: entity.on_ground, - step_height: 0.0, - supported: false, - }) - } - Target::Boat(i) => { - let entity = &self.boats[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: entity.on_ground, - step_height: 0.0, - supported: false, - }) - } - Target::Cart(i) => { - let entity = &self.carts[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: entity.on_ground, - step_height: 0.0, - supported: false, - }) - } - Target::ArmorStand(i) => { - let entity = &self.armor_stands[i]; - if entity.marker { - return None; - } - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: entity.on_ground, - step_height: 0.0, - supported: false, - }) - } - Target::Tnt(i) => { - let entity = &self.tnts[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: entity.on_ground, - step_height: 0.0, - supported: false, - }) - } - Target::Falling(i) => { - let entity = &self.falling[i]; - Some(Body { - bounds: horizon_client::falling_block::bounds(entity.position), - position: entity.position, - on_ground: false, - step_height: 0.0, - supported: false, - }) - } - Target::Orb(i) => { - let entity = &self.orbs[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: entity.on_ground, - step_height: 0.0, - supported: false, - }) - } - Target::Projectile(i) => { - let entity = &self.creatures.projectiles[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: false, - step_height: 0.0, - supported: false, - }) - } - Target::Rocket(i) => { - let entity = &self.rockets[i]; - Some(Body { - bounds: entity.bounds(), - position: entity.position, - on_ground: false, - step_height: 0.0, - supported: false, - }) - } - Target::Trident(i) => { - let entity = &self.tridents[i]; - Some(Body { - bounds: Aabb::new( - Vec3::new( - entity.position.x - 0.25, - entity.position.y, - entity.position.z - 0.25, - ), - Vec3::new( - entity.position.x + 0.25, - entity.position.y + 0.5, - entity.position.z + 0.25, - ), - ), - position: entity.position, - on_ground: false, - step_height: 0.0, - supported: false, - }) - } + }), } } @@ -583,7 +448,7 @@ impl Game<'_> { // Entity.move updates an authoritative living entity's ground flag // even for a horizontal piston push. Keeping the old support flag // would run Frost Walker while the stand is moving over open water. - if let Target::ArmorStand(i) = target { + if let Target::Entity(EntityHandle::ArmorStand(i)) = target { self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y; } if resolved.length_squared() <= 1.0e-7 @@ -614,7 +479,7 @@ impl Game<'_> { let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?; limit(&mut g.motion.as_deref_mut()?.piston) } - Target::Mob(i) => limit(&mut self.mobs[i].piston), + Target::Entity(EntityHandle::Mob(i)) => limit(&mut self.mobs[i].piston), _ => limit(accumulators.entry(target).or_default()), } } @@ -640,44 +505,8 @@ impl Game<'_> { } } } - Target::Mob(i) => { - let m = &mut self.mobs[i]; - m.position = m.position.add_vector(movement); - } - Target::Item(i) => { - let item = &mut self.dropped[i]; - item.position = item.position.add_vector(movement); - // Entity.move(MoverType.PISTON): onGround only if the push was down. - if movement.y != 0.0 { - item.on_ground = movement.y < 0.0; - } - } - Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement), - Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement), - Target::ArmorStand(i) => { - self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement) - } - Target::Tnt(i) => self.tnts[i].position = self.tnts[i].position.add_vector(movement), - Target::Falling(i) => { - self.falling[i].position = self.falling[i].position.add_vector(movement) - } - Target::Orb(i) => { - let orb = &mut self.orbs[i]; - orb.position = orb.position.add_vector(movement); - if movement.y != 0.0 { - orb.on_ground = movement.y < 0.0; - } - } - Target::Projectile(i) => { - let p = &mut self.creatures.projectiles[i]; - p.position = p.position.add_vector(movement); - p.needs_sync = true; - } - Target::Rocket(i) => { - self.rockets[i].position = self.rockets[i].position.add_vector(movement) - } - Target::Trident(i) => { - self.tridents[i].position = self.tridents[i].position.add_vector(movement) + Target::Entity(handle) => { + with_entity_mut!(self, handle, entity => entity.move_by_piston(movement)) } } } @@ -690,10 +519,6 @@ impl Game<'_> { p.velocity = piston_push::bounce_velocity(movement, p.velocity); } } - Target::Mob(i) => { - let m = &mut self.mobs[i]; - m.velocity = piston_push::bounce_velocity(movement, m.velocity); - } Target::Guest(conn) => { if let Some(host) = self.lan.as_mut() && let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn) @@ -702,47 +527,8 @@ impl Game<'_> { motion.velocity = piston_push::bounce_velocity(movement, motion.velocity); } } - Target::Item(i) => self.dropped[i].piston_slime_bounce(movement), - Target::Boat(i) => { - self.boats[i].velocity = - piston_push::bounce_velocity(movement, self.boats[i].velocity) - } - Target::Cart(i) => { - self.carts[i].velocity = - piston_push::bounce_velocity(movement, self.carts[i].velocity) - } - Target::ArmorStand(i) => { - self.armor_stands[i].velocity = - piston_push::bounce_velocity(movement, self.armor_stands[i].velocity) - } - Target::Tnt(i) => { - self.tnts[i].velocity = - piston_push::bounce_velocity(movement, self.tnts[i].velocity); - // Leave the ground so the next tick does not apply the - // on-ground Y * -0.5 friction that cancels the launch. - self.tnts[i].on_ground = false; - } - Target::Falling(i) => { - self.falling[i].velocity = - piston_push::bounce_velocity(movement, self.falling[i].velocity) - } - Target::Orb(i) => { - self.orbs[i].velocity = - piston_push::bounce_velocity(movement, self.orbs[i].velocity); - self.orbs[i].on_ground = false; - } - Target::Projectile(i) => { - let p = &mut self.creatures.projectiles[i]; - p.velocity = piston_push::bounce_velocity(movement, p.velocity); - p.needs_sync = true; - } - Target::Rocket(i) => { - self.rockets[i].velocity = - piston_push::bounce_velocity(movement, self.rockets[i].velocity) - } - Target::Trident(i) => { - self.tridents[i].velocity = - piston_push::bounce_velocity(movement, self.tridents[i].velocity) + Target::Entity(handle) => { + with_entity_mut!(self, handle, entity => Entity::piston_slime_bounce(entity, movement)) } } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index dbb00525b3..dc430ed028 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -8181,9 +8181,26 @@ impl Game<'_> { self.stop_remote_mining(); } if !ready { + // A screen's opening released the keys (`KeyMapping.releaseAll`). + self.hotbar_presses.clear(); return; } // MultiPlayerGameMode.tick syncs the carried slot before keybinds. + self.remote_send_carried(); + let (attack, use_item) = (self.left, self.right); + // A tick that clicks, attacks or uses acts with the slot its number + // keys select (`ensureHasSentCarriedItem` before the packets). + let acts = input.has_clicks() || attack || use_item; + input.tick(&mut RemoteInputWorld(self, acts), attack, use_item); + self.remote_queued_keys(); + if let Some(r) = self.remote.as_mut() { + r.input = input; + } + } + + /// `MultiPlayerGameMode.ensureHasSentCarriedItem`: the server hears of a + /// new selected slot before anything the slot acts with. + fn remote_send_carried(&mut self) { let carried_changed = self.remote.as_mut().is_some_and(|r| { if r.carried == Some(self.selected) { false @@ -8197,8 +8214,9 @@ impl Game<'_> { slot: self.selected as i16, })); } - let (attack, use_item) = (self.left, self.right); - input.tick(&mut RemoteInputWorld(self), attack, use_item); + } + + fn remote_queued_keys(&mut self) { // handleKeybinds' keybind polls: the queued presses act now, in tick // order, so no packet goes out between CLIENT_TICK_END and a pong. let pending = self @@ -8229,9 +8247,6 @@ impl Game<'_> { if self.clicks.miss_time > 0 { self.clicks.miss_time -= 1; } - if let Some(r) = self.remote.as_mut() { - r.input = input; - } } fn remote_start_attack(&mut self) -> bool { @@ -8300,11 +8315,14 @@ impl Game<'_> { } else if let Some((i, distance)) = self.mob_hit() && self.before_block(distance) { - self.send(S::Attack(play::Attack { - id: self.mobs[i].id as i32, - })); - self.remote_player_attack(i); - self.reset_attack_strength(); + // Minecraft.startAttack: an item's attack_range must hold the hit. + if self.held_range_allows(distance) { + self.send(S::Attack(play::Attack { + id: self.mobs[i].id as i32, + })); + self.remote_player_attack(i); + self.reset_attack_strength(); + } } else if !self.entity_targeted() && let Some(hit) = self.pick() { @@ -8519,7 +8537,9 @@ impl Game<'_> { // `KeyMapping.releaseAll` clears key.use; without this the button still // reads held and a use would start again on the first tick back. self.right = false; - horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(self)); + horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld( + self, false, + )); } /// `MultiPlayerGameMode.releaseUsingItem`: sends `RELEASE_USE_ITEM` and /// runs the client half of `Item.releaseUsing` (a Riptide launch moves the @@ -9818,7 +9838,8 @@ fn synced_local_use( } } -struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>); +/// The game as the tick's input sees it, and whether the tick acts. +struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>, bool); struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>); @@ -9836,10 +9857,16 @@ impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> { fn pick_hit_result(&mut self) { - self.0.tick_hit = Some(self.0.pick()); + self.0.pick_tick_targets(); + // MultiPlayerGameMode.ensureHasSentCarriedItem: an action this tick + // sends the slot the number keys chose first; otherwise the next + // tick's MultiPlayerGameMode.tick does. + if self.1 { + self.0.remote_send_carried(); + } } fn release_hit_result(&mut self) { - self.0.tick_hit = None; + self.0.release_tick_targets(); } fn using_item(&self) -> bool { self.0.using.is_some() diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index d55e52bd4f..821e76ebdf 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -795,7 +795,10 @@ impl Game<'_> { out.push(json!({"id": c.id, "kind": "minecraft:end_crystal", "position": at(c.position), "show_bottom": c.show_bottom})); } for t in &self.tridents { - out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)})); + out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position), "velocity": at(t.velocity)})); + } + for r in &self.rockets { + out.push(json!({"id": r.id, "kind": "minecraft:firework_rocket", "position": at(r.position), "velocity": at(r.velocity)})); } for p in &self.creatures.projectiles { out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity), diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 6508701059..6c55b6bbd7 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -237,9 +237,17 @@ impl Game<'_> { cart }; if let Some(p) = self.player.as_mut() { - self.previous_position = p.position; + // The tick a rider takes its seat is a teleport (`Entity.moveTo`, + // which sets the old position too): the camera must not sweep from + // where the player stood to the cushion, boat or cart. The ticks + // after it move with the vehicle and interpolate as usual. + let first = self.posed_rider != self.riding; + self.posed_rider = self.riding; if let Some(v) = seat { + self.previous_position = if first { v } else { p.position }; p.position = v; + } else { + self.previous_position = p.position; } p.velocity = Vec3::ZERO; p.fall_distance = 0.0; diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index aeb629f0a5..4df3ae4755 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -670,8 +670,9 @@ impl Game<'_> { host.play.entry(trip.conn).or_default().portal.cooldown = 10; let g = &mut host.hub.guests[i]; g.yaw = yaw; - // A new body at the exit: no fall distance or motion carried over. - g.motion = None; + // A new body at the exit: no fall distance or motion carried over, + // but the player's attributes (ServerPlayer.teleport keeps the entity). + g.new_body(true); let at = [position.x, position.y, position.z]; if portal { host.hub.land(i, at); @@ -727,8 +728,9 @@ impl Game<'_> { let g = &mut host.hub.guests[i]; let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]); let origin = g.position; - // A new body there: no fall distance or motion carried over. - g.motion = None; + // A new body there: no fall distance or motion carried over, but + // the player's attributes (ServerPlayer.teleport keeps the entity). + g.new_body(true); let cooldown = host.play.get(&t.conn).map_or(0, |p| p.portal.cooldown); let ctx = self.server_ctx(); host.hub diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index ce29e81b1d..fca643c463 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -38,6 +38,10 @@ fn sign_box() -> EditBox { edit } +/// The Done button's text, `CommonComponents.GUI_DONE`, in the selected language. +fn done_label() -> String { + super::screen_info::tr("gui.done", &[]) +} /// AbstractSignEditScreen.init's Done button: `width / 2 - 100`, /// `height / 4 + 144`, int GUI pixels. fn done_button(w: f32, h: f32) -> (f32, f32) { @@ -841,11 +845,7 @@ impl Game<'_> { } let (bx, by) = done_button(w, h); horizon_client::widgets::narratable(|| { - horizon_client::widgets::Narratable::button( - super::screen_info::tr("gui.done", &[]), - [bx, by, 200.0, 20.0], - true, - ) + horizon_client::widgets::Narratable::button(done_label(), [bx, by, 200.0, 20.0], true) }); batch.sprite_scaled( atlas, @@ -856,10 +856,11 @@ impl Game<'_> { 20.0, u32::MAX, ); - let tw = HudBatch::text_width(atlas, "Done"); + let done = done_label(); + let tw = HudBatch::text_width(atlas, &done); batch.text( atlas, - "Done", + &done, bx + 100.0 - (tw / 2.0).floor(), by + 6.0, u32::MAX, @@ -876,4 +877,27 @@ mod tests { assert_eq!(super::done_button(427.0, 334.0), (113.0, 227.0)); assert_eq!(super::done_button(320.0, 240.0), (60.0, 204.0)); } + + /// The Done button said "Done" in every language (#1927). + #[test] + fn done_button_reads_the_selected_language() { + use horizon_client::lang::{Language, ResourceSource, scoped}; + struct Vanilla; + impl ResourceSource for Vanilla { + fn read(&self, path: &str) -> Option> { + match path { + "assets/minecraft/lang/en_us.json" => Some(br#"{"gui.done": "Done"}"#.to_vec()), + "assets/minecraft/lang/de_de.json" => { + Some(br#"{"gui.done": "Fertig"}"#.to_vec()) + } + _ => None, + } + } + } + for (code, want) in [("de_de", "Fertig"), ("en_us", "Done")] { + let (label, missed) = scoped(Language::load(&[&Vanilla], code), super::done_label); + assert_eq!(label, want, "{code}"); + assert!(missed.is_empty(), "{code}: {missed:?}"); + } + } } diff --git a/crates/horizon-client/src/game/stats_screen.rs b/crates/horizon-client/src/game/stats_screen.rs index d70f3fd379..03fac444c8 100644 --- a/crates/horizon-client/src/game/stats_screen.rs +++ b/crates/horizon-client/src/game/stats_screen.rs @@ -326,20 +326,14 @@ impl Game<'_> { (false, false) => "gui/sprites/widget/tab", }; ui::nine_slice(batch, atlas, sprite, x, 0.0, tw, TAB_H, u32::MAX); - let label = Text::translate(key, &[]); - let lw = atlas.font.width_text(&label); - let color = if selected && active { - u32::MAX - } else { - 0xFFA0_A0A0 - }; - batch.text_styled( + ui::menu_tab_label( + batch, atlas, - &label, - (x + (tw - lw) / 2.0).floor(), - 8.0, - color, - true, + &Text::translate(key, &[]), + (x, 0.0, tw, TAB_H), + selected, + active, + horizon_client::hud_overlays::now_ms(), ); if selected { batch.fill(atlas, x + 2.0, TAB_H - 3.0, tw - 4.0, 1.0, u32::MAX); diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index dd6ae40226..8936643675 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -45,7 +45,7 @@ impl PrimedTnt { collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>, assets: &horizon_client::assets::Assets, facts: &horizon_client::blocks::BlockFacts, - ) -> Result<()> { + ) -> Result> { self.previous = self.position; if !self.no_gravity { self.velocity.y -= 0.04; @@ -74,14 +74,17 @@ impl PrimedTnt { if moved.y != wanted.y { self.velocity.y = 0.0; } - // Entity.applyEffectsFromBlocks: a bubble column lifts or drags it. - horizon_client::motion::bubble_column_push( + // Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the + // columns it is at the top of send their surface particles. + let mut tops = Vec::new(); + horizon_client::motion::bubble_column_push_over( world, assets.registry, facts, collision, self.bounds(), &mut self.velocity, + |pos| tops.push(pos), ); self.velocity = Vec3::new( self.velocity.x * 0.98, @@ -120,7 +123,7 @@ impl PrimedTnt { }, )?; } - Ok(()) + Ok(tops) } } @@ -199,12 +202,16 @@ impl Game<'_> { i += 1; continue; } - t.tick( + let tops = t.tick( &self.world, self.collision, &self.pack.assets, self.res.facts(), )?; + for pos in tops { + self.bubble_column_particles(pos); + } + let t = &mut self.tnts[i]; if t.fuse <= 0 { let center = [ t.position.x, @@ -766,4 +773,70 @@ mod tests { tnt.position.y ); } + + /// `Entity.onAboveBubbleColumn` sends the surface particles only from the top of + /// a column: `tick` names the column block it is at the top of, and none while + /// the column has water above it. + #[test] + fn primed_tnt_reports_the_surface_of_a_bubble_column() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = horizon_client::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap(); + let collision = horizon_client::collisions::load(&root, assets.registry).unwrap(); + let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); + let water = assets.registry.default_state("minecraft:water").unwrap(); + let soul_sand = assets + .registry + .default_state("minecraft:soul_sand") + .unwrap(); + let default = assets + .registry + .default_state("minecraft:bubble_column") + .unwrap(); + let mut up = assets.registry.state(default).unwrap().clone(); + up.properties.insert("drag".into(), "false".into()); + let column = assets.registry.resolve(&up).unwrap(); + for y in 3..=8 { + world.set(20, y, 20, column).unwrap(); + } + world.set(20, 2, 20, soul_sand).unwrap(); + let at = Vec3::new(20.5, 6.0, 20.5); + let primed = || PrimedTnt { + no_gravity: false, + id: 0, + position: at, + previous: at, + velocity: Vec3::ZERO, + fuse: 80, + on_ground: false, + owner: None, + guest: None, + }; + let mut tops = Vec::new(); + let mut tnt = primed(); + for _ in 0..30 { + tops.extend(tnt.tick(&world, &collision, &assets, &facts).unwrap()); + } + assert!(!tops.is_empty(), "it reaches the top of the column"); + assert!( + tops.iter().all(|p| *p == BlockPos::new(20, 8, 20)), + "only the surface block, got {tops:?}" + ); + // Water above the top block: it is no longer the surface. + world.set(20, 9, 20, water).unwrap(); + let mut tnt = primed(); + for _ in 0..10 { + assert!( + tnt.tick(&world, &collision, &assets, &facts) + .unwrap() + .is_empty() + ); + } + } } diff --git a/crates/horizon-client/src/game/ui_control.rs b/crates/horizon-client/src/game/ui_control.rs index 8f3657c294..8287754366 100644 --- a/crates/horizon-client/src/game/ui_control.rs +++ b/crates/horizon-client/src/game/ui_control.rs @@ -59,6 +59,7 @@ impl Game<'_> { let mut nodes = widgets::tree(&widgets::last_labelled(), &widgets::last_targets(), size); if matches!(self.screen, Screen::Inventory | Screen::Creative) && self.title.is_none() + && self.book.is_none() && let Ok((ox, oy)) = self.gui_origin() { nodes.extend(self.slots().into_iter().map(|i| { diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index 5101ca6f1c..3ea7449afc 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -426,6 +426,35 @@ impl Game<'_> { ); } self.falling = falling; + // Firework rockets (0.25 wide, eyes at 0.85 of it), tridents in flight + // (0.5 wide, eyes at 0.13; like any arrow not pushed in the ground) + // take `Entity.pushFromExplosion` alike. + let mut rockets = std::mem::take(&mut self.rockets); + for r in &mut rockets { + let p = r.position; + push_box( + self, + p, + [p.x - 0.125, p.y, p.z - 0.125], + [p.x + 0.125, p.y + 0.25, p.z + 0.125], + 0.2125, + &mut r.velocity, + ); + } + self.rockets = rockets; + let mut tridents = std::mem::take(&mut self.tridents); + for t in tridents.iter_mut().filter(|t| !t.in_ground) { + let p = t.position; + push_box( + self, + p, + [p.x - 0.25, p.y, p.z - 0.25], + [p.x + 0.25, p.y + 0.5, p.z + 0.25], + f64::from(0.13_f32), + &mut t.velocity, + ); + } + self.tridents = tridents; // VehicleEntity.ignoreExplosion: a mob's burst (a breeze's) leaves // boats and minecarts alone with mob griefing off. if !(breeze && !self.ui.rules.bool("mob_griefing")) { diff --git a/crates/horizon-client/src/lan/advancements.rs b/crates/horizon-client/src/lan/advancements.rs index a29f8e55e0..952448e810 100644 --- a/crates/horizon-client/src/lan/advancements.rs +++ b/crates/horizon-client/src/lan/advancements.rs @@ -826,6 +826,57 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// A recipe filter's `replace_input` (#5910) reaches the book swapped: + /// the book writes from the network copy, so the swap must have run + /// there, once, before the ingredient normalization. A world's pack + /// carries the filter (the way the game reads them). + #[test] + fn a_recipe_filters_swapped_input_shows_swapped_in_the_book() { + let root = resources( + "filter_swap", + &[( + "copper_ingot_from_smelting_raw_copper", + r#"{"type":"minecraft:smelting","cookingtime":200,"experience":0.7, + "ingredient":"minecraft:raw_copper", + "result":{"id":"minecraft:copper_ingot"}}"#, + )], + ); + let packs = vec![( + "recipe_filter/minecraft:swap".to_owned(), + vec![ + br#"{"filters":[{"id":"minecraft:copper_ingot_from_smelting_raw_copper", + "replace_input":{"from":"minecraft:raw_copper","to":"minecraft:redstone"}}]}"# + .to_vec(), + ], + )]; + let overlay = horizon_world::loot::PackOverlay { files: &packs }; + let sync = SyncData::load(&root, &overlay).unwrap(); + let entries = sync.advancements.book_entries( + "minecraft:copper_ingot_from_smelting_raw_copper", + &sync, + NOTIFY_AND_HIGHLIGHT, + ); + assert_eq!(entries.len(), 1, "the recipe has one display"); + let mut r = Reader::new(&entries[0]); + let e = RecipeBookEntry::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0, "the entry ends with its flags"); + let item = |n: &str| sync.id("minecraft:item", n).unwrap(); + assert_eq!( + e.requirements, + Some(vec![HolderSet::Items(vec![item("minecraft:redstone")])]), + "the filter's swapped input, not the file's" + ); + let RecipeDisplay::Furnace { ingredient, .. } = e.display else { + panic!("a smelting recipe shows a furnace display"); + }; + assert_eq!( + ingredient, + SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:redstone")])), + "the display shows the swapped input too" + ); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn blasting_reaches_the_blast_furnace_book() { let root = resources( diff --git a/crates/horizon-client/src/lan/data.rs b/crates/horizon-client/src/lan/data.rs index 5a9c7095da..4e41acdeef 100644 --- a/crates/horizon-client/src/lan/data.rs +++ b/crates/horizon-client/src/lan/data.rs @@ -271,8 +271,7 @@ impl SyncData { super::advancements::ServerAdvancements::load(resources, overlay, recipes, groups) }, }; - sync.recipes = - super::recipes::update_recipes(&sync.advancements.recipes, resources, &sync, overlay); + sync.recipes = super::recipes::update_recipes(&sync.advancements.recipes, &sync); Ok(sync) } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index ea41c35b11..8f220a6da0 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -214,6 +214,11 @@ pub struct Guest { /// What the player's file says of the body (`Entity.load`: the fire /// and air ticks and the fall distance), for the first body it gets. pub loaded_body: Option, + /// The attribute list the player's last body hands to its next one + /// (`ServerPlayer.restoreFrom`, `PlayerMotion::carried_attributes`), + /// until that body exists: the scale a player has set stays through a + /// respawn and a change of level (#3684). + pub carried_attributes: Option, /// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s /// "RootVehicle"), for the ride to come back on the next login. pub saved_vehicle: Option<[i32; 4]>, @@ -410,6 +415,7 @@ impl Guest { tracker: Tracker::default(), motion: None, loaded_body: None, + carried_attributes: None, saved_vehicle: None, logs_kept: None, inventory: vec![None; 36], @@ -600,14 +606,39 @@ impl Guest { Pose::Standing } } - /// `Avatar.POSES` for the guest's pose, with its scale attribute. + /// `Avatar.POSES` for the guest's pose, with its scale attribute: also for + /// a pose the client has announced (gliding) before the body's own tick + /// has taken it. pub fn dimensions(&self) -> Dimensions { let pose = self.pose(); match &self.motion { Some(m) if m.pose == pose => m.dimensions, - _ => pose.dimensions(1.0), + Some(m) => pose.dimensions(m.scale), + None => pose.dimensions(1.0), } } + /// The player's body is replaced (`ServerPlayer.restoreFrom`): the next + /// one starts from the old one's attributes, all of them when it is the + /// same player in another level (`restore_all`), only their base values + /// after a death. The old body, for what else it hands over. + pub fn new_body(&mut self, restore_all: bool) -> Option> { + let m = self.motion.take()?; + self.carried_attributes = Some(m.carried_attributes(restore_all)); + Some(m) + } + /// `ServerPlayer.restoreFrom(old, false)`'s `setHealth(getMaxHealth())`: + /// the health a player comes back from a death with, the max health of + /// the base values their next body takes over, before its equipment + /// modifies it (40 after `/attribute @s minecraft:max_health base set + /// 40`, not 20). + pub fn respawn_health(&self, resources: &crate::motion::MotionResources) -> f32 { + let Ok(mut body) = PlayerMotion::new(resources, Vec3::ZERO) else { + return 20.0; + }; + let _ = body.apply_saved_attributes(self.carried_attributes.as_ref()); + body.attribute("minecraft:max_health") + .map_or(20.0, |max| max as f32) + } /// `Entity.getBoundingBox`. pub fn bounds(&self) -> Aabb { let [x, y, z] = self.position; @@ -1384,4 +1415,102 @@ mod tests { Some(Component::Text(_)) )); } + + /// The prepared game data a body needs, or `None` without it. + fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + eprintln!("skipped: prepared resources unavailable"); + return None; + } + let assets = crate::assets::Assets::load(&root).ok()?; + let resources = crate::motion::MotionResources::load(&root, &assets).ok()?; + Some((assets, resources)) + } + + /// `/attribute @s base set`, as `AttributeCommand` runs it, + /// and the next tick's `refreshDirtyAttributes` (the box of a new scale). + fn set_base(m: &mut PlayerMotion, attribute: &str, value: f64) { + let key = m.attributes.key(attribute).unwrap(); + let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap(); + m.attributes + .edit(&handle, |attribute, observer| { + attribute.set_base_value(value, observer) + }) + .unwrap(); + m.refresh_attributes().unwrap(); + } + + fn assert_dims(got: Dimensions, want: (f32, f32, f32), what: &str) { + let close = |a: f32, b: f32| (a - b).abs() <= 1e-5 * b.abs().max(1.0); + assert!( + close(got.width, want.0) && close(got.height, want.1) && close(got.eye_height, want.2), + "{what}: {got:?}, want {want:?}" + ); + } + + /// `ServerPlayer.restoreFrom(old, false)` takes the base values + /// (`assignBaseValues`), then `setHealth(getMaxHealth())`: a player whose + /// max health base was set comes back with that much, not 20, and a + /// permanent modifier (`/attribute ... modifier add`) does not count, as + /// it stays with the dead body. The attribute's range is 1..1024. + #[test] + fn a_respawned_player_has_the_max_health_of_their_base_values() { + use horizon_world::attributes::{Modifier, Operation}; + let Some((_assets, resources)) = resources() else { + return; + }; + for (base, bonus, want) in [ + (40.0, 0.0, 40.0), + (10.0, 0.0, 10.0), + (20.0, 20.0, 20.0), + (0.0, 0.0, 1.0), + (2048.0, 0.0, 1024.0), + ] { + let mut g = guest_at([0.5, 64.0, 0.5]); + let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap(); + set_base(&mut m, "minecraft:max_health", base); + let key = m.attributes.key("minecraft:max_health").unwrap(); + let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap(); + let bonus = Modifier::new("test:bonus", bonus, Operation::AddValue).unwrap(); + m.attributes + .edit(&handle, |a, o| a.add_permanent(bonus, o)) + .unwrap(); + g.motion = Some(Box::new(m)); + assert!(g.new_body(false).is_some()); + assert_eq!(g.respawn_health(&resources), want, "base {base}"); + } + // No body to take anything from: the default. + let g = guest_at([0.5, 64.0, 0.5]); + assert_eq!(g.respawn_health(&resources), 20.0); + } + + /// #3684: a glide the client announced (`START_FALL_FLYING`) is the + /// player's pose before its body's own tick has taken it: the box in + /// between is the gliding one at the player's scale, not at scale 1 (a + /// 0.6 box on a player 0.1 or 2 times as big sent them back from a wall). + #[test] + fn a_glide_announced_before_the_body_ticks_has_the_scaled_box() { + let Some((_assets, resources)) = resources() else { + return; + }; + for (scale, side) in [(0.1_f64, 0.06_f32), (2.0, 1.2)] { + let mut g = guest_at([0.5, 64.0, 0.5]); + let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap(); + set_base(&mut m, "minecraft:scale", scale); + m.set_pose(Pose::Standing).unwrap(); + g.motion = Some(Box::new(m)); + g.fall_flying = true; + assert_eq!(g.pose(), Pose::FallFlying); + assert_ne!(g.motion.as_ref().unwrap().pose, Pose::FallFlying); + let d = g.dimensions(); + assert_dims( + d, + (side, side, side * (0.4 / 0.6)), + &format!("scale {scale}"), + ); + } + } } diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 5c400d3343..8e1bce535f 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -275,12 +275,19 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option) { } // LivingEntity.addAdditionalSaveData: the live body's AttributeMap.pack, // or the list the file had while the player has no body yet. - if let Some(m) = guest.motion.as_deref() { + // A player between two bodies (a respawn, a change of level) saves what + // the next one takes over. + let attributes = guest + .motion + .as_deref() + .map(crate::motion::PlayerMotion::saved_attributes) + .or_else(|| guest.carried_attributes.clone()); + if let Some(attributes) = attributes { let extra = level .player_extra .get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default())); if let horizon_world::nbt::Tag::Compound(map) = extra { - map.insert("attributes".into(), m.saved_attributes()); + map.insert("attributes".into(), attributes); } } level.recipe_book = guest.recipe_book.clone(); @@ -543,6 +550,89 @@ mod tests { Some((assets, resources)) } + /// A body at scale 0.1 with a permanent +0.4 on its step height, such as + /// a command or a datapack gives. + fn small_body(resources: &crate::motion::MotionResources) -> crate::motion::PlayerMotion { + use horizon_world::attributes::{Modifier, Operation}; + let mut m = crate::motion::PlayerMotion::new(resources, horizon_core::geometry::Vec3::ZERO) + .expect("a body"); + let key = m.attributes.key("minecraft:scale").unwrap(); + let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap(); + m.attributes + .edit(&handle, |a, o| a.set_base_value(0.1, o)) + .unwrap(); + let key = m.attributes.key("minecraft:step_height").unwrap(); + let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap(); + let step = Modifier::new("test:step", 0.4, Operation::AddValue).unwrap(); + m.attributes + .edit(&handle, |a, o| a.add_permanent(step, o)) + .unwrap(); + m + } + + #[test] + fn a_new_body_takes_the_scale_of_the_old_one() { + // #3684: ServerPlayer.restoreFrom copies the base values to the body + // that follows a death (assignBaseValues) and, for the same player in + // another level, the permanent modifiers too (assignPermanentModifiers). + // The body Horizon made on a respawn or a level change started from + // the defaults: a player at /attribute scale 0.1 was 1.0 again. + let Some((_assets, resources)) = resources() else { + eprintln!("skipped: prepared resources unavailable"); + return; + }; + let next = |g: &Guest| { + let mut m = + crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO) + .expect("a body"); + m.apply_saved_attributes(g.carried_attributes.as_ref()) + .unwrap(); + m + }; + for (restore_all, step) in [(false, 0.6), (true, 1.0)] { + let mut g = guest(); + g.motion = Some(Box::new(small_body(&resources))); + g.new_body(restore_all); + assert!(g.motion.is_none()); + let mut m = next(&g); + assert_eq!(m.attribute("minecraft:scale").unwrap(), 0.1); + assert!((m.attribute("minecraft:step_height").unwrap() - step).abs() < 1e-9); + // The tick that follows rebuilds the box at that scale. + m.refresh_attributes().unwrap(); + assert_eq!(m.dimensions.width, 0.6 * 0.1); + assert_eq!(m.dimensions.height, 1.8 * 0.1); + } + // No body before: nothing to hand over. + let mut g = guest(); + g.new_body(true); + assert!(g.carried_attributes.is_none()); + } + + #[test] + fn a_player_between_two_bodies_saves_the_attributes_the_next_one_takes() { + // #3684: logging out after a respawn, before the new body has ticked, + // must not write the file's older list over the scale. + let Some((_assets, resources)) = resources() else { + eprintln!("skipped: prepared resources unavailable"); + return; + }; + let mut g = guest(); + g.motion = Some(Box::new(small_body(&resources))); + g.new_body(false); + let mut level = Level::default(); + store(&g, &mut level, None); + let tag = level.player_tag(); + let mut read = Level::default(); + read.read_player(&tag); + read.player_extra = Some(tag); + let mut back = + crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO) + .expect("a body"); + back.apply_saved_attributes(read.player_extra.as_ref().and_then(|t| t.get("attributes"))) + .unwrap(); + assert_eq!(back.attribute("minecraft:scale").unwrap(), 0.1); + } + #[test] fn a_saved_air_comes_back() { // A player saved underwater (#1321): the file keeps the remaining air diff --git a/crates/horizon-client/src/lan/recipes.rs b/crates/horizon-client/src/lan/recipes.rs index 721c504e05..11796517b3 100644 --- a/crates/horizon-client/src/lan/recipes.rs +++ b/crates/horizon-client/src/lan/recipes.rs @@ -96,6 +96,12 @@ pub(super) fn network_recipes( let mut all = BTreeMap::new(); recipes(&resources.join("data/minecraft/recipe"), "", &mut all); overlay_recipes(overlay, &mut all); + // The data's recipe filters, as the server's own tables apply them + // (#5910), once, before the ingredient normalization, so a swapped + // name resolves and every copy — the book's displays, the property + // sets — agrees on the swapped recipe. + let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay); + all.retain(|id, r| filters.apply(id, r)); match horizon_client::items::Items::catalog(resources, overlay) { Ok(items) => all.retain(|_, r| normalize_ingredients(r, &items)), Err(e) => log::warn!("recipes: NeoForge's ingredient shapes stay unresolved: {e}"), @@ -190,26 +196,14 @@ fn normalize_ingredients(r: &mut Value, items: &horizon_client::items::Items) -> } /// The packet's payload for the network copy's recipes (`network_recipes`). -pub fn update_recipes( - all: &BTreeMap>, - resources: &Path, - sync: &SyncData, - overlay: &horizon_world::loot::PackOverlay, -) -> Vec { - // The data's recipe filters, as the server's own tables apply them. A - // filter can also swap a recipe's inputs and outputs, so it runs on - // this packet's own parsed values, the book's copy keeping its files - // as loaded. Parsing every recipe takes about a millisecond. - let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay); - let mut chosen: Vec = Vec::new(); - for (id, text) in all { - let Ok(mut own) = serde_json::from_str::(text) else { - continue; - }; - if filters.apply(id, &mut own) { - chosen.push(own); - } - } +pub fn update_recipes(all: &BTreeMap>, sync: &SyncData) -> Vec { + // The copy is already filtered and swapped (`network_recipes`); the + // filters never run twice, so one swap cannot feed a later one again. + // Parsing every recipe takes about a millisecond. + let chosen: Vec = all + .values() + .filter_map(|text| serde_json::from_str::(text).ok()) + .collect(); let tags: BTreeMap<&str, &[i32]> = sync .tags .registries diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index d0f716aec9..4225d9f921 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -297,6 +297,9 @@ pub struct Menu { /// Recipes whose result was taken (`awardUsedRecipes`) and the stacks that /// went into each, for the caller. pub crafted: Vec<(String, Vec>)>, + /// A crafting grid's result taken (`ResultSlot.onTake`): the recipe and + /// the stack taken, for the caller (`native_item_crafted`). + pub item_crafted: Vec<(String, ItemStack)>, /// Potions taken from a brewing stand's bottle slots /// (`PotionSlot.onTake`), for the caller. pub brewed: Vec, @@ -729,6 +732,7 @@ impl Menu { sounds: Vec::new(), player_sounds: Vec::new(), crafted: Vec::new(), + item_crafted: Vec::new(), brewed: Vec::new(), equipped: Vec::new(), merchant: None, @@ -1732,6 +1736,8 @@ impl Menu { let grid = self.containers[GRID].clone(); if let Some((id, _)) = env.recipes.find(&grid, n) { self.crafted.push((format!("minecraft:{id}"), grid.clone())); + self.item_crafted + .push((format!("minecraft:{id}"), taken.clone())); } let remaining = env .recipes @@ -3292,6 +3298,25 @@ pub(crate) mod tests { }); } + /// A crafting grid's result taken is recorded for `native_item_crafted`: + /// the recipe the grid matched and the stack the player took. + #[test] + fn taking_a_crafting_result_records_the_crafted_stack() { + with_menu(Kind::Crafting, |env, menu| { + menu.creative = false; + menu.set_slot(env, 1, stack("oak_log", 1)); + menu.settle(env); + menu.click(env, 0, 0, Input::Pickup).unwrap(); + assert_eq!( + menu.item_crafted, + vec![( + "minecraft:oak_planks".to_owned(), + stack("oak_planks", 4).unwrap() + )] + ); + }); + } + #[test] fn quick_move_landing_routes_plain_containers() { for (kind, size) in [ diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 5cb64199cd..a727ecf081 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -2799,6 +2799,11 @@ impl Mob { } else { 0.0 }; + // LivingEntity.getDamageAfterArmorAbsorb asks `hurtArmor` *before* + // `CombatRules.getDamageAfterAbsorb`, so a worn piece wears by the + // damage the armour, toughness and enchantment reductions have not + // touched yet, and only a hit outside `#bypasses_armor` asks at all. + let armor_hit = (!source.bypasses_armor() && amount > 0.0).then_some(amount); if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 { // CombatRules.getDamageAfterAbsorb let armor = self.kind.armor + worn + covered; @@ -2881,17 +2886,19 @@ impl Mob { if !absorbed && dealt != 0.0 { self.record_damage(source, dealt); } - // LivingEntity.hurtArmor: the body armour a mob wears (a horse's) wears - // on a hit that reached it. A wolf's armour has its own rule - // (`Wolf.actuallyHurt`, `hurt_wolf_armor`). - if dealt > 0.0 + // LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and + // it hands `doHurtEquipment` the body slot alone. A wolf's armour has + // its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's + // four pieces theirs, so the other "body slot" species — donkey, mule, + // llama, camel, the skeleton and zombie horses — wear nothing, whatever + // `drops::head_is_body` says about where the item lives. + if let Some(damage) = armor_hit + && self.species() == Species::Horse && self.extra.head.is_some() - && self.species() != Species::Wolf - && drops::head_is_body(self.species()) { self.outbox.push(MobEvent::ArmorHurt { mob: self.id, - amount: dealt, + amount: damage, kind: source.kind, }); } @@ -4325,10 +4332,14 @@ mod armor_hurt_tests { use horizon_core::geometry::Vec3; fn horse() -> Mob { + mob("minecraft:horse", Species::Horse) + } + + fn mob(id: &str, species: Species) -> Mob { let mut kind = kind(); let k = Arc::make_mut(&mut kind); - k.id = "minecraft:horse".into(); - k.species = Species::Horse; + k.id = id.into(); + k.species = species; Mob::new(1, kind, Vec3::ZERO, 0.0) } @@ -4363,6 +4374,72 @@ mod armor_hurt_tests { .any(|e| matches!(e, MobEvent::ArmorHurt { .. })) ); } + + /// `Horse` is the only mob that overrides `LivingEntity.hurtArmor` (the + /// base one is empty, `LivingEntity.java:1887`), so a donkey wearing a body + /// item — `/item replace entity @e[type=minecraft:donkey,limit=1] + /// armor.body with minecraft:wolf_armor` — wears nothing, even though + /// `drops::head_is_body` stores a donkey's body item in the slot a horse's + /// goes in. + #[test] + fn a_donkey_wearing_a_body_item_wears_nothing() { + let mut donkey = mob("minecraft:donkey", Species::Donkey); + donkey.extra.head = Some("minecraft:wolf_armor".into()); + assert!( + donkey.damage(6.0, Damage::of(DamageKind::MobAttack)), + "the hit landed" + ); + assert!( + !donkey + .outbox + .iter() + .any(|e| matches!(e, MobEvent::ArmorHurt { .. })), + "only a horse's own override wears its body piece" + ); + } + + /// `getDamageAfterArmorAbsorb` asks `hurtArmor` only when the damage type + /// is not in `#bypasses_armor` (`LivingEntity.java:1907-1909`), so a horse + /// that falls keeps its body piece whole. + #[test] + fn a_fall_does_not_wear_the_body_piece() { + let mut m = horse(); + m.extra.head = Some("minecraft:diamond_horse_armor".into()); + assert!( + m.damage(6.0, Damage::of(DamageKind::Fall)), + "the fall landed" + ); + assert!( + !m.outbox + .iter() + .any(|e| matches!(e, MobEvent::ArmorHurt { .. })), + "a #bypasses_armor hit asks for no wear" + ); + } + + /// The wear is measured by the damage `hurtArmor` is handed, which is the + /// one the armour, toughness and enchantment reductions have not touched + /// yet: 8 raw damage is `8 / 4 = 2` durability even though plate leaves far + /// less than 8 through to the health. + #[test] + fn the_wear_is_the_damage_before_the_armour_reduction() { + let mut m = horse(); + let k = Arc::make_mut(&mut m.kind); + k.armor = 20.0; + m.extra.head = Some("minecraft:diamond_horse_armor".into()); + let before = m.health; + assert!(m.damage(8.0, Damage::of(DamageKind::MobAttack))); + assert!(m.health < before, "the hit reached the health"); + let owed = m.outbox.iter().find_map(|e| match e { + MobEvent::ArmorHurt { amount, .. } => Some(*amount), + _ => None, + }); + assert_eq!( + owed, + Some(8.0), + "the pre-reduction damage, not the damage the health took" + ); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/mobs/cem.rs b/crates/horizon-client/src/mobs/cem.rs index 0b9fd7a0b6..48b06fc4f8 100644 --- a/crates/horizon-client/src/mobs/cem.rs +++ b/crates/horizon-client/src/mobs/cem.rs @@ -18,10 +18,12 @@ //! new geometry. use super::{MobType, Part, Polygon}; -use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem}; +use horizon_optifine::cem::{ + files::{atlas_name, jem_files, load_jem}, + model::{Bone, EntityModel}, +}; use std::{ collections::{BTreeMap, BTreeSet, HashMap}, - fs, path::Path, sync::Arc, }; @@ -130,39 +132,6 @@ impl CemModels { } } -/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or -/// parsed. -fn load_jem(root: &Path, path: &str) -> Option { - let Some(text) = read_small(&root.join(path)) else { - log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"); - return None; - }; - let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) { - Ok(jem) => jem, - Err(e) => { - log::warn!("Horizon: custom entity model {e}"); - return None; - } - }; - for warning in &jem.warnings { - log::warn!("Horizon: custom entity model {warning}"); - } - if let Some(texture) = &jem.texture { - if atlas_name(texture).is_none() { - log::warn!( - "Horizon: custom entity model {path}: its texture {texture} is outside \ - assets/minecraft/textures/, so not in the atlas: the vanilla one draws" - ); - } else if !root.join(texture).is_file() { - log::warn!( - "Horizon: custom entity model {path}: its texture {texture} doesn't exist, \ - so not in the atlas: the vanilla one draws" - ); - } - } - Some(jem) -} - /// Every model layer of `types` by its key. fn vanilla_layers(types: &HashMap>) -> HashMap<&str, &Arc> { types @@ -171,47 +140,6 @@ fn vanilla_layers(types: &HashMap>) -> HashMap<&str, &Arc

Option { - let len = fs::metadata(path).ok()?.len(); - if len > MAX_BYTES as u64 { - return None; - } - fs::read_to_string(path).ok() -} - -/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first: -/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat -/// `.jem` before `/.jem`. -fn jem_files(root: &Path) -> BTreeMap> { - let mut out: BTreeMap> = BTreeMap::new(); - for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] { - let Ok(entries) = fs::read_dir(root.join(base)) else { - continue; - }; - let (mut flat, mut nested) = (Vec::new(), Vec::new()); - for e in entries.flatten() { - let name = e.file_name().to_string_lossy().into_owned(); - if let Some(model) = name.strip_suffix(".jem") { - // A directory named `x.jem` isn't a model. - if e.path().is_file() { - flat.push((model.to_owned(), format!("{base}/{name}"))); - } - } else { - let path = format!("{base}/{name}/{name}.jem"); - if root.join(&path).is_file() { - nested.push((name, path)); - } - } - } - for (model, path) in flat.into_iter().chain(nested) { - out.entry(model).or_default().push(path); - } - } - out -} - /// The CEM model names with a layer key of their own; any other name `n` is `n#main`. const LAYERS: &[(&str, &str)] = &[ ("sheep_wool", "sheep#wool"), @@ -500,13 +428,6 @@ impl CemLayer { } } -/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is -/// `entity/x`. Textures outside a `textures/` folder are not in the atlas. -fn atlas_name(path: &str) -> Option { - let rest = path.strip_prefix("assets/minecraft/textures/")?; - Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned()) -} - /// The first part named `name`, depth first (`ModelPart.getChild` through the tree). fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> { if part.name == name { @@ -544,6 +465,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap) -> Pa #[cfg(test)] mod tests { use super::*; + use std::fs; fn plane(name: &str, children: Vec) -> Part { Part { @@ -705,35 +627,6 @@ mod tests { Some(part.polygons.len() == 1) } - #[test] - fn emf_files_win_and_a_flat_file_beats_its_folder() { - let (replace, attach) = (jem("leg1", false), jem("leg1", true)); - let files = [ - // EMF's folder form over OptiFine's flat file. - ("optifine/cem/cow.jem", attach.as_str()), - ("emf/cem/cow/cow.jem", replace.as_str()), - // One folder: the flat file over the folder form. - ("optifine/cem/pig.jem", replace.as_str()), - ("optifine/cem/pig/pig.jem", attach.as_str()), - // A directory named `sheep.jem` is no file: the folder form stands. - ("optifine/cem/sheep.jem/readme.txt", ""), - ("optifine/cem/sheep/sheep.jem", attach.as_str()), - ]; - let tree = plane("root", vec![plane("right_hind_leg", vec![])]); - let models = models( - "precedence", - &files, - &["cow#main", "pig#main", "sheep#main"], - &tree, - ); - assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false)); - assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false)); - assert_eq!( - attached(&models, "sheep#main", "right_hind_leg"), - Some(true) - ); - } - #[test] fn a_baby_with_its_own_file_takes_it_not_the_adults() { let (adult, baby) = (jem("head", false), jem("head", true)); diff --git a/crates/horizon-client/src/mobs/cem_anim.rs b/crates/horizon-client/src/mobs/cem_anim.rs index 10c7d9fe53..5829365c07 100644 --- a/crates/horizon-client/src/mobs/cem_anim.rs +++ b/crates/horizon-client/src/mobs/cem_anim.rs @@ -549,6 +549,24 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) { ((vx * fx + vz * fz) / len, (vx * -fz + vz * fx) / len) } }; + // `Entity.isVisuallySwimming` (`Pose.SWIMMING`, id 3): a player's or mannequin's pose comes + // in `avatar_pose`, another mob's in `pose`. A drowned has no such pose, only the shared + // swimming flag (`extra.flag`). + let swim_pose = match &*m.kind.id { + "minecraft:player" | "minecraft:mannequin" => { + m.extra.avatar_pose == crate::pose::Pose::Swimming + } + _ => m.extra.pose == 3 || (m.species() == super::Species::Drowned && m.extra.flag), + }; + // The arm the current swing moves: the main arm unless the swing named its hand. + let swinging = m.ai.swinging || m.extra.swing_right_arm.is_some(); + let swing_right = m.extra.swing_right_arm.unwrap_or(!m.main_arm_left()); + // `HumanoidArm`'s items: the main hand is on the main arm. + let (right_hand, left_hand) = if m.main_arm_left() { + (&m.extra.off_hand, &m.extra.main_hand) + } else { + (&m.extra.main_hand, &m.extra.off_hand) + }; for (i, (_, p)) in PARAMS.iter().enumerate() { use Param::*; slots[i] = match p { @@ -582,6 +600,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) { PlayerRotY => frame.player_rot[1], // Random models' rule (a later change); the model's own rule is 0 until then. RuleIndex => 0.0, + // A client knows only a wolf's anger end time (as a remaining time taken when it + // was synced, which doesn't count down) and never the start: 0 for now. AngerTime | AngerTimeStart => 0.0, Distance => (dx * dx + dy * dy + dz * dz).sqrt() as f32, MoveForward => forward, @@ -589,6 +609,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) { // How deep the entity is in its fluid, which is what `fluid_depth_down` measures // once it touches the bottom. FluidDepth | FluidDepthDown => m.fluids.water.max(m.fluids.lava) as f32, + // Both need the blocks around the mob (the ground below, the fluid's surface + // above), which the draw doesn't have: 0 for now. HeightAboveGround | FluidDepthUp => 0.0, // The synced `Mob.isAggressive`, which a client's mobs have too. Aggressive => flag(m.ai.aggressive), @@ -599,10 +621,14 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) { Hurt => flag(m.hurt_time > 0), InWater => flag(m.fluids.in_water), InLava => flag(m.fluids.in_lava), + // `isInWaterOrRain` without the rain: whether rain reaches the mob needs the + // weather and the sky above it, which the draw doesn't have. Wet => flag(m.fluids.in_water), Invisible => flag(m.invisible), OnGround => flag(m.on_ground), Riding => flag(m.extra.passenger), + // `Entity.isVehicle`: SetPassengers names its first rider. + Ridden => flag(m.extra.rider.is_some() || m.rider.is_some()), Climbing => flag(m.climbing), Jumping => flag(!m.on_ground && m.velocity.y > 0.0), Paused => flag(frame.paused), @@ -611,14 +637,32 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) { ThirdPerson => flag(!frame.first_person && !frame.third_person_reversed), ThirdPersonReversed => flag(frame.third_person_reversed), RightHanded => flag(!m.main_arm_left()), - Sitting => flag(m.extra.sitting), + // The tamed animals' DATA_FLAGS_ID, and the species that keep their own. + Sitting => flag(match m.species() { + super::Species::Fox => super::fox::flag(m, super::fox::SITTING), + super::Species::Panda => super::panda::is_sitting(m), + super::Species::Camel | super::Species::CamelHusk => super::camel::sitting(m), + _ => m.extra.sitting, + }), Tamed => flag(m.extra.tame), Sprinting => flag(m.extra.sprinting), Sneaking => flag(m.extra.crouching), - // Not known to the draw yet: 0 until something reads them. - InHand | InItemFrame | InGround | OnHead | OnShoulder | Ridden | Blocking - | Crawling | Swimming | SwingingRight | SwingingLeft | FirstPersonHand | UsingItem - | HoldingRight | HoldingLeft | Hovered => 0.0, + // A player's `isBlocking`: the item in use blocks attacks. + Blocking => flag(m.extra.blocking), + // `isVisuallyCrawling`: the swimming pose out of water. + Crawling => flag(swim_pose && !m.fluids.in_water), + Swimming => flag(swim_pose), + // DATA_LIVING_ENTITY_FLAGS' using bit (the client counts the ticks). + UsingItem => flag(m.extra.using_item > 0), + SwingingRight => flag(swinging && swing_right), + SwingingLeft => flag(swinging && !swing_right), + HoldingRight => flag(right_hand.is_some()), + HoldingLeft => flag(left_hand.is_some()), + // Where the model is drawn (a held, framed or worn item, a shoulder): the draw + // doesn't pass it yet, so 0 until something reads them. + InHand | InItemFrame | InGround | OnHead | OnShoulder | FirstPersonHand | Hovered => { + 0.0 + } }; } } @@ -882,18 +926,68 @@ mod tests { m.extra.crouching = true; // The player's main hand option set to left. m.extra.main_hand_left = true; + m.extra.blocking = true; + // The swimming pose out of water: crawling. + m.extra.pose = 3; + m.extra.using_item = 3; + // Only the main hand holds something, and it is the left one. + m.extra.set_main_hand(Some("minecraft:stick".into())); + // A swing that names no hand moves the main arm: the left. + m.ai.swinging = true; + m.extra.rider = Some(7); let model = bone("root", vec![]); let animation = animate( &model, &[( "root.tx", "is_aggressive + is_sitting * 2 + is_tamed * 4 + is_sprinting * 8 \ - + is_sneaking * 16 + is_right_handed * 32", + + is_sneaking * 16 + is_right_handed * 32 + is_blocking * 64 \ + + is_crawling * 128 + is_swimming * 256 + is_using_item * 512 \ + + is_holding_item_right * 1024 + is_holding_item_left * 2048 \ + + is_swinging_right_arm * 4096 + is_swinging_left_arm * 8192 \ + + is_ridden * 16384", )], ); let mut poses = Poses::new(&model); animation.run_in(&Frame::default(), &m, &still(), &mut poses); - assert_eq!(poses.get("root")[0], 31.0); + assert_eq!( + poses.get("root")[0], + (31 + 64 + 128 + 256 + 512 + 2048 + 8192 + 16384) as f32 + ); + } + + #[test] + fn the_swimming_pose_in_water_swims_without_crawling() { + let mut m = mob(); + m.extra.pose = 3; + let model = bone("root", vec![]); + let animation = animate(&model, &[("root.tx", "is_swimming + is_crawling * 2")]); + let mut read = |in_water: bool| { + m.fluids.in_water = in_water; + let mut poses = Poses::new(&model); + animation.run_in(&Frame::default(), &m, &still(), &mut poses); + poses.get("root")[0] + }; + assert_eq!([read(true), read(false)], [1.0, 3.0]); + } + + #[test] + fn a_fox_sits_by_its_own_flags() { + let mut m = mob(); + m.kind = std::sync::Arc::new(crate::mobs::MobType { + species: crate::mobs::Species::Fox, + ..(*m.kind).clone() + }); + let model = bone("root", vec![]); + let animation = animate(&model, &[("root.tx", "is_sitting")]); + let mut sitting = |flags: i32| { + m.extra.ints[0] = flags; + let mut poses = Poses::new(&model); + animation.run_in(&Frame::default(), &m, &still(), &mut poses); + poses.get("root")[0] + }; + assert_eq!(sitting(crate::mobs::fox::SITTING), 1.0); + assert_eq!(sitting(crate::mobs::fox::CROUCHING), 0.0); } #[test] diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index a178807a20..0f137ec30e 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -345,25 +345,28 @@ pub fn work_sound(profession: u8) -> Option<&'static str> { } /// A `CropBlock` at `p`. pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool { - matches!( - w.block_name(p[0], p[1], p[2]), - "minecraft:wheat" - | "minecraft:carrots" - | "minecraft:potatoes" - | "minecraft:beetroots" - | "minecraft:torchflower_crop" - ) + w.facts + .get(w.world.block(p[0], p[1], p[2])) + .has_class("CropBlock") } /// `CropBlock.isMaxAge` for the block at `p`. pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool { - let max = match w.block_name(p[0], p[1], p[2]) { - "minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7", - "minecraft:beetroots" => "3", - // TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1): - // the crop is never at max age (age 2 is the torchflower itself). - _ => return false, + let facts = w.facts.get(w.world.block(p[0], p[1], p[2])); + if !facts.has_class("CropBlock") { + return false; + } + // The class chain survives content registration, including the two + // getMaxAge overrides. PitcherCropBlock is not a CropBlock. + let max = if facts.has_class("BeetrootBlock") { + 3 + } else if facts.has_class("TorchflowerCropBlock") { + 2 + } else { + 7 }; - w.prop(p[0], p[1], p[2], "age") == Some(max) + w.prop(p[0], p[1], p[2], "age") + .and_then(|age| age.parse::().ok()) + .is_some_and(|age| age >= max) } /// The gift loot table of a villager (`GiveGiftToHero.getLootTableToThrow`). diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 1ce3fe6c12..200483df9c 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -775,7 +775,7 @@ fn sense_babies(m: &mut Mob, w: &MobWorld) { } mem(m).visible_villager_babies.set_list(babies); } -/// `SecondaryPoiSensor`: a farmer's farmland within four blocks. +/// `SecondaryPoiSensor.doTick`: a farmer's farmland within four blocks. fn sense_secondary(m: &mut Mob, w: &MobWorld) { let mut sites = Vec::new(); if super::villager::is_farmer(m) { @@ -784,7 +784,7 @@ fn sense_secondary(m: &mut Mob, w: &MobWorld) { for y in -2..=2 { for z in -4..=4 { let p = [c[0] + x, c[1] + y, c[2] + z]; - if w.block_name(p[0], p[1], p[2]) == "minecraft:farmland" { + if farmland(w, p) { sites.push(p); } } @@ -3494,6 +3494,10 @@ fn restock_if_due(m: &mut Mob, w: &MobWorld) { t.restock(w.game_time); } } +#[cfg(test)] +#[path = "villager_farming_tests.rs"] +mod farming_tests; + /// `HarvestFarmland.validFarmlandAroundVillager`: ripe crops and bare /// farmland around the villager. fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> { @@ -3514,10 +3518,16 @@ fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> { } valid } +/// Farmland subclasses include mod templates. +fn farmland(w: &MobWorld, p: [i32; 3]) -> bool { + w.facts + .get(w.world.block(p[0], p[1], p[2])) + .has_class("FarmlandBlock") +} +/// `HarvestFarmland.validPos`: a ripe CropBlock or air above farmland. fn farmland_valid(w: &MobWorld, p: [i32; 3]) -> bool { super::villager::ripe_crop(w, p) - || w.facts.is_air(w.world.block(p[0], p[1], p[2])) - && w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland" + || w.facts.is_air(w.world.block(p[0], p[1], p[2])) && farmland(w, [p[0], p[1] - 1, p[2]]) } /// `HarvestFarmland.getValidFarmland`: one picked by the level random. fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> { @@ -3527,17 +3537,13 @@ fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> { let i = cx.lr.next_int_bound(valid.len() as i32).unwrap_or(0) as usize; Some(valid[i]) } -/// The seeds a villager plants (`VILLAGER_PLANTABLE_SEEDS`) and their crops. -fn seed_crop(item: &str) -> Option<&'static str> { - Some(match item { - "minecraft:wheat_seeds" => "minecraft:wheat", - "minecraft:potato" => "minecraft:potatoes", - "minecraft:carrot" => "minecraft:carrots", - "minecraft:beetroot_seeds" => "minecraft:beetroots", - "minecraft:torchflower_seeds" => "minecraft:torchflower_crop", - "minecraft:pitcher_pod" => "minecraft:pitcher_crop", - _ => return None, - }) +/// A tagged BlockItem plants its block's default state. +fn seed_crop(w: &MobWorld, item: &str) -> Option { + let items = w.items?; + if !items.item_in_tag("minecraft:villager_plantable_seeds", item) { + return None; + } + items.get(item)?.block } /// `HarvestFarmland.tick` at the farmland: harvest a ripe crop, sow bare /// farmland, and move on from a growing crop. @@ -3559,23 +3565,22 @@ fn harvest_tick( if ripe { cx.events.push(MobEvent::BreakBlock { pos: p, drop: true }); } - if air && w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland" { + if air && farmland(w, [p[0], p[1] - 1, p[2]]) { let e = super::villager::ext_mut(m); - if let Some(slot) = e + if let Some((slot, crop)) = e .inventory .iter() - .position(|s| s.count > 0 && seed_crop(&s.item).is_some()) + .enumerate() + .filter(|(_, s)| s.count > 0) + .find_map(|(i, s)| seed_crop(w, &s.item).map(|crop| (i, crop))) { - let crop = seed_crop(&e.inventory[slot].item).unwrap_or("minecraft:wheat"); e.inventory[slot].count -= 1; if e.inventory[slot].count == 0 { e.inventory.remove(slot); } - cx.events.push(MobEvent::SetBlockName { + cx.events.push(MobEvent::SetBlock { pos: p, - name: crop, - effect: false, - flags: horizon_world::level::flags::UPDATE_ALL, + state: crop, }); cx.events.push(MobEvent::Sound( "item.crop.plant", diff --git a/crates/horizon-client/src/mobs/villager_farming_tests.rs b/crates/horizon-client/src/mobs/villager_farming_tests.rs new file mode 100644 index 0000000000..de42b3873b --- /dev/null +++ b/crates/horizon-client/src/mobs/villager_farming_tests.rs @@ -0,0 +1,322 @@ +use super::*; +use crate::{items::ItemStack, mod_content::ContentMod}; +use std::{ + path::PathBuf, + sync::{Arc, OnceLock}, +}; + +fn resources() -> &'static std::path::Path { + static ROOT: OnceLock = OnceLock::new(); + ROOT.get_or_init(|| { + let base = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + let scratch = std::env::temp_dir().join(format!("fresh-farming-{}", std::process::id())); + let dir = scratch.join("mod"); + let mut files = vec![( + "horizon-mod.toml".to_owned(), + "id = \"fieldtest\"\nname = \"Field test\"\nversion = \"1.0\"\napi = \"0.2\"\nside = \"both\"\ncapabilities = [\"registries\"]\n".to_owned(), + )]; + for (id, template) in [ + ("soil", "farmland"), + ("grain", "wheat"), + ("roots", "beetroots"), + ("flower", "torchflower_crop"), + ("pod", "pitcher_crop"), + ("fake_crop", "stone"), + ] { + files.push(( + format!("data/fieldtest/horizon/block/{id}.json"), + format!(r#"{{"template":"minecraft:{template}"}}"#), + )); + } + files.push(( + "data/minecraft/tags/item/villager_plantable_seeds.json".into(), + r#"{"replace":true,"values":["fieldtest:grain","fieldtest:roots","minecraft:stick"]}"#.into(), + )); + for (name, text) in files { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + let package = horizon_mods::Package::read(&dir).unwrap(); + let content = horizon_mods::Content::read(&dir, &package.manifest).unwrap(); + let out = scratch.join("resources"); + crate::mod_content::build(&base, &[ContentMod { id: "fieldtest".into(), content }], &out) + .unwrap(); + out + }) +} + +fn farmer(types: &super::super::MobTypes) -> Mob { + let mut m = Mob::new( + 1, + Arc::new(types.types["minecraft:villager"].clone()), + Vec3::new(0.5, 1.0, 0.5), + 0.0, + ); + super::super::villager::set_profession(&mut m, 5); + m +} + +fn harvest() -> B { + B::HarvestFarmland { + t: timed(60, 60), + next_ok: 0, + target: None, + valid: Vec::new(), + worked: 0, + } +} + +fn put( + world: &mut crate::world::World, + x: i32, + y: i32, + state: horizon_world::registry::BlockStateId, +) { + if !world.chunks.contains_key(&(x >> 4, 0)) { + world + .chunks + .insert((x >> 4, 0), Arc::new(crate::chunk::Chunk::empty(x >> 4, 0))); + } + world.set(x, y, 0, state).unwrap(); +} + +#[test] +#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"] +fn mod_farmland_is_a_secondary_site_and_farming_target() { + assert!(super::super::world::with_resource_test_world( + resources(), + &[], + true, + |world, reg| { + put(world, 0, 0, reg.default_state("fieldtest:soil").unwrap()); + }, + |w, types| { + let mut m = farmer(types); + sense_secondary(&mut m, w); + assert_eq!( + mem(&mut m).secondary_job_site.value(), + Some(vec![[0, 0, 0]]) + ); + let mut lr = LegacyRandom::new(11); + let mut events = Vec::new(); + let mut cx = Bx { + w, + lr: &mut lr, + events: &mut events, + now: 1000, + refresh: false, + }; + assert!(harvest().try_start(&mut m, &mut cx)); + let mut denied = w.clone(); + denied.mob_griefing = false; + mem(&mut m).look_target.clear(); + mem(&mut m).walk_target.clear(); + cx.w = &denied; + assert!(!harvest().try_start(&mut m, &mut cx)); + } + )); +} + +#[test] +#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"] +fn mod_crops_are_harvested_only_at_their_vanilla_templates_max_age() { + assert!(super::super::world::with_resource_test_world( + resources(), + &[], + true, + |world, reg| { + for (x, block, age) in [ + (0, "grain", "6"), + (1, "grain", "7"), + (2, "roots", "3"), + (3, "flower", "1"), + (4, "pod", "4"), + ] { + let mut state = reg + .state(reg.default_state(&format!("fieldtest:{block}")).unwrap()) + .unwrap() + .clone(); + state.properties.insert("age".into(), age.into()); + put(world, x, 1, reg.resolve(&state).unwrap()); + } + put( + world, + 5, + 1, + reg.default_state("fieldtest:fake_crop").unwrap(), + ); + }, + |w, types| { + let mut m = farmer(types); + let mut lr = LegacyRandom::new(12); + let mut events = Vec::new(); + for (x, ripe) in [ + (0, false), + (1, true), + (2, true), + (3, false), + (4, false), + (5, false), + ] { + events.clear(); + harvest_tick( + &mut m, + &mut Bx { + w, + lr: &mut lr, + events: &mut events, + now: 1000, + refresh: false, + }, + &mut 0, + &mut Some([x, 1, 0]), + &mut Vec::new(), + ); + assert_eq!(events.iter().any(|e| matches!(e, MobEvent::BreakBlock { pos, drop: true } if *pos == [x, 1, 0])), ripe, "x={x}"); + } + } + )); +} + +#[test] +#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"] +fn farming_uses_the_first_tagged_block_item_and_consumes_one_seed() { + assert!(super::super::world::with_resource_test_world( + resources(), + &[], + true, + |world, reg| { + for x in 0..=1 { + put(world, x, 0, reg.default_state("fieldtest:soil").unwrap()); + } + }, + |w, types| { + let mut m = farmer(types); + let inventory = &mut super::super::villager::ext_mut(&mut m).inventory; + *inventory = vec![ + ItemStack::new("minecraft:wheat_seeds", 2), + ItemStack::new("minecraft:stick", 2), + ItemStack::new("fieldtest:flower", 2), + ItemStack::new("fieldtest:roots", 1), + ItemStack::new("fieldtest:grain", 2), + ]; + let mut lr = LegacyRandom::new(13); + let mut events = Vec::new(); + harvest_tick( + &mut m, + &mut Bx { + w, + lr: &mut lr, + events: &mut events, + now: 1000, + refresh: false, + }, + &mut 0, + &mut Some([0, 1, 0]), + &mut Vec::new(), + ); + let planted = events.iter().find_map(|e| match e { + MobEvent::SetBlock { state, .. } => { + Some(w.registry.state(*state).unwrap().name.as_str()) + } + MobEvent::SetBlockName { name, .. } => Some(*name), + _ => None, + }); + assert_eq!(planted, Some("fieldtest:roots")); + let inventory = &super::super::villager::ext(&m).unwrap().inventory; + assert_eq!( + inventory + .iter() + .map(|s| (&*s.item, s.count)) + .collect::>(), + vec![ + ("minecraft:wheat_seeds", 2), + ("minecraft:stick", 2), + ("fieldtest:flower", 2), + ("fieldtest:grain", 2) + ] + ); + events.clear(); + harvest_tick( + &mut m, + &mut Bx { + w, + lr: &mut lr, + events: &mut events, + now: 1001, + refresh: false, + }, + &mut 0, + &mut Some([1, 1, 0]), + &mut Vec::new(), + ); + let expected = w.registry.default_state("fieldtest:grain").unwrap(); + assert!(events.iter().any( + |e| matches!(e, MobEvent::SetBlock { pos: [1, 1, 0], state } if *state == expected) + )); + assert_eq!( + super::super::villager::ext(&m) + .unwrap() + .inventory + .last() + .unwrap() + .count, + 1 + ); + assert_eq!( + events + .iter() + .filter(|e| matches!(e, MobEvent::Sound("item.crop.plant", _, 1.0, 1.0))) + .count(), + 1 + ); + } + )); +} + +#[test] +#[ignore = "tools/test_villager_farming_parity.py supplies the vanilla oracle"] +fn vanilla_farming_classification_matches_the_oracle() { + let path = std::env::var_os("HORIZON_FARMING_ORACLE").expect("vanilla oracle output"); + let rows: Vec<(u32, Vec)> = std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| { + let mut fields = line.split_whitespace(); + ( + fields.next().unwrap().parse().unwrap(), + fields.map(|f| f.parse().unwrap()).collect(), + ) + }) + .collect(); + assert!(!rows.is_empty()); + assert!(super::super::world::with_built_test_world( + &[], + |world, _| { + for (x, (state, _)) in rows.iter().enumerate() { + put( + world, + x as i32, + 0, + horizon_world::registry::BlockStateId(*state), + ); + } + }, + |w, _| { + for (x, (state, expected)) in rows.iter().enumerate() { + let p = [x as i32, 0, 0]; + assert_eq!( + vec![ + super::super::villager::crop(w, p), + super::super::villager::ripe_crop(w, p), + farmland_valid(w, [p[0], 1, 0]) + ], + *expected, + "state={state}" + ); + } + } + )); +} diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 53795f4c27..ebdc7846b5 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -417,24 +417,35 @@ pub(crate) fn with_built_test_world_rules( let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../.local/horizon-client") .join(horizon_core::VANILLA_VERSION); + with_resource_test_world(&root, others, mob_griefing, build, f) +} +/// The same mob world loaded from a content mod's resource root. +#[cfg(test)] +pub(crate) fn with_resource_test_world( + root: &std::path::Path, + others: &[Seen], + mob_griefing: bool, + build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry), + f: impl FnOnce(&MobWorld<'_>, &super::MobTypes), +) -> bool { if !root.join("entity-types.json").exists() || !root.join("movement-properties.nbt").exists() { return false; } - let assets = crate::assets::Assets::load(&root).unwrap(); + let assets = crate::assets::Assets::load(root).unwrap(); let registry = assets.registry; - let facts = BlockFacts::load(&root, registry).unwrap(); - let motion = crate::motion::MotionResources::load(&root, &assets).unwrap(); - let data = super::MobData::load(&root, &facts); - let types = super::MobTypes::load(&root).unwrap(); - let biomes = crate::biome::Biomes::load(&root).unwrap(); + let facts = BlockFacts::load(root, registry).unwrap(); + let motion = crate::motion::MotionResources::load(root, &assets).unwrap(); + let data = super::MobData::load(root, &facts); + let types = super::MobTypes::load(root).unwrap(); + let biomes = crate::biome::Biomes::load(root).unwrap(); let items = crate::items::Items::load( - &root, + root, registry, &facts, &horizon_world::loot::PackOverlay::NONE, ) .unwrap(); - let collision = crate::collisions::load(&root, registry).unwrap(); + let collision = crate::collisions::load(root, registry).unwrap(); let air = registry.default_state("minecraft:air").unwrap(); let mut world = crate::world::World::new(air, 0); build(&mut world, registry); diff --git a/crates/horizon-client/src/mod_api/generated.rs b/crates/horizon-client/src/mod_api/generated.rs index 098c6d02b3..b24b9b681c 100644 --- a/crates/horizon-client/src/mod_api/generated.rs +++ b/crates/horizon-client/src/mod_api/generated.rs @@ -1144,7 +1144,8 @@ pub trait Hooks: Send + Sync { /// the clicked position, whether the clicked block is being replaced, the /// item in hand) and `fluid` the fluid in the position the block would go /// in, which a waterlogged block reads. Build the state from `state` with - /// `native_state_with_property`. It is asked for every placer that goes + /// `native_state_with_property`, and know whether `state` is yours with + /// `native_state_block`. It is asked for every placer that goes /// through vanilla's placement (a player's `BlockItem.place`, a /// dispenser's `DirectionalPlaceContext`), and the answer still takes its /// survival and entity obstruction checks. A trait mod returns diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index a3a7b69d60..95494eff00 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -344,6 +344,10 @@ pub struct PlayerMotion { pub local_crouching: bool, pub pose: crate::pose::Pose, pub dimensions: crate::pose::Dimensions, + /// The `minecraft:scale` the dimensions were last built with + /// (`Entity.refreshDimensions`), for the poses the body takes before it + /// next refreshes. + pub scale: f32, pub speed: f32, /// `LocalPlayer.itemUseSpeedMultiplier` while using an item (not riding). pub item_use_speed: Option, @@ -428,6 +432,16 @@ fn attribute_error(error: impl std::fmt::Debug) -> io::Error { io::Error::other(format!("movement attribute: {error:?}")) } impl PlayerMotion { + /// `ServerGamePacketListenerImpl.handleMovePlayer`: the movement the client + /// sent is the server copy's own delta movement. A glide's wall hit reads it + /// as the speed the glide had (`LivingEntity.handleFallFlyingCollisions`'s + /// moveHorLength); a copy whose travels start from its own last velocity + /// otherwise keeps a few hundredths there, and no hit reaches the damage. + pub fn carry_client_movement(&mut self, movement: Vec3) { + if self.fall_flying { + self.velocity = movement; + } + } /// `Player.isSweepAttack`, using the resident player's known horizontal velocity. pub fn is_sweep_attack(&self, strength: f32, critical: bool, sword: bool) -> bool { let movement = self.velocity; @@ -559,6 +573,7 @@ impl PlayerMotion { local_crouching: false, pose: crate::pose::Pose::Standing, dimensions: crate::pose::Pose::Standing.dimensions(1.0), + scale: 1.0, swimming: false, is_passenger: false, fire_ticks: -20, @@ -717,6 +732,18 @@ impl PlayerMotion { /// player's `attributes` list (LivingEntity.addAdditionalSaveData), each /// instance's id, base and permanent modifiers. pub fn saved_attributes(&self) -> horizon_world::nbt::Tag { + self.packed_attributes(true) + } + /// `ServerPlayer.restoreFrom`: the attributes a player's new body takes + /// over from the old one. A body that comes back from a death takes the + /// base values only (`assignBaseValues`); the same player in another + /// level (`ServerPlayer.teleport`) is the same entity, with its permanent + /// modifiers too (`restoreAll`, `assignPermanentModifiers`). A scale of + /// 0.1 is still 0.1 after a respawn or a trip through a portal (#3684). + pub fn carried_attributes(&self, restore_all: bool) -> horizon_world::nbt::Tag { + self.packed_attributes(restore_all) + } + fn packed_attributes(&self, modifiers: bool) -> horizon_world::nbt::Tag { use horizon_client_core::nbt_value::string; use horizon_world::nbt::Tag; let list = self @@ -729,7 +756,7 @@ impl PlayerMotion { ("base", Tag::Double(packed.base.to_bits())), ]; // `modifiers` is an optional field whose default is the empty list. - if !packed.modifiers.is_empty() { + if modifiers && !packed.modifiers.is_empty() { let modifiers = packed .modifiers .iter() @@ -1035,9 +1062,9 @@ impl MotionWorld<'_, '_, '_> { if self.player.spectator { self.player.flying = true; self.player.ground.on_ground = false; - } else if !self.player.may_fly { - self.player.flying = false; - } else if !was_jumping && jumping && !self.player.was_auto_jump { + } else if self.player.may_fly && !was_jumping && jumping && !self.player.was_auto_jump { + // `LocalPlayer.aiStep`: mayfly gates the flight toggle, not the + // flying state independently supplied by the server's abilities. if self.player.jump_trigger_time == 0 { self.player.jump_trigger_time = 7; } else if !self.player.swimming { @@ -3430,6 +3457,60 @@ mod tests { assert!(player.events.is_empty()); } + /// `LivingEntity.handleFallFlyingCollisions`: a glide that meets a wall loses + /// the horizontal speed it had, and the server's copy hurts by that loss, + /// `(moveHorLength - newMoveHorLength) * 10 - 3`, as `minecraft:fly_into_wall`. + /// The speed it starts the tick with is the client's own movement + /// (`handleMovePlayer` gives the server copy its delta movement), so the + /// loss is the speed the glide actually flew at. + #[test] + fn a_glide_into_a_wall_hurts_by_the_speed_it_lost() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 1).unwrap(); + // A wall in the way of a glide along +x, at feet height. + let stone = assets.registry.default_state("minecraft:stone").unwrap(); + for y in 4..=5 { + for z in 19..=21 { + scene.set(23, y, z, stone).unwrap(); + } + } + let mut player = PlayerMotion::new(&resources, Vec3::new(22.0, 4.0, 20.5)).unwrap(); + player.fall_flying = true; + // Looking along +x (the `getLookAngle` of a flat -90 yaw) keeps the + // glide on its course through `updateFallFlyingMovement`'s pull. + player.yaw = -90.0; + player.pitch = 0.0; + player.carry_client_movement(Vec3::new(1.2, 0.0, 0.0)); + MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + } + .server_travel() + .unwrap(); + assert!( + player + .hurt_log + .last() + .is_some_and(|hurt| hurt.source == "minecraft:fly_into_wall"), + "{:?}", + player.hurt_log + ); + assert!(player.health.health() < 20.0, "{}", player.health.health()); + } + #[test] fn spectator_scroll_speed_controls_horizontal_and_vertical_flight() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -5166,6 +5247,60 @@ mod tests { assert!(w.player.sprinting); } + #[test] + fn server_forced_flight_survives_without_permission_to_toggle_it() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + eprintln!("skipped forced flight: prepared resources unavailable"); + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 3).unwrap(); + let start = Vec3::new(20.5, 20.0, 20.5); + let mut player = PlayerMotion::new(&resources, start).unwrap(); + player.may_fly = false; + player.flying = true; + player.flying_speed = 0.0; + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + // Idle, movement in all directions, and repeated jump presses must + // neither cancel server-forced flight nor move a zero-speed flyer. + for tick in 0..60 { + w.controlled_simulation_tick(crate::sprint::Keys { + forward: tick % 4 == 0, + backward: tick % 4 == 1, + left: tick % 4 == 2, + right: tick % 4 == 3, + jump: tick % 2 == 0, + shift: tick % 3 == 0, + ..Default::default() + }) + .unwrap(); + assert!(w.player.flying, "server-forced flight ended on tick {tick}"); + assert_eq!(w.player.position, start); + assert_eq!(w.player.velocity, Vec3::ZERO); + } + // A later abilities update from the server can still end flight. + w.player.flying = false; + for _ in 0..3 { + w.controlled_simulation_tick(crate::sprint::Keys::default()) + .unwrap(); + } + assert!(!w.player.flying); + assert!(w.player.position.y < start.y); + } + /// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability /// speed, doubled while the sprint state holds. The sprint key starts the /// state; releasing it does not change the speed. diff --git a/crates/horizon-client/src/pose.rs b/crates/horizon-client/src/pose.rs index 38e36c56c6..f0764a22e2 100644 --- a/crates/horizon-client/src/pose.rs +++ b/crates/horizon-client/src/pose.rs @@ -94,7 +94,8 @@ impl PlayerMotion { Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32)) } pub fn refresh_dimensions(&mut self) -> Result<()> { - self.dimensions = self.dimensions_for_pose(self.pose)?; + self.scale = self.attribute("minecraft:scale")? as f32; + self.dimensions = self.pose.dimensions(self.scale); Ok(()) } pub fn set_pose(&mut self, pose: Pose) -> Result<()> { diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index f934c169ef..91e2c08aff 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -470,7 +470,8 @@ pub(crate) struct Button { enabled: bool, id: u8, icon: Option<(&'static str, f32)>, - /// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text centred. + /// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text + /// centred in the room left of it. trailing: Option<(&'static str, f32)>, } impl Button { @@ -479,17 +480,36 @@ impl Button { /// (`AbstractWidget.extractScrollingStringOverContents`), instead of being /// drawn past it over the buttons beside it (so_so's 590 px World Type /// label on Create World, hi_in's Join Server in the Multiplayer footer). + /// A button with a `trailing` icon is `SpriteIconButton.TextAndIcon`, whose + /// label keeps clear of the icon: see [`Button::draw_label_at`]. fn draw_label(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32) { - let label = Text::literal(&self.label); - crate::ui::scrolling_text( + self.draw_label_at(batch, atlas, color, crate::hud_overlays::now_ms()); + } + + /// [`Button::draw_label`] at `now_ms`. `SpriteIconButton.TextAndIcon` + /// (`extractContents`) hands the label to `acceptScrolling` with `left` + /// at the button's x plus 2, `right` at its x plus width minus the + /// sprite's width minus 4, and `centerX` at its x plus half its width: the + /// label slides inside that box when wider, and one that fits is moved + /// left until it ends where the icon's room begins. A button without an + /// icon has `right` 2 short of its edge, where `centerX` is the middle of + /// the box and nothing moves. + fn draw_label_at(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32, now_ms: i64) { + let right = match self.trailing { + Some((_, size)) => self.x + self.w - size - 4.0, + None => self.x + self.w - 2.0, + }; + crate::ui::scrolling_text_around_at( batch, atlas, - &label, + &Text::literal(&self.label), + self.x + (self.w / 2.0).floor(), self.x + 2.0, - self.x + self.w - 2.0, + right, self.y, self.y + 20.0, self.y + 6.0, + now_ms, color, ); } diff --git a/crates/horizon-client/src/title/create_world.rs b/crates/horizon-client/src/title/create_world.rs index d2e8d3f5b4..c712e342a6 100644 --- a/crates/horizon-client/src/title/create_world.rs +++ b/crates/horizon-client/src/title/create_world.rs @@ -535,15 +535,14 @@ impl Title { u32::MAX, ); } - // The label sits 3 pixels lower on unselected tabs. - let top = if selected { 0.0 } else { 3.0 }; - batch.text_styled( + ui::menu_tab_label( + batch, atlas, &label, - x + (tw / 2.0).floor() - (lw / 2.0).floor(), - ((top + TAB_BAR - 9.0) / 2.0).floor() + 1.0, - u32::MAX, + (x, 0.0, tw, TAB_BAR), + selected, true, + crate::hud_overlays::now_ms(), ); } let (gx, gy) = grid(w, h, o.tab); diff --git a/crates/horizon-client/src/title/tests.rs b/crates/horizon-client/src/title/tests.rs index 8b19774e24..85dc343a1b 100644 --- a/crates/horizon-client/src/title/tests.rs +++ b/crates/horizon-client/src/title/tests.rs @@ -2074,3 +2074,148 @@ fn labels_at(f: &Fixture) -> (f32, f32, (f32, f32)) { (customize.x + 10.0, customize.y + 10.0), ) } + +/// A button with an icon at its right end (`SpriteIconButton.TextAndIcon`, the +/// Music & Sounds button of the Accessibility screen): `extractContents` gives +/// the label `left = x + 2` and `right = x + width - spriteWidth - 4`. +mod text_and_icon { + use super::*; + + fn atlas() -> HudAtlas { + HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap() + } + fn button(label: &str, icon: Option<(&'static str, f32)>) -> Button { + Button { + x: 30.0, + y: 40.0, + w: 150.0, + label: label.into(), + enabled: true, + id: 0, + icon: None, + trailing: icon, + } + } + /// The x of every vertex of the label's text. + fn xs(batch: &HudBatch) -> Vec { + batch + .vertices + .iter() + .filter(|v| v.text == 1) + .map(|v| v.pos[0]) + .collect() + } + /// The first `n` letters of a text with no repeating pattern. + fn label(n: usize) -> String { + "Bezpostrzydnie połōnczynie Direct Connection Join Server" + .chars() + .take(n) + .collect() + } + fn width(atlas: &HudAtlas, label: &str) -> i32 { + HudBatch::text_width(atlas, label).ceil() as i32 + } + + /// A label that fits beside the icon but, centred on the button, would end + /// under it (`Settings sa Aksesibilidad...` in `fil_ph`, and about 40 other + /// languages) is moved left until it ends where the icon's room begins: + /// `Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)` in + /// `defaultScrollingHelper`. It does not scroll. The same label in a plain + /// button stays centred. + #[test] + fn a_label_that_fits_beside_the_icon_is_moved_clear_of_it() { + let atlas = atlas(); + // A 16 px icon: `left = x + 2`, `right = x + 130`, `centerX = x + 75`. + let (left, right, center) = (32, 30 + 130, 30 + 75); + // The widest label that fits; `right - width / 2 < centerX` by most. + let text = (1..60) + .map(label) + .rfind(|t| width(&atlas, t) <= right - left) + .expect("a label that fits"); + let half = width(&atlas, &text) / 2; + assert!(right - half < center, "{text}: {}", width(&atlas, &text)); + let drawn_from = |from: i32| { + let mut batch = HudBatch::new([640, 480]); + batch.text(&atlas, &text, from as f32, 46.0, u32::MAX, true); + xs(&batch) + }; + let draw = |b: &Button, now_ms: i64| { + let mut batch = HudBatch::new([640, 480]); + b.draw_label_at(&mut batch, &atlas, u32::MAX, now_ms); + xs(&batch) + }; + let b = button(&text, Some(("icon/music_notes", 16.0))); + // Vanilla: `textX = right - width / 2`, drawn from `textX - width / 2`. + assert!(draw(&b, 0) == drawn_from(right - half - half), "{text}"); + for now_ms in [700, 1500, 31_337] { + assert!(draw(&b, now_ms) == draw(&b, 0), "it scrolls at {now_ms}"); + } + let plain = button(&text, None); + assert!(draw(&plain, 0) == drawn_from(center - half), "plain {text}"); + } + + /// A label wider than the room beside the icon scrolls inside + /// `x + 2 ..= x + width - spriteWidth - 4`, never under the icon. + #[test] + fn a_label_wider_than_the_room_beside_the_icon_scrolls_clear_of_it() { + let atlas = atlas(); + let text = label(40); + let b = button(&text, Some(("icon/music_notes", 16.0))); + assert!(width(&atlas, &text) > 130 - 2); + let mut seen = std::collections::BTreeSet::new(); + for now_ms in (0..40_000).step_by(250) { + let mut batch = HudBatch::new([640, 480]); + b.draw_label_at(&mut batch, &atlas, u32::MAX, now_ms); + let xs = xs(&batch); + assert!(!xs.is_empty()); + for x in &xs { + assert!( + (32.0..=30.0 + 150.0 - 16.0 - 4.0).contains(x), + "{x} at {now_ms} ms" + ); + } + seen.insert(xs.iter().map(|x| x.to_bits()).collect::>()); + } + assert!(seen.len() > 3, "the label hardly moves"); + } +} + +/// `MenuTabBar.MenuTabButton.renderLabel`: a tab's label slides inside the +/// tab's box with a 1 px margin when it is wider than the tab (here a window +/// too narrow for the English labels), instead of running into the next tab. +#[test] +fn a_create_world_tab_label_wider_than_its_tab_stays_inside_it() { + let mut f = Fixture::new(); + f.click("Singleplayer"); + let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap(); + // 90 px wide: three 20 px tabs from x = 14, too narrow for "Game". + let mut batch = HudBatch::new([90, 262]); + f.title.draw(&atlas, &mut batch, (0.0, 100.0), true); + let labels: Vec = batch + .vertices + .iter() + .filter(|v| v.text == 1 && v.pos[1] < 24.0) + .map(|v| v.pos[0]) + .collect(); + assert!(!labels.is_empty()); + let boxes: Vec<(f32, f32)> = (0..3) + .map(|tab| { + ( + 14.0 + 20.0 * tab as f32 + 1.0, + 14.0 + 20.0 * (tab + 1) as f32 - 1.0, + ) + }) + .collect(); + for x in &labels { + assert!( + boxes.iter().any(|(lo, hi)| (*lo..=*hi).contains(x)), + "label vertex at {x} is outside every tab's box" + ); + } + for (tab, (lo, hi)) in boxes.iter().enumerate() { + assert!( + labels.iter().any(|x| (*lo..=*hi).contains(x)), + "tab {tab} draws no label" + ); + } +} diff --git a/crates/horizon-items/src/items.rs b/crates/horizon-items/src/items.rs index 9c817202ba..e4c896c547 100644 --- a/crates/horizon-items/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -1378,6 +1378,33 @@ impl LootContext<'_> { fn enchantment_level(&self, id: &str) -> u32 { self.held.map_or(0, |h| h.enchantment_level(id)) } + /// The context a mod's own loot kind sees in this engine: the block and + /// the tool it has, and a seed from the roll's random. The entity, the + /// damage, the player and the biome belong to the main loot engine's + /// rolls; they are None here (docs/native-mods.md, API 133). + fn native_loot_context( + &self, + random: &mut R, + ) -> horizon_world::loot::NativeLootContext<'_> { + horizon_world::loot::NativeLootContext { + entity: None, + damage: None, + player: None, + block: (!self.block.is_empty()).then_some(self.block), + tool: self.held.map(|h| horizon_world::loot::NativeLootTool { + item: h.item.to_string(), + enchantments: h + .enchantments() + .iter() + .map(|(id, lvl)| (id.to_string(), *lvl as i32)) + .collect(), + }), + luck: 0.0, + biome: None, + seed: random.next_long(), + place: horizon_world::loot::current_native_loot_place(), + } + } fn number(v: &Value, random: &mut R) -> f32 { match v { Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32, @@ -1551,28 +1578,9 @@ impl LootContext<'_> { // does not have: held true (a double plant's other half is taken // as there). Unknown types fail, as in loot.rs. "minecraft:location_check" => true, - // A mod's own kind: the condition hook answers. This engine - // (block drops, entity drops, loot_table, interact_loot) has - // no LootStack, so the function hook isn't here yet. + // A mod's own kind: the condition hook answers. k if !c.is_null() && k.contains(':') && !k.starts_with("minecraft:") => { - let ctx = horizon_world::loot::NativeLootContext { - entity: None, - damage: None, - player: None, - block: (!self.block.is_empty()).then_some(self.block), - tool: self.held.map(|h| horizon_world::loot::NativeLootTool { - item: h.item.to_string(), - enchantments: h - .enchantments() - .iter() - .map(|(id, lvl)| (id.to_string(), *lvl as i32)) - .collect(), - }), - luck: 0.0, - biome: None, - seed: random.next_long(), - place: horizon_world::loot::current_native_loot_place(), - }; + let ctx = self.native_loot_context(random); let mut holds = false; horizon_world::loot::native_loot_condition(k, c, &ctx, &mut holds); holds @@ -1870,12 +1878,155 @@ impl LootContext<'_> { stack.count = stack.count.min(max as u32); } } + // A mod's own kind: the function hook changes the stack, as it + // does in the main loot engine. + k if k.contains(':') && !k.starts_with("minecraft:") => { + let ctx = self.native_loot_context(random); + mod_function(k, f, &ctx, stack, horizon_world::loot::native_loot_function); + } _ => {} } } } } +/// The items engine's stack as the loot engine's (`LootStack`), for a mod's +/// function hook: the parts of it the hook can change and the engine takes +/// back. The components the engine keeps as saved travel as their JSON, as +/// `set_components` writes them. +fn loot_of(stack: &ItemStack) -> horizon_world::loot::LootStack { + let mut components: Vec<(String, Value)> = Vec::new(); + if let Some(data) = stack.data() { + for (id, value) in &data.other { + components.push(( + id.clone(), + horizon_client_core::scoreboard::tag_to_json(value), + )); + } + if let Some(name) = &data.custom_name { + components.push(( + "minecraft:custom_name".to_owned(), + horizon_client_core::scoreboard::tag_to_json(name), + )); + } + if let Some((material, pattern)) = &data.trim { + components.push(( + "minecraft:trim".to_owned(), + serde_json::json!({"material": &**material, "pattern": &**pattern}), + )); + } + } + horizon_world::loot::LootStack { + item: stack.item.to_string(), + count: stack.count as i32, + damage: stack.damage as i32, + enchantments: stack + .crafting_enchantments() + .iter() + .map(|(id, level)| (id.to_string(), *level as i32)) + .collect(), + potion: stack.potion().map(str::to_owned), + map: false, + exploration: None, + ominous_amplifier: stack + .data() + .and_then(|d| d.ominous_amplifier) + .map(i32::from), + stew_effects: stack + .data() + .map(|d| { + d.stew_effects + .iter() + .map(|(effect, ticks)| (effect.to_string(), *ticks)) + .collect() + }) + .unwrap_or_default(), + names: Vec::new(), + components, + } +} + +/// What a mod's loot function left in its stack, applied to the engine's. +/// Only what changed is written, so a field the hook did not touch keeps the +/// engine's own form of it. +fn apply_loot( + before: &horizon_world::loot::LootStack, + after: &horizon_world::loot::LootStack, + stack: &mut ItemStack, +) { + if after.item != before.item { + stack.item = Arc::from(after.item.as_str()); + } + if after.count != before.count { + stack.count = after.count.max(0) as u32; + } + if after.damage != before.damage { + stack.damage = after.damage.max(0) as u32; + } + if after.enchantments != before.enchantments { + let levels = after + .enchantments + .iter() + .filter(|(_, level)| *level > 0) + .map(|(id, level)| (Arc::from(id.as_str()), *level as u32)) + .collect(); + stack.set_crafting_enchantments(levels); + } + if after.potion != before.potion { + stack.data_mut().potion = after.potion.as_deref().map(Arc::from); + } + if after.ominous_amplifier != before.ominous_amplifier { + stack.data_mut().ominous_amplifier = after.ominous_amplifier.map(|a| a.clamp(0, 255) as u8); + } + if after.stew_effects != before.stew_effects { + stack.data_mut().stew_effects = after + .stew_effects + .iter() + .map(|(effect, ticks)| (Arc::from(effect.as_str()), *ticks)) + .collect(); + } + // `SetNameFunction`'s components: the names the hook set. + for (id, text) in &after.names { + if !before.names.iter().any(|(i, t)| i == id && t == text) { + stack.data_mut().set_loot_name(id, text); + } + } + // `SetComponentsFunction`'s patch: the components the hook changed or + // added are set, and the ones it dropped (gone from the stack) removed. + for (id, value) in &after.components { + if !before.components.iter().any(|(i, v)| i == id && v == value) { + stack.data_mut().set_loot_component(id, value); + } + } + for (id, _) in &before.components { + if !after.components.iter().any(|(i, _)| i == id) { + stack.data_mut().set_loot_component(id, &Value::Null); + } + } + stack.tidy(); +} + +/// `native_loot_function` with the items engine's stack: the stack as the +/// loot engine's, the hook, and back what it changed. `hook` is the hook +/// point (`horizon_world::loot::native_loot_function`); a test passes its own. +fn mod_function( + kind: &str, + data: &Value, + ctx: &horizon_world::loot::NativeLootContext<'_>, + stack: &mut ItemStack, + hook: impl FnOnce( + &str, + &Value, + &horizon_world::loot::NativeLootContext<'_>, + &mut horizon_world::loot::LootStack, + ), +) { + let before = loot_of(stack); + let mut after = before.clone(); + hook(kind, data, ctx, &mut after); + apply_loot(&before, &after, stack); +} + /// `DispensibleContainerItem`: shared contents for hand use and dispensers. pub fn bucket_contents( item: &str, @@ -2159,6 +2310,96 @@ mod tests { assert_eq!(drops(Value::Null, "minecraft:stone"), 1); } + /// A mod's function kind changes the stack the items engine drops: what + /// the hook leaves in the stack comes back (item, count, a component), + /// through the conversion the block-drop path uses. + #[test] + fn a_mod_function_changes_the_stack_the_items_engine_drops() { + let items = Items::for_tests(HashMap::new()); + let props = horizon_world::chunk::BlockProperties::new(); + let mut random = LegacyRandom::new(7); + let block = ctx(&items, None, &props, "minecraft:stone"); + let native = block.native_loot_context(&mut random); + let mut stack = ItemStack::new("minecraft:stone", 1); + mod_function( + "mymod:seven", + &json!({"type": "mymod:seven"}), + &native, + &mut stack, + |_, _, _, loot| { + loot.item = "minecraft:diamond".to_owned(); + loot.count = 7; + loot.components.push(( + "minecraft:custom_data".to_owned(), + json!({"machine": "furnace"}), + )); + }, + ); + assert_eq!(&*stack.item, "minecraft:diamond"); + assert_eq!(stack.count, 7); + assert_eq!( + stack.data().and_then(|d| d.other("minecraft:custom_data")), + Some(&horizon_client_core::scoreboard::json_to_tag( + &json!({"machine": "furnace"}) + )) + ); + } + + /// A function that leaves the stack alone keeps its own form: the round + /// trip through the loot engine's stack changes nothing. + #[test] + fn a_mod_function_that_changes_nothing_keeps_the_stacks_form() { + let items = Items::for_tests(HashMap::new()); + let props = horizon_world::chunk::BlockProperties::new(); + let mut random = LegacyRandom::new(7); + let block = ctx(&items, None, &props, "minecraft:stone"); + let native = block.native_loot_context(&mut random); + let mut stack = ItemStack::new("minecraft:diamond_sword", 1); + stack.enchant("minecraft:sharpness", 3); + stack + .data_mut() + .set_loot_name("minecraft:custom_name", &json!("Excalibur")); + let before = stack.clone(); + mod_function( + "mymod:nothing", + &json!({"type": "mymod:nothing"}), + &native, + &mut stack, + |_, _, _, _| {}, + ); + assert_eq!(stack, before); + } + + /// The items engine reaches the function hook: a pool whose function kind + /// is a mod's draws the hook's one `next_long` where a vanilla pool draws + /// none. The hook is unclaimed here, so the drop keeps its count. + #[test] + fn a_mod_function_kind_reaches_the_items_engines_roll() { + let items = Items::for_tests(HashMap::new()); + let props = horizon_world::chunk::BlockProperties::new(); + let vanilla = json!({"pools":[{"rolls":1,"entries":[ + {"type":"minecraft:item","name":"minecraft:stone"}]}]}); + let modded = json!({"pools":[{"rolls":1,"entries":[ + {"type":"minecraft:item","name":"minecraft:stone"}], + "modifier":[{"type":"mymod:duplicate","factor":2}]}]}); + let mut a = LegacyRandom::new(100); + let mut b = LegacyRandom::new(100); + let _ = ctx(&items, None, &props, "minecraft:stone").evaluate(&vanilla, &mut a); + let expected: Vec = (0..5).map(|_| b.next_long()).collect(); + let got: Vec = (0..5).map(|_| a.next_long()).collect(); + assert_eq!(got, expected, "a vanilla table shifted the random"); + + let mut c = LegacyRandom::new(100); + let mut d = LegacyRandom::new(100); + let drops = ctx(&items, None, &props, "minecraft:stone").evaluate(&modded, &mut c); + let _ = d.next_long(); // the hook's seed + let expected: Vec = (0..5).map(|_| d.next_long()).collect(); + let got: Vec = (0..5).map(|_| c.next_long()).collect(); + assert_eq!(got, expected, "the mod function did not draw its seed"); + assert_eq!(drops.len(), 1); + assert_eq!(drops[0].count, 1); + } + #[test] fn fortune_silk_touch_and_looting() { let items = Items::for_tests(HashMap::new()); diff --git a/crates/horizon-optifine/Cargo.toml b/crates/horizon-optifine/Cargo.toml index 91c71f29bc..a4de2b3b9e 100644 --- a/crates/horizon-optifine/Cargo.toml +++ b/crates/horizon-optifine/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true rust-version.workspace = true -description = "OptiFine-format resource pack files: .properties, the name, range, biome and block matchers, paths and the optifine/ folders; CEM entity models and connected-texture rules" +description = "OptiFine-format resource pack files: .properties, the name, range, biome and block matchers, paths and the optifine/ folders; CEM entity models and connected-texture rules, and the Fusion mod's connected-texture format" [dependencies] log = "0.4" diff --git a/crates/horizon-optifine/src/cem/files.rs b/crates/horizon-optifine/src/cem/files.rs new file mode 100644 index 0000000000..f4fe5f6379 --- /dev/null +++ b/crates/horizon-optifine/src/cem/files.rs @@ -0,0 +1,136 @@ +//! Where packs keep their custom entity models and how a game reads one: the `.jem` files of +//! `emf/cem/` and `optifine/cem/` by model name, each read under [`MAX_BYTES`], parsed, its +//! warnings logged; and the atlas name of a model's texture. Shared by the mobs' and the block +//! entities' models. + +use super::model::{EntityModel, MAX_BYTES, parse_jem}; +use std::{collections::BTreeMap, fs, path::Path}; + +/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first: +/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat +/// `.jem` before `/.jem`. +pub fn jem_files(root: &Path) -> BTreeMap> { + let mut out: BTreeMap> = BTreeMap::new(); + for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] { + let Ok(entries) = fs::read_dir(root.join(base)) else { + continue; + }; + let (mut flat, mut nested) = (Vec::new(), Vec::new()); + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if let Some(model) = name.strip_suffix(".jem") { + // A directory named `x.jem` isn't a model. + if e.path().is_file() { + flat.push((model.to_owned(), format!("{base}/{name}"))); + } + } else { + let path = format!("{base}/{name}/{name}.jem"); + if root.join(&path).is_file() { + nested.push((name, path)); + } + } + } + for (model, path) in flat.into_iter().chain(nested) { + out.entry(model).or_default().push(path); + } + } + out +} + +/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or +/// parsed. +pub fn load_jem(root: &Path, path: &str) -> Option { + let Some(text) = read_small(&root.join(path)) else { + log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"); + return None; + }; + let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) { + Ok(jem) => jem, + Err(e) => { + log::warn!("Horizon: custom entity model {e}"); + return None; + } + }; + for warning in &jem.warnings { + log::warn!("Horizon: custom entity model {warning}"); + } + if let Some(texture) = &jem.texture { + if atlas_name(texture).is_none() { + log::warn!( + "Horizon: custom entity model {path}: its texture {texture} is outside \ + assets/minecraft/textures/, so not in the atlas: the vanilla one draws" + ); + } else if !root.join(texture).is_file() { + log::warn!( + "Horizon: custom entity model {path}: its texture {texture} doesn't exist, \ + so not in the atlas: the vanilla one draws" + ); + } + } + Some(jem) +} + +/// A pack file's text, None when it's missing or larger than a model may read: its size is +/// checked before anything is read. +fn read_small(path: &Path) -> Option { + let len = fs::metadata(path).ok()?.len(); + if len > MAX_BYTES as u64 { + return None; + } + fs::read_to_string(path).ok() +} + +/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is +/// `entity/x`. Textures outside a `textures/` folder are not in the atlas. +pub fn atlas_name(path: &str) -> Option { + let rest = path.strip_prefix("assets/minecraft/textures/")?; + Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emf_files_win_and_a_flat_file_beats_its_folder() { + let root = std::env::temp_dir().join(format!("horizon-jem-files-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for path in [ + // EMF's folder form over OptiFine's flat file. + "optifine/cem/cow.jem", + "emf/cem/cow/cow.jem", + // One folder: the flat file over the folder form. + "optifine/cem/pig.jem", + "optifine/cem/pig/pig.jem", + // A directory named `sheep.jem` is no file: the folder form stands. + "optifine/cem/sheep.jem/readme.txt", + "optifine/cem/sheep/sheep.jem", + ] { + let path = root.join("assets/minecraft").join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "{}").unwrap(); + } + let files = jem_files(&root); + fs::remove_dir_all(&root).unwrap(); + let paths = |paths: &[&str]| { + paths + .iter() + .map(|p| format!("assets/minecraft/{p}")) + .collect::>() + }; + assert_eq!( + files, + BTreeMap::from([ + ( + "cow".to_owned(), + paths(&["emf/cem/cow/cow.jem", "optifine/cem/cow.jem"]) + ), + ( + "pig".to_owned(), + paths(&["optifine/cem/pig.jem", "optifine/cem/pig/pig.jem"]) + ), + ("sheep".to_owned(), paths(&["optifine/cem/sheep/sheep.jem"])), + ]) + ); + } +} diff --git a/crates/horizon-optifine/src/cem/mod.rs b/crates/horizon-optifine/src/cem/mod.rs index e266539fb1..e569f11e6d 100644 --- a/crates/horizon-optifine/src/cem/mod.rs +++ b/crates/horizon-optifine/src/cem/mod.rs @@ -5,5 +5,6 @@ mod curves; pub mod expr; +pub mod files; pub mod json; pub mod model; diff --git a/crates/horizon-optifine/src/ctm/connect.rs b/crates/horizon-optifine/src/ctm/connect.rs index a4011c788e..d797edd791 100644 --- a/crates/horizon-optifine/src/ctm/connect.rs +++ b/crates/horizon-optifine/src/ctm/connect.rs @@ -88,6 +88,34 @@ pub struct Axes { } impl Axes { + /// Where a quad's own texture points, from its corners' positions and UVs, snapped to the + /// block's axes (Continuity's `orient=texture`): a turned or mirrored model's face turns or + /// mirrors its neighbours too. `None` for a quad whose UVs don't span it. + pub fn texture(positions: &[[f32; 3]; 4], uv: &[[f32; 2]; 4]) -> Option { + let edge = |k: usize| [0, 1, 2].map(|c| positions[k][c] - positions[0][c]); + let step = |k: usize| [uv[k][0] - uv[0][0], uv[k][1] - uv[0][1]]; + let (e1, e2, d1, d2) = (edge(1), edge(3), step(1), step(3)); + // The edges in UV terms: e = du * (d/du) + dv * (d/dv), solved for the two gradients. + let det = d1[0] * d2[1] - d2[0] * d1[1]; + if !det.is_finite() || det.abs() < 1e-6 { + return None; + } + let along_u = [0, 1, 2].map(|c| (e1[c] * d2[1] - e2[c] * d1[1]) / det); + let along_v = [0, 1, 2].map(|c| (e2[c] * d1[0] - e1[c] * d2[0]) / det); + // Only a direction close to one of the block's axes (within about 25°): a plant's 45° + // planes have none. + let snap = |v: [f32; 3]| { + let k = (0..3).max_by(|&a, &b| v[a].abs().total_cmp(&v[b].abs()))?; + let length = v.iter().map(|c| c * c).sum::().sqrt(); + let mut out = [0; 3]; + out[k] = if v[k] > 0.0 { 1 } else { -1 }; + (v[k].abs() > 0.9 * length).then_some(out) + }; + // v grows downward: the texture's up is against it. + let (right, up) = (snap(along_u)?, neg(snap(along_v)?)); + (dot(right, up) == 0).then_some(Self { right, up }) + } + /// On a vanilla cube: `BlockElement`'s default UVs (OptiFine's `ctm`, Continuity's /// `orient=none`). pub const fn cube(face: Face) -> Self { @@ -219,7 +247,7 @@ const TEMPLATE: [u8; 47] = [ ]; /// A mask without the corners whose sides don't both join. -const fn reduce(mask: u8) -> u8 { +pub(crate) const fn reduce(mask: u8) -> u8 { let mut out = mask & (U | R | D | L); let corners = [(UR, U | R), (DR, D | R), (DL, D | L), (UL, U | L)]; let mut i = 0; @@ -568,4 +596,57 @@ mod tests { .count(); assert!(differs > 20, "{differs}"); } + + #[test] + fn a_quads_own_texture_directions() { + // A north face with the cube's UVs: its texture points as the cube's does. + let north = [ + [1.0, 1.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + ]; + let standard = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + assert_eq!( + Axes::texture(&north, &standard), + Some(Axes::cube(Face::North)) + ); + // Mirrored (a vanilla pane face): right turns to east. + let mirrored = [[1.0, 0.0], [0.0, 0.0], [0.0, 1.0], [1.0, 1.0]]; + assert_eq!( + Axes::texture(&north, &mirrored), + Some(Axes { + right: EAST, + up: UP + }) + ); + // A top face turned a quarter (y=90): the texture's up points east, its right south. + let top = [ + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 1.0, 1.0], + [0.0, 1.0, 1.0], + ]; + let turned = [[0.0, 1.0], [0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]; + assert_eq!( + Axes::texture(&top, &turned), + Some(Axes { + right: SOUTH, + up: EAST + }) + ); + // UVs that don't span the quad, or that run diagonally across it. + assert_eq!(Axes::texture(&north, &[[0.5, 0.5]; 4]), None); + let diagonal = [[0.5, 0.0], [1.0, 0.5], [0.5, 1.0], [0.0, 0.5]]; + assert_eq!(Axes::texture(&north, &diagonal), None); + assert_eq!(Axes::texture(&north, &[[f32::NAN; 2]; 4]), None); + // A plant's plane, turned 45° about y: no block axis to connect along. + let plant = [ + [1.0, 1.0, 1.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + [1.0, 0.0, 1.0], + ]; + assert_eq!(Axes::texture(&plant, &standard), None); + } } diff --git a/crates/horizon-optifine/src/fusion.rs b/crates/horizon-optifine/src/fusion.rs new file mode 100644 index 0000000000..c3769c661c --- /dev/null +++ b/crates/horizon-optifine/src/fusion.rs @@ -0,0 +1,64 @@ +//! Fusion's resource pack format (the Fusion Connected Textures mod): a `fusion` section in a +//! texture's `.mcmeta` ([`texture`]: connecting, random, continuous and scrolling textures, and +//! the emissive, render type and tinting options every type takes), the connection predicates +//! that say when a connecting texture joins a neighbour ([`predicate`]), the `connections` of a +//! `fusion:connecting` block model ([`model`]), and which tiles of a connecting texture's image a +//! face draws ([`layout`]). +//! +//! The layouts read the neighbour masks of [`crate::ctm::connect`], which OptiFine's connected +//! textures build too: the two formats share how a face finds its neighbours. +//! +//! Written from Fusion's public wiki (its texture type, model type and model predicate pages) +//! and the MIT-licensed layout templates of SuperMartijn642/FusionExamples, read off the images. +//! Fusion itself is all rights reserved: none of its code was read. + +pub mod layout; +pub mod model; +pub mod predicate; +pub mod texture; + +pub use layout::{Layout, Tiles}; +pub use predicate::Connection; +pub use texture::Meta; + +use crate::cem::json::Json; + +/// A type name without the `fusion:` namespace packs may give it (`fusion:connecting`). +fn kind(name: &str) -> &str { + name.strip_prefix("fusion:").unwrap_or(name) +} + +fn bool_field(json: &Json, key: &str) -> Result, String> { + match json.get(key) { + None => Ok(None), + Some(Json::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` is not true or false")), + } +} + +fn str_field<'a>(json: &'a Json, key: &str) -> Result, String> { + match json.get(key) { + None => Ok(None), + Some(Json::Str(s)) => Ok(Some(s)), + Some(_) => Err(format!("`{key}` is not a string")), + } +} + +/// An integer field, which must be in `range`. +fn int_field( + json: &Json, + key: &str, + range: std::ops::RangeInclusive, +) -> Result, String> { + match json.get(key) { + None => Ok(None), + Some(Json::Num(n)) if n.fract() == 0.0 && range.contains(&(*n as i64)) => { + Ok(Some(*n as i64)) + } + Some(_) => Err(format!( + "`{key}` is not an integer from {} to {}", + range.start(), + range.end() + )), + } +} diff --git a/crates/horizon-optifine/src/fusion/layout.rs b/crates/horizon-optifine/src/fusion/layout.rs new file mode 100644 index 0000000000..d21d63a135 --- /dev/null +++ b/crates/horizon-optifine/src/fusion/layout.rs @@ -0,0 +1,399 @@ +//! A connecting texture's image is a grid of tiles; its layout says which tiles a face draws +//! for the neighbours that join it (a [`crate::ctm::connect`] mask). Tiles are numbered row by +//! row from the top left of the grid. +//! +//! The tiles are FusionExamples' `templates/template .png`: each marks its joined sides +//! (and, where both sides beside a corner join, that corner) in green. `compact`'s template +//! names its 5 tiles, not their masks; which one a face takes is the wiki's GIF of the layout. + +use crate::ctm::connect::{D, DL, DR, L, R, U, UL, UR, reduce}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Layout { + /// 5 tiles, a quarter of the face from each. + Pieced, + /// 16 tiles: the four sides only. + Simple, + /// 47 tiles: the sides and the corners. + Full, + /// 4 tiles in a column: alone, a column's top, middle and bottom. + Vertical, + /// 4 tiles in a row: alone, a row's left end, middle and right end. + Horizontal, + /// 5 tiles, a whole one per face. + Compact, + /// Edge pieces drawn over the face toward the neighbours that join it. + Overlay, +} + +/// What a face draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tiles { + One(u8), + /// A tile per quarter of the face: top left, top right, bottom left, bottom right. + Quarters([u8; 4]), + /// [`Layout::overlay_tiles`] of this mask, layered; none when nothing joins. + Overlay(u8), +} + +/// Every side. +const SIDES: u8 = U | R | D | L; +const CORNERS: u8 = UR | DR | DL | UL; + +/// The full template's tiles as the neighbours each joins. Its one empty cell is never drawn: a +/// lone corner, which [`reduce`] never leaves. +const EMPTY: u8 = UR; +const FULL: [u8; 48] = [ + 0, + R, + R | L, + L, + R | D, + D | L, + U | R | D, + R | D | L, + D, + R | DR | D, + R | DR | D | DL | L, + D | DL | L, + U | R, + U | L, + U | R | L, + U | D | L, + U | D, + U | UR | R | DR | D, + SIDES | CORNERS, + U | D | DL | L | UL, + U | UR | R | D, + R | DR | D | L, + U | R | DR | D, + R | D | DL | L, + U, + U | UR | R, + U | UR | R | L | UL, + U | L | UL, + U | R | L | UL, + U | D | DL | L, + U | UR | R | L, + U | D | L | UL, + SIDES | UR | DL, + SIDES, + SIDES | UR | DR, + SIDES | DR | DL, + SIDES | DR, + SIDES | DL, + SIDES | UL | UR | DL, + SIDES | UL | UR | DR, + SIDES | UL | DR, + EMPTY, + SIDES | UL | UR, + SIDES | UL | DL, + SIDES | UR, + SIDES | UL, + SIDES | UL | DL | DR, + SIDES | UR | DL | DR, +]; + +/// The simple template's 16 tiles as the sides each joins. +const SIMPLE: [u8; 16] = [ + 0, + SIDES, + D, + L, + R | L, + U | D, + R, + U, + U | R | D, + R | D | L, + R | D, + D | L, + U | R | L, + U | D | L, + U | R, + U | L, +]; + +/// `table`'s tile for each mask its entries can be: the inverse of the template. +const fn invert(table: [u8; N]) -> [u8; 256] { + let mut out = [0; 256]; + let mut i = 0; + while i < N { + if table[i] != EMPTY { + out[table[i] as usize] = i as u8; + } + i += 1; + } + out +} + +const FULL_INDEX: [u8; 256] = invert(FULL); +const SIMPLE_INDEX: [u8; 256] = invert(SIMPLE); + +/// The overlay grid (6 x 3). On its left half an edge piece toward each side and a piece in +/// each corner; on its right the pieces for two and three sides joined through the corners +/// between them, and the ring for all eight neighbours. +const RING: u8 = 10; +/// Clockwise from the top: each side, and the corner after it. +const CYCLE: [(u8, u8); 4] = [(U, UR), (R, DR), (D, DL), (L, UL)]; +/// By the side a run starts at, in `CYCLE`'s order: the piece for it alone, with the next side, +/// with the next two. +const EDGE: [u8; 4] = [13, 6, 1, 8]; +const PAIR: [u8; 4] = [15, 3, 5, 17]; +const TRIPLE: [u8; 4] = [9, 4, 11, 16]; +/// The corner pieces, in `CYCLE`'s order of the corners. +const CORNER: [u8; 4] = [12, 0, 2, 14]; + +impl Layout { + pub fn parse(name: &str) -> Option { + Some(match name { + "pieced" => Self::Pieced, + "simple" => Self::Simple, + "full" => Self::Full, + "vertical" => Self::Vertical, + "horizontal" => Self::Horizontal, + "compact" => Self::Compact, + "overlay" => Self::Overlay, + _ => return None, + }) + } + + /// The image's grid: columns, rows. + pub fn grid(self) -> (u32, u32) { + match self { + Self::Pieced | Self::Compact => (5, 1), + Self::Simple => (4, 4), + Self::Full => (8, 6), + Self::Vertical => (1, 4), + Self::Horizontal => (4, 1), + Self::Overlay => (6, 3), + } + } + + /// The tiles a face draws when the neighbours in `mask` join it. + pub fn tiles(self, mask: u8) -> Tiles { + let sides = mask & SIDES; + let joined = |bits: u8| mask & bits == bits; + let straight = |a, b| match (joined(a), joined(b)) { + (false, false) => 0, + (false, true) => 1, + (true, true) => 2, + (true, false) => 3, + }; + match self { + // The 5 tiles are `ctm_compact`'s: alone, joined all round, up and down, left and + // right, all but the corner. + Self::Pieced => Tiles::Quarters(crate::ctm::connect::compact_quarters(mask)), + Self::Simple => Tiles::One(SIMPLE_INDEX[sides as usize]), + Self::Full => Tiles::One(FULL_INDEX[reduce(mask) as usize]), + // Alone, then the end that joins `b` (a column's top, a row's left end), the + // middle, the other end. + Self::Vertical => Tiles::One(straight(U, D)), + Self::Horizontal => Tiles::One(straight(L, R)), + // An end, the centre, a vertical and a horizontal section and a cross: joined + // both ways, the centre only with every corner too. + Self::Compact => Tiles::One(match (joined(L | R), joined(U | D)) { + (true, true) if joined(CORNERS) => 1, + (true, true) => 4, + (false, true) => 2, + (true, false) => 3, + (false, false) => 0, + }), + Self::Overlay => Tiles::Overlay(mask), + } + } + + /// The overlay grid's tiles for `mask`, bottom first. Going round the face, each run of + /// joined sides with joined corners between them is one piece; then each joined corner with + /// neither side beside it. Unlike OptiFine's overlay, a corner matters between two joined + /// sides: without it they are two pieces (the wiki's GIF of the layout). + pub fn overlay_tiles(mask: u8) -> impl Iterator { + let mut out = [0; 4]; + let mut n = 0; + let mut push = |tile| { + out[n] = tile; + n += 1; + }; + let side = |i: usize| mask & CYCLE[i % 4].0 != 0; + // Side `i` joins the next one clockwise. + let linked = |i: usize| side(i) && side(i + 1) && mask & CYCLE[i % 4].1 != 0; + if mask == 0xff { + push(RING); + } else { + for start in (0..4).filter(|&i| side(i) && !linked(i + 3)) { + let mut len = 1; + while len < 4 && linked(start + len - 1) { + len += 1; + } + match len { + 1 => push(EDGE[start]), + 2 => push(PAIR[start]), + 3 => push(TRIPLE[start]), + // Every side, one corner open: the grid has no piece for it. Two three-side + // pieces, overlapping on the middle two sides, keep every joined corner. + _ => { + push(TRIPLE[start]); + push(TRIPLE[(start + 1) % 4]); + } + } + } + for (i, &(s, corner)) in CYCLE.iter().enumerate() { + if mask & corner != 0 && mask & (s | CYCLE[(i + 1) % 4].0) == 0 { + push(CORNER[i]); + } + } + } + out.into_iter().take(n) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn one(layout: Layout, mask: u8) -> u8 { + match layout.tiles(mask) { + Tiles::One(t) => t, + other => panic!("{other:?}"), + } + } + + /// The tiles `layout` draws over every mask. + fn drawn(layout: Layout) -> std::collections::HashSet { + (0..=255u8).map(|mask| one(layout, mask)).collect() + } + + #[test] + fn every_tile_is_drawn_for_some_mask() { + // A mask a table lacks would fall to tile 0 and leave its own tile unused. The full + // template's empty cell, (1, 5), is the one never drawn. + let mut full: Vec = drawn(Layout::Full).into_iter().collect(); + full.sort_unstable(); + assert_eq!(full, (0..48).filter(|&t| t != 41).collect::>()); + for layout in [ + Layout::Simple, + Layout::Vertical, + Layout::Horizontal, + Layout::Compact, + ] { + let (w, h) = layout.grid(); + assert_eq!(drawn(layout).len() as u32, w * h, "{layout:?}"); + } + } + + #[test] + fn full_layout_reads_the_template() { + // Alone: the top-left tile. A row's middle: the third. + assert_eq!(one(Layout::Full, 0), 0); + assert_eq!(one(Layout::Full, L | R), 2); + // Joined all round: (2, 2); with no corners: (1, 4). + assert_eq!(one(Layout::Full, 0xff), 18); + assert_eq!(one(Layout::Full, SIDES), 33); + // A corner whose sides don't both join changes nothing: up and the up-right corner is + // the tile for up alone, (0, 3). + assert_eq!(one(Layout::Full, U | UR), 24); + assert_eq!(one(Layout::Full, U | UR | R), 25); + // Every side and only the top-left corner: (5, 5). + assert_eq!(one(Layout::Full, SIDES | UL), 45); + // Every side and some corners, off the bottom two rows: opposite corners (0, 4) and + // (0, 5); one corner (4, 4); three (6, 4). + assert_eq!(one(Layout::Full, SIDES | UR | DL), 32); + assert_eq!(one(Layout::Full, SIDES | UL | DR), 40); + assert_eq!(one(Layout::Full, SIDES | DR), 36); + assert_eq!(one(Layout::Full, SIDES | DL), 37); + assert_eq!(one(Layout::Full, SIDES | UL | UR | DL), 38); + // Three sides and one corner between them: (4, 3) and (6, 3). + assert_eq!(one(Layout::Full, U | R | L | UL), 28); + assert_eq!(one(Layout::Full, U | R | L | UR), 30); + } + + #[test] + fn simple_and_straight_layouts() { + assert_eq!(one(Layout::Simple, D), 2); + assert_eq!(one(Layout::Simple, U | L | UL), 15); + // Corners don't count. + assert_eq!(one(Layout::Simple, R | D | DR), 10); + // The three-side tiles: (0, 2), (1, 2), (0, 3), (1, 3). + assert_eq!(one(Layout::Simple, U | R | D), 8); + assert_eq!(one(Layout::Simple, R | D | L), 9); + assert_eq!(one(Layout::Simple, U | R | L), 12); + assert_eq!(one(Layout::Simple, U | D | L), 13); + // A column: bottom (joined up), middle, top. + assert_eq!(one(Layout::Vertical, U), 3); + assert_eq!(one(Layout::Vertical, U | D), 2); + assert_eq!(one(Layout::Vertical, D), 1); + assert_eq!(one(Layout::Vertical, L | R), 0); + // A row: the left end joins right. + assert_eq!(one(Layout::Horizontal, R), 1); + assert_eq!(one(Layout::Horizontal, L | R | U), 2); + assert_eq!(one(Layout::Horizontal, L), 3); + } + + #[test] + fn compact_picks_one_whole_tile() { + // A row's end draws the end tile; its middle the horizontal section. + assert_eq!(one(Layout::Compact, R), 0); + assert_eq!(one(Layout::Compact, L | R), 3); + // The top middle of a 3 x 3 square joins left, right and down: horizontal. + assert_eq!(one(Layout::Compact, L | R | D | DL | DR), 3); + assert_eq!(one(Layout::Compact, U | D | R), 2); + // The square's middle, and a plus sign's. + assert_eq!(one(Layout::Compact, 0xff), 1); + assert_eq!(one(Layout::Compact, SIDES), 4); + // Some corners aren't all of them: still the cross. + assert_eq!(one(Layout::Compact, SIDES | UL | UR | DR), 4); + // A square's corner joins two sides: an end. + assert_eq!(one(Layout::Compact, R | D | DR), 0); + } + + #[test] + fn pieced_quarters() { + // Joined right only: the left quarters are the lone tile, the right ones horizontal. + assert_eq!(Layout::Pieced.tiles(R), Tiles::Quarters([0, 3, 0, 3])); + // Joined up and right without the corner: the top-right quarter is the cross piece. + assert_eq!(Layout::Pieced.tiles(U | R), Tiles::Quarters([2, 4, 0, 3])); + assert_eq!( + Layout::Pieced.tiles(U | R | UR), + Tiles::Quarters([2, 1, 0, 3]) + ); + } + + #[test] + fn overlay_layers_edges_and_corners() { + let cells = |mask| Layout::overlay_tiles(mask).collect::>(); + assert_eq!(cells(0), [0u8; 0]); + // Sand below: the bottom edge piece, (1, 0); to the right: (0, 1). + assert_eq!(cells(D), [1]); + assert_eq!(cells(R), [6]); + // Sand all round: the ring, (4, 1). + assert_eq!(cells(0xff), [10]); + // Two sides and the corner between them: one L-shaped piece off the right half. + assert_eq!(cells(L | D | DL), [5]); + assert_eq!(cells(R | D | DR), [3]); + assert_eq!(cells(U | R | UR), [15]); + assert_eq!(cells(U | L | UL), [17]); + // Without the corner: two edge pieces. + assert_eq!(cells(R | D), [6, 1]); + // Three sides and both corners between them. + assert_eq!(cells(R | D | L | DR | DL), [4]); + assert_eq!(cells(U | R | D | UR | DR), [9]); + assert_eq!(cells(U | D | L | UL | DL), [11]); + assert_eq!(cells(U | R | L | UL | UR), [16]); + // The wiki's GIF: sand up, up-left, left and right, not up-right. The up-left piece, + // (5, 2), and the right edge, (0, 1). + assert_eq!(cells(U | UL | L | R), [6, 17]); + // Every side and no corner: four edges. Every side but the top-left corner: no + // template or GIF shows it, so this pins `overlay_tiles`' own choice, the pieces for + // up-right-down and right-down-left. + assert_eq!(cells(U | R | D | L), [13, 6, 1, 8]); + assert_eq!(cells(!UL), [9, 4]); + // A lone corner each. + assert_eq!(cells(DL), [2]); + assert_eq!(cells(UL), [14]); + // Above and below: two edge pieces. + assert_eq!(cells(U | D), [13, 1]); + // A column of sand to the left, and sand at the right corners only: the left edge, + // then the top-right and bottom-right corner pieces. + assert_eq!(cells(UL | L | DL | UR | DR), [8, 12, 0]); + } +} diff --git a/crates/horizon-optifine/src/fusion/model.rs b/crates/horizon-optifine/src/fusion/model.rs new file mode 100644 index 0000000000..a3c888cc3c --- /dev/null +++ b/crates/horizon-optifine/src/fusion/model.rs @@ -0,0 +1,244 @@ +//! Fusion block models (`"loader": "fusion:model"`): the model type, and the `connections` of a +//! connecting model, which name the predicates each face's connecting texture joins by. + +use super::{Connection, kind}; +use crate::cem::json::Json; + +/// A model's Fusion type. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelType { + Base, + Connecting, + Composite, +} + +impl ModelType { + /// `None` for a model Fusion leaves to vanilla. + pub fn of(model: &Json) -> Option { + if model.get("loader").and_then(Json::as_str) != Some("fusion:model") { + return None; + } + Some(match kind(model.get("type")?.as_str()?) { + "base" => Self::Base, + "connecting" => Self::Connecting, + "composite" => Self::Composite, + _ => return None, + }) + } +} + +/// A connecting model's `connections`. +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Connections { + /// None given: each texture joins by its own. + #[default] + Textures, + /// One set of predicates for every face. + All(Connection), + /// Predicates by key, as `textures` names textures: each a predicate or `#another`. + Keys(Vec<(String, Entry)>), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Entry { + Predicate(Connection), + Ref(String), +} + +/// `#key` chains longer than this are a loop. +const DEPTH: usize = 32; + +impl Connections { + /// A model's `connections` value: a predicate (an object with a `type`, or an array), or an + /// object of keys. + pub fn parse(json: &Json) -> Result { + let keys = match json { + Json::Obj(keys) if json.get("type").is_none() => keys, + _ => return Ok(Self::All(Connection::parse(json)?)), + }; + keys.iter() + .map(|(key, value)| { + let entry = match value.as_str() { + Some(r) => Entry::Ref(r.strip_prefix('#').unwrap_or(r).to_owned()), + None => Entry::Predicate( + Connection::parse(value) + .map_err(|e| format!("connections `{key}`: {e}"))?, + ), + }; + Ok((key.clone(), entry)) + }) + .collect::>() + .map(Self::Keys) + } + + /// The model's `connections`: [`Connections::Textures`] without one. + pub fn of(model: &Json) -> Result { + model + .get("connections") + .map_or(Ok(Self::Textures), Self::parse) + } + + /// A child model's over its parent's: a child's keys add to and replace the parent's, and a + /// parent's predicates for every face stand for the keys the child doesn't define. + pub fn inherit(self, parent: &Self) -> Self { + match (self, parent) { + (Self::Textures, parent) => parent.clone(), + (Self::Keys(mut child), Self::Keys(parent)) => { + for (key, entry) in parent { + if !child.iter().any(|(k, _)| k == key) { + child.push((key.clone(), entry.clone())); + } + } + Self::Keys(child) + } + (Self::Keys(mut child), Self::All(all)) => { + if !child.iter().any(|(k, _)| k == "default") { + child.push(("default".to_owned(), Entry::Predicate(all.clone()))); + } + Self::Keys(child) + } + (child, _) => child, + } + } + + /// The predicates of a face: its `connections` reference, else its `texture` one (each + /// `#key` or `key`). A key the model doesn't define takes `default`'s; `None` leaves the + /// texture's own. + pub fn for_face(&self, connections: Option<&str>, texture: &str) -> Option<&Connection> { + let keys = match self { + Self::Textures => return None, + Self::All(c) => return Some(c), + Self::Keys(keys) => keys, + }; + let get = |key: &str| keys.iter().rev().find(|(k, _)| k == key).map(|(_, e)| e); + let key = connections.unwrap_or(texture); + let mut key = key.strip_prefix('#').unwrap_or(key); + for _ in 0..DEPTH { + match get(key).or_else(|| get("default"))? { + Entry::Predicate(c) => return Some(c), + Entry::Ref(next) => key = next, + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::BlockMatch; + + fn json(text: &str) -> Json { + Json::parse(text).unwrap() + } + + fn block(id: &str) -> Connection { + Connection::Neighbour(vec![BlockMatch { + id: id.into(), + states: vec![], + }]) + } + + #[test] + fn model_types() { + let ty = |text| ModelType::of(&json(text)); + assert_eq!( + ty(r#"{"type": "fusion:connecting", "loader": "fusion:model"}"#), + Some(ModelType::Connecting) + ); + assert_eq!( + ty(r#"{"loader": "fusion:model", "type": "base"}"#), + Some(ModelType::Base) + ); + // Without the loader Fusion leaves a model to vanilla. + assert_eq!(ty(r#"{"type": "connecting"}"#), None); + } + + #[test] + fn keys_resolve_like_textures() { + // The wiki's example: ice and lapis join by `blue`, dirt by its own, stone by `default`. + let c = Connections::of(&json( + r##"{"connections": { + "blue": [{"type": "match_block", "block": "ice"}, {"type": "match_block", "block": "lapis_block"}], + "lapis": "#blue", + "dirt": {"type": "is_same_block"}, + "default": {"type": "is_same_state"} + }}"##, + )) + .unwrap(); + let blue = Connection::Or(vec![block("minecraft:ice"), block("minecraft:lapis_block")]); + assert_eq!(c.for_face(Some("#blue"), "#ice"), Some(&blue)); + assert_eq!(c.for_face(Some("#lapis"), "#lapis"), Some(&blue)); + assert_eq!(c.for_face(None, "#dirt"), Some(&Connection::SameBlock)); + assert_eq!(c.for_face(None, "#stone"), Some(&Connection::SameState)); + } + + #[test] + fn one_predicate_for_every_face() { + let c = Connections::of(&json( + r#"{"connections": {"type": "fusion:or", "predicates": [{"type": "fusion:is_same_block"}]}}"#, + )) + .unwrap(); + assert_eq!( + c.for_face(None, "#pane"), + Some(&Connection::Or(vec![Connection::SameBlock])) + ); + // No connections: the textures' own; no `default`: also the texture's. + assert_eq!( + Connections::of(&json("{}")).unwrap().for_face(None, "#all"), + None + ); + let keyed = + Connections::of(&json(r#"{"connections": {"top": {"type": "true"}}}"#)).unwrap(); + assert_eq!(keyed.for_face(None, "#side"), None); + assert_eq!(keyed.for_face(None, "top"), Some(&Connection::Const(true))); + } + + #[test] + fn loops_and_inheritance() { + let looped = + Connections::of(&json(r##"{"connections": {"a": "#b", "b": "#a"}}"##)).unwrap(); + assert_eq!(looped.for_face(None, "#a"), None); + // A reference to a key nobody defines takes `default`'s. + let dangling = Connections::of(&json( + r##"{"connections": {"a": "#missing", "default": {"type": "true"}}}"##, + )) + .unwrap(); + assert_eq!( + dangling.for_face(None, "#a"), + Some(&Connection::Const(true)) + ); + // A parent's predicates for every face fill the keys a child leaves out, unless the + // child has its own `default`. + let all = Connections::All(Connection::SameBlock); + let child = Connections::of(&json(r#"{"connections": {"top": {"type": "true"}}}"#)) + .unwrap() + .inherit(&all); + assert_eq!(child.for_face(None, "#top"), Some(&Connection::Const(true))); + assert_eq!(child.for_face(None, "#side"), Some(&Connection::SameBlock)); + let child = Connections::of(&json(r#"{"connections": {"default": {"type": "false"}}}"#)) + .unwrap() + .inherit(&all); + assert_eq!( + child.for_face(None, "#side"), + Some(&Connection::Const(false)) + ); + let parent = Connections::of(&json( + r#"{"connections": {"top": {"type": "true"}, "side": {"type": "false"}}}"#, + )) + .unwrap(); + let child = Connections::of(&json( + r#"{"connections": {"side": {"type": "is_same_block"}}}"#, + )) + .unwrap() + .inherit(&parent); + assert_eq!(child.for_face(None, "#top"), Some(&Connection::Const(true))); + assert_eq!(child.for_face(None, "#side"), Some(&Connection::SameBlock)); + assert_eq!( + Connections::Textures + .inherit(&parent) + .for_face(None, "#top"), + Some(&Connection::Const(true)) + ); + } +} diff --git a/crates/horizon-optifine/src/fusion/predicate.rs b/crates/horizon-optifine/src/fusion/predicate.rs new file mode 100644 index 0000000000..36b44bc1d8 --- /dev/null +++ b/crates/horizon-optifine/src/fusion/predicate.rs @@ -0,0 +1,349 @@ +//! Connection predicates: whether a connecting texture joins the neighbour in one of the eight +//! directions of its face. Each direction is tested on its own, and the true ones make the +//! face's [`crate::ctm::connect`] mask. +//! +//! The predicate's block lists are `B`: [`BlockMatch`]es as parsed, which the game turns into +//! whatever it tests block states with ([`Connection::map_blocks`]). What a test reads of the +//! world, the game answers ([`Connection::joins`]). + +use super::kind; +use crate::BlockMatch; +use crate::cem::json::Json; +use crate::ctm::connect::{D, DL, DR, L, R, U, UL, UR}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Connection> { + /// `is_same_block`: the neighbour is the same block. + SameBlock, + /// `is_same_state`: the same block in the same state. + SameState, + /// `match_block`, `match_state`: the neighbour is one of these. + Neighbour(B), + /// `match_block_in_front`, `match_state_in_front`: the block in front of the neighbour (on + /// the side the face looks out of) is one of these. + InFront(B), + /// `is_direction`: the direction is one of these mask bits. + Direction(u8), + /// `is_face_visible`: the neighbour's face on this side isn't covered. + FaceVisible, + And(Vec>), + Or(Vec>), + Not(Box>), + Const(bool), +} + +/// A test only the world answers, for one direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Query<'a, B> { + SameBlock, + SameState, + Neighbour(&'a B), + InFront(&'a B), + FaceVisible, +} + +/// Directions in the texture's terms, as mask bits. +fn direction(name: &str) -> Option { + Some(match name { + "top" => U, + "top_right" => UR, + "right" => R, + "bottom_right" => DR, + "bottom" => D, + "bottom_left" => DL, + "left" => L, + "top_left" => UL, + _ => return None, + }) +} + +/// A value, or each value of an array. +fn one_or_many(value: &Json) -> &[Json] { + match value { + Json::Arr(items) => items, + single => std::slice::from_ref(single), + } +} + +/// `one` or `many` of a field: a string or an array of them. +fn names<'a>(json: &'a Json, one: &str, many: &str) -> Result, String> { + let (key, value) = [one, many] + .into_iter() + .find_map(|key| Some((key, json.get(key)?))) + .ok_or_else(|| format!("no `{one}` or `{many}`"))?; + one_or_many(value) + .iter() + .map(|v| { + v.as_str() + .ok_or_else(|| format!("`{key}` holds a non-string")) + }) + .collect() +} + +/// `block`/`blocks`, each with `properties` (a value or an array of values per property) when +/// `states` is set. +fn blocks(json: &Json, states: bool) -> Result, String> { + let mut properties = Vec::new(); + if states && let Some(map) = json.get("properties") { + for (key, values) in map.as_object().ok_or("`properties` is not an object")? { + let values = one_or_many(values) + .iter() + .map(|v| match v { + Json::Str(s) => Ok(s.clone()), + Json::Bool(b) => Ok(b.to_string()), + Json::Num(n) => Ok(n.to_string()), + _ => Err(format!("property `{key}` has a value that isn't one")), + }) + .collect::>()?; + properties.push((key.clone(), values)); + } + } + Ok(names(json, "block", "blocks")? + .into_iter() + .map(|name| BlockMatch { + id: crate::matchers::namespaced(name), + states: properties.clone(), + }) + .collect()) +} + +impl Connection { + /// A predicate, or an array of them that joins when any does (a texture's or a model's + /// `connections`). + pub fn parse(json: &Json) -> Result { + if let Some(items) = json.as_array() { + return Ok(Self::Or(Self::parse_all(items)?)); + } + let ty = super::str_field(json, "type")?.ok_or("a predicate without a `type`")?; + let list = |key: &str| match json.get(key).and_then(Json::as_array) { + Some(items) => Self::parse_all(items), + None => Err(format!("`{ty}` without a `{key}` array")), + }; + Ok(match kind(ty) { + "is_same_block" => Self::SameBlock, + "is_same_state" => Self::SameState, + "match_block" => Self::Neighbour(blocks(json, false)?), + "match_state" => Self::Neighbour(blocks(json, true)?), + "match_block_in_front" => Self::InFront(blocks(json, false)?), + "match_state_in_front" => Self::InFront(blocks(json, true)?), + "is_direction" => Self::Direction( + names(json, "direction", "directions")? + .into_iter() + .map(|d| direction(d).ok_or_else(|| format!("unknown direction `{d}`"))) + .try_fold(0, |mask, bit| bit.map(|b| mask | b))?, + ), + "is_face_visible" => Self::FaceVisible, + "and" => Self::And(list("predicates")?), + "or" => Self::Or(list("predicates")?), + "not" => Self::Not(Box::new(Self::parse( + json.get("predicate").ok_or("`not` without a `predicate`")?, + )?)), + "true" => Self::Const(true), + "false" => Self::Const(false), + other => return Err(format!("unknown connection predicate `{other}`")), + }) + } + + fn parse_all(items: &[Json]) -> Result, String> { + items.iter().map(Self::parse).collect() + } +} + +impl Connection { + /// The same predicate with its block lists turned into `C`s. + pub fn map_blocks(self, f: &mut impl FnMut(B) -> C) -> Connection { + let all = + |items: Vec, f: &mut _| items.into_iter().map(|p| p.map_blocks(f)).collect(); + match self { + Self::SameBlock => Connection::SameBlock, + Self::SameState => Connection::SameState, + Self::Neighbour(b) => Connection::Neighbour(f(b)), + Self::InFront(b) => Connection::InFront(f(b)), + Self::Direction(d) => Connection::Direction(d), + Self::FaceVisible => Connection::FaceVisible, + Self::And(items) => Connection::And(all(items, f)), + Self::Or(items) => Connection::Or(all(items, f)), + Self::Not(p) => Connection::Not(Box::new(p.map_blocks(f))), + Self::Const(c) => Connection::Const(c), + } + } + + /// Whether the face joins toward `direction` (one mask bit), `world` answering the tests + /// about the neighbour there. `and` and `or` stop at their first deciding predicate. + pub fn joins(&self, direction: u8, world: &mut impl FnMut(Query<'_, B>) -> bool) -> bool { + match self { + Self::SameBlock => world(Query::SameBlock), + Self::SameState => world(Query::SameState), + Self::Neighbour(b) => world(Query::Neighbour(b)), + Self::InFront(b) => world(Query::InFront(b)), + Self::Direction(d) => d & direction != 0, + Self::FaceVisible => world(Query::FaceVisible), + Self::And(items) => items.iter().all(|p| p.joins(direction, world)), + Self::Or(items) => items.iter().any(|p| p.joins(direction, world)), + Self::Not(p) => !p.joins(direction, world), + Self::Const(c) => *c, + } + } + + /// The mask of the eight directions that join, `world` answering for the direction it is + /// given. + pub fn mask(&self, mut world: impl FnMut(u8, Query<'_, B>) -> bool) -> u8 { + [U, UR, R, DR, D, DL, L, UL] + .into_iter() + .filter(|&bit| self.joins(bit, &mut |q| world(bit, q))) + .fold(0, |mask, bit| mask | bit) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(text: &str) -> Connection { + Connection::parse(&Json::parse(text).unwrap()).unwrap() + } + + #[test] + fn parses_every_predicate() { + let p = parse( + r#"[ + {"type": "fusion:is_same_block"}, + {"type": "match_state", "blocks": ["redstone_torch", "create:lamp"], + "properties": {"lit": "false", "facing": ["north", "south"]}}, + {"type": "and", "predicates": [ + {"type": "match_block_in_front", "block": "Sand"}, + {"type": "not", "predicate": {"type": "is_direction", "directions": ["top", "bottom_left"]}}, + {"type": "is_face_visible"}, {"type": "true"} + ]} + ]"#, + ); + let states = vec![ + ("lit".to_owned(), vec!["false".to_owned()]), + ( + "facing".to_owned(), + vec!["north".to_owned(), "south".to_owned()], + ), + ]; + assert_eq!( + p, + Connection::Or(vec![ + Connection::SameBlock, + Connection::Neighbour(vec![ + BlockMatch { + id: "minecraft:redstone_torch".into(), + states: states.clone(), + }, + BlockMatch { + id: "create:lamp".into(), + states, + }, + ]), + Connection::And(vec![ + Connection::InFront(vec![BlockMatch { + id: "minecraft:sand".into(), + states: vec![], + }]), + Connection::Not(Box::new(Connection::Direction(U | DL))), + Connection::FaceVisible, + Connection::Const(true), + ]), + ]) + ); + // `match_block` has no properties to test. + assert_eq!( + parse(r#"{"type": "match_block", "block": "stone", "properties": {"a": "b"}}"#), + Connection::Neighbour(vec![BlockMatch { + id: "minecraft:stone".into(), + states: vec![], + }]) + ); + } + + #[test] + fn directions_and_property_values() { + for (name, bit) in [ + ("top", U), + ("top_right", UR), + ("right", R), + ("bottom_right", DR), + ("bottom", D), + ("bottom_left", DL), + ("left", L), + ("top_left", UL), + ] { + let p = parse(&format!( + r#"{{"type": "is_direction", "direction": "{name}"}}"# + )); + assert_eq!(p, Connection::Direction(bit), "{name}"); + } + // Booleans and numbers as block states write them. + assert_eq!( + parse( + r#"{"type": "match_state", "block": "light", "properties": {"waterlogged": false, "level": 15}}"# + ), + Connection::Neighbour(vec![BlockMatch { + id: "minecraft:light".into(), + states: vec![ + ("waterlogged".into(), vec!["false".into()]), + ("level".into(), vec!["15".into()]), + ], + }]) + ); + } + + #[test] + fn refuses_what_it_cannot_read() { + for bad in [ + r#"{"type": "is_same_thing"}"#, + r#"{"block": "stone"}"#, + r#"{"type": "match_block"}"#, + r#"{"type": "is_direction", "direction": "up"}"#, + r#"{"type": "and", "predicates": {"type": "true"}}"#, + r#"{"type": "not"}"#, + r#"{"type": "match_state", "block": "stone", "properties": ["lit"]}"#, + ] { + assert!( + Connection::parse(&Json::parse(bad).unwrap()).is_err(), + "{bad}" + ); + } + } + + #[test] + fn masks_from_the_world() { + // Block transitions' sand edge: sand beside the face, or sand in front of the + // neighbour, but only where the neighbour's face shows. + let p = parse( + r#"{"type": "or", "predicates": [ + {"type": "match_block_in_front", "block": "sand"}, + {"type": "and", "predicates": [ + {"type": "match_block", "block": "sand"}, {"type": "is_face_visible"}]} + ]}"#, + ); + // Sand to the right, its face covered; sand in front of the neighbour above. + let mask = p.mask(|dir, q| match q { + Query::Neighbour(_) => dir == R, + Query::InFront(_) => dir == U, + Query::FaceVisible => false, + _ => unreachable!("not in the predicate"), + }); + assert_eq!(mask, U); + // `is_direction` needs no world; `not` and `and` combine it. + let p = parse( + r#"{"type": "and", "predicates": [{"type": "is_same_state"}, + {"type": "not", "predicate": {"type": "is_direction", "direction": "left"}}]}"#, + ); + assert_eq!( + p.mask(|_, q| q == Query::SameState), + !L, + "joins everywhere but left" + ); + } + + #[test] + fn map_blocks_keeps_the_shape() { + let p = parse(r#"{"type": "not", "predicate": {"type": "match_block", "block": "dirt"}}"#); + let mapped = p.map_blocks(&mut |b: Vec| b.len()); + assert_eq!(mapped, Connection::Not(Box::new(Connection::Neighbour(1)))); + } +} diff --git a/crates/horizon-optifine/src/fusion/texture.rs b/crates/horizon-optifine/src/fusion/texture.rs new file mode 100644 index 0000000000..0be229bc42 --- /dev/null +++ b/crates/horizon-optifine/src/fusion/texture.rs @@ -0,0 +1,417 @@ +//! The `fusion` section of a texture's `.mcmeta` (beside vanilla's `animation`): the texture's +//! type and its options. + +use super::{Connection, Layout, bool_field, int_field, kind, str_field}; +use crate::cem::json::Json; + +/// A texture's Fusion metadata. +#[derive(Clone, Debug, PartialEq)] +pub struct Meta { + pub base: Base, + pub kind: Kind, +} + +/// The options every type takes (`base` on its own). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Base { + /// Drawn fully bright, as at light level 15. + pub emissive: bool, + pub render_type: Option, + /// Tinted as this, whatever the block tints. + pub tinting: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderType { + Opaque, + Cutout, + Translucent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tinting { + Grass, + Foliage, + Water, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Kind { + Base, + Connecting { + layout: Layout, + /// The texture's own; a `fusion:connecting` model's replace them. + connections: Connection, + /// Each tile's own metadata, as if it were a texture. + sub_texture: Option>, + /// Each tile holds its animation frames, instead of each frame holding every tile. + per_tile_animation: bool, + }, + Random { + columns: u32, + rows: u32, + source: RandomSource, + seed: Option, + sub_texture: Option>, + per_tile_animation: bool, + }, + /// One image stretched over `columns` x `rows` blocks. + Continuous { + columns: u32, + rows: u32, + }, + Scrolling(Scrolling), +} + +/// What picks a random texture's tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RandomSource { + Position, + PositionFacing, + PositionAxis, +} + +/// An animation whose frame, `frame_width` x `frame_height` pixels, moves across the image from +/// one corner to another, each step shown for `frame_time` ticks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Scrolling { + pub from: Corner, + pub to: Corner, + pub frame_width: u32, + pub frame_height: u32, + pub frame_time: u32, + pub loop_type: LoopType, + /// Ticks between loops. + pub loop_pause: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Corner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoopType { + /// Around the image to the start. + Wrap, + /// Back to the start at once. + Reset, + /// Back the way it came. + Reverse, +} + +/// `key`'s value, which must be one of `options`. +fn choice(json: &Json, key: &str, options: &[(&str, T)]) -> Result, String> { + match str_field(json, key)? { + None => Ok(None), + Some(value) => options + .iter() + .find(|(name, _)| *name == value) + .map(|&(_, v)| Some(v)) + .ok_or_else(|| format!("`{key}` can't be `{value}`")), + } +} + +const CORNERS: [(&str, Corner); 4] = [ + ("top_left", Corner::TopLeft), + ("top_right", Corner::TopRight), + ("bottom_left", Corner::BottomLeft), + ("bottom_right", Corner::BottomRight), +]; + +impl Meta { + /// A `.mcmeta` file's Fusion metadata: `None` when it has no `fusion` section. + pub fn from_mcmeta(text: &str) -> Result, String> { + let json = Json::parse(text)?; + json.get("fusion").map(Self::parse).transpose() + } + + /// A `fusion` section (or a `sub_texture`). + pub fn parse(json: &Json) -> Result { + if json.as_object().is_none() { + return Err("a texture's Fusion metadata that isn't an object".into()); + } + let base = Base { + emissive: bool_field(json, "emissive")?.unwrap_or(false), + render_type: choice( + json, + "render_type", + &[ + ("opaque", RenderType::Opaque), + ("cutout", RenderType::Cutout), + ("translucent", RenderType::Translucent), + ], + )?, + tinting: choice( + json, + "tinting", + &[ + ("biome_grass", Tinting::Grass), + ("biome_foliage", Tinting::Foliage), + ("biome_water", Tinting::Water), + ], + )?, + }; + let sub_texture = || { + json.get("sub_texture") + .map(|s| Self::parse(s).map(Box::new)) + .transpose() + }; + let per_tile_animation = + || Ok::<_, String>(bool_field(json, "per_tile_animation")?.unwrap_or(false)); + // Without a type the options stand alone (Fusion Emissive Ores' overlays: only + // `emissive` and `render_type`). + let ty = str_field(json, "type")?.unwrap_or("base"); + let kind = match kind(ty) { + "base" => Kind::Base, + "connecting" => Kind::Connecting { + layout: match str_field(json, "layout")? { + None => Layout::Full, + Some(l) => Layout::parse(l).ok_or_else(|| format!("unknown layout `{l}`"))?, + }, + connections: match json.get("connections") { + None => Connection::SameState, + Some(c) => Connection::parse(c)?, + }, + sub_texture: sub_texture()?, + per_tile_animation: per_tile_animation()?, + }, + "random" => Kind::Random { + columns: int_field(json, "columns", 1..=100)?.unwrap_or(1) as u32, + rows: int_field(json, "rows", 1..=100)?.unwrap_or(1) as u32, + source: choice( + json, + "randomness_source", + &[ + ("position", RandomSource::Position), + ("position_facing", RandomSource::PositionFacing), + ("position_axis", RandomSource::PositionAxis), + ], + )? + .unwrap_or(RandomSource::PositionFacing), + // JSON numbers here are `f64`s: from 2^53 a seed may not be the one written. + seed: int_field(json, "seed", -(1 << 53) + 1..=(1 << 53) - 1)?, + sub_texture: sub_texture()?, + per_tile_animation: per_tile_animation()?, + }, + "continuous" => Kind::Continuous { + columns: int_field(json, "columns", 1..=32)?.unwrap_or(1) as u32, + rows: int_field(json, "rows", 1..=32)?.unwrap_or(1) as u32, + }, + "scrolling" => { + let positive = |key, default| { + Ok::<_, String>( + int_field(json, key, 1..=i64::from(u32::MAX))?.unwrap_or(default) as u32, + ) + }; + Kind::Scrolling(Scrolling { + from: choice(json, "from", &CORNERS)?.unwrap_or(Corner::TopLeft), + to: choice(json, "to", &CORNERS)?.unwrap_or(Corner::BottomLeft), + frame_width: positive("frame_width", 16)?, + frame_height: positive("frame_height", 16)?, + frame_time: positive("frame_time", 10)?, + loop_type: choice( + json, + "loop_type", + &[ + ("wrap", LoopType::Wrap), + ("reset", LoopType::Reset), + ("reverse", LoopType::Reverse), + ], + )? + .unwrap_or(LoopType::Wrap), + loop_pause: int_field(json, "loop_pause", 0..=i64::from(u32::MAX))?.unwrap_or(0) + as u32, + }) + } + other => return Err(format!("unknown texture type `{other}`")), + }; + Ok(Self { base, kind }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::BlockMatch; + + fn meta(text: &str) -> Meta { + Meta::from_mcmeta(text).unwrap().unwrap() + } + + #[test] + fn vanilla_mcmeta_has_none() { + assert_eq!( + Meta::from_mcmeta(r#"{"animation": {"frametime": 2}}"#), + Ok(None) + ); + } + + #[test] + fn options_without_a_type_are_base() { + assert_eq!( + meta(r#"{"fusion": {"emissive": true, "render_type": "cutout"}}"#), + Meta { + base: Base { + emissive: true, + render_type: Some(RenderType::Cutout), + tinting: None, + }, + kind: Kind::Base, + } + ); + } + + #[test] + fn connecting_with_defaults_and_base_options() { + let m = meta( + r#"{"fusion": {"type": "connecting", "render_type": "cutout", "tinting": "biome_grass"}, + "animation": {"frametime": 10}}"#, + ); + assert_eq!( + m, + Meta { + base: Base { + emissive: false, + render_type: Some(RenderType::Cutout), + tinting: Some(Tinting::Grass), + }, + kind: Kind::Connecting { + layout: Layout::Full, + connections: Connection::SameState, + sub_texture: None, + per_tile_animation: false, + }, + } + ); + } + + #[test] + fn connecting_with_a_random_sub_texture() { + let m = meta( + r#"{"fusion": {"type": "fusion:connecting", "layout": "pieced", "emissive": true, + "connections": [{"type": "is_same_block"}, {"type": "match_block", "block": "iron_block"}], + "sub_texture": {"type": "random", "rows": 5, "columns": 4, "seed": -3}, + "per_tile_animation": true}}"#, + ); + assert!(m.base.emissive); + let Kind::Connecting { + layout, + connections, + sub_texture, + per_tile_animation, + } = m.kind + else { + panic!("{m:?}"); + }; + assert_eq!(layout, Layout::Pieced); + assert!(per_tile_animation); + assert_eq!( + connections, + Connection::Or(vec![ + Connection::SameBlock, + Connection::Neighbour(vec![BlockMatch { + id: "minecraft:iron_block".into(), + states: vec![], + }]), + ]) + ); + assert_eq!( + sub_texture.unwrap().kind, + Kind::Random { + columns: 4, + rows: 5, + source: RandomSource::PositionFacing, + seed: Some(-3), + sub_texture: None, + per_tile_animation: false, + } + ); + } + + #[test] + fn continuous_and_scrolling() { + assert_eq!( + meta(r#"{"fusion": {"type": "continuous", "rows": 2, "columns": 3}}"#).kind, + Kind::Continuous { + columns: 3, + rows: 2, + } + ); + assert_eq!( + meta( + r#"{"fusion": {"type": "scrolling", "to": "top_right", "frame_time": 4, + "frame_height": 8, "loop_type": "reverse", "loop_pause": 20}}"# + ) + .kind, + Kind::Scrolling(Scrolling { + from: Corner::TopLeft, + to: Corner::TopRight, + frame_width: 16, + frame_height: 8, + frame_time: 4, + loop_type: LoopType::Reverse, + loop_pause: 20, + }) + ); + } + + #[test] + fn defaults() { + assert_eq!( + meta(r#"{"fusion": {"type": "random", "randomness_source": "position_axis"}}"#).kind, + Kind::Random { + columns: 1, + rows: 1, + source: RandomSource::PositionAxis, + seed: None, + sub_texture: None, + per_tile_animation: false, + } + ); + assert_eq!( + meta(r#"{"fusion": {"type": "continuous"}}"#).kind, + Kind::Continuous { + columns: 1, + rows: 1 + } + ); + assert_eq!( + meta(r#"{"fusion": {"type": "scrolling"}}"#).kind, + Kind::Scrolling(Scrolling { + from: Corner::TopLeft, + to: Corner::BottomLeft, + frame_width: 16, + frame_height: 16, + frame_time: 10, + loop_type: LoopType::Wrap, + loop_pause: 0, + }) + ); + } + + #[test] + fn refuses_bad_values() { + for bad in [ + r#"{"fusion": {"type": "random", "seed": 9007199254740993}}"#, + r#"{"fusion": {"type": "random", "rows": 101}}"#, + r#"{"fusion": {"type": "scrolling", "frame_time": 0}}"#, + r#"{"fusion": {"type": "scrolling", "loop_pause": -1}}"#, + r#"{"fusion": {"type": "connecting", "sub_texture": {"type": "random", "rows": 0}}}"#, + r#"{"fusion": {"type": "glowing"}}"#, + r#"{"fusion": "connecting"}"#, + r#"{"fusion": {"type": "connecting", "sub_texture": "random"}}"#, + r#"{"fusion": {"type": "connecting", "layout": "diagonal"}}"#, + r#"{"fusion": {"type": "base", "emissive": "yes"}}"#, + r#"{"fusion": {"type": "base", "render_type": "solid"}}"#, + r#"{"fusion": {"type": "random", "rows": 0}}"#, + r#"{"fusion": {"type": "continuous", "columns": 33}}"#, + r#"{"fusion": {"type": "scrolling", "frame_width": 1.5}}"#, + ] { + assert!(Meta::from_mcmeta(bad).is_err(), "{bad}"); + } + } +} diff --git a/crates/horizon-optifine/src/lib.rs b/crates/horizon-optifine/src/lib.rs index c3b4d3eebd..c3084d88a5 100644 --- a/crates/horizon-optifine/src/lib.rs +++ b/crates/horizon-optifine/src/lib.rs @@ -5,17 +5,20 @@ //! a module of their own, free of the game's types (`random`: random entity textures' rules; //! `random_textures`: where a pack keeps their variants; [`ctm`]: connected textures; `cit`: //! custom item textures, which the client matches against its stacks; `emissive`: emissive -//! overlays; `blink`: ETF's blinking textures). +//! overlays; `blink`: ETF's blinking textures; [`fusion`]: the Fusion mod's connected +//! textures, whose layouts read [`ctm`]'s neighbour masks). //! //! Written from OptiFine's public documentation of the file formats (and ETF's, for its additions //! to random entities and emissive textures); `cit` also from CIT Resewn's wiki and the conditions of its MIT-licensed -//! source (THIRD_PARTY_NOTICES.md). No OptiFine code was read. +//! source (THIRD_PARTY_NOTICES.md); `fusion` from Fusion's wiki and FusionExamples' MIT-licensed +//! layout templates (THIRD_PARTY_NOTICES.md). No OptiFine or Fusion code was read. pub mod blink; pub mod cem; pub mod cit; pub mod ctm; pub mod emissive; +pub mod fusion; pub mod matchers; pub mod paths; pub mod properties; diff --git a/crates/horizon-protocol/src/adapter.rs b/crates/horizon-protocol/src/adapter.rs new file mode 100644 index 0000000000..52b7439919 --- /dev/null +++ b/crates/horizon-protocol/src/adapter.rs @@ -0,0 +1,184 @@ +//! Speaking to servers of an older release: the first layer of the native protocol adapter (roadmap +//! `older-servers`). Horizon's packets are 26.3's; a server on another protocol numbers them differently. +//! This maps packet ids per connection state, both ways. It does not translate packet contents. +pub mod v26_2; + +use crate::{ + Direction, Result, State, + buf::{Reader, write_varint}, +}; + +/// The protocol a server speaks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ServerVersion { + /// The pinned version ([`crate::PROTOCOL_VERSION`]): nothing to translate. + #[default] + Current, + V26_2, +} + +impl ServerVersion { + pub const ALL: [Self; 2] = [Self::Current, Self::V26_2]; + + /// The protocol number its handshake states. + pub fn protocol(self) -> i32 { + match self { + Self::Current => crate::PROTOCOL_VERSION, + Self::V26_2 => v26_2::PROTOCOL, + } + } + + /// The release name, as a server list shows it. + pub fn name(self) -> &'static str { + match self { + Self::Current => crate::GAME_VERSION, + Self::V26_2 => v26_2::NAME, + } + } + + /// The version a server's status reports by its protocol number. + pub fn from_protocol(protocol: i32) -> Option { + Self::ALL.into_iter().find(|v| v.protocol() == protocol) + } + + /// The pinned version's id for a packet this version's server sent as `id`. `None` when the pinned + /// version has no such packet. + pub fn clientbound_id(self, state: State, id: i32) -> Option { + self.map(state, Direction::Clientbound, id) + } + + /// The id this version's server knows a packet Horizon sends as `id` by. `None` when that server has + /// no such packet, so it must not be sent. + pub fn serverbound_id(self, state: State, id: i32) -> Option { + self.map(state, Direction::Serverbound, id) + } + + fn map(self, state: State, direction: Direction, id: i32) -> Option { + let table = match (self, direction) { + (Self::Current, _) => return Some(id), + (Self::V26_2, Direction::Clientbound) => v26_2::clientbound(state), + (Self::V26_2, Direction::Serverbound) => v26_2::serverbound(state), + }; + let mapped = *table.get(usize::try_from(id).ok()?)?; + mapped.map(i32::from) + } + + /// A packet body (VarInt id + payload) Horizon sends, renumbered for this version's server. `None` + /// when that server has no such packet. + pub fn serverbound_body(self, state: State, body: &[u8]) -> Result>> { + let mut r = Reader::new(body); + let id = r.varint()?; + let payload = r.rest(); + let Some(mapped) = self.serverbound_id(state, id) else { + return Ok(None); + }; + if mapped == id { + return Ok(Some(body.to_vec())); + } + let mut out = Vec::with_capacity(body.len() + 1); + write_varint(&mut out, mapped); + out.extend_from_slice(payload); + Ok(Some(out)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ids; + + // Expected ids below are read off the 26.2 and 26.3 packets reports by hand + // (`version_port.py diff 26.2 26.3`), not from the generated tables. + + #[test] + fn a_26_2_server_is_known_by_its_protocol() { + assert_eq!( + ServerVersion::from_protocol(776), + Some(ServerVersion::V26_2) + ); + assert_eq!( + ServerVersion::from_protocol(crate::PROTOCOL_VERSION), + Some(ServerVersion::Current) + ); + assert_eq!(ServerVersion::from_protocol(775), None); + } + + #[test] + fn renumbered_clientbound_play_packets_reach_their_26_3_ids() { + let v = ServerVersion::V26_2; + // keep_alive 44 -> 45, login 49 -> 50, respawn 82 -> 84, clear_dialog 139 -> 142. + assert_eq!( + v.clientbound_id(State::Play, 44), + Some(ids::play::clientbound::KEEP_ALIVE) + ); + assert_eq!( + v.clientbound_id(State::Play, 49), + Some(ids::play::clientbound::LOGIN) + ); + assert_eq!( + v.clientbound_id(State::Play, 82), + Some(ids::play::clientbound::RESPAWN) + ); + assert_eq!( + v.clientbound_id(State::Play, 139), + Some(ids::play::clientbound::CLEAR_DIALOG) + ); + // Unchanged before the first insertion (add_transient_block at 37). + assert_eq!(v.clientbound_id(State::Play, 0), Some(0)); + // Past 26.2's 141 packets. + assert_eq!(v.clientbound_id(State::Play, 141), None); + // Configuration: store_cookie 10 -> 11 (post_effects took 10). + assert_eq!( + v.clientbound_id(State::Configuration, 10), + Some(ids::configuration::clientbound::STORE_COOKIE) + ); + } + + #[test] + fn serverbound_packets_26_2_lacks_are_not_sent() { + let v = ServerVersion::V26_2; + // punch is new in 26.3; swing, which 26.2 has at 63, is gone. + assert_eq!( + v.serverbound_id(State::Play, ids::play::serverbound::PUNCH), + None + ); + // resource_pack 50 -> 49, spectator_action 63 -> 62, keep_alive unchanged. + assert_eq!( + v.serverbound_id(State::Play, ids::play::serverbound::RESOURCE_PACK), + Some(49) + ); + assert_eq!( + v.serverbound_id(State::Play, ids::play::serverbound::SPECTATOR_ACTION), + Some(62) + ); + assert_eq!( + v.serverbound_id(State::Play, ids::play::serverbound::KEEP_ALIVE), + Some(ids::play::serverbound::KEEP_ALIVE) + ); + assert_eq!( + v.serverbound_id(State::Handshake, ids::handshake::serverbound::INTENTION), + Some(0) + ); + } + + #[test] + fn serverbound_bodies_keep_their_payload_under_the_new_id() { + let v = ServerVersion::V26_2; + let mut body = Vec::new(); + write_varint(&mut body, ids::play::serverbound::RESOURCE_PACK); + body.extend_from_slice(&[1, 2, 3]); + assert_eq!( + v.serverbound_body(State::Play, &body).unwrap(), + Some(vec![49, 1, 2, 3]) + ); + let mut punch = Vec::new(); + write_varint(&mut punch, ids::play::serverbound::PUNCH); + assert_eq!(v.serverbound_body(State::Play, &punch).unwrap(), None); + assert_eq!( + ServerVersion::Current + .serverbound_body(State::Play, &body) + .unwrap(), + Some(body) + ); + } +} diff --git a/crates/horizon-protocol/src/adapter/v26_2.rs b/crates/horizon-protocol/src/adapter/v26_2.rs new file mode 100644 index 0000000000..a2b05a74f5 --- /dev/null +++ b/crates/horizon-protocol/src/adapter/v26_2.rs @@ -0,0 +1,84 @@ +//! Packet ids of protocol 776 (26.2) against the pinned 777 (26.3). +//! Generated by tools/generate_packet_id_adapter.py from both versions' packets reports; +//! do not edit by hand. +use crate::State; + +pub const PROTOCOL: i32 = 776; +pub const NAME: &str = "26.2"; + +#[rustfmt::skip] +const HANDSHAKE_CLIENTBOUND: &[Option] = &[ +]; +#[rustfmt::skip] +const HANDSHAKE_SERVERBOUND: &[Option] = &[ + Some(0), +]; +#[rustfmt::skip] +const STATUS_CLIENTBOUND: &[Option] = &[ + Some(0), Some(1), +]; +#[rustfmt::skip] +const STATUS_SERVERBOUND: &[Option] = &[ + Some(0), Some(1), +]; +#[rustfmt::skip] +const LOGIN_CLIENTBOUND: &[Option] = &[ + Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), +]; +#[rustfmt::skip] +const LOGIN_SERVERBOUND: &[Option] = &[ + Some(0), Some(1), Some(2), Some(3), Some(4), +]; +#[rustfmt::skip] +const CONFIGURATION_CLIENTBOUND: &[Option] = &[ + Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(11), Some(12), + Some(13), Some(14), Some(15), Some(16), Some(17), Some(18), Some(19), Some(20), +]; +#[rustfmt::skip] +const CONFIGURATION_SERVERBOUND: &[Option] = &[ + Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), +]; +#[rustfmt::skip] +const PLAY_CLIENTBOUND: &[Option] = &[ + Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(10), Some(11), + Some(12), Some(13), Some(14), Some(15), Some(16), Some(17), Some(18), Some(19), Some(20), Some(21), Some(22), Some(23), + Some(24), Some(25), Some(26), Some(27), Some(28), Some(29), Some(30), Some(31), Some(32), Some(33), Some(34), Some(35), + Some(36), Some(38), Some(39), Some(40), Some(41), Some(42), Some(43), Some(44), Some(45), Some(46), Some(47), Some(48), + Some(49), Some(50), Some(51), Some(52), Some(53), Some(54), Some(55), Some(56), Some(57), Some(58), Some(59), Some(60), + Some(61), Some(62), Some(63), Some(64), Some(65), Some(66), Some(67), Some(68), Some(69), Some(70), Some(71), Some(72), + Some(73), Some(74), Some(75), Some(76), Some(77), Some(78), Some(79), Some(80), Some(81), Some(82), Some(84), Some(85), + Some(86), Some(87), Some(88), Some(89), Some(90), Some(91), Some(92), Some(93), Some(94), Some(95), Some(96), Some(97), + Some(98), Some(99), Some(100), Some(101), Some(102), Some(103), Some(104), Some(105), Some(106), Some(107), Some(108), Some(109), + Some(110), Some(111), Some(112), Some(113), Some(114), Some(115), Some(116), Some(117), Some(118), Some(119), Some(120), Some(121), + Some(122), Some(124), Some(125), Some(126), Some(127), Some(128), Some(129), Some(130), Some(131), Some(132), Some(133), Some(134), + Some(135), Some(136), Some(137), Some(138), Some(139), Some(140), Some(141), Some(142), Some(143), +]; +#[rustfmt::skip] +const PLAY_SERVERBOUND: &[Option] = &[ + Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(10), Some(11), + Some(12), Some(13), Some(14), Some(15), Some(16), Some(17), Some(18), Some(19), Some(20), Some(21), Some(22), Some(23), + Some(24), Some(25), Some(26), Some(27), Some(28), Some(29), Some(30), Some(31), Some(32), Some(33), Some(34), Some(35), + Some(36), Some(37), Some(38), Some(39), Some(40), Some(41), Some(42), Some(43), Some(44), Some(45), None, Some(46), + Some(47), Some(48), Some(49), Some(50), Some(51), Some(52), Some(53), Some(54), Some(55), Some(56), Some(57), Some(58), + Some(59), Some(60), Some(61), Some(62), Some(64), Some(65), Some(66), Some(67), Some(68), +]; +/// For each id a 26.2 server sends in `state`, the pinned version's id of that packet. +pub fn clientbound(state: State) -> &'static [Option] { + match state { + State::Handshake => HANDSHAKE_CLIENTBOUND, + State::Status => STATUS_CLIENTBOUND, + State::Login => LOGIN_CLIENTBOUND, + State::Configuration => CONFIGURATION_CLIENTBOUND, + State::Play => PLAY_CLIENTBOUND, + } +} +/// For each pinned-version id Horizon sends in `state`, the id a 26.2 server knows it by. +pub fn serverbound(state: State) -> &'static [Option] { + match state { + State::Handshake => HANDSHAKE_SERVERBOUND, + State::Status => STATUS_SERVERBOUND, + State::Login => LOGIN_SERVERBOUND, + State::Configuration => CONFIGURATION_SERVERBOUND, + State::Play => PLAY_SERVERBOUND, + } +} diff --git a/crates/horizon-protocol/src/connection.rs b/crates/horizon-protocol/src/connection.rs index 7136a60a99..a7e1d30600 100644 --- a/crates/horizon-protocol/src/connection.rs +++ b/crates/horizon-protocol/src/connection.rs @@ -83,11 +83,7 @@ impl Read for Pipe { } }; } - let n = out.len().min(state.0.len()); - for (o, b) in out.iter_mut().zip(state.0.drain(..n)) { - *o = b; - } - Ok(n) + state.0.read(out) } } impl Write for Pipe { @@ -169,7 +165,12 @@ pub struct Counters { pub struct ConnReader { stream: Stream, + /// Bytes read: `buffer[start..end]` are not consumed yet, and reads go + /// straight into `buffer[end..]`. buffer: Vec, + start: usize, + end: usize, + inflater: frame::Inflater, decrypt: Option, pub threshold: Option, pub counters: Arc, @@ -291,6 +292,9 @@ impl Connection { reader: ConnReader { stream, buffer: Vec::new(), + start: 0, + end: 0, + inflater: frame::Inflater::default(), decrypt: None, threshold: None, counters: counters.clone(), @@ -317,7 +321,8 @@ impl Connection { pub fn enable_encryption(&mut self, secret: &[u8; 16]) { let mut decrypt = Cfb8::new(secret, secret); // Bytes already read past the last packet were sent encrypted. - decrypt.decrypt(&mut self.reader.buffer); + let r = &mut self.reader; + decrypt.decrypt(&mut r.buffer[r.start..r.end]); self.reader.decrypt = Some(decrypt); self.writer.encrypt = Some(Cfb8::new(secret, secret)); } @@ -341,47 +346,85 @@ impl ConnReader { /// Block until a whole packet arrives: (id, payload). pub fn recv(&mut self) -> Result<(i32, Vec)> { loop { - if let Some(frame) = frame::split(&mut self.buffer)? { - let body = frame::decompress(frame, self.threshold)?; - if body.is_empty() { - // A frame with no packet in it. Vanilla's PacketDecoder - // only decodes a packet from a readable buffer, so an - // empty compressed frame is skipped and the connection - // stays up. + let pending = &self.buffer[self.start..self.end]; + let mut wanted = 0; + if let Some((prefix, len)) = frame::header(pending)? { + if pending.len() >= prefix + len { + let frame = self.start + prefix..self.start + prefix + len; + self.start = frame.end; + if let Some(packet) = self.packet(frame)? { + self.counters.received.fetch_add(1, Ordering::Relaxed); + return Ok(packet); + } continue; } + wanted = prefix + len; + } + self.fill(wanted)?; + } + } + /// The packet in `buffer[frame]`, or `None` for a frame with no packet in it: + /// vanilla's PacketDecoder only decodes a packet from a readable buffer, so an + /// empty compressed frame is skipped and the connection stays up. + fn packet(&mut self, frame: std::ops::Range) -> Result)>> { + let frame = &self.buffer[frame]; + match frame::body(frame, self.threshold, &mut self.inflater)? { + frame::Body::Plain(at) => { + let body = &frame[at..]; + if body.is_empty() { + return Ok(None); + } + let mut r = Reader::new(body); + let id = r.varint()?; + Ok(Some((id, body[r.position()..].to_vec()))) + } + frame::Body::Inflated(mut body) => { let mut r = Reader::new(&body); let id = r.varint()?; - let start = r.position(); - self.counters.received.fetch_add(1, Ordering::Relaxed); - return Ok((id, body[start..].to_vec())); + let at = r.position(); + body.drain(..at); + Ok(Some((id, body))) } - let mut chunk = [0u8; 16384]; - let n = match self.stream.read(&mut chunk) { - Ok(0) => return Err(Error::end_of_stream()), - Ok(n) => n, - // The read timeout passed: Windows reports TimedOut, Unix - // WouldBlock. - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock - ) => - { - return Err(Error::timed_out()); - } - Err(e) => return Err(e.into()), - }; - self.counters - .bytes_in - .fetch_add(n as i64, Ordering::Relaxed); - let data = &mut chunk[..n]; - if let Some(c) = &mut self.decrypt { - c.decrypt(data); - } - self.buffer.extend_from_slice(data); } } + /// Read once from the stream into the buffer, with room for at least + /// `frame` bytes of unconsumed data (a whole frame, once its length is known). + fn fill(&mut self, frame: usize) -> Result<()> { + const READ: usize = 1 << 16; + let pending = self.end - self.start; + let room = READ.max(frame.saturating_sub(pending)); + if self.buffer.len() - self.end < room { + self.buffer.copy_within(self.start..self.end, 0); + (self.start, self.end) = (0, pending); + if self.buffer.len() < pending + room { + self.buffer.resize(pending + room, 0); + } + } + let n = match self.stream.read(&mut self.buffer[self.end..]) { + Ok(0) => return Err(Error::end_of_stream()), + Ok(n) => n, + // The read timeout passed: Windows reports TimedOut, Unix + // WouldBlock. + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + return Err(Error::timed_out()); + } + Err(e) => return Err(e.into()), + }; + self.counters + .bytes_in + .fetch_add(n as i64, Ordering::Relaxed); + let data = &mut self.buffer[self.end..self.end + n]; + if let Some(c) = &mut self.decrypt { + c.decrypt(data); + } + self.end += n; + Ok(()) + } /// A [`Closer`] for this connection. pub fn closer(&self) -> Result { Ok(Closer(self.stream.try_clone()?)) @@ -599,6 +642,38 @@ mod tests { assert!(c.recv().is_err()); } + /// Packets from one byte to past the read size, compressed and + /// encrypted, arrive whole and in order however the reads cut them. + #[test] + fn packets_of_every_size_arrive_whole() { + let (a, b) = Pipe::pair(); + let bodies: Vec> = (0..400u32) + .map(|i| { + let len = [1, 2, 63, 64, 65, 300, 4000, 70_000, 200_000][i as usize % 9]; + (0..len).map(|j| (j as u32 * 31 + i) as u8 & 0x7f).collect() + }) + .collect(); + let sent = bodies.clone(); + let server = std::thread::spawn(move || { + let mut c = Connection::local(b); + c.set_compression(64); + c.enable_encryption(&[3; 16]); + for body in &sent { + c.send(body).unwrap(); + } + c + }); + let mut c = Connection::local(a); + c.set_compression(64); + c.enable_encryption(&[3; 16]); + for body in &bodies { + let (id, payload) = c.recv().unwrap(); + assert_eq!(id, i32::from(body[0])); + assert_eq!(payload, body[1..]); + } + drop(server.join().unwrap()); + } + #[test] fn dropping_a_pipe_end_closes_it() { let (a, b) = Pipe::pair(); diff --git a/crates/horizon-protocol/src/frame.rs b/crates/horizon-protocol/src/frame.rs index 25d113f476..243a07c87f 100644 --- a/crates/horizon-protocol/src/frame.rs +++ b/crates/horizon-protocol/src/frame.rs @@ -4,8 +4,8 @@ use crate::{ Error, Result, buf::{Reader, write_varint}, }; -use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; -use std::io::{Read, Write}; +use flate2::{Compression, Decompress, FlushDecompress, Status, write::ZlibEncoder}; +use std::io::Write; /// Frames are at most three VarInt bytes long (21 bits). pub const MAX_FRAME: usize = (1 << 21) - 1; @@ -55,12 +55,12 @@ pub fn prefixed(contents: &[u8]) -> Vec { out } -/// Split one complete frame off the front of `buffer`, if available. -/// Returns the frame contents (without the length prefix). -pub fn split(buffer: &mut Vec) -> Result>> { +/// The length prefix at the front of `bytes`, once it is complete: its own +/// size and the frame's length. +pub fn header(bytes: &[u8]) -> Result> { let mut len = 0usize; for i in 0..3 { - let Some(&b) = buffer.get(i) else { + let Some(&b) = bytes.get(i) else { return Ok(None); }; len |= usize::from(b & 0x7F) << (7 * i); @@ -70,27 +70,55 @@ pub fn split(buffer: &mut Vec) -> Result>> { // corrupted frame, not an empty packet. return Err(Error::new("Frame length cannot be zero")); } - let start = i + 1; - if buffer.len() < start + len { - return Ok(None); - } - let frame = buffer[start..start + len].to_vec(); - buffer.drain(..start + len); - return Ok(Some(frame)); + return Ok(Some((i + 1, len))); } } Err(Error::new("length wider than 21 bits")) } -/// Undo compression on a frame, giving the packet body (id + payload). -pub fn decompress(frame: Vec, threshold: Option) -> Result> { +/// Inflates compressed frames, keeping its state from one to the next +/// (`CompressionDecoder`'s one `Inflater`). +pub struct Inflater(Decompress); +impl Default for Inflater { + fn default() -> Self { + Self(Decompress::new(true)) + } +} +impl Inflater { + /// The zlib stream at the front of `input`, which must hold exactly `size` bytes. + fn inflate(&mut self, input: &[u8], size: usize) -> Result> { + self.0.reset(true); + // One byte more than declared: a longer stream fills it. + let mut out = Vec::with_capacity(size + 1); + let status = self + .0 + .decompress_vec(input, &mut out, FlushDecompress::Finish) + .map_err(|e| Error::new(format!("Badly compressed packet - {e}")))?; + if out.len() != size || !matches!(status, Status::StreamEnd) { + return Err(Error::new(format!( + "Badly compressed packet - actual length {} does not match declared size {size}", + out.len() + ))); + } + Ok(out) + } +} + +/// A frame's packet body: where it starts in the frame, or inflated. +pub enum Body { + Plain(usize), + Inflated(Vec), +} + +/// `CompressionDecoder`: the body of a frame, inflated when its data length says so. +pub fn body(frame: &[u8], threshold: Option, inflater: &mut Inflater) -> Result { let Some(threshold) = threshold else { - return Ok(frame); + return Ok(Body::Plain(0)); }; - let mut r = Reader::new(&frame); + let mut r = Reader::new(frame); let size = r.varint()?; if size == 0 { - return Ok(frame[r.position()..].to_vec()); + return Ok(Body::Plain(r.position())); } let size = usize::try_from(size).map_err(|_| Error::new("negative uncompressed length"))?; if size < threshold { @@ -103,24 +131,36 @@ pub fn decompress(frame: Vec, threshold: Option) -> Result> { "Badly compressed packet - size of {size} is larger than protocol maximum" ))); } - let start = r.position(); - let mut out = Vec::with_capacity(size); - ZlibDecoder::new(&frame[start..]) - .take(size as u64 + 1) - .read_to_end(&mut out)?; - if out.len() != size { - return Err(Error::new(format!( - "Badly compressed packet - actual length {} does not match declared size {size}", - out.len() - ))); - } - Ok(out) + inflater + .inflate(&frame[r.position()..], size) + .map(Body::Inflated) } #[cfg(test)] mod tests { use super::*; + /// Split one complete frame off the front of `buffer`, if available. + fn split(buffer: &mut Vec) -> Result>> { + let Some((start, len)) = header(buffer)? else { + return Ok(None); + }; + if buffer.len() < start + len { + return Ok(None); + } + let frame = buffer[start..start + len].to_vec(); + buffer.drain(..start + len); + Ok(Some(frame)) + } + + /// The packet body (id + payload) of a frame. + fn decompress(frame: Vec, threshold: Option) -> Result> { + Ok(match body(&frame, threshold, &mut Inflater::default())? { + Body::Plain(at) => frame[at..].to_vec(), + Body::Inflated(body) => body, + }) + } + #[test] fn uncompressed_frames_round_trip() { let body = vec![0x00, 1, 2, 3]; @@ -169,6 +209,33 @@ mod tests { assert!(decompress(frame, Some(256)).is_err()); } + /// One inflater serves every frame, also after a bad one. + #[test] + fn the_inflater_is_reused() { + let mut inflater = Inflater::default(); + let frame = |body: &[u8], declared: usize| { + let mut frame = Vec::new(); + write_varint(&mut frame, declared as i32); + let mut z = ZlibEncoder::new(frame, Compression::default()); + z.write_all(body).unwrap(); + z.finish().unwrap() + }; + for n in [300usize, 70_000, 256, 5000] { + let body: Vec = (0..n).map(|i| (i * 7 % 13) as u8).collect(); + match body_of(&frame(&body, n), &mut inflater) { + Ok(Body::Inflated(out)) => assert_eq!(out, body), + _ => panic!("{n} bytes did not inflate"), + } + // A stream longer than declared, then one shorter. + assert!(body_of(&frame(&body, n - 1), &mut inflater).is_err()); + assert!(body_of(&frame(&body, n + 1), &mut inflater).is_err()); + } + } + + fn body_of(frame: &[u8], inflater: &mut Inflater) -> Result { + body(frame, Some(256), inflater) + } + #[test] fn a_noncanonical_length_prefix_still_reads() { // The data-length VarInt is read, not measured: zero as `0x80 0x00`. diff --git a/crates/horizon-protocol/src/lib.rs b/crates/horizon-protocol/src/lib.rs index a5bba9286e..055d052d4c 100644 --- a/crates/horizon-protocol/src/lib.rs +++ b/crates/horizon-protocol/src/lib.rs @@ -3,6 +3,7 @@ //! every connection state, shared by the Horizon client and (later) server. // `deny`, not `forbid`: crypto/blocks.rs allows the ARMv8 AES intrinsics, nothing else. #![deny(unsafe_code)] +pub mod adapter; pub mod buf; pub mod chunk; pub mod connection; diff --git a/crates/horizon-server/src/discord.rs b/crates/horizon-server/src/discord.rs index 0e36110878..949ea1c2a6 100644 --- a/crates/horizon-server/src/discord.rs +++ b/crates/horizon-server/src/discord.rs @@ -20,7 +20,7 @@ const BATCH: Duration = Duration::from_secs(2); const GAP: Duration = Duration::from_secs(2); /// Lines in one picture; more in a burst are counted and skipped. const MAX_LINES: usize = 20; -const EVENTS: [&str; 5] = ["chat", "join", "leave", "death", "kick"]; +const EVENTS: [&str; 6] = ["chat", "join", "leave", "death", "kick", "announce"]; /// Zero-width joiner: after an `@` or a `<`, Discord no longer reads a mention. const ZWJ: char = '\u{200D}'; @@ -159,6 +159,7 @@ struct Run { const WHITE: u32 = 0xFFFFFF; const YELLOW: u32 = 0xFFFF55; const GRAY: u32 = 0xAAAAAA; +const GOLD: u32 = 0xFFAA00; /// ChatFormatting's colours, by their code `0`-`f`. const PALETTE: [u32; 16] = [ 0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA, 0x555555, @@ -176,6 +177,8 @@ impl Line { "join" => vec![Run::plain(&format!("{name} joined the game"), YELLOW)], "leave" => vec![Run::plain(&format!("{name} left the game"), YELLOW)], "kick" => vec![Run::plain(&format!("{name} was kicked"), YELLOW)], + // A line a native server mod posts (`native_server_event`), drawn like a broadcast. + "announce" => legacy(text("message"), GOLD), // The game writes this message itself: should a nickname stand for the player there, the real name // follows it. Lines say who someone is only by `name` (their account), never by a nickname. "death" if !text("message").contains(name.as_str()) => { @@ -539,6 +542,25 @@ mod tests { } } + #[test] + fn an_announcement_is_its_message_in_gold_and_cannot_ping() { + let line = Line::from_event( + &json!({"type": "announce", "name": "", "message": "Jackpot! @everyone"}), + ) + .unwrap(); + assert_eq!(line.plain(), "Jackpot! @everyone"); + assert!(line.runs.iter().all(|r| r.color == GOLD)); + assert!(!defuse(&line.plain(), false).contains("@everyone")); + assert!( + Settings { + events: vec!["announce".into()], + ..Settings::default() + } + .check() + .is_ok() + ); + } + #[test] fn section_codes_colour_the_runs() { let runs = legacy("a§cred§lbold§rplain§9blue§zx", WHITE); diff --git a/crates/horizon-server/src/management_methods.rs b/crates/horizon-server/src/management_methods.rs index e032a55d74..1bad129364 100644 --- a/crates/horizon-server/src/management_methods.rs +++ b/crates/horizon-server/src/management_methods.rs @@ -50,6 +50,15 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu .ok_or_else(|| invalid_params("Not a string"))?, ), ), + "serversettings/game_mode" => game.game_mode(client, None), + "serversettings/game_mode/set" => game.game_mode( + client, + Some( + argument + .as_str() + .ok_or_else(|| invalid_params("Not a string"))?, + ), + ), "allowlist" => Ok(lists.allowlist()), "bans" => Ok(lists.bans()), "ip_bans" => Ok(lists.ip_bans()), @@ -281,6 +290,29 @@ struct Game<'a> { } impl Game<'_> { + /// `MinecraftServerSettingsServiceImpl.getGameMode` and `setGameMode`. + fn game_mode(&self, client: u32, value: Option<&str>) -> Result { + let mut request = json!({ "method": "serversettings/game_mode" }); + if let Some(value) = value { + request["method"] = json!("serversettings/game_mode/set"); + request["value"] = json!(value); + } + let reply = self.ask(&format!("management {client} {request}"))?; + if let Some(error) = reply["error"].as_str() { + return Err((INTERNAL_ERROR, Some(error.to_owned()))); + } + if let Some(value) = value { + crate::config::update( + &self.shared.dir, + "world", + "game_mode", + toml::Value::String(value.into()), + ) + .map_err(|e| (INTERNAL_ERROR, Some(e)))?; + } + Ok(reply["value"].clone()) + } + /// `MinecraftServerSettingsServiceImpl.getMotd` and `setMotd`: the live server-list message. fn motd(&self, client: u32, value: Option<&str>) -> Result { let mut request = json!({ "method": "serversettings/motd" }); diff --git a/crates/horizon-shader-api/src/inputs.rs b/crates/horizon-shader-api/src/inputs.rs index 548607658c..91b38f5711 100644 --- a/crates/horizon-shader-api/src/inputs.rs +++ b/crates/horizon-shader-api/src/inputs.rs @@ -241,6 +241,7 @@ pub const MATERIAL_INPUT: Struct = Struct { emission: "f32" = "How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks.", normal: "vec3" = "The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs.", tangent: "vec4" = "The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes.", + colored_light: "vec3" = "The frame's coloured lights at this point (`[[particle_lights]]` and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches.", }, }; diff --git a/crates/horizon-shader-api/src/manifest.rs b/crates/horizon-shader-api/src/manifest.rs index 944d6b0d08..8b7015d4bf 100644 --- a/crates/horizon-shader-api/src/manifest.rs +++ b/crates/horizon-shader-api/src/manifest.rs @@ -24,6 +24,8 @@ pub const MAX_TEXTURES: usize = 8; pub const MAX_TEXTURE_SIZE: u32 = 2048; /// `[[lights]]` entries a manifest may declare. pub const MAX_LIGHTS: usize = 256; +/// `[[particle_lights]]` entries a manifest may declare. +pub const MAX_PARTICLE_LIGHTS: usize = 32; /// Sprite tags a pack may declare: the bits of `MaterialInput.tags`. pub const MAX_TAGS: usize = 32; @@ -62,6 +64,9 @@ pub struct Manifest { /// The colour of block light by the block giving it off: the first entry with a matching /// pattern wins (`Manifest::light_of`). pub lights: Vec, + /// Particles that join the frame's coloured lights (`MaterialInput.colored_light`): the first + /// entry naming a particle wins. + pub particle_lights: Vec, pub budget: Budget, /// When the material stage's water mirror (`reflection`) is drawn. pub reflection: Option, @@ -123,6 +128,16 @@ pub struct PackTexture { pub repeat: bool, } +/// `[[particle_lights]]`: `particles` (names, as `firefly` or `minecraft:firefly`) light what's +/// within `reach` blocks in `color`, as strongly as they are opaque. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ParticleLight { + pub particles: Vec, + pub color: [f32; 3], + pub reach: f32, +} + /// `[[lights]]`: the colour of the block light from some blocks, as the material stage reads /// it through `block_light_color`. #[derive(Clone, Debug, Deserialize, PartialEq)] @@ -344,6 +359,8 @@ struct Raw { #[serde(default)] lights: Vec, #[serde(default)] + particle_lights: Vec, + #[serde(default)] budget: RawBudget, reflection: Option, shadow: Option, @@ -650,6 +667,34 @@ impl Manifest { "lights are read by the material stage: give it a [material]".into(), )); } + if !raw.particle_lights.is_empty() && raw.material.is_none() { + return Err(err( + "particle lights are read by the material stage: give it a [material]".into(), + )); + } + if raw.particle_lights.len() > MAX_PARTICLE_LIGHTS { + return Err(err(format!( + "{} [[particle_lights]]; a pack may declare {MAX_PARTICLE_LIGHTS}", + raw.particle_lights.len() + ))); + } + for l in &raw.particle_lights { + if l.particles.is_empty() { + return Err(err("a [[particle_lights]] entry needs particles".into())); + } + if l.color.iter().any(|c| !(0.0..=1.0).contains(c)) { + return Err(err(format!( + "particle light color {:?} is not in 0..=1 per channel", + l.color + ))); + } + if !(0.25..=16.0).contains(&l.reach) { + return Err(err(format!( + "particle light reach {} is not in 0.25..=16", + l.reach + ))); + } + } if raw.lights.len() > MAX_LIGHTS { return Err(err(format!( "{} [[lights]]; a pack may declare {MAX_LIGHTS}", @@ -728,6 +773,7 @@ impl Manifest { materials: raw.materials, textures, lights: raw.lights, + particle_lights: raw.particle_lights, budget: Budget { gpu_ms }, reflection: raw.reflection, shadow: raw.shadow, diff --git a/crates/horizon-shader-api/src/stages.rs b/crates/horizon-shader-api/src/stages.rs index 4e2ba45e59..7ddcd7897b 100644 --- a/crates/horizon-shader-api/src/stages.rs +++ b/crates/horizon-shader-api/src/stages.rs @@ -423,7 +423,7 @@ struct HorizonMaterialIn { let normal = horizon_material_normal(in.world); let tangent = horizon_material_tangent(in.world, in.uv, normal); let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags, in.material, - in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent); + in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent, vec3(0.0)); return material(input).color; } ", diff --git a/crates/horizon-shader-api/tests/schema.rs b/crates/horizon-shader-api/tests/schema.rs index 88796ad366..7a5a997097 100644 --- a/crates/horizon-shader-api/tests/schema.rs +++ b/crates/horizon-shader-api/tests/schema.rs @@ -647,3 +647,18 @@ fn materials_name_blocks_and_move_sprites_to_the_translucent_pass() { ); assert!(m.translucent_layers(&[(0, 9)], 4).is_empty()); } + +#[test] +fn reads_particle_lights_and_refuses_bad_ones() { + let entry = |rest: &str| { + format!( + "{}[[particle_lights]]\nparticles = [\"firefly\"]\n{rest}\n", + sample_text() + ) + }; + let m = Manifest::parse(&entry("color = [0.3, 0.35, 0.2]\nreach = 4.0")).unwrap(); + assert_eq!(m.particle_lights[0].particles, ["firefly"]); + assert_eq!(m.particle_lights[0].reach, 4.0); + assert!(refused(&entry("color = [2.0, 0.0, 0.0]\nreach = 4.0")).contains("0..=1")); + assert!(refused(&entry("color = [1.0, 1.0, 1.0]\nreach = 40.0")).contains("reach")); +} diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs index b3fd83eb7a..2aaefb1948 100644 --- a/crates/horizon-shaders/src/post.rs +++ b/crates/horizon-shaders/src/post.rs @@ -210,6 +210,8 @@ pub struct PostProgram { pub flips: Vec<(u32, bool)>, /// How a gbuffers program's output blends into its buffers; `None` writes them. pub blend: Option, + /// Per-buffer blend overrides, keyed by colortex index (not output location). + pub buffer_blends: Vec<(u32, Option)>, /// Bit n: `colortexMipmapEnabled` in its fragment stage, so buffer n's mip chain is /// regenerated right before it (6.1.7; not for gbuffers programs). pub mipmaps: u16, @@ -385,6 +387,7 @@ pub struct Post { pub entities_translucent: Option, /// What draws particles. pub particles: Option, + pub damaged_block: Option, /// What draws the game's clouds. pub clouds: Option, /// What draws rain and snow, after deferred. @@ -482,6 +485,7 @@ pub fn load_post( block: None, entities_translucent: None, particles: None, + damaged_block: None, clouds: None, weather: None, hand: None, @@ -609,6 +613,19 @@ pub fn load_post( ), None => (None, None), }; + let damaged_block = inputs + .entities + .as_ref() + .and_then(|i| gbuffers(&["gbuffers_damagedblock"], Group::Gbuffers, i)) + .map(|mut p| { + p.blend = blend(properties, &p.name).unwrap_or(Some([ + Factor::DstColor, + Factor::SrcColor, + Factor::One, + Factor::Zero, + ])); + p + }); // Translucent entities and particles blend (OQ-23). Particles keep vanilla's alpha cut at // 0.1; translucent entities lose only fully transparent texels (a shadow fades out). let translucent = @@ -682,6 +699,7 @@ pub fn load_post( loader.post.block = block; loader.post.entities_translucent = entities_translucent; loader.post.particles = particles; + loader.post.damaged_block = damaged_block; loader.post.clouds = clouds; loader.post.weather = weather; loader.post.hand = hand; @@ -723,6 +741,7 @@ fn load_shadow( &post.block, &post.entities_translucent, &post.particles, + &post.damaged_block, &post.clouds, &post.weather, &post.hand, @@ -860,6 +879,7 @@ impl Loader<'_> { } let compute = self.compute(&name); return (!compute.is_empty()).then(|| PostProgram { + buffer_blends: buffer_blends(self.properties, &name), flips: flips(self.properties, &name), name, group, @@ -945,6 +965,7 @@ impl Loader<'_> { _ => Vec::new(), }; Some(PostProgram { + buffer_blends: buffer_blends(self.properties, &name), flips: flips(self.properties, &name), name, group, @@ -1043,6 +1064,7 @@ impl Loader<'_> { .unwrap_or(buffers.len()); buffers.truncate(kept); Some(PostProgram { + buffer_blends: buffer_blends(self.properties, &name), flips: flips(self.properties, &name), name, group, @@ -1167,6 +1189,23 @@ fn blend(properties: &Properties, program: &str) -> Option> { Some(Some(factors.try_into().ok()?)) } +/// Blend overrides apply to buffer names even when DRAWBUFFERS remaps locations. +fn buffer_blends(properties: &Properties, program: &str) -> Vec<(u32, Option)> { + let prefix = format!("blend.{program}."); + properties + .entries + .iter() + .filter_map(|e| { + let buffer = config::buffer_index(e.key.strip_prefix(&prefix)?)?; + let value = blend( + properties, + &format!("{program}.{}", e.key.strip_prefix(&prefix)?), + )?; + Some((buffer as u32, value)) + }) + .collect() +} + /// `flip..=` keys of one program. fn flips(properties: &Properties, program: &str) -> Vec<(u32, bool)> { let prefix = format!("flip.{program}."); @@ -1641,3 +1680,32 @@ mod tests { ); } } + +#[cfg(test)] +mod buffer_blend_tests { + use super::*; + #[test] + fn buffer_blends_use_colortex_numbers_and_preserve_explicit_off() { + let props = Properties::parse( + "blend.gbuffers_water=off\nblend.gbuffers_water.colortex13=ONE ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA\nblend.gbuffers_water.colortex2=off\n", + &Macros::standard(), + ); + let overrides = buffer_blends(&props, "gbuffers_water"); + assert_eq!(blend(&props, "gbuffers_water"), Some(None)); + assert_eq!( + overrides, + vec![ + ( + 13, + Some([ + Factor::One, + Factor::OneMinusSrcAlpha, + Factor::One, + Factor::OneMinusSrcAlpha + ]) + ), + (2, None) + ] + ); + } +} diff --git a/crates/horizon-shaders/src/translate/image_parameters.rs b/crates/horizon-shaders/src/translate/image_parameters.rs new file mode 100644 index 0000000000..f20871ba43 --- /dev/null +++ b/crates/horizon-shaders/src/translate/image_parameters.rs @@ -0,0 +1,331 @@ +//! Specialize opaque image parameters to their bound resources. GLSL image parameters do not +//! carry a storage format, while Naga's image types do. This preserves the actual global image +//! type/access and writes no pack files. Unsupported dynamic handles remain compiler errors. +use crate::macros::lex; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone)] +struct Function { + name: String, + result: String, + params: Vec, + images: Vec<(usize, String)>, + body: String, +} +struct Site { + start: usize, + end: usize, + function: usize, + prototype: bool, +} +struct Instance { + name: String, + signature: String, + body: String, +} +struct Rewriter { + functions: Vec, + resources: BTreeSet, + instances: BTreeMap<(usize, Vec), Instance>, +} +fn word(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') +} +fn image(s: &str) -> bool { + s.strip_prefix("uimage") + .or_else(|| s.strip_prefix("iimage")) + .or_else(|| s.strip_prefix("image")) + .is_some_and(|s| { + matches!( + s, + "1D" | "2D" + | "3D" + | "Cube" + | "Buffer" + | "1DArray" + | "2DArray" + | "CubeArray" + | "2DMS" + | "2DMSArray" + ) + }) +} +fn next(tokens: &[&str], i: usize) -> Option { + (i + 1..tokens.len()).find(|&j| !tokens[j].trim().is_empty()) +} +fn close(tokens: &[&str], open: usize, left: &str, right: &str) -> Option { + let mut depth = 0; + for (i, t) in tokens.iter().enumerate().skip(open) { + if *t == left { + depth += 1; + } else if *t == right { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + } + None +} +fn args(tokens: &[&str], open: usize, end: usize) -> Vec { + let (mut depth, mut first) = (0, open + 1); + let mut result = Vec::new(); + for i in open + 1..end { + match tokens[i] { + "(" | "[" => depth += 1, + ")" | "]" => depth -= 1, + "," if depth == 0 => { + result.push(tokens[first..i].concat()); + first = i + 1; + } + _ => {} + } + } + let last = tokens[first..end].concat(); + if !last.trim().is_empty() { + result.push(last); + } + result +} +impl Rewriter { + fn calls(&mut self, text: &str) -> Result { + let tokens = lex(text); + let mut out = String::new(); + let mut i = 0; + while i < tokens.len() { + let Some(open) = next(&tokens, i).filter(|&o| tokens[o] == "(") else { + out.push_str(tokens[i]); + i += 1; + continue; + }; + let Some(end) = close(&tokens, open, "(", ")") else { + out.push_str(tokens[i]); + i += 1; + continue; + }; + let params = args(&tokens, open, end); + let candidates: Vec<_> = self + .functions + .iter() + .enumerate() + .filter(|(_, f)| f.name == tokens[i] && f.params.len() == params.len()) + .map(|(i, _)| i) + .collect(); + if candidates.is_empty() { + out.push_str(tokens[i]); + i += 1; + continue; + } + if candidates.len() != 1 { + return Err(format!( + "image-parameter overload of {} is ambiguous", + tokens[i] + )); + } + let id = candidates[0]; + let function = self.functions[id].clone(); + let mut bindings = Vec::new(); + for (k, _) in &function.images { + let value = lex(¶ms[*k]) + .into_iter() + .filter(|t| !t.trim().is_empty()) + .collect::(); + if !word(&value) || !self.resources.contains(&value) { + return Err(format!( + "image parameter of {} must resolve to a bound image", + function.name + )); + } + bindings.push(value); + } + let key = (id, bindings.clone()); + if !self.instances.contains_key(&key) { + if self.instances.len() >= 256 { + return Err("image parameter specialization exceeds 256 variants".into()); + } + let name = format!("hz_image_call_{}", self.instances.len()); + let params = function + .params + .iter() + .enumerate() + .filter(|(k, _)| !function.images.iter().any(|(i, _)| i == k)) + .map(|(_, p)| p.clone()) + .collect::>() + .join(","); + let signature = format!("{} {name}({params})", function.result); + self.instances.insert( + key.clone(), + Instance { + name, + signature, + body: String::new(), + }, + ); + let substitutions: BTreeMap<_, _> = function + .images + .iter() + .zip(&bindings) + .map(|((_, p), b)| (p.as_str(), b.as_str())) + .collect(); + let body = lex(&function.body) + .iter() + .map(|t| substitutions.get(t).copied().unwrap_or(t)) + .collect::(); + let body = self.calls(&body)?; + self.instances.get_mut(&key).unwrap().body = body; + } + let name = self.instances[&key].name.clone(); + let mut kept = Vec::new(); + for (k, param) in params.iter().enumerate() { + if !function.images.iter().any(|(i, _)| *i == k) { + kept.push(self.calls(param)?); + } + } + let call = format!("{name}({})", kept.join(",")); + out.push_str(&call.replace('\n', " ")); + out.extend( + tokens[i..=end] + .iter() + .flat_map(|t| t.chars().filter(|c| *c == '\n')), + ); + i = end + 1; + } + Ok(out) + } +} + +pub(super) fn rewrite(text: &str) -> Result { + let tokens = lex(text); + let words: Vec<_> = tokens + .iter() + .enumerate() + .filter(|(_, t)| !t.trim().is_empty()) + .map(|(i, _)| i) + .collect(); + let resources = words + .windows(2) + .filter(|p| image(tokens[p[0]]) && word(tokens[p[1]])) + .map(|p| tokens[p[1]].to_owned()) + .collect(); + let mut functions: Vec = Vec::new(); + let mut sites = Vec::new(); + let mut depth = 0; + let mut i = 0; + while i < tokens.len() { + match tokens[i] { + "{" => depth += 1, + "}" => depth -= 1, + _ => {} + } + if depth != 0 || !word(tokens[i]) { + i += 1; + continue; + } + let Some(open) = next(&tokens, i).filter(|&o| tokens[o] == "(") else { + i += 1; + continue; + }; + let Some(end) = close(&tokens, open, "(", ")") else { + i += 1; + continue; + }; + let Some(after) = next(&tokens, end).filter(|&j| matches!(tokens[j], "{" | ";")) else { + i += 1; + continue; + }; + let params = args(&tokens, open, end); + let mut images = Vec::new(); + for (k, p) in params.iter().enumerate() { + let w: Vec<_> = lex(p) + .into_iter() + .filter(|t| !t.trim().is_empty()) + .collect(); + if let Some(at) = w.iter().position(|t| image(t)) + && let Some(name) = w.get(at + 1) + { + images.push((k, (*name).to_owned())); + } + } + if images.is_empty() { + i += 1; + continue; + } + let Some(start) = (0..i).rev().find(|&j| !tokens[j].trim().is_empty()) else { + i += 1; + continue; + }; + if !word(tokens[start]) { + i += 1; + continue; + } + let prototype = tokens[after] == ";"; + let stop = if prototype { + after + } else { + close(&tokens, after, "{", "}").ok_or("unterminated image function")? + }; + let function = Function { + name: tokens[i].into(), + result: tokens[start].into(), + params, + images, + body: if prototype { + String::new() + } else { + tokens[after + 1..stop].concat() + }, + }; + let id = functions + .iter() + .position(|f| f.name == function.name && f.params == function.params) + .unwrap_or(functions.len()); + if id == functions.len() { + functions.push(function); + } else if !prototype { + functions[id] = function; + } + sites.push(Site { + start, + end: stop + 1, + function: id, + prototype, + }); + i = stop + 1; + } + if sites.is_empty() { + return Ok(text.into()); + } + let mut r = Rewriter { + functions, + resources, + instances: BTreeMap::new(), + }; + let mut chunks = Vec::new(); + let mut first = 0; + for site in &sites { + chunks.push(r.calls(&tokens[first..site.start].concat())?); + first = site.end; + } + chunks.push(r.calls(&tokens[first..].concat())?); + let mut out = String::new(); + for (k, site) in sites.iter().enumerate() { + out.push_str(&chunks[k]); + for ((id, _), instance) in &r.instances { + if *id == site.function { + let code = if site.prototype { + format!("{};", instance.signature) + } else { + format!("{}{{{}}}", instance.signature, instance.body) + }; + out.push_str(&code.replace('\n', " ")); + } + } + out.extend( + tokens[site.start..site.end] + .iter() + .flat_map(|t| t.chars().filter(|c| *c == '\n')), + ); + } + out.push_str(chunks.last().unwrap()); + Ok(out) +} diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs index e45815a33c..3318b100c5 100644 --- a/crates/horizon-shaders/src/translate/mod.rs +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -29,6 +29,7 @@ use crate::Preprocessed; mod atomic_vectors; mod geometry; +mod image_parameters; mod ordering; mod rewrite; mod scan; @@ -980,7 +981,14 @@ pub(super) fn split_sampler(ty: &str) -> (String, &'static str) { /// Parses and validates one stage; errors at the pack's files and lines. fn compile(stage: &Stage, text: &str, header_lines: usize) -> Result> { - // `text` is the source the span is in: the rewrites below keep its lines. + let specialized = image_parameters::rewrite(text).map_err(|message| { + vec![Error { + file: stage.pp.files.first().cloned().unwrap_or_default(), + line: 1, + message, + }] + })?; + let text = specialized.as_str(); let at = |text: &str, span: naga::Span, message: String| { let start = span.to_range().map_or(0, |r| r.start); let line = text[..start.min(text.len())].matches('\n').count(); @@ -2362,4 +2370,39 @@ mod tests { "the uniform is back after the block" ); } + + #[test] + fn storage_buffer_extension_keeps_the_legacy_buffer_keyword() { + let fragment = "#version 130\n#extension GL_ARB_shader_storage_buffer_object : enable\nlayout(std430,binding=0) readonly buffer Data{uvec4 cells[4];}data;void main(){gl_FragColor=vec4(data.cells[0]);}"; + let result = program( + &[("composite.vsh", VSH_120), ("composite.fsh", fragment)], + Target::Window, + ); + assert!(result.is_ok(), "{:?}", result.err()); + } + #[test] + fn ordinary_legacy_buffer_identifiers_still_translate() { + let fragment = "#version 120\nvoid main(){float buffer=0.5;gl_FragColor=vec4(buffer);}"; + let result = program( + &[("composite.vsh", VSH_120), ("composite.fsh", fragment)], + Target::Window, + ); + assert!(result.is_ok(), "{:?}", result.err()); + } +} + +#[cfg(test)] +mod image_parameter_tests { + use crate::{Macros, Pack, preprocess, translate_compute}; + #[test] + fn nested_image_helpers_specialize_multiple_storage_formats() { + let src = "#version 430\nlayout(local_size_x=1)in;layout(rgba16f) uniform image3D imageA;layout(r32f) uniform image3D imageB;void write_value(writeonly image3D img,ivec3 p){imageStore(img,p,vec4(0.5));}\nvoid nested(image3D img,ivec3 p){write_value(img,p);}\nvoid main(){nested(imageA,ivec3(0));nested(imageB,ivec3(0));}"; + let pack = Pack::from_zip_bytes( + "formats.zip", + crate::pack::tests::zip(&[("shaders/setup.csh", src)]), + ) + .unwrap(); + let result = translate_compute(&preprocess(&pack, "setup.csh", &Macros::standard())); + assert!(result.is_ok(), "{:?}", result.err()); + } } diff --git a/crates/horizon-shaders/src/translate/scan.rs b/crates/horizon-shaders/src/translate/scan.rs index a3a8389fb5..89038a33fb 100644 --- a/crates/horizon-shaders/src/translate/scan.rs +++ b/crates/horizon-shaders/src/translate/scan.rs @@ -729,6 +729,11 @@ impl<'p> Stage<'p> { let mut binding = None; for (k, &i) in words.iter().enumerate() { match self.tokens[i] { + // An extension adds real buffer syntax even in a GLSL 1.x stage. + // It must not take the legacy identifier rename for `buffer`. + "buffer" => { + self.replaced.insert(i, "buffer".into()); + } "binding" => { binding = words .get(k + 2) diff --git a/crates/horizon-world/src/heightmap.rs b/crates/horizon-world/src/heightmap.rs index 7b2a5588fb..8cd8dd38d8 100644 --- a/crates/horizon-world/src/heightmap.rs +++ b/crates/horizon-world/src/heightmap.rs @@ -133,6 +133,18 @@ impl Heightmap { x.wrapping_add(z.wrapping_mul(16)) as usize } + /// Whether `update` at (`x`, `y`, `z`) returns at once, whatever the + /// state: the block is two or more below the first available one. `x` + /// and `z` are within 0..16. Most generation writes are (the surface + /// rule turns every stone under y 8 to deepslate), and this skips the + /// checks `update` does first. + #[inline] + pub fn below_update(&self, chunk: &impl HeightmapChunk, x: i32, y: i32, z: i32) -> bool { + debug_assert!((0..16).contains(&x) && (0..16).contains(&z)); + let first = (self.data.read(Self::index(x, z)) as i32).wrapping_add(self.min_y); + y <= first.wrapping_sub(2) && self.check_chunk(chunk).is_ok() + } + pub fn first_available(&self, x: i32, z: i32) -> Result { Ok((self.data.get(Self::index(x, z))? as i32).wrapping_add(self.min_y)) } diff --git a/crates/horizon-world/src/worldgen/terrain.rs b/crates/horizon-world/src/worldgen/terrain.rs index bae816d481..e57b5f1322 100644 --- a/crates/horizon-world/src/worldgen/terrain.rs +++ b/crates/horizon-world/src/worldgen/terrain.rs @@ -344,10 +344,17 @@ impl<'r, B: TerrainBlockStates> TerrainChunk<'r, B> { let previous = self.set_block_raw(x, y, z, state)?; let mut missing = Vec::new(); for &kind in self.stage.heightmaps() { - if self.heightmap(kind).is_some() { - self.update_heightmap(kind, x & 15, y, z & 15, state)?; - } else { - missing.push(kind); + match self.heightmap(kind) { + // `Heightmap::update` would return at once: no state is read. + Some(map) if map.below_update(&self.blocks, x & 15, y, z & 15) => { + if self.blocks.registry.state(state).is_none() { + return Err(TerrainError::InvalidState); + } + } + Some(_) => { + self.update_heightmap(kind, x & 15, y, z & 15, state)?; + } + None => missing.push(kind), } } if !missing.is_empty() { diff --git a/crates/horizon-world/tests/feature_region.rs b/crates/horizon-world/tests/feature_region.rs index ce24cdfd00..6b6f3e52da 100644 --- a/crates/horizon-world/tests/feature_region.rs +++ b/crates/horizon-world/tests/feature_region.rs @@ -473,3 +473,56 @@ fn spread_out_regions_route_like_dense_ones() { ); } } +/// `set_block_worldgen` skips a heightmap's update for writes deep under its +/// surface; the maps end as when every write updates them. +#[test] +fn worldgen_writes_keep_heightmaps_as_updating_each_write() { + let registry = registry(); + for stage in [PreLightChunkStage::Biomes, PreLightChunkStage::Terrain] { + let new = || { + let mut chunk = + TerrainChunk::new(ChunkPos::new(2, -3), -32, 64, ®istry, Properties).unwrap(); + chunk.set_generation_stage(stage); + chunk.prime_heightmaps(stage.heightmaps()).unwrap(); + chunk + }; + let (mut fast, mut each) = (new(), new()); + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + for _ in 0..20_000 { + let (x, z) = (next(4) as i32, next(4) as i32); + // Mostly solid columns written top down, as the surface rule does, + // with air and water mixed in. + let y = 31 - next(64) as i32; + let state = BlockStateId([2, 2, 2, 4, 6, 0, 3, 7][next(8) as usize]); + fast.set_block_worldgen(x, y, z, state).unwrap(); + each.set_block_raw(x, y, z, state).unwrap(); + for &kind in stage.heightmaps() { + each.update_heightmap(kind, x, y, z, state).unwrap(); + } + } + for &kind in stage.heightmaps() { + for z in 0..16 { + for x in 0..16 { + assert_eq!( + fast.heightmap(kind).unwrap().first_available(x, z).unwrap(), + each.heightmap(kind).unwrap().first_available(x, z).unwrap(), + "{kind:?} at {x} {z}" + ); + } + } + } + for y in -32..32 { + for z in 0..4 { + for x in 0..4 { + assert_eq!(fast.block(x, y, z), each.block(x, y, z)); + } + } + } + } +} diff --git a/docs/api/agent-control.md b/docs/api/agent-control.md index fb686d1992..b34b123c42 100644 --- a/docs/api/agent-control.md +++ b/docs/api/agent-control.md @@ -42,8 +42,8 @@ API offers; the other commands the game's socket answers are test hooks and expe ## Not there yet -- **A version.** A `hello` command that answers the protocol version, the game's release and the features it has; - MCP `serverInfo` carries the same version; `horizon-ctl` refuses a game whose major it doesn't speak. +- **A version that is checked.** `hello` answers it (and MCP `serverInfo` carries the release), but `horizon-ctl` + still drives any game: it should refuse one whose line it doesn't speak (`horizon-ctl --api 0.1`). - **Reply schemas.** Each command's reply described in the table (fields, types, units), so the reference documents replies as well as arguments and MCP tools declare an output schema. - **Events as a stream.** `events --since` polls today; a subscription that pushes events as they happen. @@ -52,4 +52,6 @@ API offers; the other commands the game's socket answers are test hooks and expe ## Changelog -- Unversioned so far: the command table and the generated reference are the contract. +- 0.1.0: the first numbered line. `hello` answers `protocol`, `release` (the game's version, which MCP `serverInfo` + carries too) and `features` (the command groups the game serves). Before it the command table and the generated + reference were the contract. diff --git a/docs/api/shaders-reference.md b/docs/api/shaders-reference.md index d622b85f7e..d4cdf146b3 100644 --- a/docs/api/shaders-reference.md +++ b/docs/api/shaders-reference.md @@ -91,6 +91,7 @@ One point of a block's surface, for `fn material(input: MaterialInput) -> Surfac | `emission` | `f32` | How strongly the block itself shines, 0..1, by its kind of light: a redstone torch or a candle 7/15, a torch or lava 14/15, glowstone 1; 0 for most blocks. | | `normal` | `vec3` | The surface's geometric normal, a unit vector in world axes, facing the camera. A normal within about 3.6 degrees of an axis is that axis exactly, so distant block faces do not shimmer; flowing water's slopes are steeper and keep theirs. | | `tangent` | `vec4` | The surface's tangent: xyz the unit direction in world axes along which `uv.x` grows, w the sign (1 or -1) that makes `cross(normal, tangent.xyz) * tangent.w` the direction `uv.y` grows along (down the texture). With `normal` it turns a normal map's normals into world axes. | +| `colored_light` | `vec3` | The frame's coloured lights at this point (`[[particle_lights]]` and mods' visual lights), 0..1, to light the surface with as the pack likes; 0 where none reaches. | ### `Surface` diff --git a/docs/api/shaders.md b/docs/api/shaders.md index a4a188172b..2b1122c413 100644 --- a/docs/api/shaders.md +++ b/docs/api/shaders.md @@ -114,6 +114,11 @@ wrap = "repeat" # optional: repeat (default) or clamp color = [0.3, 0.7, 1.0] # each channel 0 to 4 blocks = ["soul_*"] # block names, any number of * each +[[particle_lights]] # 32 at most: these particles join the frame's coloured lights +particles = ["firefly"] # names, `firefly` or `minecraft:firefly`; the first entry naming one wins +color = [0.8, 0.9, 0.4] # each channel 0 to 1; the particle's alpha is its intensity +reach = 4.0 # blocks, 0.25 to 16; the 16 nearest lights shine, read by MaterialInput.colored_light + [[tags]] # 32 at most: bit i of MaterialInput.tags is the i-th tag id = "glass" sprites = ["block/glass", "block/*_stained_glass"] # sprite names, at most one * each @@ -149,6 +154,7 @@ stages. ## Changelog +- 2026-11-02: API 0.1 (additions): `[[particle_lights]]` and `MaterialInput.colored_light`. - 2026-11-01: API 0.1 (additions): `[[materials]]` and `MaterialInput.material`, an id by sprite, and `[shadow] solid_materials`. - 2026-11-01: API 0.1 (additions): entities cast into `shadow_map`, the player's own model in first person too, and the round diff --git a/docs/api/stability.md b/docs/api/stability.md index e5817610a2..5616e8f3f0 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -18,7 +18,7 @@ promise, how something is deprecated and removed, and the tests that keep old mo | --- | --- | --- | | Content mods | the manifest's `api` line | `api = "0.2"` in `horizon-mod.toml` | | Native mods | the Horizon and horizon-mixins revisions the bundle pins | `horizon_revision` in `horizon-mixin.toml` | -| Agent control | the control protocol's version (planned: a `hello` reply and MCP `serverInfo`) | `horizon-ctl --api 1` (planned) | +| Agent control | the control protocol's version (`hello` replies it; MCP `serverInfo` carries the release) | `horizon-ctl --api 0.1` (planned) | | Server control API | the path (`/api/v1/`) | the path | | `server.toml` | the file's keys; a key is never renamed in place | nothing: old files keep loading | | Launcher commands | the command names and their `--json` output | nothing | diff --git a/docs/architecture.md b/docs/architecture.md index 21ca3a42ad..838ff3bcf9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,7 @@ command above to inspect the selection rather than relying on historical counts. | 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `R/entity.wgsl` | entity_model | | 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers | | 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `R/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` | -| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap | +| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `R/block_entity_cem.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap | | 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks | | 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `R/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing | | 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter | diff --git a/docs/benchmark.md b/docs/benchmark.md index 0eba8b00a3..2b517a913a 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -59,6 +59,24 @@ Options: `--distances 8,16`, `--sizes 1920x1080`, `--scenes village,ocean`, `--s On Windows, run `python tools\perf_bench.py ...` the same way. Horizon's control endpoint there is a localhost TCP port, which the game writes to a `.json` file in the temporary folder. `--build` can point at an installed release (`%APPDATA%\horizon-launcher\versions\vX.Y.Z`, with `--resources` its `.local\horizon-client\26.3`), and the report then gives that release's version. +## Frame pacing + +`tools/pacing_bench.py` measures smoothness rather than throughput: every frame of the sessions where players see +hitches, a fast flight (64 blocks a second over new terrain), eight long teleports, trips to the Nether and back, and +the inventory and pause menu opened and closed twenty times. For each it reports the median, p99 and p99.9 frame +times, the slowest frame, the 1% and 0.1% lows and the frames over 33 ms. The game runs with `HORIZON_SPIKES`, so +every slow frame also logs its timed spans (wall and thread CPU time): the report keeps the slowest ones next to the +figures, which says whether a hitch was work (a span's CPU time) or waiting (the GPU, a lock, a file). +`Renderer`'s frame and `entity_geometry` time their steps as spans too (`spans::laps`). + +```sh +python3 tools/pacing_bench.py --build target/fast --label main --distances 32 +python3 tools/pacing_bench.py --compare main mybranch +``` + +`--size 320x180` keeps a software renderer (Mesa's lavapipe on a machine with no GPU) from hiding the CPU side; there +only the spans' CPU times mean anything. + ## Results Saved runs go to `benchmarks/perf/`; `--table` prints them as Markdown. diff --git a/docs/graphics.md b/docs/graphics.md index 6407e19cfd..2826a33c3a 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -37,6 +37,8 @@ Every row has a one-line description and its cost in frame time (none, low, medi **HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. The screen shows these as plain numbers: they are nits only where the compositor passes PQ through unchanged (Windows, Hyprland with tone mapping off), not on KWin or macOS, and the game can't tell which. +HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare. + HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta. Which settings each screen shows, their descriptions and costs are in `crates/horizon-client-render/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them. diff --git a/docs/horizon-ctl-commands.md b/docs/horizon-ctl-commands.md index 2bcfde074a..d673163ecd 100644 --- a/docs/horizon-ctl-commands.md +++ b/docs/horizon-ctl-commands.md @@ -12,6 +12,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch | `wait-ready [SECONDS]` | `wait_ready` | Wait until the player has spawned and no chunks are pending | `seconds` (number): how long to wait (default 120) | | `logs [--lines N] [--crash]` | `logs` | The end of the game's log, or its newest crash report | `--lines` (integer): how many lines of latest.log (default 100)
`--crash` (boolean): the newest crash report instead | | `status` | `status` | Game, renderer and streaming state (the scripting socket's original summary) | none | +| `hello` | `hello` | The control protocol's version, the game's release and the command groups it serves | none | | `worlds` | `worlds` | The saved worlds, newest first: folder, name, mode, last played, version | none | | `world-rename FOLDER NAME` | `world_rename` | Rename a world, as Edit World does (its folder stays) | `folder` (string, required): world folder from worlds
`name` (text, required): the new name | | `world-backup FOLDER` | `world_backup` | Zip a world into backups/, as Edit World's Make Backup does | `folder` (string, required): world folder from worlds | @@ -137,6 +138,6 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch | `sleep SECONDS` | CLI only | Wait (in scripts) | none | | `mcp` | CLI only | Serve the Model Context Protocol on stdin/stdout, one tool per command | none | | `bench SECONDS` | `bench` | Reset frame stats, wait, reply with the summary | `seconds` (number, required): how long to measure | -| `stats [reset]` | `stats` | Frame-time summary since the last reset | `reset` (boolean): start counting again | +| `stats [reset] [raw]` | `stats` | Frame-time summary since the last reset | `reset` (boolean): start counting again
`raw` (boolean): every frame time too (frame_ms, cpu_ms, gpu_ms) | | `gpu-passes [on\|off]` | CLI only | Time Horizon's own render passes on the GPU, shown in F3 and in stats and bench | `mode` (on, off): on or off; without it, only reports | | `fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]` | CLI only | Fly the camera along a line, then reply with the frame summary | none | diff --git a/docs/hosting.md b/docs/hosting.md index a8c3858a42..4d38da1f20 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -308,6 +308,10 @@ sends, saving...), the slowest ticks, how many entities of each type, chunks, pl settings that matter (view and simulation distance, max players). They hold no names, UUIDs or addresses, so you can attach them to an issue as they are. On the console it is `horizon-report [SECONDS]`. +Between reports, the log keeps the spikes: a tick slower than 250 ms writes a `Horizon: slow tick` line with +its phases (`HORIZON_SPIKES=MS` in the engine's environment sets another threshold), and a server more than +two seconds behind says `Can't keep up!` as vanilla's does, at most every 15 seconds. + `config set` changes `server.toml` without opening it: `horizon-server config set world.difficulty=hard server.max_players=10`. Each key is a section and a name from the file (`properties.spawn-protection=0` for any other vanilla key). The values are checked as the server checks them, and nothing is written if one is wrong. @@ -385,7 +389,7 @@ Integrations → Webhooks → New Webhook → Copy Webhook URL, then: ```toml [discord] webhook = "https://discord.com/api/webhooks/..." # a secret: whoever has it can post in the channel -events = ["chat", "join", "leave", "death"] # and "kick" +events = ["chat", "join", "leave", "death"] # and "kick", "announce" (lines native mods post) enabled = true # false pauses it and keeps the webhook ``` diff --git a/docs/mods.md b/docs/mods.md index addd612814..94ef32e5ce 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -144,8 +144,8 @@ round-trip through saved NBT; negative zero is not supported. A key such as recipe uses it, as a milk bucket leaves a bucket (vanilla's `Item.Properties.craftRemainder`, not a data component): a vanilla item or one of the mod's own, refused at load otherwise. It holds on the crafting -table, the inventory grid and the crafter, not yet as furnace fuel or in a -brewing stand. What the item leaves once eaten +table, the inventory grid, the crafter and as furnace fuel (the last of the stack leaves it in +the fuel slot, a stack with more left drops it), not yet in a brewing stand. What the item leaves once eaten or drunk is the `minecraft:use_remainder` component instead: ```json diff --git a/docs/native-mods.md b/docs/native-mods.md index 6bb81b5b07..5536f1bdbc 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -11,6 +11,16 @@ switch. The first install shows a trust notice, and every build waits behind a r after a review (its later releases are checked by machine); and they play in a game of their own, built for the instance they are installed in. +## Villager farming + +Farmers recognize mod blocks made from farmland and `CropBlock` templates, including declarations from +`native_blocks`. A crop block's generated item is its seed: add that item to +`minecraft:villager_plantable_seeds` to let farmers plant its default block state from their inventory. +Untagged items and tagged items without a block are skipped. Crop maturity follows the template's class: +beetroots mature at age 3, ordinary crops at 7; torchflower crops never reach their maximum age 2 while +still crops. Pitcher crops are not `CropBlock`s and are not harvested by this behavior. Farming still +requires a farmer at work and `mob_griefing` enabled. + ## Pinned to a Nightly A native mod builds on one exact Horizon commit: `[compatibility] horizon_revision` in its `horizon-mixin.toml`, @@ -426,10 +436,14 @@ tool, the block, the luck, the biome and a seed from the roll's random. An uncla stack, an unclaimed condition fails, as unknown kinds do; a bare type name is minecraft's own. The tables the items engine rolls (block drops, entity drops, `loot_table`, `interact_loot` and the -built-in tables) answer the condition hook, with the tool and the block in the context; the function hook -there follows when the stack types converge. In the items engine's context the `entity`, `damage`, `player` -and `biome` fields are `None` and `luck` is 0; in the main loot engine's context the `tool` and `block` -fields are `None`. +built-in tables) answer both hooks, with the tool and the block in the context. The function hook there +gets the stack as the main engine's (`LootStack`): its item, count, damage, enchantments (a book's stored +ones), potion, ominous bottle amplifier, stew effects and the components the engine keeps as saved, as +their JSON; what it changes comes back to the engine's stack, and a component it adds arrives with the +drop. A function that asks for a map's structure search (`map`/`exploration`) changes nothing there: the +items engine has no level to search. In the items engine's context the `entity`, `damage`, `player` and +`biome` fields are `None` and `luck` is 0; in the main loot engine's context the `tool` and `block` fields +are `None`. Both engines' contexts carry `place`, where the roll happens: `origin` (a broken block's centre, a dead mob's position), `block_entity` (the broken block's entity as it was before the break, saved with its id, position and @@ -437,6 +451,15 @@ position), `block_entity` (the broken block's entity as it was before the break, vanilla's `time_check` and `weather_check`). The game sets it for a block a player or an explosion breaks and for a mob's death loot; on other rolls `place` is `None`. +### A mod's own ingredient kinds + +A recipe's ingredient that names a type in a namespace outside `minecraft:` reaches +`native_ingredient(kind, data, items)` at load time. Fill `items` with the item ids the +ingredient matches (item ids only: components are not checked; a match-time hook follows +separately). Duplicates are removed keeping first-seen order. The hook may be called more +than once per recipe; the answer must be the same. An unclaimed kind leaves the list empty +and the recipe fails, as an unknown kind does. + ### Data files A bundle's `data/` folder (any files) is staged as `native-data//` at the root of the workspace. A helper or @@ -860,7 +883,7 @@ API 131 adds an item's use tick and release, as vanilla's `Item.onUseTick` and ` API 132 adds `native_inventory(uuid, kind)`: a player's inventory (`Player`: 41 slots, main, armour, off hand; `Ender`: 27), live when they are on the server, else from their file, never while they log in or are dead; `native_move_stack((from, kind, slot), expected, (to, kind))`: one stack moved, checked against `expected`, where it fits whole, or nothing moves (at most one side a file, written first); `native_send_map(uuid, map_id, colors)`: a 128 x 128 map picture sent to a player, for a filled map with that `map_id`. What an operator's `/invsee` and a price chart on a map need. Bundles need "hooks": 132. -API 133 adds a loot table's function and condition of a mod's own kind. The entry that names a `type` in a namespace outside `minecraft:` — where the loot roll used to silently skip it — reaches `native_loot_function(kind, data, ctx, stack)` or `native_loot_condition(kind, data, ctx, holds)` when the table rolls. The context brings the entity, the damage, the player, the tool, the block, the luck, the biome and a seed from the roll's random. The condition hook answers in the tables the items engine rolls (block drops, entity drops, loot_table, interact_loot), where entity, damage, player and biome are None; the function hook there follows when the stack types converge. An unclaimed function leaves the stack, an unclaimed condition fails, as unknown kinds do. Bundles that use them need "hooks": 133. +API 133 adds a loot table's function and condition of a mod's own kind. The entry that names a `type` in a namespace outside `minecraft:` — where the loot roll used to silently skip it — reaches `native_loot_function(kind, data, ctx, stack)` or `native_loot_condition(kind, data, ctx, holds)` when the table rolls. The context brings the entity, the damage, the player, the tool, the block, the luck, the biome and a seed from the roll's random. Both hooks answer in the tables the items engine rolls (block drops, entity drops, loot_table, interact_loot), where entity, damage, player and biome are None; the function hook there gets the stack as the main engine's (its item, count, damage, enchantments, potion and saved components) and takes back what it changes, but a function that asks for a map's structure search changes nothing there. An unclaimed function leaves the stack, an unclaimed condition fails, as unknown kinds do. Bundles that use them need "hooks": 133. API 134 adds a server's levels opening and closing, on the single player server thread and on a dedicated server: `native_level_load(dimension)` when a level opens, before any of its chunks (NeoForge's `LevelEvent.Load`), and `native_level_unload(dimension)` once it has closed, after its last save (`LevelEvent.Unload`). The level the world opens in comes before `WorldOpenReady`; the Nether and the End open when someone first goes there and close after 300 ticks with nobody in them. Every level still open closes when the server stops, before `WorldCloseFinal`, or when its open or its loop fails, so each load has one unload. Bundles that use them need "hooks": 134. @@ -902,6 +925,8 @@ API 152 adds a player opening and closing any menu on a server, on the single pl API 153 adds a player starting or stopping flight: `native_player_toggle_flight(player, name, flying, cancel)` is heard once for each change a player's client makes to its flying (vanilla honours it only for a player who may fly), on a server, with the player's state swapped in. A mod sets `cancel` to refuse it: the player keeps the state they had and the server sends their abilities back, so the client lands or flies again. Only what a client asks for is heard, not the flight a game-mode change starts or ends, and not a LAN host's own player. Bundles that use it need `"hooks": 153`. +API @API:physical_window_size@ adds `native_physical_window_size()`, the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. Bundles that use it need `"hooks": @API:physical_window_size@`. + ## What the launcher does In the window: install a native mod from the Mods page, into the instance picked on the left ("Installs into @@ -1270,6 +1295,12 @@ For mods that run where the world is simulated: a dedicated server, a LAN host, `native_player`, `native_give_items` and the other helpers reach them. A respawn at an anchor in another dimension, or at the world spawn's free column, may still move them on a later tick, once that level has loaded round them; the End-exit respawn (after the credits) does not fire it. +- **`native_player_fish(player, name, state, cancel)`**: a player's fishing rod, on a server: `state` 0 the cast, 1 a fish + biting, 2 the reel-in of a bite (the catch), 3 the reel-in with nothing on the line, 4 the reel-in of a bobber hooked + to an entity or item. Set `cancel` and nothing is thrown (0), the fish gets away and the wait starts over (1), or the + bobber stays out with no wear on the rod (2 to 4). The bobber ending by itself (too far, rod put away, owner dead) is + not heard, nor is the host of a LAN world's rod. The player's state is swapped in, so `native_acting_player`, + `native_tell` and the item helpers reach them. - **`native_perform_command(player, command, ran)`**: a player typed a registered command (without its slash) and their level allows it, whether the world allows commands or not. The mod parses the line and sets `ran`. The player's state is swapped in for the call, as for their other actions, so `native_player` reads them; a @@ -1277,8 +1308,14 @@ For mods that run where the world is simulated: a dedicated server, a LAN host, - Helpers: `native_server_players()` (uuid, name, level, dimension, position and game mode of each player on); `native_tell(uuid, text)`, a system line to one player, also the acting player inside their hook; `native_broadcast(text)`, to every player and the server's - log; `native_feedback(text)`, the command's answer to the player whose command it is; `native_world_dir()`, the + log; `native_server_event(name, uuid, message)`, an `announce` event on a dedicated server's event stream, which the Discord + bridge relays when `[discord] events` lists `announce`; `native_feedback(text)`, the command's answer to the player whose command it is; `native_world_dir()`, the open world's folder, where a server mod keeps its data in a folder of its own so the world's backups carry it. +- **`native_item_damaged(player, name, item, amount, cancel)`**: an item of a player is about to lose durability, on a + server (`ItemStack.hurtAndBreak`: tools, armour, shield, rod, elytra), after Unbreaking has had its say. Change + `amount` (0 leaves the item whole) or set `cancel` for no wear. Not heard for a creative player or when Unbreaking + saved the item, nor for a mob's armour, a dispenser's tool or the host of a LAN world. The player's state is swapped + in, so `native_acting_player`, `native_tell` and the item helpers reach them. - Items (API 9), of the acting player (inside `native_perform_command`, whose player is swapped in): `native_held()` (the main hand's item, count, and whether it is plain), `native_count_items(item)`, `native_carried_items()`, `native_take_items(item, count)` (the main hand first, then as `Inventory.add` orders slots), diff --git a/docs/resource-packs.md b/docs/resource-packs.md index 737cc6c0ad..bdabadbaee 100644 --- a/docs/resource-packs.md +++ b/docs/resource-packs.md @@ -38,9 +38,10 @@ harness named checks that loader against vanilla). | Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` | | Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` | | Item definitions and item models (all model types, display transforms, tints) | `items/`, `models/item/` | works | `test_item_model_parity` | -| Custom entity models (OptiFine/EMF): a `.jem` reshapes a mob's model layer, with its animations and texture; Entity Model Features' files win over OptiFine's | `optifine/cem/`, `emf/cem/` | partly | `mobs::cem` and `mobs::cem_anim` unit tests; no per-bone textures, no random models, no textures under `optifine/` (vanilla's own entity models are code in 26.3, which `test_entity_model_parity` checks) | +| Custom entity models (OptiFine/EMF): a `.jem` reshapes a mob's model layer, with its animations and texture, or a block entity's model (chests, shulker boxes, banners, heads, bells, books, conduits, decorated pots) with its texture but no animations yet; Entity Model Features' files win over OptiFine's | `optifine/cem/`, `emf/cem/` | partly | `mobs::cem`, `mobs::cem_anim`, `block_entity_cem` and `block_entity_render` unit tests, `horizon-optifine` `cem::files` (which file wins); trapped and ender chests read only their own files and copper chests keep vanilla's model; no sign, hanging sign or bed models, no per-bone textures, no random models, no textures under `optifine/` (vanilla's own entity models are code in 26.3, which `test_entity_model_parity` checks) | | Random entity textures (OptiFine/ETF): numbered variants and their rules (biome, height, name, profession, colour, baby, health, moon, time, weather, size, blocks), chosen per entity from its UUID and rechecked once a second | `optifine/random/`, `optifine/mob/`, `mcpatcher/mob/`, `etf/random/`, `textures/entity/` with a `.properties` | partly | `horizon-optifine` `random` and `random_textures` tests, `mobs::random_textures` tests; no `nbt.*` or ETF's extra conditions (teams, items...), no block entities | -| Emissive textures (OptiFine/ETF): `_e.png` (or the pack's `suffix.emissive`) drawn full bright over mobs, their layers and armour, armour stands, the elytra, the dragon, thrown tridents, block entities and placed blocks | `_e.png` beside its texture (also random variants' and blink frames'); the suffix from `optifine/emissive.properties`, `etf/emissive.properties` or `textures/emissive.properties` | partly | `horizon-optifine` `emissive` tests, `mobs::render::emissive_tests`, `block_entity_render` and `native_emissive` tests; at most 96 block overlays (native mods keep 32 more), cutout; none on translucent faces, nor on blocks whose texture is pixel-identical to another's (they share a layer); not over items, armour trims, other projectiles, minecarts or end crystals, nor connected-texture tiles | +| Connected textures (OptiFine/Continuity): every method (`ctm`, `ctm_compact` with `ctm.N`, `horizontal`, `vertical`, `horizontal+vertical`, `vertical+horizontal`, `top`, `random`, `repeat`, `fixed`, `overlay`, `overlay_ctm`/`_random`/`_repeat`/`_fixed`, and Continuity's `overlay_horizontal`/`_vertical`/`_h+v`/`_v+h`), `matchTiles`/`matchBlocks` and the file-name forms, `tiles` (ranges, names, paths, ``, ``), `weight`, `faces`, `biomes`, `heights` (and the old `minHeight`/`maxHeight`), `connect`, `connectTiles`/`connectBlocks`, `innerSeams`, `weights`, `randomLoops`, `symmetry`, `linked`, `width`/`height`, `tintIndex`/`tintBlock`, `layer`; Continuity's `orient`, `prioritize`, `optifineOnly` and its stacked-pane culling; tiles a rule picked matched again by another; panes drawn with OptiFine's unmirrored UVs | `optifine/ctm/**/*.properties` (and `mcpatcher/ctm/`) in any namespace, with their tiles | partly | `horizon-optifine` `ctm` and `ctm::connect` tests (the tile tables read off OptiFine's template images), `connected` and `mesher::quarter_tests` tests; `name=` rules are not loaded (the client meshing chunks never receives block entities' custom names, so they could never match); `random` picks from its own fixed hash, so random tiles land in other places than in OptiFine (same weights); tiles glow with their `_e` overlays, sharing the emissive row's table and limits (block overlays come first) | +| Emissive textures (OptiFine/ETF): `_e.png` (or the pack's `suffix.emissive`) drawn full bright over mobs, their layers and armour, armour stands, the elytra, the dragon, thrown tridents, block entities, placed blocks and connected-texture tiles | `_e.png` beside its texture (also random variants' and blink frames'); the suffix from `optifine/emissive.properties`, `etf/emissive.properties` or `textures/emissive.properties` | partly | `horizon-optifine` `emissive` tests, `mobs::render::emissive_tests`, `block_entity_render` and `native_emissive` tests; at most 96 block overlays (native mods keep 32 more), cutout; none on translucent faces, nor on blocks whose texture is pixel-identical to another's (they share a layer); not over items, armour trims, other projectiles, minecarts or end crystals | | Blinking textures (ETF): `_blink.png` and `_blink2.png`, `blinkLength`/`blinkFrequency` | `textures/entity/`, random variants' folders | works | `horizon-optifine` `blink` tests, `mobs::render` blink test; sleeping villagers keep their eyes shut | | Textures, any resolution (16x to 512x) | `textures/` | works | atlases stitched from the overlay; Faithful 32x, Default HD 128x and FPBR measured in [reuse-pack-overlays.md](reuse-pack-overlays.md) | | Texture animation: frames, frame time, frame size | `*.png.mcmeta` `animation` | works | `test_sprite_animation_parity` | diff --git a/docs/server.md b/docs/server.md index 1133d9e6ba..d3c9387bbe 100644 --- a/docs/server.md +++ b/docs/server.md @@ -475,7 +475,8 @@ runtime or new dependency. Server-sent events can replace the polling later if a `RconThread.create` does: no password or a port outside 1 to 65535 leaves it off with vanilla's warning. The packets are `RconClient`'s: login (3), command (2), the output in packets of 4096 characters, `-1` for a refused login, `Unknown request ` for another type, `Error executing: ()` for a command that failed. - A command goes to the game as `horizon-server cmd` sends it (`console ` on the control endpoint), so the + A command goes to the game as an `rcon ` request on the control endpoint (`horizon-server cmd` sends + `console `, the same console under the `Server` source), so the output is what the console prints, its messages joined as `RconConsoleSource` joins them. Differences, each one named in `rcon.rs`: the packets are read from the stream, the password is compared in constant time, a connection that hasn't logged in within 30 seconds is closed and at most 16 are open. A command runs as diff --git a/docs/shaders.md b/docs/shaders.md index d1db4ff132..7174220c3b 100644 --- a/docs/shaders.md +++ b/docs/shaders.md @@ -97,7 +97,7 @@ by Horizon: a pack changes how things look, not how they are drawn. | `[sky]` | `fn sky(input: SkyInput) -> vec4` | the sky's colour along each view ray, opaque (alpha is ignored), drawn where vanilla draws its sky dome (the Overworld's sky; not the Nether's or the End's); the sun, moon, stars and clouds are drawn over it | | `[material]` | `fn material(input: MaterialInput) -> Surface` | the lit colour of each point of a block's surface, before the alpha test and fog: every terrain section and distant tile, solid, cutout and translucent (not entities, block entities or particles) | | `[weather]` | `fn weather(input: WeatherInput) -> vec4` | the colour of each point of a rain or snow streak, blended over the world with its alpha; Horizon's light and fog aren't applied, so the pack applies its own | -| `[[post]]` | `fn post(input: PostInput) -> vec4` | a full-screen pass over the world's picture (up to 8, in order), under the hand and HUD | +| `[[post]]` | `fn post(input: PostInput) -> vec4` | a full-screen pass over the world's picture (up to 8, in order), under the hand and HUD. Return `input.color.a` as your alpha: HDR output reads it to find light sources | A pack has at least one stage; a stage it leaves out keeps vanilla's look. Each input says what vanilla would draw (`input.color` in the sky and post stages; `albedo * tint * vec4(lightmap, 1.0)` for a material), so a pack can start diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 426b739c17..a57a6c492a 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Exportiere den Server außerhalb der vom Launcher verwalteten Ordner.", "{path} is not a folder to build into.": "{path} ist kein Ordner, in den gebaut wird.", "Could not copy the build: {error}": "Build konnte nicht kopiert werden: {error}", - "Could not move the build to {path}: {error}": "Build konnte nicht nach {path} verschoben werden: {error}" + "Could not move the build to {path}: {error}": "Build konnte nicht nach {path} verschoben werden: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Native Mods können dein Mikrofon für Sprachchat verwenden, wenn du es aktivierst." } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 20eb5a6f24..c8674ee020 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Exporte el servidor fuera de las carpetas administradas por el launcher.", "{path} is not a folder to build into.": "{path} no es una carpeta para compilar.", "Could not copy the build: {error}": "No se pudo copiar la compilación: {error}", - "Could not move the build to {path}: {error}": "No se pudo mover la compilación a {path}: {error}" + "Could not move the build to {path}: {error}": "No se pudo mover la compilación a {path}: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Los mods nativos pueden usar tu micrófono para el chat de voz cuando lo actives." } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 3ef46b41da..83c440482b 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Exportez le serveur en dehors des dossiers gérés par le launcher.", "{path} is not a folder to build into.": "{path} n'est pas un dossier dans lequel compiler.", "Could not copy the build: {error}": "Impossible de copier la compilation : {error}", - "Could not move the build to {path}: {error}": "Impossible de déplacer la compilation vers {path} : {error}" + "Could not move the build to {path}: {error}": "Impossible de déplacer la compilation vers {path} : {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Les mods natifs peuvent utiliser votre micro pour le chat vocal lorsque vous l'activez." } diff --git a/launcher/lang/ja.json b/launcher/lang/ja.json index 3091fb83c4..25bdf5eac9 100644 --- a/launcher/lang/ja.json +++ b/launcher/lang/ja.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "ランチャーが管理するフォルダーの外にサーバーをエクスポートしてください。", "{path} is not a folder to build into.": "{path} はビルド先のフォルダーではありません。", "Could not copy the build: {error}": "ビルドをコピーできませんでした: {error}", - "Could not move the build to {path}: {error}": "ビルドを {path} に移動できませんでした: {error}" + "Could not move the build to {path}: {error}": "ビルドを {path} に移動できませんでした: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "ネイティブモッドは、有効にした場合、音声チャットにマイクを使用できます。" } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 96cd9bcf87..d5dae031bf 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Eksportuj serwer poza foldery zarządzane przez launcher.", "{path} is not a folder to build into.": "{path} nie jest folderem do kompilacji.", "Could not copy the build: {error}": "Nie można skopiować kompilacji: {error}", - "Could not move the build to {path}: {error}": "Nie można przenieść kompilacji do {path}: {error}" + "Could not move the build to {path}: {error}": "Nie można przenieść kompilacji do {path}: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Native mody mogą użyć Twojego mikrofonu do czatu głosowego, gdy to włączysz." } diff --git a/launcher/lang/pt-br.json b/launcher/lang/pt-br.json index 7f5f77e5ea..5460004773 100644 --- a/launcher/lang/pt-br.json +++ b/launcher/lang/pt-br.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Exporte o servidor fora das pastas gerenciadas pelo launcher.", "{path} is not a folder to build into.": "{path} não é uma pasta para criação de build.", "Could not copy the build: {error}": "Não foi possível copiar o build: {error}", - "Could not move the build to {path}: {error}": "Não foi possível mover o build para {path}: {error}" + "Could not move the build to {path}: {error}": "Não foi possível mover o build para {path}: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Mods nativos podem usar seu microfone para bate-papo por voz quando você ativar isso." } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index f56568b050..cb9ed56883 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Экспортируйте сервер вне управляемых лаунчером папок.", "{path} is not a folder to build into.": "{path} не является папкой для сборки.", "Could not copy the build: {error}": "Не удалось скопировать сборку: {error}", - "Could not move the build to {path}: {error}": "Не удалось переместить сборку в {path}: {error}" + "Could not move the build to {path}: {error}": "Не удалось переместить сборку в {path}: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Нативные моды могут использовать ваш микрофон для голосового чата, когда вы это включите." } diff --git a/launcher/lang/tr.json b/launcher/lang/tr.json index ecb25b3d48..e999fb6100 100644 --- a/launcher/lang/tr.json +++ b/launcher/lang/tr.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Sunucuyu launcher tarafından yönetilen klasörlerin dışına aktarın.", "{path} is not a folder to build into.": "{path} derleme yapılacak klasör değil.", "Could not copy the build: {error}": "Build kopyalanamadı: {error}", - "Could not move the build to {path}: {error}": "Build {path} konumuna taşınamadı: {error}" + "Could not move the build to {path}: {error}": "Build {path} konumuna taşınamadı: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Yerel modlar, bunu etkinleştirdiğinizde sesli sohbet için mikrofonunuzu kullanabilir." } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index de3c95cffe..8d44097448 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "Експортуйте сервер позаду папок, которыmi керує лаунчер.", "{path} is not a folder to build into.": "{path} не є папкою для збірки.", "Could not copy the build: {error}": "Не вдалося скопіювати збірку: {error}", - "Could not move the build to {path}: {error}": "Не вдалося перемістити збірку у {path}: {error}" + "Could not move the build to {path}: {error}": "Не вдалося перемістити збірку у {path}: {error}", + "Native mods can use your microphone for voice chat when you enable it.": "Нативні моди можуть використовувати ваш мікрофон для голосового чату, коли ви це увімкнете." } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index b79e6ab6cd..ca18369572 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -789,5 +789,6 @@ "Export the server outside the launcher's managed folders.": "将服务器导出到启动器管理的文件夹之外。", "{path} is not a folder to build into.": "{path} 不是可供构建的文件夹。", "Could not copy the build: {error}": "无法复制构建:{error}", - "Could not move the build to {path}: {error}": "无法将构建移动到 {path}:{error}" + "Could not move the build to {path}: {error}": "无法将构建移动到 {path}:{error}", + "Native mods can use your microphone for voice chat when you enable it.": "原生模组在您启用时可使用您的麦克风进行语音聊天。" } diff --git a/launcher/src/game_app.rs b/launcher/src/game_app.rs index d4fc9972dd..82b46e8f49 100644 --- a/launcher/src/game_app.rs +++ b/launcher/src/game_app.rs @@ -40,10 +40,16 @@ const INFO_PLIST: &str = r#" public.app-category.games NSHighResolutionCapable + NSMicrophoneUsageDescription + __MIC_DESCRIPTION__ "#; +/// Where `ensure` fills in the localized `NSMicrophoneUsageDescription` (native mods can open the +/// microphone for voice chat; without the key macOS would kill the game instead of asking). +const MIC_DESCRIPTION: &str = "__MIC_DESCRIPTION__"; + /// Brings `version`'s Horizon.app up to date and returns the game's program inside it. A program /// replaced in the version folder (a reinstall, a build copied over) is linked again. pub fn ensure(version: &Path) -> io::Result { @@ -51,7 +57,16 @@ pub fn ensure(version: &Path) -> io::Result { let programs = contents.join("MacOS"); fs::create_dir_all(&programs)?; fs::create_dir_all(contents.join("Resources"))?; - write_if_changed(&contents.join("Info.plist"), INFO_PLIST.as_bytes())?; + // Filled here so the prompt follows the launcher's language: the game reads the bundle as + // it starts, and the plist is not a place the game can localize. + let plist = INFO_PLIST.replacen( + MIC_DESCRIPTION, + &crate::shortcut::xml(crate::i18n::tr( + "Native mods can use your microphone for voice chat when you enable it.", + )), + 1, + ); + write_if_changed(&contents.join("Info.plist"), plist.as_bytes())?; write_if_changed(&contents.join("Resources/horizon.icns"), ICON)?; for (i, name) in PROGRAMS.into_iter().enumerate() { let (from, to) = (version.join(name), programs.join(name)); @@ -105,6 +120,20 @@ mod tests { )); let plist = fs::read_to_string(version.join("Horizon.app/Contents/Info.plist")).unwrap(); assert!(plist.contains("Horizon")); + // The mic prompt macOS needs before a native mod may open the microphone: a key with a + // nonempty localized value, not the placeholder the template ships with. + let (_, rest) = plist + .split_once("NSMicrophoneUsageDescription") + .expect("Info.plist has NSMicrophoneUsageDescription"); + let value = rest + .split_once("
") + .expect("the key has a string value") + .0 + .split_once("") + .expect("an opening tag") + .1; + assert!(!value.is_empty()); + assert!(!plist.contains("__MIC_DESCRIPTION__")); assert_eq!( fs::read(version.join("Horizon.app/Contents/Resources/horizon.icns")).unwrap(), ICON @@ -143,4 +172,12 @@ mod tests { assert!(ensure(&version).is_err()); fs::remove_dir_all(&version).unwrap(); } + + #[test] + fn xml_escapes_the_characters_plist_strings_must_not_carry() { + assert_eq!( + crate::shortcut::xml(r#"a & b < c > "d""#), + r#"a & b < c > "d""# + ); + } } diff --git a/launcher/src/launch_page.rs b/launcher/src/launch_page.rs index bfa3b06929..56784dd135 100644 --- a/launcher/src/launch_page.rs +++ b/launcher/src/launch_page.rs @@ -11,12 +11,23 @@ use horizon_launcher::launch; pub(super) struct LaunchPanel { /// The instance it is for: picking another closes it. instance: String, - /// What Save did, or why not. - saved: Option>, + /// What Save did: Ok with the summary of what runs (None when nothing runs), or Err with reason. + saved: Option, String>>, /// What this computer has for the Linux switches, looked at when the panel opens. found: launch::Found, } +/// What saving the memory slider did, kept to show under the slider in the active language. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum MemorySaved { + /// Saved to the instance: the limit in MB, or None for no limit. + Ok(Option), + /// What was typed was not a valid memory limit. + Invalid, + /// Saving failed on disk. + Error(String), +} + /// The memory row on Home. What it shows comes from the selected instance, except during a drag. #[derive(Default)] pub(super) struct MemorySlider { @@ -26,7 +37,7 @@ pub(super) struct MemorySlider { /// The value being dragged to; inside, none is no limit. dragging: Option>, /// What the last change did, or why not. - saved: Option>, + saved: Option, /// The instance whose value is being typed: saved to it when the box loses the keyboard, even /// if that's because another instance was picked. typing: Option, @@ -94,12 +105,8 @@ impl Launcher { }) }); let saved = saved.map(|i| { - let said = match i.launch.summary() { - Some(what) => trf("Saved: the next Play {what}.", &[("what", &what)]), - None => tr("Saved: nothing runs around the game.").to_string(), - }; self.reload_instances(); - said + i.launch.summary() }); if let Some(p) = &mut self.launch { p.saved = Some(saved); @@ -119,10 +126,7 @@ impl Launcher { }; *on = !*on; }); - panel.saved = Some(saved.map(|i| match i.launch.summary() { - Some(what) => trf("Saved: the next Play {what}.", &[("what", &what)]), - None => tr("Saved: nothing runs around the game.").to_string(), - })); + panel.saved = Some(saved.map(|i| i.launch.summary())); self.reload_instances(); } @@ -167,13 +171,10 @@ impl Launcher { Some(java) => java.memory_mb = memory, None => m.launch.set_memory_limit(memory), }); - self.memory_slider.saved = Some(saved.map(|_| match memory { - Some(mb) => trf( - "Saved: the next Play uses at most {mb} MB.", - &[("mb", &mb.to_string())], - ), - None => tr("Saved: no memory limit on the next Play.").to_string(), - })); + self.memory_slider.saved = Some(match saved { + Ok(_) => MemorySaved::Ok(memory), + Err(e) => MemorySaved::Error(e), + }); self.reload_instances(); } @@ -189,10 +190,7 @@ impl Launcher { fn save_typed_memory(&mut self, id: &str) { let typed = self.servers.value(Field::MemoryLimit).to_owned(); let Ok(memory) = launch::parse_memory(&typed) else { - self.memory_slider.saved = Some(Err(tr( - "Type a number of MB like 4096, or 4gb, or leave it empty for no limit.", - ) - .to_string())); + self.memory_slider.saved = Some(MemorySaved::Invalid); return; }; let same = instances::list() @@ -310,9 +308,18 @@ impl Launcher { MouseButton::Left, cx.listener(|this, _: &MouseUpEvent, _, cx| this.finish_memory(cx)), ); - let result = self.memory_slider.saved.clone().map(|s| match s { - Ok(said) => text(theme::CAPTION).text_color(t.muted).child(said), - Err(e) => text(theme::CAPTION).text_color(t.danger).child(e), + let result = self.memory_slider.saved.as_ref().map(|s| match s { + MemorySaved::Ok(Some(mb)) => text(theme::CAPTION).text_color(t.muted).child(trf( + "Saved: the next Play uses at most {mb} MB.", + &[("mb", &mb.to_string())], + )), + MemorySaved::Ok(None) => text(theme::CAPTION) + .text_color(t.muted) + .child(tr("Saved: no memory limit on the next Play.")), + MemorySaved::Invalid => text(theme::CAPTION).text_color(t.danger).child(tr( + "Type a number of MB like 4096, or 4gb, or leave it empty for no limit.", + )), + MemorySaved::Error(e) => text(theme::CAPTION).text_color(t.danger).child(e.clone()), }); Some( blocks::panel(&t) @@ -475,7 +482,12 @@ impl Launcher { }, )); let result = saved.map(|s| match s { - Ok(said) => text(theme::CAPTION).text_color(t.muted).child(said), + Ok(Some(what)) => text(theme::CAPTION) + .text_color(t.muted) + .child(trf("Saved: the next Play {what}.", &[("what", &what)])), + Ok(None) => text(theme::CAPTION) + .text_color(t.muted) + .child(tr("Saved: nothing runs around the game.")), Err(e) => text(theme::CAPTION).text_color(t.danger).child(e), }); Some( diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index d764b600c8..42a7cbb157 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -355,6 +355,40 @@ pub fn log_path() -> PathBuf { root().join("build.log") } +/// Makes the build log fresh for a new attempt: its folder made, the last build's log +/// emptied. The error says why it couldn't, so the Build log page can show it instead of +/// the old file's contents. +pub fn clear_log() -> Result<(), String> { + fs::create_dir_all(root()).map_err(|e| e.to_string())?; + fs::write(log_path(), "").map_err(|e| format!("{}: {e}", log_path().display())) +} + +/// Appends `message` (with a newline) to the build log, so a failure that happens before any +/// command runs is still recorded. +pub fn append_log(message: &str) -> Result<(), String> { + let mut log = fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path()) + .map_err(|e| format!("{}: {e}", log_path().display()))?; + writeln!(log, "{message}").map_err(|e| e.to_string()) +} + +/// Records the whole attempt, including selection or export after compilation. +fn logged(build: impl FnOnce() -> Result) -> Result { + clear_log()?; + let result = build(); + match &result { + Ok(_) => { + let _ = append_log("Build completed."); + } + Err(error) => { + let _ = append_log(&format!("The build failed: {error}")); + } + } + result +} + const MANIFEST: &str = "horizon-mixin.toml"; /// A registry download's manifest (`.hnmod`, `horizon_mods::index::native`), beside `mixins//`. const NATIVE_MANIFEST: &str = horizon_mods::index::native::MANIFEST; @@ -1437,12 +1471,13 @@ struct Built { /// The pipeline both targets build with ([`build`], [`build_server`]): `folder`'s mods on the /// Horizon revision they pin (else `base`'s, the installed version whose game files the build /// reuses: a native build's own revision when it is one, [`base_for`]), compiled as `target` -/// (the game, or the headless engine) and installed as its own version, `native--` -/// (the key hashes the revision, the mods' files, the tool's commit, the profile and the -/// target). A build already made is used again. Mods pinning different revisions build on the -/// newest one, with the newest horizon-mixins they name, only when the player agreed to try -/// (`anyway`): else the error starts with [`MISMATCH`] and says which. Nothing of the player's -/// outside the launcher's data is touched: no instance is edited and no version selected here. +/// (the game, or the headless engine) and installed as its own version, +/// `native--` (the key hashes the revision, the mods' files, the tool's commit, +/// the profile and the target). A build already made is used again. Mods pinning different +/// revisions build on the newest one, with the newest horizon-mixins they name, only when the +/// player agreed to try (`anyway`): else the error starts with [`MISMATCH`] and says which. +/// Nothing of the player's outside the launcher's data is touched: no instance is edited and +/// no version selected here. fn pipeline( base: &str, folder: &Path, @@ -1454,7 +1489,6 @@ fn pipeline( let started = std::time::Instant::now(); backend::need_prepared(base)?; fs::create_dir_all(root()).map_err(|e| e.to_string())?; - let _ = fs::write(log_path(), ""); // The mods are copied first: the build and its key use the copy, whatever happens to the // folder meanwhile. let incoming = root().join("mods.incoming"); @@ -1740,19 +1774,21 @@ pub fn build( profile: Profile, progress: impl Fn(Step), ) -> Result { - let built = pipeline(base, folder, anyway, profile, Target::Client, &progress)?; - instances::edit(&instance.id, |meta| { - meta.version = Some(built.version.clone()) - })?; - prune(&built.version); - if built.plan != Plan::UpToDate { - progress(Step::Done { - secs: built.secs, - profile: built.profile, - plan: built.plan, - }); - } - Ok(built.version) + logged(|| { + let built = pipeline(base, folder, anyway, profile, Target::Client, &progress)?; + instances::edit(&instance.id, |meta| { + meta.version = Some(built.version.clone()) + })?; + prune(&built.version); + if built.plan != Plan::UpToDate { + progress(Step::Done { + secs: built.secs, + profile: built.profile, + plan: built.plan, + }); + } + Ok(built.version) + }) } /// Builds `folder`'s mods as the headless engine a dedicated server runs (`horizon-engine`, @@ -1773,58 +1809,60 @@ pub fn build_server( profile: Profile, progress: impl Fn(Step), ) -> Result { - if output.symlink_metadata().is_ok() { - return Err(trf( - "{path} already exists. Move it away, or build into another folder.", - &[("path", &output.display().to_string())], - )); - } - let parent = output - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or(Path::new(".")); - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - let output = parent.canonicalize().map_err(|e| e.to_string())?.join( - output - .file_name() - .ok_or_else(|| tr("Choose a new folder for the server export."))?, - ); - for managed in [backend::version_dir(""), root()] { - if managed - .canonicalize() - .is_ok_and(|dir| output.starts_with(dir)) - { - return Err(tr("Export the server outside the launcher's managed folders.").into()); + logged(|| { + if output.symlink_metadata().is_ok() { + return Err(trf( + "{path} already exists. Move it away, or build into another folder.", + &[("path", &output.display().to_string())], + )); } - } - let built = pipeline(base, folder, anyway, profile, Target::Server, &progress)?; - export(&Target::Server.directory(&built.version), &output)?; - // Exports are independent; keep only the newest cached server builds. - let cached = root().join("server-builds"); - let builds = fs::read_dir(&cached) - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().join(".installed").is_file()) - .map(|e| { - let at = fs::metadata(e.path().join(".installed")) - .and_then(|m| m.modified()) - .unwrap_or(std::time::UNIX_EPOCH); - (e.file_name().to_string_lossy().into_owned(), at) - }) - .collect(); - for old in stale(builds, &built.version, &[]) { - let _ = fs::remove_dir_all(cached.join(old)); - } - if built.plan != Plan::UpToDate { - progress(Step::Done { - secs: built.secs, - profile: built.profile, - plan: built.plan, - }); - } - let engine = output.join(Target::Server.binary()); - Ok(std::path::absolute(&engine).unwrap_or(engine)) + let parent = output + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let output = parent.canonicalize().map_err(|e| e.to_string())?.join( + output + .file_name() + .ok_or_else(|| tr("Choose a new folder for the server export."))?, + ); + for managed in [backend::version_dir(""), root()] { + if managed + .canonicalize() + .is_ok_and(|dir| output.starts_with(dir)) + { + return Err(tr("Export the server outside the launcher's managed folders.").into()); + } + } + let built = pipeline(base, folder, anyway, profile, Target::Server, &progress)?; + export(&Target::Server.directory(&built.version), &output)?; + // Exports are independent; keep only the newest cached server builds. + let cached = root().join("server-builds"); + let builds = fs::read_dir(&cached) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.path().join(".installed").is_file()) + .map(|e| { + let at = fs::metadata(e.path().join(".installed")) + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + (e.file_name().to_string_lossy().into_owned(), at) + }) + .collect(); + for old in stale(builds, &built.version, &[]) { + let _ = fs::remove_dir_all(cached.join(old)); + } + if built.plan != Plan::UpToDate { + progress(Step::Done { + secs: built.secs, + profile: built.profile, + plan: built.plan, + }); + } + let engine = output.join(Target::Server.binary()); + Ok(std::path::absolute(&engine).unwrap_or(engine)) + }) } /// Copies the built version folder `from` into `output` (outside the managed versions) as one @@ -2006,6 +2044,8 @@ fn stale( #[cfg(test)] mod tests { + static LOG_TEST: std::sync::Mutex<()> = std::sync::Mutex::new(()); + use super::*; #[test] @@ -2818,6 +2858,7 @@ mod tests { /// and no instance is edited or version selected. #[test] fn a_server_build_refuses_an_existing_output_before_building() { + let _guard = LOG_TEST.lock().unwrap(); let output = dir("server-output"); let folder = dir("server-mods"); write_bundle(&folder, "hello", "fn hook() {}"); @@ -2833,6 +2874,7 @@ mod tests { ) .unwrap_err(); assert!(err.contains("already exists"), "{err}"); + assert!(fs::read_to_string(log_path()).unwrap().contains(&err)); let managed = root().join("forbidden-export"); let err = build_server( "v0.0.0-not-installed", @@ -2871,6 +2913,57 @@ mod tests { ); } + /// The build log ([`clear_log`], [`append_log`]): fresh for every attempt, and an early + /// failure (before any command ran) recorded in it, in one test because they share the + /// one log file. + #[test] + fn the_log_is_fresh_per_attempt_and_keeps_early_failures() { + let _guard = LOG_TEST.lock().unwrap(); + clear_log().unwrap(); + append_log("the last build's output").unwrap(); + clear_log().unwrap(); + assert_eq!(fs::read(log_path()).unwrap(), b""); + append_log("a line").unwrap(); + append_log("another").unwrap(); + assert_eq!(fs::read_to_string(log_path()).unwrap(), "a line\nanother\n"); + // A build that fails before anything runs empties the old log and says why. + let folder = dir("log-early"); + write_bundle(&folder, "hello", "fn hook() {}"); + let out = dir("log-early-out"); + let err = build_server( + "v0.0.0-not-installed", + &folder, + &out.join("server"), + true, + Profile::Fast, + |_| {}, + ) + .unwrap_err(); + assert!(err.contains("is not prepared yet"), "{err}"); + let log = fs::read_to_string(log_path()).unwrap(); + assert!(log.contains("is not prepared yet"), "{log}"); + assert!(!log.contains("the last build's output"), "{log}"); + // A failure after compiler output keeps those diagnostics and the final reason. + let failure: Result<(), String> = logged(|| { + append_log("error[E0425]: cannot find value `missing` in this scope")?; + Err("Could not export the engine".into()) + }); + assert!(failure.is_err()); + let log = fs::read_to_string(log_path()).unwrap(); + assert!(log.starts_with("error[E0425]"), "{log}"); + assert!( + log.ends_with("The build failed: Could not export the engine\n"), + "{log}" + ); + assert!(!log.contains("Build completed."), "{log}"); + let success: Result<(), String> = logged(|| Ok(())); + success.unwrap(); + assert_eq!( + fs::read_to_string(log_path()).unwrap(), + "Build completed.\n" + ); + } + fn record(horizon: &str, mods: &[(&str, &str)]) -> Record { Record { horizon: horizon.into(), diff --git a/launcher/src/shortcut.rs b/launcher/src/shortcut.rs index b4e97abd43..11f55810dd 100644 --- a/launcher/src/shortcut.rs +++ b/launcher/src/shortcut.rs @@ -112,7 +112,7 @@ fn sh_quote(text: &str) -> String { format!("'{}'", text.replace('\'', r"'\''")) } -fn xml(text: &str) -> String { +pub(crate) fn xml(text: &str) -> String { text.replace('&', "&") .replace('<', "<") .replace('>', ">") diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java index 5fb2443d8d..51026150a6 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java @@ -461,7 +461,7 @@ public final class VanillaSceneMotionOracle { case "frozen"->p.frozen=op.get("value").getAsInt(); case "portal_tick"->p.portalTick(op.get("creative").getAsBoolean()); case "weaving"->p.weaving=op.get("value").getAsBoolean(); - case "flying"->{var a=p.getAbilities();a.flying=op.get("value").getAsBoolean();a.mayfly=op.has("may_fly")?op.get("may_fly").getAsBoolean():a.flying;} + case "flying"->{var a=p.getAbilities();a.flying=op.get("value").getAsBoolean();a.mayfly=op.has("may_fly")?op.get("may_fly").getAsBoolean():a.flying;if(op.has("speed"))a.setFlyingSpeed(single(op.get("speed")));} case "trigger"->trigger.setInt(p,op.get("value").getAsInt()); case "delay"->delay.setInt(p,op.get("value").getAsInt()); case "ack_attributes"->{p.attrs.getAttributesToUpdate().clear();p.attrs.getAttributesToSync().clear();} diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaVillagerFarmingOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaVillagerFarmingOracle.java new file mode 100644 index 0000000000..6dc03ad2e8 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaVillagerFarmingOracle.java @@ -0,0 +1,31 @@ +package dev.horizonlab.parity; + +import java.nio.file.Files; +import java.nio.file.Path; +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.CropBlock; +import net.minecraft.world.level.block.FarmlandBlock; + +/** HarvestFarmland's class and maturity checks, including non-CropBlock age properties. */ +public final class VanillaVillagerFarmingOracle { + public static void main(String[] args) throws Exception { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + try (var out = Files.newBufferedWriter(Path.of(args[0]))) { + for (String name : new String[] {"wheat", "carrots", "potatoes", "beetroots", + "torchflower_crop", "pitcher_crop", "cocoa", "farmland", "stone"}) { + var block = BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:" + name)); + for (var state : block.getStateDefinition().getPossibleStates()) { + boolean crop = block instanceof CropBlock; + boolean ripe = block instanceof CropBlock c && c.isMaxAge(state); + out.write(Block.getId(state) + " " + crop + " " + ripe + " " + (block instanceof FarmlandBlock)); + out.newLine(); + } + } + } + } +} diff --git a/tests/test_pacing_bench.py b/tests/test_pacing_bench.py new file mode 100644 index 0000000000..a5b433d9eb --- /dev/null +++ b/tests/test_pacing_bench.py @@ -0,0 +1,28 @@ +import unittest + +from tools.pacing_bench import stats + + +class StatsTests(unittest.TestCase): + def test_lows_percentiles_and_hitch_counts(self): + # 998 frames of 5 ms, one of 40 ms and one of 100 ms. + frames = [5.0] * 998 + [40.0, 100.0] + s = stats(frames) + self.assertEqual(s["frames"], 1000) + self.assertEqual(s["p50_ms"], 5.0) + self.assertEqual(s["p99_ms"], 5.0) + self.assertEqual(s["p999_ms"], 40.0) + self.assertEqual(s["max_ms"], 100.0) + # The slowest 1% (10 frames): 8 x 5 + 40 + 100 = 180 ms, 18 ms each. + self.assertAlmostEqual(s["low_1pct_fps"], round(1000 / 18.0, 1)) + # The slowest 0.1% is the single 100 ms frame. + self.assertEqual(s["low_01pct_fps"], 10.0) + self.assertEqual(s["over_33ms"], 2) + self.assertEqual(s["over_2x_median"], 2) + + def test_no_frames(self): + self.assertEqual(stats([]), {"frames": 0}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/attack_swap_e2e.py b/tools/attack_swap_e2e.py new file mode 100644 index 0000000000..22c8e1f402 --- /dev/null +++ b/tools/attack_swap_e2e.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Attribute swapping from a spear to a mace against an actual vanilla 26.3 server. + +`Minecraft.tick` picks the crosshair target (`pick`, with the held item's +`attack_range`: a spear reaches 4.5 blocks) before `handleKeybinds` takes a +number key and then the attack click. A number key and a click in the same +tick therefore swing the new item at the target the spear picked, and the +server's `handleAttack` checks that item's range with a 3 block buffer. The +server is slowed to one tick a second so both inputs land in one client tick. + +Cases: the swap and the click in one tick hit a pig 3.75 blocks away; the +swap a tick before the click misses it (the mace's own pick reaches 3); a +mace click at 2.25 blocks hits (the setup lands attacks at all). A pig +hovering at eye height inside the spear's 2 block minimum (its box, with the +0.125 margin, ends 1.475 along the ray): with a wall 3.5 away the spear's pick +ends on the wall (`getHitEntitiesAlong`'s block result), so the mace misses +the pig; with nothing behind it the spear's pick misses and the plain pick +finds the pig. A stick given an `attack_range` does not attack that pig, +inside its minimum (`startAttack`). +Requires prepared resources and built horizon-client/horizon-ctl binaries. +""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + +import lab +import test_rust_parity as base +from integration_env import game_environment +from test_dedicated_server_parity import Server, game_time +from test_locator_bar_parity import server_dir + +ROOT = Path(__file__).resolve().parents[1] +EXE = '.exe' if os.name == 'nt' else '' +NAME = 'SwapTester' +# Tagged: a generated world has pigs of its own. +PIG = '@e[type=minecraft:pig,tag=swap,limit=1]' +# Player at 0.5 80 0.5 looking east; the pig's near face is `distance` - 0.45 +# from the eyes' column. +OPEN = ['fill 2 80 -3 8 84 3 minecraft:air'] +WALL = [*OPEN, 'fill 4 80 -3 4 84 3 minecraft:stone'] +SWAP = ['key 2 tap', 'mouse left click'] +APART = ['key 2 tap', 'wait 1', 'mouse left click'] +EYE = 81.62 +CASES = [ + # (label, terrain, pig distance, pig feet y, look height, inputs, expected hit) + ('same-tick', OPEN, 4.2, 80, 80.45, SWAP, True), + ('tick-apart', OPEN, 4.2, 80, 80.45, APART, False), + ('mace-near', OPEN, 2.7, 80, 80.45, APART, True), + ('min-reach-wall', WALL, 0.9, 80.8, EYE, SWAP, False), + ('min-reach-open', OPEN, 0.9, 80.8, EYE, SWAP, True), + ('ranged-stick', OPEN, 0.9, 80.8, EYE, ['key 3 tap', 'wait 1', 'mouse left click'], False), +] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--game', type=Path, default=ROOT / f'target/fast/horizon-client{EXE}') + parser.add_argument('--ctl', type=Path, default=ROOT / f'target/fast/horizon-ctl{EXE}') + parser.add_argument('--resources', type=Path, default=ROOT / '.local/horizon-client/26.3') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + output = (args.output or Path(tempfile.mkdtemp(prefix='attack-swap-'))).resolve() + output.mkdir(parents=True, exist_ok=True) + _, jars, libraries = base.verify_artifacts() + dependencies = os.pathsep.join(map(str, [jars['server'], *libraries])) + command = ['java', '-Xmx2G', '-Duser.timezone=UTC', '-cp', dependencies, + 'net.minecraft.server.Main', 'nogui'] + port = server_dir(output / 'server') + server = Server(command, output / 'server', 'vanilla') + env = game_environment(os.environ, output) + results = [] + # Keep AF_UNIX paths short even in nested worktrees. + with tempfile.TemporaryDirectory(prefix='aswap-', dir=None if os.name == 'nt' else '/tmp') as socket_home: + socket = Path(socket_home) / 'ctl.sock' + + def ctl(*commands, timeout=500): + r = subprocess.run([str(args.ctl.resolve()), '--socket', str(socket), *map(str, commands)], + cwd=output, env=env, capture_output=True, text=True, timeout=timeout) + data = json.loads(r.stdout) + if r.returncode or data.get('error'): + raise RuntimeError(f'{commands}: {data}; {r.stderr}') + return data + + def pig_health(): + start = len(server.lines) + server.console(f'data get entity {PIG} Health') + game_time(server) + values = [float(m.group(1)) for line in server.lines[start:] + if (m := re.search(r'entity data: (-?\d+(?:\.\d+)?)f', line))] + assert len(values) == 1, server.lines[start:] + return values[0] + + try: + server.wait_for(r'Done \(') + ctl('launch', '--game', args.game.resolve(), '--offscreen', '--no-pause', '--name', NAME, + '--resources', args.resources.resolve(), '--render-distance', 2, '--width', 960, '--height', 720) + ctl('connect', f'127.0.0.1:{port}', NAME) + ctl('wait-ready', 400) + for line in (f'op {NAME}', 'gamerule spawn_mobs false', f'gamemode survival {NAME}', + 'kill @e[type=!minecraft:player]', + 'fill -3 79 -3 8 79 3 minecraft:stone', 'fill -3 80 -3 8 84 3 minecraft:air', + f'tp {NAME} 0.5 80 0.5 -90 0', + f'item replace entity {NAME} hotbar.0 with minecraft:iron_spear', + f'item replace entity {NAME} hotbar.1 with minecraft:mace', + f'item replace entity {NAME} hotbar.2 with minecraft:stick' + '[minecraft:attack_range={min_reach:2.0f,max_reach:4.5f}]'): + server.console(line) + game_time(server) + ctl('wait', 20) + for label, terrain, distance, feet, look, inputs, expected in CASES: + server.console(f'kill {PIG}') + for line in terrain: + server.console(line) + server.console(f'summon minecraft:pig {0.5 + distance} {feet} 0.5 ' + '{NoAI:1b,Silent:1b,NoGravity:1b,Tags:["swap"]}') + server.console(f'tp {NAME} 0.5 80 0.5 -90 0') + game_time(server) + ctl('key', '1', 'tap') + # A full attack charge with the spear in hand. + ctl('wait', 40) + ctl('look-at', 0.5 + distance, look, 0.5) + before = pig_health() + server.console('tick rate 1') + game_time(server) + ctl('wait', 2) + ctl('wait', 1) + for action in inputs: + ctl(*action.split()) + server.console('tick rate 20') + game_time(server) + ctl('wait', 10) + after = pig_health() + hit = after < before + result = dict(label=label, distance=distance, health_before=before, health_after=after, + hit=hit, expected=expected, pass_=hit == expected) + results.append(result) + (output / 'results.json').write_text(json.dumps(results, indent=2)) + print(json.dumps(result), flush=True) + finally: + try: + ctl('quit') + except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError): + pass + server.stop() + passed = sum(r['pass_'] for r in results) + print(f'{passed}/{len(results)} attack swaps match vanilla: {output}') + assert len(results) == len(CASES) and passed == len(results), results + + +if __name__ == '__main__': + main() diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 9be73ffd70..80012db3e5 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -1002,6 +1002,19 @@ def progression(): cmd('/advancement revoke @s everything'), cmd('/advancement revoke @s everything'), cmd('/advancement grant @s only minecraft:nonexistent'), + # The advancement subtree a `from` grants, one criterion of an + # advancement that needs hundreds (`adventuring_time` wants every + # biome), and a `through` that keeps what is below the named one. + cmd('/advancement revoke @s everything'), + cmd('/advancement grant @s from minecraft:story/mine_stone'), + cmd('/advancement grant @s only minecraft:adventure/adventuring_time minecraft:plains'), + cmd('/advancement grant @s only minecraft:adventure/adventuring_time'), + cmd('/advancement revoke @s only minecraft:adventure/adventuring_time minecraft:plains'), + cmd('/advancement grant @s through minecraft:story/upgrade_tools'), + cmd('/advancement grant @s until minecraft:story/mine_stone'), + cmd('/advancement revoke @s everything'), + cmd('/advancement grant @e[type=!minecraft:player] everything'), + cmd('/experience query @s levels'), cmd('/recipe give @s minecraft:oak_planks'), cmd('/recipe give @s minecraft:oak_planks'), cmd('/recipe take @s minecraft:oak_planks'), diff --git a/tools/generate_packet_id_adapter.py b/tools/generate_packet_id_adapter.py new file mode 100644 index 0000000000..dd756fc5bb --- /dev/null +++ b/tools/generate_packet_id_adapter.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Generate crates/horizon-protocol/src/adapter/v.rs: how an older release numbers the packets of each +connection state against the pinned one, from both versions' data generator packets reports. + + generate_packet_id_adapter.py --old ~/code/horizon-parity/versions/26.2 + +`--old` (and `--new`, the pinned version in .local by default) is a directory `tools/version_port.py prepare +VERSION --download` made: `jars/client.jar` and `datagen/reports/packets.json`. +Packets are matched by name, as both reports list them; a packet only one version has maps to `None`. +""" + +import argparse +import json +from pathlib import Path + +try: + from . import lab +except ImportError: + import lab + +ROOT = Path(__file__).resolve().parents[1] +STATES = ["handshake", "status", "login", "configuration", "play"] +PER_LINE = 12 + + +def ids(report, state, direction): + """{name: id} of one state and direction, checked to number its packets 0..n.""" + packets = {name: entry["protocol_id"] for name, entry in report.get(state, {}).get(direction, {}).items()} + assert sorted(packets.values()) == list(range(len(packets))), (state, direction) + return packets + + +def table(source, target): + """For each id of `source` (in order), the id `target` gives the same packet, or None.""" + by_id = sorted(source.items(), key=lambda kv: kv[1]) + return [target.get(name) for name, _ in by_id] + + +def rust_table(name, values): + cells = ["None" if v is None else f"Some({v})" for v in values] + lines = [f"const {name}: &[Option] = &["] + for i in range(0, len(cells), PER_LINE): + lines.append(" " + ", ".join(cells[i:i + PER_LINE]) + ",") + lines.append("];") + return lines + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--old", type=Path, required=True, help="the older version's prepared directory") + parser.add_argument("--new", type=Path, help="the pinned version's prepared directory (default: .local)") + parser.add_argument("--out", type=Path) + args = parser.parse_args() + old = json.loads((args.old / "datagen/reports/packets.json").read_text()) + old_info = lab.version_info(args.old / "jars/client.jar") + if args.new: + new = json.loads((args.new / "datagen/reports/packets.json").read_text()) + info = lab.version_info(args.new / "jars/client.jar") + else: + new = json.loads((ROOT / ".local/datagen/out/reports/packets.json").read_text()) + info = lab.version_info() + old_version, old_protocol = old_info["id"], old_info["protocol_version"] + module = "v" + old_version.replace(".", "_") + out = args.out or ROOT / f"crates/horizon-protocol/src/adapter/{module}.rs" + lines = [ + f"//! Packet ids of protocol {old_protocol} ({old_version}) against the pinned" + f" {info['protocol_version']} ({info['name']}).", + "//! Generated by tools/generate_packet_id_adapter.py from both versions' packets reports;", + "//! do not edit by hand.", + "use crate::State;", + "", + f"pub const PROTOCOL: i32 = {old_protocol};", + f'pub const NAME: &str = "{old_version}";', + "", + ] + for state in STATES: + for direction in ["clientbound", "serverbound"]: + o, n = ids(old, state, direction), ids(new, state, direction) + # Clientbound: indexed by the old server's id. Serverbound: indexed by Horizon's id. + values = table(o, n) if direction == "clientbound" else table(n, o) + assert all(v is None or v < 256 for v in values) + lines.append("#[rustfmt::skip]") + lines.extend(rust_table(f"{state.upper()}_{direction.upper()}", values)) + for direction, doc in [ + ("clientbound", f"For each id a {old_version} server sends in `state`, the pinned version's id" + " of that packet."), + ("serverbound", f"For each pinned-version id Horizon sends in `state`, the id a {old_version}" + " server knows it by."), + ]: + lines.append(f"/// {doc}") + lines.append(f"pub fn {direction}(state: State) -> &'static [Option] {{") + lines.append(" match state {") + for state in STATES: + lines.append(f" State::{state.capitalize()} => {state.upper()}_{direction.upper()},") + lines.append(" }") + lines.append("}") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text("\n".join(lines) + "\n") + + +if __name__ == "__main__": + main() diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 172bff6789..d64b8f059b 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -10989,6 +10989,22 @@ CHECKS += ['pet_death'] CHECK_FNS['pet_death'] = check_pet_death +def check_data_uuid(host, guest, out): + """EntityDataAccessor: `/data get entity @s UUID` prints the player who runs it, their own + offline UUID, for the guest and for the host (a server player too), not the world owner's.""" + seen = {} + for game, name in ((guest, GUEST_NAME), (host, host.player)): + reply = server_reply(game, 'data get entity @s UUID', '[I;') + seen[name] = ','.join(re.findall(r'-?\d+', reply.partition('[I;')[2].partition(']')[0])) + for name, ints in seen.items(): + assert ints == offline_uuid_ints(name), f"{name}'s UUID is not their own: {seen}" + return seen + + +CHECKS += ['data_uuid'] +CHECK_FNS['data_uuid'] = check_data_uuid + + def check_arrow_pickup(host, guest, out): """ClientPacketListener.handleTakeItemEntity (#4486): an arrow a player picks up (AbstractArrow.playerTouch, once in the ground) flies into them on every client, the @@ -11638,6 +11654,54 @@ def check_blast_bystanders(host, guest, out): CHECKS += ['blast-bystanders'] CHECK_FNS['blast-bystanders'] = check_blast_bystanders +def check_wind_remaining(host, guest, out): + """A wind burst pushes a firework rocket and a thrown trident (ServerExplosion.hurtEntities -> + Entity.pushFromExplosion; neither overrides ignoreExplosion). Real 26.3 server, wind charge into the floor, + tick freeze, tick step 1 after the burst: the rocket's Motion x -0.3174, -0.3650, -0.4197 and y 0.5971, 0.6371, + 0.6771 on the next three ticks (it starts at (0, 0.08, 0), 0.8 west of the burst, 1.0 above the floor); a + trident 0.8 east gets (+0.446, +0.490) on the first tick when NoGravity holds. A summoned trident here + ignores NoGravity and falls first, so only its direction is asserted.""" + hx, hy, hz = (int(v // 1) for v in pos_of(host)) + x, y, z = hx + 6, hy, hz + host('command', command='gamemode spectator') + + def read(): + found = {} + for p in host.raw('simulation')['projectiles']: + if p['kind'] in ('minecraft:trident', 'minecraft:firework_rocket') and abs(p['position'][0] - x) < 4: + found[p['kind'].split(':')[1]] = p['velocity'] + return found + detail = {} + try: + host('command', command='tick unfreeze') + for c in (f'fill {x - 8} {y - 1} {z - 8} {x + 8} {y - 1} {z + 8} minecraft:bedrock', + f'fill {x - 8} {y} {z - 8} {x + 8} {y + 5} {z + 8} minecraft:air', 'kill @e[type=!player]', 'tick freeze', + f'summon minecraft:trident {x + 1.3} {y + 1.0} {z + 0.5} {{NoGravity:1b}}', + f'summon minecraft:firework_rocket {x - 0.3} {y + 1.0} {z + 0.5} {{LifeTime:200}}', 'tick step 2'): + host('command', command=c) + host.wait(8) + detail['before'] = read() + host('command', command=f'summon minecraft:wind_charge {x + 0.5} {y + 0.6} {z + 0.5} {{Motion:[0.0,-1.0,0.0]}}') + detail['ticks'] = [] + for _ in range(3): + host('command', command='tick step 1') + host.wait(4) + detail['ticks'].append(read()) + finally: + host('command', command='tick unfreeze') + host('command', command='kill @e[type=!player]') + host('command', command='gamemode creative') + first = detail['ticks'][0] + assert 'firework_rocket' in first and 'trident' in first, f'no rocket listed: {detail}' + rocket = first['firework_rocket'] + assert abs(rocket[0] + 0.3174) < 0.01 and abs(rocket[1] - 0.5971) < 0.01, f'the rocket was not pushed like vanilla: {detail}' + trident = first['trident'] + assert trident[0] > 0.2 and trident[1] > 0.1, f'the trident was not pushed away: {detail}' + return detail + + +CHECKS += ['wind-remaining'] +CHECK_FNS['wind-remaining'] = check_wind_remaining def check_selector_tags(host, guest, out): @@ -11747,5 +11811,55 @@ CHECKS += ['mining-drop-latency'] CHECK_FNS['mining-drop-latency'] = check_mining_drop_latency +def check_scale_body(host, guest, out): + """#3684: a player's `minecraft:scale` is the same entity's attribute through a change of level and + a death. ServerPlayer.teleport keeps the player; ServerPlayer.restoreFrom copies the base values to + the body after a death (assignBaseValues), then sets its health to the max they give. The eye the + client sees stays at 0.162 (1.62 * 0.1), not back at 1.62, and a max health base of 40 respawns the + player with 40.""" + seen = {} + + def scale_of(player): + # The eye height the player's client has: the server's scale reaches it as an attribute update. + state = player('state')['player'] + return round(state['eye'][1] - state['position'][1], 3) + + for label, player in (('host', host), ('guest', guest)): + s = seen[label] = {} + player('command', command='gamemode creative') + player('command', command='attribute @s minecraft:scale base set 0.1') + player('command', command='attribute @s minecraft:max_health base set 40') + player.wait(20) + s['set'] = scale_of(player) + x, z = (int(v // 1) for v in pos_of(player)[::2]) + into_level(player, NETHER, x, 100, z) + s['nether'] = scale_of(player) + player('command', command=f'execute in {OVERWORLD} run tp @s {x + 0.5} 200 {z + 0.5}') + wait_dimension(player, OVERWORLD, 400) + player.wait(40) + s['overworld'] = scale_of(player) + player('command', command='kill @s') + player.wait(30) + player.raw('respawn') + player.wait(60) + back_to_game(player) + s['respawned'] = scale_of(player) + s['health'] = player('state')['player']['health'] + player('command', command='attribute @s minecraft:scale base set 1') + player('command', command='attribute @s minecraft:max_health base set 20') + player.wait(20) + s['reset'] = scale_of(player) + for label, s in seen.items(): + for step in ('set', 'nether', 'overworld', 'respawned'): + assert s[step] == 0.162, f'{label} after {step}: {s}' + assert s['reset'] == 1.62, f'{label} reset: {s}' + assert s['health'] == 40, f'{label} respawned health: {s}' + return seen + + +CHECKS += ['scale-body'] +CHECK_FNS['scale-body'] = check_scale_body + + if __name__ == '__main__': main() diff --git a/tools/native-hook-fragments/difficulty_changed.json b/tools/native-hook-fragments/difficulty_changed.json new file mode 100644 index 0000000000..c77fb988a0 --- /dev/null +++ b/tools/native-hook-fragments/difficulty_changed.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "difficulty_changed", + "target": "horizon_client::game::native_hooks::Game<'_>::native_difficulty_changed", + "neoforge": "DifficultyChangeEvent", + "side": "server" + } + ], + "rust_summary": "the server's difficulty changing (`native_difficulty_changed`).", + "doc_summary": "the server's difficulty changed, from one of `peaceful`, `easy`, `normal`, `hard` to another, through `native_difficulty_changed` (NeoForge's `DifficultyChangeEvent`). It is vanilla's `MinecraftServer.setDifficulty`, which `/difficulty`, a dedicated server's management and a test environment's difficulty all go through, and it is told only when the difficulty is another one. It only hears: the change has already happened. Bundles that use it need \"hooks\": @API:difficulty_changed@.", + "comments": [] +} diff --git a/tools/native-hook-fragments/framerate_limit.json b/tools/native-hook-fragments/framerate_limit.json new file mode 100644 index 0000000000..31bd8891bc --- /dev/null +++ b/tools/native-hook-fragments/framerate_limit.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "framerate_limit", + "target": "horizon_client::game::native_hooks::Game<'_>::native_framerate_limit", + "neoforge": "none (vanilla FramerateLimitTracker.getFramerateLimit fires no event)", + "side": "client", + "cancellable": false + } + ], + "rust_summary": "lowering the frame rate limit, as Dynamic FPS does (`native_framerate_limit`).", + "doc_summary": "lowering the frame rate limit: `native_framerate_limit(focused, minimized, in_world, limit)` is asked each frame with the limit Horizon is about to use (Max Framerate after the inactivity throttle and Battery Mode, 260 for unlimited), whether the window has focus, whether it is minimised and whether a world is open. A mod may lower `limit`, for example to throttle a window in the background as Dynamic FPS does; Horizon keeps the lower of its own limit and the mod's and ignores values below 1. Bundles that use it need `\"hooks\": @API:framerate_limit@`." +} diff --git a/tools/native-hook-fragments/ingredient.json b/tools/native-hook-fragments/ingredient.json new file mode 100644 index 0000000000..d2a2f7c36a --- /dev/null +++ b/tools/native-hook-fragments/ingredient.json @@ -0,0 +1,6 @@ +{ + "hooks": [{"name": "ingredient", "target": "horizon_client_lib::crafting::native_ingredient"}], + "rust_summary": "a recipe ingredient of a mod's own kind\n(`native_ingredient`, which the unknown ingredient type in a namespace of\nown reaches with the kind, its JSON and the item list to fill).", + "doc_summary": "a recipe ingredient of a mod's own kind. The ingredient entry that names a type in a namespace outside minecraft: reaches native_ingredient(kind, data, items) at load time. Fill items with the item ids the ingredient matches (item ids only: components are not checked; a match-time hook follows separately). Duplicates are removed keeping first-seen order. An unclaimed kind leaves the list empty and the recipe fails. The hook may be called more than once per recipe; the answer must be the same. Bundles that use it need \"hooks\": @API:ingredient@.", + "comments": ["crates/horizon-client/src/crafting.rs"] +} diff --git a/tools/native-hook-fragments/item_crafted.json b/tools/native-hook-fragments/item_crafted.json new file mode 100644 index 0000000000..e570cebacd --- /dev/null +++ b/tools/native-hook-fragments/item_crafted.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "item_crafted", + "target": "horizon_client::game::native_hooks::Game<'_>::native_item_crafted", + "neoforge": "PlayerEvent.ItemCraftedEvent", + "side": "server" + } + ], + "rust_summary": "a player taking a crafting grid's result (`native_item_crafted`).", + "doc_summary": "a player took a crafting grid's result, through `native_item_crafted` (NeoForge's `PlayerEvent.ItemCraftedEvent`): the player's uuid, the recipe the grid matched (`minecraft:oak_planks`) and the result stack it took, with its components. It only hears; the take has already happened. It fires for the crafting grid of an inventory or a crafting table, on the integrated server and on a dedicated server, guests included, and not for a stonecutter's, a smithing table's or a furnace's result. Bundles that use it need \"hooks\": @API:item_crafted@.", + "comments": [] +} diff --git a/tools/native-hook-fragments/native_item_damaged.json b/tools/native-hook-fragments/native_item_damaged.json new file mode 100644 index 0000000000..17ebc4bf99 --- /dev/null +++ b/tools/native-hook-fragments/native_item_damaged.json @@ -0,0 +1,12 @@ +{ + "hooks": [ + { + "name": "item_damaged", + "target": "horizon_client::game::native_hooks::Game<'_>::native_item_damaged", + "neoforge": "none (vanilla: ItemStack.hurtAndBreak; PlayerDestroyItemEvent is only the break)", + "side": "server" + } + ], + "rust_summary": "a player's item losing durability, changeable and cancellable (`native_item_damaged`).", + "doc_summary": "a player's item losing durability: `native_item_damaged(player, name, item, amount, cancel)` is heard where `ItemStack.hurtAndBreak` wears the item of a player of a server (Bukkit's `PlayerItemDamageEvent`), after Unbreaking, with the points about to be lost. A mod changes `amount` or sets `cancel` for no wear. The player's state is swapped in. Bundles that use it need `\"hooks\": @API:native_item_damaged@`." +} diff --git a/tools/native-hook-fragments/native_player_fish.json b/tools/native-hook-fragments/native_player_fish.json new file mode 100644 index 0000000000..67644d2b6c --- /dev/null +++ b/tools/native-hook-fragments/native_player_fish.json @@ -0,0 +1,12 @@ +{ + "hooks": [ + { + "name": "player_fish", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_fish", + "neoforge": "none (NeoForge's ItemFishedEvent fires after the loot roll, in FishingHook.retrieve; Bukkit's PlayerFishEvent)", + "side": "server" + } + ], + "rust_summary": "a player's fishing rod: cast, bite, catch, reel-in, cancellable (`native_player_fish`).", + "doc_summary": "a player's fishing rod: `native_player_fish(player, name, state, cancel)` is heard for the cast (`state` 0), a fish biting (1), the reel-in with a catch (2), the reel-in with nothing on the line (3) and the reel-in of a bobber hooked to an entity or item (4), for a player of a server (Bukkit's `PlayerFishEvent`; NeoForge's `ItemFishedEvent` fires later, once the loot is rolled). A mod sets `cancel` to stop a cast, make the fish get away, or leave the bobber out on a reel-in. The player's state is swapped in. Bundles that use it need `\"hooks\": @API:native_player_fish@`." +} diff --git a/tools/native-hook-fragments/physical_window_size.json b/tools/native-hook-fragments/physical_window_size.json new file mode 100644 index 0000000000..4ab27f7d92 --- /dev/null +++ b/tools/native-hook-fragments/physical_window_size.json @@ -0,0 +1,5 @@ +{ + "hooks": [], + "rust_summary": "the window's physical pixel size (`native_physical_window_size`).", + "doc_summary": "the helper `native_physical_window_size()`, which returns the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. Bundles that use it need `\"hooks\": @API:physical_window_size@`." +} diff --git a/tools/native-hook-fragments/server_event.json b/tools/native-hook-fragments/server_event.json new file mode 100644 index 0000000000..a9bfd25b46 --- /dev/null +++ b/tools/native-hook-fragments/server_event.json @@ -0,0 +1,6 @@ +{ + "hooks": [], + "rust_summary": "a server mod's line for the event stream and the Discord bridge (`native_server_event`).", + "doc_summary": "`native_server_event(name, uuid, message)` posts an `announce` event to a dedicated server's event stream (`GET /api/v1/events`); horizon-server's Discord bridge relays it, in gold, when `[discord] events` lists `announce` (off by default). False elsewhere and for an empty message. Bundles that use it need \"hooks\": @API:server_event@.", + "comments": ["crates/horizon-client/src/game/native_server.rs"] +} diff --git a/tools/native-hook-fragments/state_block.json b/tools/native-hook-fragments/state_block.json new file mode 100644 index 0000000000..2ac31b1b17 --- /dev/null +++ b/tools/native-hook-fragments/state_block.json @@ -0,0 +1,6 @@ +{ + "hooks": [], + "rust_summary": "a helper that names the block a state belongs to (`native_state_block`).", + "doc_summary": "`native_state_block(state)` names the block a state belongs to (`BlockState.getBlock()`), on a server hook's level and on the game: `native_state_for_placement` is asked for every block placed, so a mod compares it with its own block to answer only for it. Bundles that use it need \"hooks\": @API:state_block@.", + "comments": ["crates/horizon-client/src/game/native_hooks.rs"] +} diff --git a/tools/pacing_bench.py b/tools/pacing_bench.py new file mode 100644 index 0000000000..a0aae6452b --- /dev/null +++ b/tools/pacing_bench.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Frame pacing benchmark: every frame of long stutter-prone sessions, and what the slow ones did. + +perf_bench.py measures throughput on short flights; this measures smoothness (1% and 0.1% lows, +p99/p99.9, the worst frame, frames over 33 ms) in the situations where players see hitches: + + fly16 the village flight of perf_bench, 16 blocks/s, 12 s + ocean perf_bench's ocean flight (16 blocks/s, 12 s): Horizon's worst lows on Windows at RD 32 + fly64 a 1,920-block straight line at 64 blocks/s (30 s), new terrain all the way + teleport 8 teleports of 1,500-3,000 blocks, 5 s recorded after each + nether the overworld -> Nether -> overworld (/execute in ... run tp), 8 s recorded after each + inventory the inventory and the pause menu opened and closed 20 times each + +One game per render distance, from a fresh world (seed 1234, creative), driven over horizon-ctl. +The game runs with HORIZON_SPIKES so each slow frame logs its timed spans; the report keeps the +slowest frames' span lines next to each scenario's figures. + + python3 tools/pacing_bench.py --build target/fast --label main --distances 32 + python3 tools/pacing_bench.py --compare main mybranch + +Results: .local/pacing-bench/