diff --git a/.github/workflows/pack-bench.yml b/.github/workflows/pack-bench.yml new file mode 100644 index 0000000000..3fb1e1671d --- /dev/null +++ b/.github/workflows/pack-bench.yml @@ -0,0 +1,61 @@ +name: pack load bench +# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs +# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull +# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't +# start a workflow its main lacks), against horizon-rust/horizon's main. +on: + push: + branches: ['pack-bench/**'] + workflow_dispatch: + inputs: + base: + description: Ref to compare against + default: main + scale: + description: How many times the default pack sizes + default: '1' +permissions: + contents: read +env: + HORIZON_TELEMETRY_URL: "off" + CARGO_TERM_COLOR: never + +jobs: + bench: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install Rust and the ALSA and udev headers + run: | + sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev + rustup toolchain install stable --profile minimal + - uses: Swatinem/rust-cache@v2 + with: + shared-key: pack-bench + - name: Bench the branch + env: + SCALE: ${{ inputs.scale || '1' }} + run: | + cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md + - name: Bench the base, with the branch's bench + env: + BASE: ${{ inputs.base || 'main' }} + SCALE: ${{ inputs.scale || '1' }} + run: | + git fetch https://github.com/horizon-rust/horizon.git "$BASE" + git worktree add ../base FETCH_HEAD + cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/ + # A bench row that times a function the base lacks keeps it from building there: then + # the branch's own rows compare the two ways. + (cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \ + --example pack_load_bench -- --scale "$SCALE") > base.md \ + || echo "The bench does not build on the base." > base.md + - name: Summary + run: | + { + echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md + echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md + } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 1882b34231..55e08c69a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex | Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` | | Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` | | Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` | -| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` | +| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` | | Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) | | Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` | | Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` | diff --git a/crates/horizon-assets/src/item_bake.rs b/crates/horizon-assets/src/item_bake.rs index 9afb2fbfbf..65c6822025 100644 --- a/crates/horizon-assets/src/item_bake.rs +++ b/crates/horizon-assets/src/item_bake.rs @@ -7,7 +7,7 @@ use crate::{ json::Result, math::{Mat4, quaternion_xyz}, model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material}, - resolve::ModelLibrary, + resolve::{ModelLibrary, ResolvedCuboidModel}, }; use std::{collections::BTreeSet, f32::consts::PI}; @@ -110,6 +110,15 @@ pub fn bake_item_model( let model = library .resolve(name) .or_else(|_| library.resolve(crate::resolve::MISSING_MODEL))?; + bake_resolved(&model, sprites) +} + +/// Bake an already resolved model, e.g. one from +/// [`ModelLibrary::resolve_with_textures`]. +pub fn bake_resolved( + model: &ResolvedCuboidModel, + sprites: &dyn SpriteLookup, +) -> Result { let known = |sprite: &str| sprite == MISSING_SPRITE || sprites.mask(sprite).is_some(); let material = |m: &Material| { if known(&m.sprite) { @@ -143,7 +152,7 @@ pub fn bake_item_model( quads } else { let mut quads = - bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))?; + bake_block_model(model, crate::bake::BlockModelTransform::new([0; 3], false))?; for q in &mut quads { q.material = material(&q.material); } @@ -298,3 +307,87 @@ fn extrude_layer(mask: &SpriteMask, material: &Material, layer: i32) -> Result Option { + match sprite { + "minecraft:item/stick" => Some(SpriteMask { + width: 2, + height: 2, + frames: vec![vec![true, false, false, false]], + }), + "horizon_cit:item/cit" => Some(SpriteMask { + width: 1, + height: 1, + frames: vec![vec![false]], + }), + _ => None, + } + } + } + fn library() -> ModelLibrary { + ModelLibrary::with_item_generator(BTreeMap::from( + [ + ("item/generated", r#"{"parent":"builtin/generated","display":{"gui":{"scale":[2,2,2]}}}"#), + ("item/stick", r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#), + ("horizon:loop", r#"{"parent":"horizon:loop2"}"#), + ("horizon:loop2", r#"{"parent":"horizon:loop"}"#), + ("block/slab", r##"{"textures":{"particle":"#side","side":"block/absent"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"},"down":{"texture":"#side","cullface":"down"}}}]}"##), + ] + .map(|(name, json)| (name.to_owned(), CuboidModel::from_json(json).unwrap())), + )) + .unwrap() + } + #[test] + fn baking_by_name_falls_back_to_the_missing_model() { + let library = library(); + // Front, back, and the 8 unit edges around the L of 3 opaque pixels. + let stick = bake_item_model(&library, &Sprites, "item/stick").unwrap(); + assert_eq!(stick.quads.len(), 10); + // An unknown sprite bakes as the missing sprite. + let slab = bake_item_model(&library, &Sprites, "block/slab").unwrap(); + assert_eq!(slab.quads.len(), 2); + assert_eq!(slab.particle.sprite, MISSING_SPRITE); + // A cyclic chain, an error to `resolve`, bakes as the missing cube. + let cyclic = bake_item_model(&library, &Sprites, "horizon:loop").unwrap(); + assert_eq!(cyclic.quads.len(), 6); + assert!( + cyclic + .quads + .iter() + .all(|q| q.material.sprite == MISSING_SPRITE) + ); + } + #[test] + fn replaced_layer_bakes_with_its_own_sprite() { + let library = library(); + let child = BTreeMap::from([( + "layer0".to_owned(), + TextureSlot::Material(Material { + sprite: "horizon_cit:item/cit".to_owned(), + force_translucent: false, + }), + )]); + let model = library.resolve_with_textures("item/stick", &child).unwrap(); + let baked = bake_resolved(&model, &Sprites).unwrap(); + // The CIT sprite's own mask, one opaque pixel: front, back and 4 edges. + assert_eq!(baked.quads.len(), 6); + assert!( + baked + .quads + .iter() + .all(|q| q.material.sprite == "horizon_cit:item/cit") + ); + assert_eq!(baked.particle.sprite, "horizon_cit:item/cit"); + assert_eq!(baked.transforms[5].as_ref().unwrap().scale, [2.0; 3]); + } +} diff --git a/crates/horizon-assets/src/resolve.rs b/crates/horizon-assets/src/resolve.rs index 896d679dc7..be7a42071e 100644 --- a/crates/horizon-assets/src/resolve.rs +++ b/crates/horizon-assets/src/resolve.rs @@ -102,10 +102,54 @@ impl ModelLibrary { missing, }) } + /// This library plus `extra` models; an `extra` model replaces a + /// same-named one, but not the built-in missing and generated models. + /// Every existing model is shared, not copied. + pub fn overlaid(&self, extra: BTreeMap) -> Result { + let mut models = self.models.clone(); + let mut added = BTreeSet::new(); + for (name, model) in extra { + let name = identifier(&name)?; + if !added.insert(name.clone()) { + return Err(error("duplicate normalized model identifier")); + } + // The built-ins win, as `from_models` keeps `missing` and + // `with_item_generator` keeps `generated`. + if name != MISSING_MODEL && name != GENERATED_MODEL { + models.insert(name, Arc::new(model)); + } + } + Ok(Self { + models, + missing: Arc::clone(&self.missing), + }) + } /// Follow the complete parent chain. A missing resource uses the built-in /// model; a cyclic chain is rejected, as native discovery excludes it. /// The returned model can outlive this library without copying its elements. pub fn resolve(&self, name: &str) -> Result { + self.resolve_chain(name, None) + } + /// `name`'s resolved model with a virtual child in front of it that has + /// only `textures`: the child's slots win, and `#references` anywhere in + /// the chain re-bind through them (so `builtin/generated`'s + /// `particle: #layer0` follows a replaced `layer0`). The child's slots are + /// taken as given: sprites namespaced, references without their `#`, as + /// the JSON decoder leaves them. A cyclic chain is an + /// error, as for `resolve`: a caller wanting the missing model falls + /// back to it. + pub fn resolve_with_textures( + &self, + name: &str, + child: &BTreeMap, + ) -> Result { + self.resolve_chain(name, Some(child)) + } + fn resolve_chain( + &self, + name: &str, + child: Option<&BTreeMap>, + ) -> Result { let mut name = identifier(name)?; let mut visited = BTreeSet::new(); let mut chain = Vec::new(); @@ -156,6 +200,9 @@ impl ModelLibrary { for model in chain.into_iter().rev() { slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v))); } + if let Some(child) = child { + slots.extend(child.iter().map(|(k, v)| (k.clone(), v))); + } let mut textures = BTreeMap::::new(); let mut unresolved_textures = BTreeSet::::new(); for start in slots.keys() { @@ -274,6 +321,195 @@ mod tests { } ); } + fn library(models: &[(&str, &str)]) -> ModelLibrary { + ModelLibrary::with_item_generator( + models + .iter() + .map(|(name, json)| (name.to_string(), CuboidModel::from_json(json).unwrap())) + .collect(), + ) + .unwrap() + } + const GENERATED: &str = r#"{"parent":"builtin/generated","display":{"ground":{"translation":[0,2,0],"scale":[0.5,0.5,0.5]}}}"#; + const CIT: &str = "horizon_cit:minecraft/optifine/cit/x"; + #[test] + fn child_textures_replace_slots_and_rebind_the_generated_particle() { + let library = library(&[ + ("item/generated", GENERATED), + ( + "item/stick", + r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#, + ), + ]); + let child = BTreeMap::from([( + "layer0".to_owned(), + TextureSlot::Material(Material { + sprite: CIT.to_owned(), + force_translucent: false, + }), + )]); + let model = library.resolve_with_textures("item/stick", &child).unwrap(); + assert!(model.uses_item_generator()); + assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT); + assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT); + assert_eq!( + model.display.contexts[6], + DisplayTransform::Explicit(Some(ItemTransform { + rotation: [0.0; 3], + translation: [0.0, 0.125, 0.0], + scale: [0.5; 3], + })) + ); + // The library itself is untouched. + let plain = library.resolve("item/stick").unwrap(); + assert_eq!( + plain.material("particle").unwrap().unwrap().sprite, + "minecraft:item/stick" + ); + } + #[test] + fn overlaid_models_inherit_from_the_library_and_share_the_rest() { + let base = library(&[ + ("item/generated", GENERATED), + ( + "item/handheld", + r#"{"parent":"item/generated","display":{"thirdperson_righthand":{"rotation":[0,-90,55]}}}"#, + ), + ( + "item/stick", + r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#, + ), + ( + "item/apple", + r#"{"parent":"item/generated","textures":{"layer0":"item/apple"}}"#, + ), + ]); + let cit = format!(r#"{{"parent":"item/handheld","textures":{{"layer0":"{CIT}"}}}}"#); + let over = base + .overlaid(BTreeMap::from([ + (CIT.to_owned(), CuboidModel::from_json(&cit).unwrap()), + ( + "item/stick".to_owned(), + CuboidModel::from_json( + r#"{"parent":"item/generated","textures":{"layer0":"item/bone"}}"#, + ) + .unwrap(), + ), + ])) + .unwrap(); + let model = over.resolve(CIT).unwrap(); + assert!(model.uses_item_generator()); + assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT); + let DisplayTransform::Explicit(Some(right)) = &model.display.contexts[1] else { + panic!("handheld's right-hand transform is inherited"); + }; + assert_eq!(right.rotation, [0.0, -90.0, 55.0]); + let sprite = |l: &ModelLibrary| { + l.resolve("item/stick").unwrap().textures["layer0"] + .sprite + .clone() + }; + assert_eq!(sprite(&over), "minecraft:item/bone"); + assert_eq!(sprite(&base), "minecraft:item/stick"); + assert!(!base.resolve(CIT).unwrap().uses_item_generator()); + assert!(Arc::ptr_eq( + &base.models["minecraft:item/apple"], + &over.models["minecraft:item/apple"] + )); + } + fn material(sprite: &str) -> TextureSlot { + TextureSlot::Material(Material { + sprite: sprite.to_owned(), + force_translucent: false, + }) + } + #[test] + fn child_slots_take_part_in_reference_chains() { + let library = library(&[ + ( + "item/two", + r##"{"textures":{"layer0":"#tex","layer1":"item/b","tex":"item/a"}}"##, + ), + ("horizon:loop", r#"{"parent":"horizon:loop2"}"#), + ("horizon:loop2", r#"{"parent":"horizon:loop"}"#), + ]); + let child = BTreeMap::from([ + // A base alias binds through a slot only the child sets... + ("tex".to_owned(), material(CIT)), + // ...and a child alias through the base's slots. + ( + "particle".to_owned(), + TextureSlot::Reference("layer1".to_owned()), + ), + // A child alias to itself resolves to nothing, without looping. + ("self".to_owned(), TextureSlot::Reference("self".to_owned())), + ]); + let model = library.resolve_with_textures("item/two", &child).unwrap(); + assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT); + assert_eq!( + model.material("particle").unwrap().unwrap().sprite, + "minecraft:item/b" + ); + assert_eq!(model.material("self").unwrap(), None); + assert!(model.unresolved_textures.contains("self")); + // An absent base is the missing cube under the child; a cyclic one an error. + let absent = library + .resolve_with_textures("item/absent", &child) + .unwrap(); + assert_eq!(absent.elements().len(), 1); + assert!( + library + .resolve_with_textures("horizon:loop", &child) + .is_err() + ); + } + #[test] + fn an_overlay_replaces_parents_but_not_the_built_ins_and_refuses_twins() { + let base = library(&[ + ("item/generated", GENERATED), + ("item/handheld", r#"{"parent":"item/generated"}"#), + ( + "item/sword", + r#"{"parent":"item/handheld","textures":{"layer0":"item/sword"}}"#, + ), + ]); + let model = |json: &str| CuboidModel::from_json(json).unwrap(); + let over = base + .overlaid(BTreeMap::from([ + ( + "item/handheld".to_owned(), + model( + r#"{"parent":"item/generated","display":{"head":{"rotation":[0,45,0]}}}"#, + ), + ), + // Neither built-in can be replaced. + ("builtin/missing".to_owned(), model(r#"{"elements":[]}"#)), + ("builtin/generated".to_owned(), model("{}")), + ])) + .unwrap(); + // The base's sword sees the overlay's parent; the base library does not. + let head = |l: &ModelLibrary| l.resolve("item/sword").unwrap().display.contexts[4].clone(); + let DisplayTransform::Explicit(Some(rotated)) = head(&over) else { + panic!("the replaced parent's head transform"); + }; + assert_eq!(rotated.rotation, [0.0, 45.0, 0.0]); + assert_ne!(head(&base), head(&over)); + // The generator kept: its `particle: #layer0`, which `{}` would drop. + let sword = over.resolve("item/sword").unwrap(); + assert_eq!( + sword.material("particle").unwrap().unwrap().sprite, + "minecraft:item/sword" + ); + // The missing cube kept, looked up by its own name, not as a fallback. + let missing = over.resolve("builtin/missing").unwrap(); + assert_eq!(missing.elements().len(), 1); + // Two spellings of one id. + let twins = BTreeMap::from([ + ("item/x".to_owned(), model("{}")), + ("minecraft:item/x".to_owned(), model("{}")), + ]); + assert!(base.overlaid(twins).is_err()); + } #[test] fn absent_parents_fall_back_but_cycles_invalidate_descendants() { let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap(); diff --git a/crates/horizon-client-render/examples/pack_load_bench.rs b/crates/horizon-client-render/examples/pack_load_bench.rs new file mode 100644 index 0000000000..1bca9ce16a --- /dev/null +++ b/crates/horizon-client-render/examples/pack_load_bench.rs @@ -0,0 +1,436 @@ +//! How long a big resource pack takes to load and reload, on generated files only (no game +//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a +//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median +//! is printed as a Markdown table. +//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]` +//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side). +use horizon_client_render::{ + hud::HudAtlas, + model_library::ParsedModels, + packs::{self, Pack}, + sprites::Sprites, +}; +use std::{ + fs, + path::{Path, PathBuf}, + time::Instant, +}; + +/// A small deterministic generator: the same files on every run and every branch. +struct Noise(u64); +impl Noise { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } +} + +/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress +/// about as well as real textures do. +fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec { + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, side, side * frames); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize); + let mut colour = [0u8; 4]; + for i in 0..side * side * frames { + if i % 3 == 0 { + let n = noise.next(); + colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255]; + if alpha && n >> 40 & 3 == 0 { + colour[3] = 0; + } + } + pixels.extend_from_slice(&colour); + } + writer.write_image_data(&pixels).unwrap(); + } + out +} + +fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec { + let n = noise.next() % 4; + format!( + r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##, + h = 4 + n * 4 + ) + .into_bytes() +} + +fn write(root: &Path, name: &str, bytes: &[u8]) { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); +} + +/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535 +/// entries. +#[derive(Default)] +struct ZipOut { + out: Vec, + central: Vec, + count: u16, +} +impl ZipOut { + fn put(&mut self, name: &str, body: &[u8]) { + let data = miniz_oxide::deflate::compress_to_vec(body, 6); + let offset = u32::try_from(self.out.len()).unwrap(); + let sizes = [data.len() as u32, body.len() as u32]; + self.out.extend(0x0403_4b50u32.to_le_bytes()); + self.out.extend([20, 0, 0, 0, 8, 0]); + self.out.extend([0; 8]); + sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes())); + self.out.extend((name.len() as u16).to_le_bytes()); + self.out.extend([0; 2]); + self.out.extend(name.as_bytes()); + self.out.extend(&data); + self.central.extend(0x0201_4b50u32.to_le_bytes()); + self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]); + self.central.extend([0; 8]); + sizes + .iter() + .for_each(|s| self.central.extend(s.to_le_bytes())); + self.central.extend((name.len() as u16).to_le_bytes()); + self.central.extend([0; 12]); + self.central.extend(offset.to_le_bytes()); + self.central.extend(name.as_bytes()); + self.count = self.count.checked_add(1).expect("too many zip entries"); + } + fn finish(mut self, path: &Path) { + let start = self.out.len() as u32; + self.out.extend(&self.central); + self.out.extend(0x0605_4b50u32.to_le_bytes()); + self.out.extend([0; 4]); + self.out.extend(self.count.to_le_bytes()); + self.out.extend(self.count.to_le_bytes()); + self.out.extend((self.central.len() as u32).to_le_bytes()); + self.out.extend(start.to_le_bytes()); + self.out.extend([0; 2]); + fs::write(path, self.out).unwrap(); + } +} + +const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#; + +/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON +/// files (lang, sounds, fonts, atlases) every reload merges. +fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec { + let mut blocks = Vec::new(); + for i in 0..1100 * scale { + let name = format!("block/b{i}"); + let frames = if i % 50 == 0 { 8 } else { 1 }; + write( + root, + &format!("assets/minecraft/textures/{name}.png"), + &png(noise, 16, frames, i % 7 == 0), + ); + if frames > 1 { + write( + root, + &format!("assets/minecraft/textures/{name}.png.mcmeta"), + br#"{"animation":{"frametime":2}}"#, + ); + } + write( + root, + &format!("assets/minecraft/models/block/b{i}.json"), + &model(noise, "minecraft", &name), + ); + blocks.push(format!("minecraft:{name}")); + } + for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] { + for i in 0..count * scale { + let sub = if folder == "gui" { "sprites/" } else { "" }; + write( + root, + &format!( + "assets/minecraft/textures/{folder}/{sub}{}/t{i}.png", + i % 20 + ), + &png(noise, side, 1, true), + ); + } + } + for i in 0..1500 * scale { + write( + root, + &format!("assets/minecraft/models/item/i{i}.json"), + &model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)), + ); + } + let lang: String = (0..4000) + .map(|i| format!("\"key.{i}\":\"Value {i}\"")) + .collect::>() + .join(","); + write( + root, + "assets/minecraft/lang/en_us.json", + format!("{{{lang}}}").as_bytes(), + ); + write( + root, + "assets/minecraft/sounds.json", + br#"{"a":{"sounds":["a"]}}"#, + ); + write( + root, + "assets/minecraft/atlases/blocks.json", + br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#, + ); + write(root, "pack.mcmeta", META); + blocks +} + +/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each +/// with its own items' textures and models, its sounds and its lang files. +fn server_zip(path: &Path, scale: usize, noise: &mut Noise) { + let mut zip = ZipOut::default(); + zip.put("pack.mcmeta", META); + for n in 0..190 { + let ns = format!("plugin{n}"); + for i in 0..50 * scale { + let texture = format!("item/{}/t{i}", i % 5); + zip.put( + &format!("assets/{ns}/textures/{texture}.png"), + &png(noise, 32, 1, true), + ); + zip.put( + &format!("assets/{ns}/models/item/{}/m{i}.json", i % 5), + &model(noise, &ns, &texture), + ); + if i % 10 == 0 { + zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]); + } + } + zip.put( + &format!("assets/{ns}/lang/en_us.json"), + br#"{"item.custom":"Custom"}"#, + ); + } + let lang: String = (0..2000) + .map(|i| format!("\"server.{i}\":\"Server {i}\"")) + .collect::>() + .join(","); + zip.put( + "assets/minecraft/lang/en_us.json", + format!("{{{lang}}}").as_bytes(), + ); + zip.finish(path); +} + +/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style +/// tiles beside them: what the big OptiFine packs look like on disk. +fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) { + let mut zip = ZipOut::default(); + zip.put("pack.mcmeta", META); + for i in 0..1100 * scale { + zip.put( + &format!("assets/minecraft/textures/block/b{i}.png"), + &png(noise, 64, 1, i % 7 == 0), + ); + if i % 4 == 0 { + for tile in 0..12 { + zip.put( + &format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"), + &png(noise, 64, 1, false), + ); + } + zip.put( + &format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"), + format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(), + ); + } + } + for i in 0..750 * scale { + zip.put( + &format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20), + &png(noise, 64, 1, true), + ); + } + zip.finish(path); +} + +fn median(mut ms: Vec) -> f64 { + ms.sort_by(f64::total_cmp); + ms[ms.len() / 2] +} + +/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run. +fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 { + median( + (0..runs) + .map(|_| { + before(); + let started = Instant::now(); + f(); + started.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ) +} + +fn zip_pack(id: &str, path: PathBuf) -> Pack { + // Read the way `packs::folder` reads a `resourcepacks/` zip. + let dir = path.parent().unwrap().join(format!("{id}-dir")); + fs::create_dir_all(&dir).unwrap(); + let at = dir.join(path.file_name().unwrap()); + fs::rename(&path, &at).unwrap(); + packs::folder(&dir, packs::RESOURCE_FORMAT) + .into_iter() + .next() + .unwrap_or_else(|| panic!("{id} is not a pack")) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let scale = args + .iter() + .position(|a| a == "--scale") + .and_then(|i| args.get(i + 1)) + .map_or(1, |s| s.parse().unwrap()); + let runs = 3; + let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + fs::create_dir_all(&scratch).unwrap(); + let mut noise = Noise(0x9e37_79b9_7f4a_7c15); + let vanilla_root = scratch.join("vanilla"); + let blocks = vanilla(&vanilla_root, scale, &mut noise); + server_zip(&scratch.join("server.zip"), scale, &mut noise); + hd_zip(&scratch.join("hd.zip"), scale, &mut noise); + let server = zip_pack("server", scratch.join("server.zip")); + let hd = zip_pack("hd", scratch.join("hd.zip")); + let cache = scratch.join("cache"); + let both = [hd.clone(), server.clone()]; + + let mut rows: Vec<(String, f64)> = Vec::new(); + let mut row = |name: &str, ms: f64| { + eprintln!("{name}: {ms:.1} ms"); + rows.push((name.to_owned(), ms)); + }; + let fresh = || { + let _ = fs::remove_dir_all(&cache); + }; + row( + "overlay: build, both packs (cold)", + time(runs, fresh, || { + packs::resource_root(&vanilla_root, &both, &cache).unwrap(); + }), + ); + let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap(); + row( + "overlay: reuse, both packs (cache hit)", + time( + runs, + || {}, + || { + packs::resource_root(&vanilla_root, &both, &cache).unwrap(); + }, + ), + ); + // The pack screen: one pack turned off, then on again. + row( + "overlay: toggle the server pack off (new selection)", + time( + runs, + || { + let only = + packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap(); + fs::remove_dir_all(&only).unwrap(); + }, + || { + packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap(); + }, + ), + ); + row( + "models: parse every model", + time( + runs, + || {}, + || { + std::hint::black_box(ParsedModels::load(&root)); + }, + ), + ); + let sprite_cache = root.join("horizon-sprites.cache"); + // OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does. + let tiles: Vec = (0..1100 * scale) + .step_by(4) + .flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png"))) + .collect(); + let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| { + time( + runs, + || { + if cold { + let _ = fs::remove_file(&sprite_cache); + } + }, + || { + let mut sprites = Sprites::with_disk_cache(&root); + if prefetch { + sprites.prefetch(&root, names.iter().map(String::as_str)); + } + for name in names { + if files { + sprites.insert_file(&root, name).unwrap(); + } else { + sprites.insert(&root, name).unwrap(); + } + } + sprites.save_disk_cache(); + sprites.finish(); + std::hint::black_box(&sprites.pixels); + }, + ) + }; + row( + "block sprites: one by one (no cache)", + sprites(true, false, &blocks, false), + ); + row( + "block sprites: prefetched (no cache)", + sprites(true, true, &blocks, false), + ); + row( + "block sprites: from the disk cache", + sprites(false, false, &blocks, false), + ); + row("CTM tiles: one by one", sprites(false, false, &tiles, true)); + row("CTM tiles: prefetched", sprites(false, true, &tiles, true)); + row( + "GUI atlas: decode and stitch, both packs", + time( + runs, + || {}, + || { + if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) { + eprintln!("GUI atlas failed: {e}"); + } + }, + ), + ); + row( + "GUI atlas: vanilla alone", + time( + runs, + || {}, + || { + let _ = HudAtlas::build(&vanilla_root); + }, + ), + ); + + let cores = std::thread::available_parallelism().map_or(1, |n| n.get()); + println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |"); + println!("|---|---:|"); + for (name, ms) in &rows { + println!("| {name} | {ms:.0} |"); + } + let _ = fs::remove_dir_all(&scratch); +} diff --git a/crates/horizon-client-render/src/atlas_sources.rs b/crates/horizon-client-render/src/atlas_sources.rs index c3e9b2a5da..cf3217f4a4 100644 --- a/crates/horizon-client-render/src/atlas_sources.rs +++ b/crates/horizon-client-render/src/atlas_sources.rs @@ -208,10 +208,26 @@ pub fn list_textures(root: &Path) -> Vec { out.sort(); out } -/// The file of a texture identifier. +/// The file of a texture identifier. A Custom Item Textures id (`cit_assets`) names its +/// resource path, wherever the folder: `directory` is not used for it. An id whose path would +/// leave the root (a pack's model may write any) names a file that is never there. pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf { + // No file can have a NUL in its name, so this one is never there. + const NOWHERE: &str = "assets/minecraft/textures/horizon/\0refused.png"; + if texture.starts_with("horizon_cit:") { + return root.join( + crate::cit_assets::resource(texture, "png") + .as_deref() + .unwrap_or(NOWHERE), + ); + } let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture)); - root.join(format!("assets/{namespace}/{directory}/{path}.png")) + let rel = format!("assets/{namespace}/{directory}/{path}.png"); + root.join(if horizon_optifine::paths::valid(&rel) { + &rel + } else { + NOWHERE + }) } /// `SpriteSourceList.list`: every sprite id of the atlas and its origin. diff --git a/crates/horizon-client-render/src/block_entities.rs b/crates/horizon-client-render/src/block_entities.rs index 4e0b4a53e7..84b7d359ff 100644 --- a/crates/horizon-client-render/src/block_entities.rs +++ b/crates/horizon-client-render/src/block_entities.rs @@ -23,7 +23,7 @@ //! Which states tick is a table by state id, built by the game from the block //! registry; whether a slot is made is decided when the slot is promoted at //! the start of the next tick, from the block state then. -use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks}; +use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks}; use crate::fast_hash::{FxHashMap, FxHashSet}; use std::collections::HashMap; use std::sync::Arc; @@ -76,6 +76,9 @@ pub struct BlockEntities { /// The fluid tanks of mod blocks' block entities, by block state id; /// built with `states`, like the containers. mod_fluids: FxHashMap>, + /// The energy buffers of mod blocks' block entities, by block state id; + /// built with `states`, like the containers. + mod_energy: FxHashMap>, /// The positions of the mod block entities (`BlockEntity::Mod`) by chunk, /// for the renderer: the mesher lists block entities by their template, /// which leaves out a mod block whose template has none. One taken out @@ -192,6 +195,13 @@ impl BlockEntities { pub fn mod_fluids(&self, state: u32) -> Option<&Arc> { self.mod_fluids.get(&state) } + pub fn set_mod_energy(&mut self, energy: FxHashMap>) { + self.mod_energy = energy; + } + /// The energy buffer of a mod block's block entity in block state `state`. + pub fn mod_energy(&self, state: u32) -> Option<&Arc> { + self.mod_energy.get(&state) + } fn next(&mut self) -> u32 { let n = self.next_slot; diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs index ddfd6aec43..9f50876e83 100644 --- a/crates/horizon-client-render/src/chunk/block_entity.rs +++ b/crates/horizon-client-render/src/chunk/block_entity.rs @@ -8,7 +8,13 @@ use horizon_world::{ level::sculk::SculkSpreader, nbt::{Compound, Tag}, }; -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; /// An entity's UUID as its four ints (`UUIDUtil.CODEC`). pub type Uuid = [i32; 4]; @@ -94,18 +100,98 @@ pub enum BlockEntity { /// `TestInstanceBlockEntity` (`test_instance_block.rs`). TestInstanceBlock(Box), } -/// A mod's block entity: its type id (`mymod:machine`), its saved +/// A mod's block entity, read and changed through its fields +/// (`ModBlockEntityFields`) as a struct's: `entity.nbt`. Each change goes +/// through `DerefMut` and gives it a new `revision`, so a client tells +/// whether what it kept of it (`BlockEntityDraw::keep`) is still right +/// without comparing the whole entity, and no write can skip that. +pub struct ModBlockEntity { + fields: ModBlockEntityFields, + revision: ModRevision, +} +/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved /// compound without `id`, `x`, `y`, `z` and `Items`, its item slots /// (`Items`) when its block has a container (`ModContainer`), and the data /// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow), /// which the mod sets and nothing saves. #[derive(Clone, Debug, Default, PartialEq)] -pub struct ModBlockEntity { +pub struct ModBlockEntityFields { pub id: String, pub nbt: Compound, pub items: Vec>, pub data: Vec, } +/// Which contents a `ModBlockEntity` has had: one per entity made (a clone +/// is another), counted on by each change. Equal revisions, equal contents. +/// Neither saved nor compared. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ModRevision { + born: u64, + changes: u64, +} +static MOD_BLOCK_ENTITIES_MADE: AtomicU64 = AtomicU64::new(0); +impl ModBlockEntity { + pub fn new(fields: ModBlockEntityFields) -> Self { + Self { + fields, + revision: ModRevision { + born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed), + changes: 0, + }, + } + } + pub fn revision(&self) -> ModRevision { + self.revision + } + /// A client's copy loads the tag the server sent (`loadWithComponents` + /// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields + /// replace its own of the same names. One it already has as sent is + /// left alone, so a tag sent again unchanged keeps its revision. + pub fn merge_update_tag(&mut self, tag: &Compound) { + for (k, v) in tag { + if self.nbt.get(k) != Some(v) { + self.nbt.insert(k.clone(), v.clone()); + } + } + } +} +impl From for ModBlockEntity { + fn from(fields: ModBlockEntityFields) -> Self { + Self::new(fields) + } +} +impl Default for ModBlockEntity { + fn default() -> Self { + Self::new(ModBlockEntityFields::default()) + } +} +impl Clone for ModBlockEntity { + fn clone(&self) -> Self { + Self::new(self.fields.clone()) + } +} +impl PartialEq for ModBlockEntity { + fn eq(&self, other: &Self) -> bool { + self.fields == other.fields + } +} +impl std::fmt::Debug for ModBlockEntity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fields.fmt(f) + } +} +impl std::ops::Deref for ModBlockEntity { + type Target = ModBlockEntityFields; + fn deref(&self) -> &ModBlockEntityFields { + &self.fields + } +} +impl std::ops::DerefMut for ModBlockEntity { + fn deref_mut(&mut self) -> &mut ModBlockEntityFields { + self.revision.changes += 1; + &mut self.fields + } +} /// The container of a mod block's block entity, as a `WorldlyContainer` /// says it: its slot count, the slots each face reaches /// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east), @@ -135,6 +221,23 @@ pub struct ModFluidTanks { /// The tanks a drain may take from (`canTakeItemThroughFace`). pub outputs: Vec, } +/// The energy buffer of a mod block's block entity (NeoForge's +/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract +/// moves, and the faces a fill may come through and a drain may leave by. +/// The amount is not here: it lives in the entity's saved compound, under +/// `energy`, so it survives restarts and the block's own tick reads it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ModEnergyBuffer { + pub capacity: i32, + /// The most one insert moves; 0 takes none. + pub max_insert: i32, + /// The most one extract moves; 0 gives none. + pub max_extract: i32, + /// The faces, by `Direction` number, a fill may come through. + pub insert_faces: [bool; 6], + /// The faces a drain may leave by. + pub extract_faces: [bool; 6], +} /// The vanilla menus a mod container can open, with the slot count each /// shows: slot grids, and the furnaces' (input, fuel, result), whose four /// data slots draw the flame (`data[0]` of `data[1]`) and the arrow @@ -588,3 +691,64 @@ pub struct CommandBlock { pub auto: bool, pub condition_met: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() { + let mut machine = ModBlockEntity::new(ModBlockEntityFields { + id: "mymod:machine".into(), + data: vec![0], + ..Default::default() + }); + let made = machine.revision(); + // Reads leave it. + assert_eq!(machine.data[0] + machine.nbt.len() as i32, 0); + assert_eq!(machine.revision(), made); + // Every way to change a field moves it. + let mut seen = vec![made]; + let changes: [fn(&mut ModBlockEntity); 4] = [ + |m| m.data[0] = 1, + |m| _ = m.nbt.insert("progress".into(), Tag::Int(3)), + |m| m.items.push(None), + |m| m.id.push('s'), + ]; + for change in changes { + change(&mut machine); + assert!(!seen.contains(&machine.revision())); + seen.push(machine.revision()); + } + // Another entity, or a clone, never shares one, even with equal + // contents; equality is the contents' alone. + let copy = machine.clone(); + assert_eq!(copy, machine); + assert!(!seen.contains(©.revision())); + let fresh = ModBlockEntity::default(); + assert_ne!(fresh.revision(), ModBlockEntity::default().revision()); + } + + #[test] + fn an_update_tag_sent_again_unchanged_keeps_the_revision() { + let mut machine = ModBlockEntity::default(); + let tag = Compound::from([ + ("progress".into(), Tag::Int(3)), + ("fuel".into(), Tag::Int(9)), + ]); + machine.merge_update_tag(&tag); + let merged = machine.revision(); + machine.merge_update_tag(&tag); + assert_eq!(machine.revision(), merged); + // One field changed: replaced, the other kept, the revision moved. + machine.merge_update_tag(&Compound::from([("progress".into(), Tag::Int(4))])); + assert_ne!(machine.revision(), merged); + assert_eq!( + machine.nbt, + Compound::from([ + ("progress".into(), Tag::Int(4)), + ("fuel".into(), Tag::Int(9)), + ]) + ); + } +} diff --git a/crates/horizon-client-render/src/cit_assets.rs b/crates/horizon-client-render/src/cit_assets.rs new file mode 100644 index 0000000000..214d332780 --- /dev/null +++ b/crates/horizon-client-render/src/cit_assets.rs @@ -0,0 +1,164 @@ +//! The ids Custom Item Textures' files take in Horizon's atlas and model library. A CIT rule +//! names its textures and models by resource path, mostly under `optifine/cit/` where vanilla +//! never looks: those take the reserved namespace [`NAMESPACE`], their path the resource path's +//! (`assets/minecraft/optifine/cit/swords/flame.png` is +//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or +//! `models/`) keeps its vanilla id, so it is not packed twice. + +use horizon_optifine::cit::{Kind, Rule}; +use std::collections::BTreeSet; + +/// The namespace of CIT files outside `textures/` and `models/`. +pub const NAMESPACE: &str = "horizon_cit"; + +/// `:`, if both keep to the identifier rules (lower-case letters, digits, +/// `_.-`, and `/` in the path). CIT Resewn refuses the other paths too. +fn identifier(namespace: &str, path: &str) -> Option { + if namespace.is_empty() || path.is_empty() { + return None; + } + horizon_client_core::identifier::parse_identifier(&format!("{namespace}:{path}")) +} + +/// The id of a resource path ending in `.`: `assets///

` is `:

`, any +/// other `assets/` is `horizon_cit:`. +fn id(resource: &str, folder: &str, ext: &str) -> Option { + let rest = resource + .strip_prefix("assets/")? + .strip_suffix(ext)? + .strip_suffix('.')?; + if let Some((namespace, path)) = rest.split_once('/') + && let Some(path) = path.strip_prefix(folder).and_then(|p| p.strip_prefix('/')) + { + // A pack's own `assets/horizon_cit/` would name another file than its id maps back to. + return (namespace != NAMESPACE) + .then(|| identifier(namespace, path)) + .flatten(); + } + identifier(NAMESPACE, rest) +} + +/// The atlas sprite id of a `.png` resource path. +pub fn sprite_id(resource: &str) -> Option { + id(resource, "textures", "png") +} + +/// The model id of a `.json` resource path. +pub fn model_id(resource: &str) -> Option { + id(resource, "models", "json") +} + +/// The resource path of a `horizon_cit` id, with the extension `ext`; `None` for other ids, +/// and for one that would leave the resource root (a pack model may write any id). +pub fn resource(id: &str, ext: &str) -> Option { + let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?; + horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}")) +} + +/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.` values. +/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with +/// a warning. +pub fn item_sprites(rules: &[Rule]) -> BTreeSet { + let mut out = BTreeSet::new(); + for rule in rules.iter().filter(|r| r.kind == Kind::Item) { + let named = rule.textures.iter().map(|(_, path)| path); + for path in rule.texture.iter().chain(named) { + match sprite_id(path) { + Some(id) => { + out.insert(id); + } + None => log::warn!( + "custom item textures: {} names {path}, which is not a valid texture id", + rule.path + ), + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atlas_sources::texture_path; + use std::path::Path; + + #[test] + fn cit_paths_take_the_reserved_namespace_and_come_back() { + let png = "assets/minecraft/optifine/cit/swords/flame.png"; + let id = sprite_id(png).unwrap(); + assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame"); + assert_eq!(resource(&id, "png").as_deref(), Some(png)); + let json = "assets/minecraft/optifine/cit/swords/flame.json"; + let id = model_id(json).unwrap(); + assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame"); + assert_eq!(resource(&id, "json").as_deref(), Some(json)); + } + + #[test] + fn files_under_textures_and_models_keep_their_vanilla_ids() { + let id = sprite_id("assets/minecraft/textures/item/stick.png"); + assert_eq!(id.as_deref(), Some("minecraft:item/stick")); + let id = model_id("assets/gems/models/item/ruby.json"); + assert_eq!(id.as_deref(), Some("gems:item/ruby")); + // A model id is not a resource of this module's own. + assert_eq!(resource("minecraft:item/stick", "png"), None); + // `textures` as a folder name elsewhere is still a CIT path. + let id = sprite_id("assets/minecraft/optifine/cit/textures/a.png"); + assert_eq!( + id.as_deref(), + Some("horizon_cit:minecraft/optifine/cit/textures/a") + ); + } + + #[test] + fn paths_no_identifier_can_name_are_refused() { + for path in [ + "assets/minecraft/optifine/cit/Flame.png", + "assets/minecraft/optifine/cit/fire sword.png", + "assets/minecraft/textures/item/Stick.png", + "assets/minecraft/optifine/cit/a.json", + "optifine/cit/a.png", + ] { + assert_eq!(sprite_id(path), None, "{path}"); + } + assert_eq!(model_id("assets/minecraft/optifine/cit/A.json"), None); + assert_eq!( + sprite_id("assets//textures/item/x.png"), + None, + "no namespace" + ); + } + + #[test] + fn the_reserved_namespace_names_only_files_inside_the_root() { + // A pack's own `horizon_cit` namespace would map back to another file. + assert_eq!(sprite_id("assets/horizon_cit/textures/item/x.png"), None); + // A model may write any id: only ones inside the root name a file. + for id in [ + "horizon_cit:../../etc/x", + "horizon_cit:a/../../x", + "horizon_cit:a\\..\\x", + ] { + assert_eq!(resource(id, "png"), None, "{id}"); + // Nor does the texture's file: refused, not read from elsewhere. + let file = texture_path(Path::new("/r"), id, "textures"); + assert!(file.starts_with("/r/assets") && !file.to_string_lossy().contains("..")); + } + let escape = texture_path(Path::new("/r"), "minecraft:../../../x", "textures"); + assert!(!escape.to_string_lossy().contains(".."), "{escape:?}"); + } + + #[test] + fn both_id_forms_find_their_file() { + let root = Path::new("/r"); + assert_eq!( + texture_path(root, "horizon_cit:minecraft/optifine/cit/a", "textures"), + root.join("assets/minecraft/optifine/cit/a.png") + ); + assert_eq!( + texture_path(root, "minecraft:item/stick", "textures"), + root.join("assets/minecraft/textures/item/stick.png") + ); + } +} diff --git a/crates/horizon-client-render/src/connected.rs b/crates/horizon-client-render/src/connected.rs index ba045c2e82..e844042d2a 100644 --- a/crates/horizon-client-render/src/connected.rs +++ b/crates/horizon-client-render/src/connected.rs @@ -41,9 +41,35 @@ pub enum Pick { Quarters([TileLayer; 4]), } +/// A tile drawn over a face, after its own (`overlay*` methods). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Overlay { + pub layer: u16, + /// `layer=translucent`: drawn in the translucent pass, else with an alpha cutout. + pub translucent: bool, + /// `tintIndex` and the state whose tint it is (`tintBlock`, else the face's block). + pub tint: Option<(i32, Option)>, +} + struct LoadedRule { rule: Rule, tiles: Vec, + overlay: OverlayJoin, +} + +/// What an overlay rule joins: `connectBlocks` as state ids and `connectTiles` as sprite +/// layers (sorted), and its tint. +#[derive(Default)] +struct OverlayJoin { + states: Vec, + tiles: Vec, + tint: Option<(i32, Option)>, +} + +impl OverlayJoin { + fn any(&self) -> bool { + !self.states.is_empty() || !self.tiles.is_empty() + } } #[derive(Clone, Copy, Default)] @@ -98,14 +124,24 @@ impl ConnectedTextures { mut face_layer: impl FnMut(BlockStateId, Face) -> Option, ) -> Self { let mut rules = ctm::load(root); - // Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section - // meshes see no block entity). - rules.retain(|r| !r.method.is_overlay() && r.name.is_none()); + // Rules with a custom name load no tiles: section meshes see no block entity. + rules.retain(|r| r.name.is_none()); // Rule indices are `u16`. rules.truncate(usize::from(u16::MAX)); if rules.is_empty() { return Self::default(); } + // The tiles decode side by side; the loop below inserts them in rule order, under the + // same names. + let tiles: Vec = rules + .iter() + .flat_map(|rule| &rule.tiles) + .filter_map(|tile| match tile { + Tile::Texture(path) => Some(sprite_name(path)), + _ => None, + }) + .collect(); + sprites.prefetch(root, tiles.iter().map(String::as_str)); let loaded = rules .into_iter() .filter_map(|rule| { @@ -130,7 +166,11 @@ impl ConnectedTextures { }) .collect::>>(); match tiles { - Ok(tiles) => Some(LoadedRule { rule, tiles }), + Ok(tiles) => Some(LoadedRule { + rule, + tiles, + overlay: OverlayJoin::default(), + }), Err(e) => { log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path); None @@ -144,7 +184,11 @@ impl ConnectedTextures { .map(|s| (s.name.as_str(), &s.properties)) }); let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied()); - if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) { + if out + .rules + .iter() + .any(|r| r.rule.connect == Connect::Tile || !r.overlay.tiles.is_empty()) + { out.face_layers = (0..registry.len() as u32) .map(|id| { Face::ALL @@ -166,6 +210,48 @@ impl ConnectedTextures { rules, ..Self::default() }; + let states: Vec<_> = states.collect(); + // Each block's states, for `connectBlocks` and `tintBlock`. + let mut block_states: HashMap<&str, Vec> = HashMap::new(); + if out.rules.iter().any(|r| r.rule.method.is_overlay()) { + for (id, state) in states.iter().enumerate() { + if let Some((name, _)) = state { + block_states.entry(name).or_default().push(id as u32); + } + } + } + for r in out.rules.iter_mut().filter(|r| r.rule.method.is_overlay()) { + let rule = &r.rule; + // `connectBlocks` and `connectTiles` are "only for method overlay". + if rule.method == Method::Overlay { + for m in &rule.connect_blocks { + let candidates = block_states + .get(m.id.as_str()) + .map_or(&[][..], Vec::as_slice); + r.overlay.states.extend(candidates.iter().filter(|&&id| { + states[id as usize].is_some_and(|(name, props)| { + m.matches(name, |k| props.get(k).map(String::as_str)) + }) + })); + } + r.overlay.states.sort_unstable(); + r.overlay.states.dedup(); + r.overlay.tiles = rule + .connect_tiles + .iter() + .filter_map(|t| layer(&sprite_name(t)).map(|l| l as u16)) + .collect(); + r.overlay.tiles.sort_unstable(); + } + // A `tintBlock` the game doesn't have tints nothing. + let tint = match rule.tint_block.as_deref() { + None => Some(None), + Some(block) => block_states.get(block).map(|ids| Some(ids[0])), + }; + r.overlay.tint = tint + .filter(|_| rule.tint_index >= 0) + .map(|b| (rule.tint_index, b)); + } let mut blocks = HashMap::new(); let mut by_layer: Vec> = Vec::new(); // Block rules by the block each entry names, so a state tests only its own. @@ -249,6 +335,7 @@ impl ConnectedTextures { pos: [i32; 3], neighbour: impl Fn([i32; 3]) -> u32, biome: impl Fn() -> Option<&'b str>, + mut overlay: impl FnMut(Overlay), ) -> Pick { let (mut by_state, mut by_layer) = ( self.list(&self.by_state, state as usize), @@ -279,14 +366,16 @@ 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) + && let Some(pick) = + self.apply(r, state, layer, face, pos, &neighbour, &biome, &mut overlay) { return pick; } } } - /// One rule on a face: `None` when it doesn't apply or picks ``. + /// One rule on a face: `None` when it doesn't apply, picks `` or draws an overlay + /// (handed to `overlay`: the rules after it still choose the face's own sprite). #[allow(clippy::too_many_arguments)] fn apply<'b>( &self, @@ -297,6 +386,7 @@ impl ConnectedTextures { pos: [i32; 3], neighbour: &impl Fn([i32; 3]) -> u32, biome: &impl Fn() -> Option<&'b str>, + overlay: &mut impl FnMut(Overlay), ) -> Option { let rule = &r.rule; // `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with @@ -312,8 +402,17 @@ impl ConnectedTextures { return None; } let info = self.states.get(state as usize).copied().unwrap_or_default(); + let join = &r.overlay; let joins = |offset: [i32; 3]| { let other = neighbour(offset); + if join.any() { + // An overlay joins the blocks it is drawn beside. + return join.states.binary_search(&other).is_ok() + || self + .face_layers + .get(other as usize) + .is_some_and(|f| join.tiles.binary_search(&f[face as usize]).is_ok()); + } match rule.connect { Connect::Block => { self.states.get(other as usize).map(|s| s.block) == Some(info.block) @@ -332,7 +431,36 @@ impl ConnectedTextures { }; let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins); let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default); - let index = match rule.method { + let mut draw = |t: TileLayer| { + if let TileLayer::Layer { layer, .. } = t { + overlay(Overlay { + layer, + translucent: rule.layer == ctm::Layer::Translucent, + tint: join.tint, + }); + } + }; + // An overlay picks its tile as the method it is the overlay of. + let method = match rule.method { + Method::Overlay => { + // The 17 transition tiles, toward the blocks it names (none: it draws nothing). + if join.any() { + let mask = geometry::neighbours(axes(face), face.normal(), false, joins); + geometry::overlay_tiles(mask).for_each(|i| draw(tile(i))); + } + return None; + } + Method::OverlayCtm => Method::Ctm, + Method::OverlayRandom => Method::Random, + Method::OverlayRepeat => Method::Repeat, + Method::OverlayFixed => Method::Fixed, + Method::OverlayHorizontal => Method::Horizontal, + Method::OverlayVertical => Method::Vertical, + Method::OverlayHorizontalVertical => Method::HorizontalVertical, + Method::OverlayVerticalHorizontal => Method::VerticalHorizontal, + m => m, + }; + let index = match method { Method::Ctm => geometry::ctm_index(mask()), Method::CtmCompact => { let mask = mask(); @@ -377,9 +505,13 @@ impl ConnectedTextures { let face = geometry::symmetric_face(face, rule.symmetry); geometry::repeat_index(pos, axes(face), rule.width, rule.height) } - // Overlays are not loaded yet. + // Every overlay was mapped to its method above. _ => return None, }; + if rule.method.is_overlay() { + draw(tile(index)); + return None; + } match tile(index) { TileLayer::Skip => None, TileLayer::Default => Some(Pick::Keep), @@ -412,6 +544,7 @@ mod tests { .into_iter() .enumerate() .map(|(n, rule)| LoadedRule { + overlay: OverlayJoin::default(), tiles: rule .tiles .iter() @@ -432,7 +565,7 @@ mod tests { } /// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower, - /// upper); the stone sprite is layer 7. + /// upper); the stone sprite is layer 7, glass 3. fn world(files: &[(&str, &str)]) -> ConnectedTextures { let props = |pairs: &[(&str, &str)]| -> BlockProperties { pairs @@ -452,7 +585,11 @@ mod tests { ConnectedTextures::build( rules(files), states.iter().map(|(n, p)| Some((*n, p))), - |name| (name == "minecraft:block/stone").then_some(7), + |name| match name { + "minecraft:block/stone" => Some(7), + "minecraft:block/glass" => Some(3), + _ => None, + }, ) } @@ -473,7 +610,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, |_| {}) } const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46"; @@ -596,8 +733,8 @@ 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, |_| {}); + let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None, |_| {}); assert_eq!(lower, upper, "{x}"); tiles.insert(format!("{lower:?}")); } @@ -610,8 +747,9 @@ mod tests { "glass.properties", "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)); + let at = |y, biome: &'static str| { + ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome), |_| {}) + }; assert_eq!(at(10, "minecraft:desert"), tile(1000)); assert_eq!(at(64, "minecraft:desert"), Pick::Keep); assert_eq!(at(10, "minecraft:plains"), Pick::Keep); @@ -645,7 +783,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, |_| {}); 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)); @@ -710,6 +848,136 @@ mod tests { assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000)); } + /// The overlays `pick` hands out, with what it picks for the face itself. + fn overlays_in( + ctm: &ConnectedTextures, + state: u32, + face: Face, + blocks: &[([i32; 3], u32)], + ) -> (Vec, Pick) { + let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1); + let mut out = Vec::new(); + let pick = ctm.pick( + state, + 3, + face, + [0, 64, 0], + at, + || None, + |o| out.push(o.layer), + ); + (out, pick) + } + + #[test] + fn overlays_draw_toward_the_blocks_they_name() { + // Stone gets glass's edge where glass is beside it; then a fixed tile for the stone. + let ctm = world(&[ + ( + "a_overlay.properties", + "matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\ntintIndex=0\ntintBlock=oak_log", + ), + ("b.properties", "matchBlocks=stone\nmethod=fixed\ntiles=0"), + ]); + // Nothing beside: no overlay, and b draws. + assert_eq!(overlays_in(&ctm, 2, Face::Up, &[]), (vec![], tile(2000))); + // Glass to the north of a top face (its texture's up): tile 15. Glass east and west + // too: one tile for the three sides, 13. + assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 1)]).0, [1015]); + let three = [([0, 0, -1], 1), ([1, 0, 0], 1), ([-1, 0, 0], 1)]; + assert_eq!(overlays_in(&ctm, 2, Face::Up, &three).0, [1013]); + // Glass only at the north-east corner: the corner tile, 14. + assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([1, 0, -1], 1)]).0, [1014]); + // Stone beside stone draws nothing. + assert_eq!( + overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 2)]).0, + [0u16; 0] + ); + let mut tint = None; + ctm.pick( + 2, + 3, + Face::Up, + [0, 64, 0], + |o| if o == [0, 0, -1] { 1 } else { 0 }, + || None, + |o| { + tint = Some((o.tint, o.translucent)); + }, + ); + assert_eq!( + tint, + Some((Some((0, Some(3))), false)), + "oak_log's first state is 3" + ); + } + + #[test] + fn overlay_joins_layers_and_tints() { + let mut ctm = world(&[ + ( + "a.properties", + "matchBlocks=stone\nmethod=overlay\nconnectTiles=glass\ntiles=0-16\nlayer=translucent\ntintIndex=0\ntintBlock=no_such_block", + ), + ( + "b.properties", + "matchBlocks=glass\nmethod=overlay_ctm\nconnectBlocks=stone\ntiles=0-46", + ), + ]); + // connectTiles joins neighbours showing a named sprite on the same side: the door to the + // north shows glass (sprite 3) on top. + let mut faces = vec![[9u16; 6]; 7]; + faces[5][Face::Up as usize] = 3; + ctm.face_layers = faces; + let mut got = Vec::new(); + let door_north = |o: [i32; 3]| if o == [0, 0, -1] { 5 } else { 0 }; + ctm.pick( + 2, + 7, + Face::Up, + [0, 64, 0], + door_north, + || None, + |o| got.push(o), + ); + assert_eq!( + got, + [Overlay { + layer: 1015, + translucent: true, + // A tintBlock the game lacks tints nothing. + tint: None, + }] + ); + // connectBlocks is only for `overlay`: overlay_ctm joins glass by `connect`, not stone. + let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 2)]); + assert_eq!(layers, [2000]); + let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]); + assert_eq!(layers, [2001]); + } + + #[test] + fn overlay_methods_pick_like_their_own() { + let ctm = world(&[ + ( + "a.properties", + "matchBlocks=glass\nmethod=overlay_ctm\ntiles=0-46\nlayer=translucent", + ), + ( + "b.properties", + "matchBlocks=glass\nmethod=overlay_fixed\ntiles=", + ), + ( + "c.properties", + "matchBlocks=glass\nmethod=overlay_fixed\ntiles=0", + ), + ]); + // overlay_ctm joins glass (connect=block): the left end of a row, tile 1; b's + // draws nothing; c draws; the face keeps its own sprite. + let (layers, pick) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]); + assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep)); + } + #[test] fn sprite_names() { assert_eq!( diff --git a/crates/horizon-client-render/src/containers.rs b/crates/horizon-client-render/src/containers.rs index 7bc73dd3b3..a88db0f2b9 100644 --- a/crates/horizon-client-render/src/containers.rs +++ b/crates/horizon-client-render/src/containers.rs @@ -3,8 +3,9 @@ //! from the data-driven `context_int_provider`/`context_float_provider` files. pub use crate::chunk::block_entity::{ Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, - EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer, - ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin, + EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, + ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur, + Shelf, Skull, SkullSkin, }; use crate::items::ItemStack; use std::collections::HashMap; diff --git a/crates/horizon-client-render/src/datapack.rs b/crates/horizon-client-render/src/datapack.rs index 1d5aa3de94..e85b7dd9f8 100644 --- a/crates/horizon-client-render/src/datapack.rs +++ b/crates/horizon-client-render/src/datapack.rs @@ -575,7 +575,7 @@ pub struct DataStore { /// `item_modifier` files, for `/item modify`. pub item_modifiers: BTreeMap, /// The content tables' raw files from the packs after the built-in one: - /// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by + /// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by /// `/:`, each id's files in pack order. The tables /// merge them over what they read from the resource root. pub files: BTreeMap>>, @@ -584,7 +584,7 @@ pub struct DataStore { } /// `/:` for a pack file the content tables read: /// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`, -/// `villager_trade/`, `dialog/` or `tags//`, where the id keeps the sub +/// `villager_trade/`, `dialog/`, `data_maps/` or `tags//`, where the id keeps the sub /// path so a nested tag kind stays addressable. fn content_id(file: &str) -> Option { let rest = file.strip_prefix("data/")?; @@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option { let (kind, rest) = rest.split_once('/')?; let kind = match kind { "recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set" - | "villager_trade" | "dialog" => kind, + | "villager_trade" | "dialog" | "data_maps" => kind, // A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/ // test:sub/mytag`), the whole path after the namespace, so nested // registries keep their directory (`TagLoader`'s id shape). @@ -623,6 +623,7 @@ impl DataStore { let builtin = pack.id == "vanilla"; for f in pack.list_kinds(&[ "advancement", + "data_maps", "dialog", "function", "item_modifier", @@ -882,6 +883,15 @@ pub fn worldgen_overrides( mod tests { use super::*; + #[test] + fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() { + assert_eq!( + content_id("data/mymod/data_maps/item/fuel.json").as_deref(), + Some("data_maps/mymod:item/fuel") + ); + assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None); + } + #[test] fn enabled_packs_override_worldgen_in_order() { let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id())); diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index b0fddc4e8c..2ced33e5d5 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -111,6 +111,7 @@ mod end_sky; mod flat; mod frame_gen; mod hdr; +mod hdr_icc; mod hdr_stats; pub use hdr::Calibration as HdrCalibration; pub mod block_light; @@ -126,6 +127,7 @@ mod occlusion; mod oit; mod oit_features; mod pack_atlas; +mod pack_center_depth; pub mod pack_draw; mod pack_entities; mod pack_hand; @@ -7793,6 +7795,9 @@ impl Renderer { if let Some(timer) = &mut self.own_timing { timer.submitted(&self.queue); } + if let Some((_, Some(post))) = &mut self.pack_post { + post.submitted(); + } let failed = checks.and_then(GpuChecks::pop); if let Some((request, buffer)) = readback { // Out of memory on the way: the PNG would be black; taken again at half the size. @@ -8250,19 +8255,19 @@ impl Renderer { Ok([width, height]) } /// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's - /// third edition), with the peak and paper white as its content light levels (cLLI). + /// third edition), with the peak and paper white as its content light levels (cLLI), and + /// an ICC profile for the programs that don't read cICP (`hdr_icc`). fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> { let Some(hdr) = &self.hdr else { return Ok(()); }; let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?); - let mut encoder = png::Encoder::new( - std::io::BufWriter::new(std::fs::File::create(path)?), - self.config.width, - self.config.height, - ); - encoder.set_color(png::ColorType::Rgb); - encoder.set_depth(png::BitDepth::Sixteen); + let mut info = png::Info::with_size(self.config.width, self.config.height); + info.color_type = png::ColorType::Rgb; + info.bit_depth = png::BitDepth::Sixteen; + info.icc_profile = Some(hdr_icc::profile().into()); + let encoder = + png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?; let mut writer = encoder.write_header()?; // BT.2020 primaries, PQ, RGB, full range. writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?; diff --git a/crates/horizon-client-render/src/gfx/hdr_icc.rs b/crates/horizon-client-render/src/gfx/hdr_icc.rs new file mode 100644 index 0000000000..edbb8cca2d --- /dev/null +++ b/crates/horizon-client-render/src/gfx/hdr_icc.rs @@ -0,0 +1,159 @@ +//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020 +//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white. +//! An ICC profile has no values above white, so those programs show a correct SDR picture +//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed +//! them washed out. Programs that read `cICP` ignore this (PNG's third edition). + +/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white. +const WHITE: f64 = 203.0; +/// PQ's values from 0 to 1, in the curve's steps. +const STEPS: usize = 4096; +/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford). +const RED: [f64; 3] = [0.67348, 0.27904, -0.00193]; +const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998]; +const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685]; +const D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits. +fn pq_nits(signal: f64) -> f64 { + let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0); + let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0); + let p = signal.powf(1.0 / m2); + 10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1) +} + +fn s15_fixed16(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +fn xyz(v: [f64; 3]) -> Vec { + let mut tag = b"XYZ \0\0\0\0".to_vec(); + tag.extend(v.iter().flat_map(|&c| s15_fixed16(c))); + tag +} + +fn curve() -> Vec { + let mut tag = b"curv\0\0\0\0".to_vec(); + tag.extend((STEPS as u32).to_be_bytes()); + for i in 0..STEPS { + let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0); + tag.extend(((y * 65535.0).round() as u16).to_be_bytes()); + } + tag +} + +/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts. +fn description(text: &str) -> Vec { + let mut tag = b"desc\0\0\0\0".to_vec(); + tag.extend((text.len() as u32 + 1).to_be_bytes()); + tag.extend(text.as_bytes()); + tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]); + tag +} + +/// An ICC v2 matrix-shaper display profile. +pub(super) fn profile() -> Vec { + let trc = curve(); + let copyright = b"text\0\0\0\0Public domain\0".to_vec(); + let tags: [(&[u8; 4], &[u8]); 9] = [ + (b"desc", &description("BT.2020 PQ, 203 nits = white")), + (b"cprt", ©right), + (b"wtpt", &xyz(D50)), + (b"rXYZ", &xyz(RED)), + (b"gXYZ", &xyz(GREEN)), + (b"bXYZ", &xyz(BLUE)), + (b"rTRC", &trc), + (b"gTRC", &trc), + (b"bTRC", &trc), + ]; + let data_start = 128 + 4 + 12 * tags.len(); + let mut table = (tags.len() as u32).to_be_bytes().to_vec(); + let mut data = Vec::new(); + let mut trc_at = None; + for (name, tag) in tags { + // The three channels share one curve. + let is_trc = tag == &trc[..]; + let at = match trc_at.filter(|_| is_trc) { + Some(at) => at, + None => { + let at = data_start + data.len(); + data.extend(tag); + data.resize(data.len().next_multiple_of(4), 0); + at + } + }; + if is_trc { + trc_at = Some(at); + } + table.extend(name); + table.extend((at as u32).to_be_bytes()); + table.extend((tag.len() as u32).to_be_bytes()); + } + let mut out = vec![0; 128]; + let size = data_start + data.len(); + out[0..4].copy_from_slice(&(size as u32).to_be_bytes()); + out[8..12].copy_from_slice(&[2, 0x40, 0, 0]); + out[12..16].copy_from_slice(b"mntr"); + out[16..20].copy_from_slice(b"RGB "); + out[20..24].copy_from_slice(b"XYZ "); + out[36..40].copy_from_slice(b"acsp"); + // Relative colorimetric, and the connection space's illuminant. + out[64..68].copy_from_slice(&1u32.to_be_bytes()); + for (i, &c) in D50.iter().enumerate() { + out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c)); + } + out.extend(table); + out.extend(data); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] { + let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize; + let at = (0..u32_at(128)) + .map(|i| 132 + 12 * i) + .find(|&e| &profile[e..e + 4] == name) + .unwrap(); + &profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)] + } + + #[test] + fn the_profile_is_whole() { + let p = profile(); + assert_eq!( + u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize, + p.len() + ); + assert_eq!(&p[36..40], b"acsp"); + assert_eq!(p.len() % 4, 0); + assert_eq!(&tag(&p, b"desc")[0..4], b"desc"); + // One curve, shared: the profile stays small. + assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr()); + assert!(p.len() < 10_000, "{}", p.len()); + } + + #[test] + fn the_primaries_add_up_to_white() { + for i in 0..3 { + assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001); + } + } + + #[test] + fn the_curve_reaches_white_at_reference_white_and_stays() { + let p = profile(); + let trc = tag(&p, b"rTRC"); + let value = |signal: f64| { + let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize; + u16::from_be_bytes([trc[i], trc[i + 1]]) + }; + assert_eq!(value(0.0), 0); + // 203 nits in PQ is a signal of 0.5807. + assert!(value(0.57) < 65535); + assert_eq!(value(0.59), 65535); + assert_eq!(value(1.0), 65535); + } +} diff --git a/crates/horizon-client-render/src/gfx/pack_center_depth.rs b/crates/horizon-client-render/src/gfx/pack_center_depth.rs new file mode 100644 index 0000000000..30435dbadb --- /dev/null +++ b/crates/horizon-client-render/src/gfx/pack_center_depth.rs @@ -0,0 +1,296 @@ +//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can +//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float` +//! target, copied to one of a few small buffers and read back frames later without waiting for +//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; + +const SLOTS: usize = 3; +const FREE: u8 = 0; +/// Copied this frame: mapped once the frame is submitted. +const COPIED: u8 = 1; +const PENDING: u8 = 2; +const MAPPED: u8 = 3; +/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`. +const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + +const SHADER: &str = " +@group(0) @binding(0) var depth: texture_depth_2d; +@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let p = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + return vec4(p * 2.0 - 1.0, 0.0, 1.0); +} +@fragment fn fs() -> @location(0) vec4 { + let centre = vec2(textureDimensions(depth) / 2u); + return vec4(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0); +} +"; + +struct Slot { + buffer: wgpu::Buffer, + state: Arc, +} + +pub struct CenterDepth { + slots: Vec, + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + /// The 1x1 target the centre texel is loaded into. + texel: wgpu::Texture, + texel_view: wgpu::TextureView, + /// The bind group of the depth texture it reads, made again when that texture changes. + group: Option<(wgpu::Texture, wgpu::BindGroup)>, + /// The depth last read back: GL window depth, 0..1. + latest: Option, +} + +impl CenterDepth { + pub fn new(device: &wgpu::Device) -> Self { + let slots = (0..SLOTS) + .map(|_| Slot { + buffer: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Shader pack centre depth"), + size: u64::from(ROW), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }), + state: Arc::new(AtomicU8::new(FREE)), + }) + .collect(); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Shader pack centre depth"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Shader pack centre depth"), + source: wgpu::ShaderSource::Wgsl(SHADER.into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Shader pack centre depth"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let format = wgpu::TextureFormat::R32Float; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Shader pack centre depth"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(format.into())], + }), + multiview_mask: None, + cache: None, + }); + let texel = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Shader pack centre depth"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let texel_view = texel.create_view(&Default::default()); + Self { + slots, + pipeline, + layout, + texel, + texel_view, + group: None, + latest: None, + } + } + + /// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and + /// copies it into a free slot; with none free (the GPU is behind), this frame is skipped. + pub fn copy( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + depth: &wgpu::Texture, + ) { + let Some(slot) = self + .slots + .iter() + .find(|s| s.state.load(Ordering::Acquire) == FREE) + else { + return; + }; + if self.group.as_ref().is_none_or(|(t, _)| t != depth) { + let view = depth.create_view(&Default::default()); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader pack centre depth"), + layout: &self.layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }], + }); + self.group = Some((depth.clone(), group)); + } + let Some((_, group)) = &self.group else { + return; + }; + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack centre depth"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.texel_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, group, &[]); + pass.draw(0..3, 0..1); + } + encoder.copy_texture_to_buffer( + self.texel.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &slot.buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(ROW), + rows_per_image: Some(1), + }, + }, + self.texel.size(), + ); + slot.state.store(COPIED, Ordering::Release); + } + + /// After the frame's submission: start reading back what it copied. + pub fn submitted(&mut self) { + for slot in &self.slots { + if slot.state.load(Ordering::Acquire) != COPIED { + continue; + } + slot.state.store(PENDING, Ordering::Release); + let state = slot.state.clone(); + slot.buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + let next = if result.is_ok() { MAPPED } else { FREE }; + state.store(next, Ordering::Release); + }); + } + } + + /// The newest depth read back so far, `None` before the first arrives. + pub fn collect(&mut self, device: &wgpu::Device) -> Option { + let _ = device.poll(wgpu::PollType::Poll); + for slot in &self.slots { + if slot.state.load(Ordering::Acquire) != MAPPED { + continue; + } + let depth = slot + .buffer + .slice(..4) + .get_mapped_range() + .ok() + .map(|bytes| bytemuck::pod_read_unaligned::(&bytes[..4])); + slot.buffer.unmap(); + slot.state.store(FREE, Ordering::Release); + if let Some(depth) = depth.filter(|d| d.is_finite()) { + self.latest = Some(depth.clamp(0.0, 1.0)); + } + } + self.latest + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> { + let instance = + wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; + pollster::block_on(adapter.request_device(&Default::default())).ok() + } + + /// A depth texture cleared to a known depth comes back as that depth, a frame or more + /// later, without the frame waiting for it. + #[test] + fn the_centre_depth_comes_back() { + let Some((device, queue)) = test_device() else { + return; + }; + let depth = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 9, + height: 7, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = depth.create_view(&Default::default()); + let mut center = CenterDepth::new(&device); + assert_eq!(center.collect(&device), None); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.375), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + center.copy(&device, &mut encoder, &depth); + queue.submit([encoder.finish()]); + center.submitted(); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + assert_eq!(center.collect(&device), Some(0.375)); + // Its slot is free again: every slot can be used once more. + for _ in 0..SLOTS { + let mut encoder = device.create_command_encoder(&Default::default()); + center.copy(&device, &mut encoder, &depth); + queue.submit([encoder.finish()]); + center.submitted(); + } + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + assert_eq!(center.collect(&device), Some(0.375)); + } +} diff --git a/crates/horizon-client-render/src/gfx/pack_post.rs b/crates/horizon-client-render/src/gfx/pack_post.rs index 35b6ff9548..8bf1e98497 100644 --- a/crates/horizon-client-render/src/gfx/pack_post.rs +++ b/crates/horizon-client-render/src/gfx/pack_post.rs @@ -512,6 +512,8 @@ pub struct PackPost { page_groups: HashMap, /// 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`. + center_depth: Option, /// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`. custom: horizon_shaders::Custom, atlas: Option, @@ -2242,6 +2244,7 @@ impl PackPost { block_ids, page_groups: HashMap::new(), timing: None, + center_depth: None, custom, atlas: world.atlas.as_ref().map(|a| a.0.clone()), lightmap: world.lightmap, @@ -2903,6 +2906,13 @@ impl PackPost { } } + /// After the frame's submission: the centre depth it copied starts its way back. + pub fn submitted(&mut self) { + if let Some(center) = &mut self.center_depth { + center.submitted(); + } + } + /// Whether the pack draws entities or block entities itself. pub fn has_entities(&self) -> bool { self.entities.is_some() @@ -3620,6 +3630,10 @@ impl PackPost { if let Some(t) = &mut self.timing { t.begin(device, queue); } + self.uniforms.center_depth_raw = self + .center_depth + .get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device)) + .collect(device); self.uniforms.update(frame, inputs, size); // Until the camera's biome is known, `smooth` waits for the world's values. if !inputs.biome_known { @@ -3858,6 +3872,10 @@ impl PackPost { self.draw_translucent(device, queue, encoder, size, terrain); } let targets = self.targets.as_ref().expect("resized"); + // depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`. + if let Some(center) = &mut self.center_depth { + center.copy(device, encoder, &targets.depth_texture); + } let view = |m: u16, i: usize, alt: bool| { targets.color[i] .as_ref() diff --git a/crates/horizon-client-render/src/gfx/pack_uniforms.rs b/crates/horizon-client-render/src/gfx/pack_uniforms.rs index 43a46d4254..9539efb27f 100644 --- a/crates/horizon-client-render/src/gfx/pack_uniforms.rs +++ b/crates/horizon-client-render/src/gfx/pack_uniforms.rs @@ -252,6 +252,7 @@ pub const NAMES: &[&str] = &[ "is_sprinting", "endFlashIntensity", "endFlashPosition", + "centerDepthSmooth", "isRightHanded", "is_on_ground", "bedrockLevel", @@ -274,6 +275,9 @@ pub struct FrameUniforms { previous: Option<(glam::DVec3, Mat4, Mat4)>, wetness: f32, eye_smooth: [f32; 2], + /// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`. + pub center_depth_raw: Option, + center_depth: Option, /// The pack's shadow settings (7.1). pub shadow: Shadow, } @@ -289,6 +293,8 @@ impl Default for FrameUniforms { previous: None, wetness: 0.0, eye_smooth: [0.0; 2], + center_depth_raw: None, + center_depth: None, shadow: Shadow::default(), } } @@ -433,6 +439,15 @@ impl FrameUniforms { for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) { *smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt); } + // `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value + // read back is taken as it is. + if let Some(raw) = self.center_depth_raw { + self.center_depth = Some(match self.center_depth { + Some(v) => smooth(v, raw, 1.0 / 20.0, dt), + None => raw, + }); + } + let center_depth = self.center_depth.unwrap_or(1.0); // Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start // and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start // of one with the nearer end of the other made fog begin at the camera on clear days. @@ -479,6 +494,7 @@ impl FrameUniforms { "upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))), "sunPosition" => v3(sun), "endFlashPosition" => v3(to_view(inputs.end_flash_direction)), + "centerDepthSmooth" => Value::f(&[center_depth]), "moonPosition" => v3(moon), "shadowLightPosition" => v3(if day { sun } else { moon }), "sunAngle" => Value::f(&[sun_angle]), diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 924a04b630..5f1c252318 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -137,6 +137,22 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec)> { } Ok((info.width, info.height, rgba)) } +/// The most texels of OptiFine random entity variants the entity atlas packs: 2048 rows of +/// its 4096, which keeps the default atlas (4096x5120) under the 8192 rows many GPUs take. +const RANDOM_TEXELS: u64 = 4096 * 2048; +/// The `files` (in order) whose sizes add up to at most `budget` texels. +fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet { + let mut texels = 0u64; + files + .iter() + .filter(|p| { + let (w, h) = png_size(p).unwrap_or((0, 0)); + texels += u64::from(w) * u64::from(h); + texels <= budget + }) + .map(|p| (*p).clone()) + .collect() +} /// A PNG's width and height, from its header alone. fn png_size(path: &Path) -> Option<(u32, u32)> { use std::io::Read; @@ -215,6 +231,119 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage { } (name.clone(), bw, bh, out) } +/// The textures the enabled packs' Custom Item Textures item rules draw, under their +/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate. +/// They are item textures, so an animated strip keeps its first frame. None without +/// `optifine/cit` rules. +fn cit_sprites( + root: &Path, + packed: &[PackedImage], +) -> Vec<(PackedImage, Option, bool)> { + let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect(); + let ids: Vec = crate::cit_assets::item_sprites(&crate::cit::load_rules(root)) + .into_iter() + // Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up; + // Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own. + .filter(|id| { + let name = id.strip_prefix("minecraft:").unwrap_or(id); + !have.contains(name) && !name.starts_with("horizon/") + }) + .collect(); + // No more than the atlas could ever hold, by their headers, so a pack naming thousands of + // big textures is cut short before they are decoded, not after. Each is charged what it + // can be packed as (a strip's frame, or a still image whole), 1024x512 at most since + // `cit_sprite` refuses more, with a gutter; + // the glyph rows and the shelves' waste (a tenth) are kept aside. + let side = u64::from(MAX_ATLAS_SIZE); + let used: u64 = packed + .iter() + .map(|i| u64::from(i.1 + 1) * u64::from(i.2 + 1)) + .sum(); + let mut left = (side * side * 9 / 10).saturating_sub(side * u64::from(GLYPH_ROWS) + used); + let before = ids.len(); + let ids: Vec = ids + .into_iter() + .filter(|id| { + let path = crate::atlas_sources::texture_path(root, id, "textures"); + let Some((w, h)) = png_size(&path) else { + return true; + }; + let (w, h) = (u64::from(w), u64::from(h)); + if w * h > 1024 * 512 * 64 { + // Refused by `cit_sprite` without decoding: costs nothing. + return true; + } + // A strip's frame is at most its width squared; a still image is all of it. + let frame = (w * h).min(w * w).min(1024 * 512) + w + h + 1; + match left.checked_sub(frame) { + Some(rest) => { + left = rest; + true + } + None => false, + } + }) + .collect(); + if ids.len() < before { + log::warn!( + "left out {} custom item textures: more than the atlas holds", + before - ids.len() + ); + } + // Decoded on the threads `pack` decodes the rest on: a big pack names thousands. + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()); + let chunk = ids.len().div_ceil(threads).max(1); + std::thread::scope(|scope| { + let workers: Vec<_> = ids + .chunks(chunk) + .map(|ids| scope.spawn(|| ids.iter().filter_map(|id| cit_sprite(root, id)).collect())) + .collect(); + workers + .into_iter() + .flat_map(|w| { + w.join().unwrap_or_else(|_| { + log::warn!("a custom item texture decoder panicked; its textures are left out"); + Vec::new() + }) + }) + .collect() + }) +} +/// One of `cit_sprites`, or `None` (with a warning) when it does not load or is too big. +fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option, bool)> { + let name = id.strip_prefix("minecraft:").unwrap_or(id); + let path = crate::atlas_sources::texture_path(root, id, "textures"); + // The limit `pack` keeps every other texture to, for a frame; and from the header first, + // so an image bigger than 64 such frames is never decoded. + let fits = |w: u32, h: u32| w <= 1024 && h <= 512; + if let Some((w, h)) = png_size(&path) + && u64::from(w) * u64::from(h) > 1024 * 512 * 64 + { + log::warn!("skipped custom item texture {}: {w}x{h}", path.display()); + return None; + } + let image = match decode(&path) { + Ok(image) => image, + Err(e) => { + log::warn!("skipped custom item texture {}: {e}", path.display()); + return None; + } + }; + let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok(); + let animation = animation_of(mcmeta.as_deref()); + // Only an animation is cut to its first frame: a still CIT texture is drawn whole, and + // armour layers are twice as wide as they are tall. + let (w, h, pixels) = match &animation { + Some(animation) => first_frame(image, Some(animation)), + None => image, + }; + if !fits(w, h) { + log::warn!("skipped custom item texture {}: {w}x{h}", path.display()); + return None; + } + let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta); + Some(((name.to_owned(), w, h, pixels), flags, animation.is_some())) +} /// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for /// the full atlas. A pack may add others through `atlases/blocks.json`. const WALKED_TEXTURE_FOLDERS: &[&str] = &[ @@ -492,6 +621,25 @@ impl HudAtlas { random_textures: Default::default(), } } + /// `layout`, but when `extras` (Custom Item Textures) make the images more than `max` + /// holds, without them: they are left out, not the packs. + fn layout_leaving_out( + images: &mut Vec, + animated: &mut Vec, + extras: &HashSet, + max: u32, + ) -> Result<(u32, Vec<(u32, u32)>, u32)> { + match Self::layout(images, max) { + Err(e) if !extras.is_empty() => { + images.retain(|i| !extras.contains(&i.0)); + animated.retain(|n| !extras.contains(n)); + let layout = Self::layout(images, max)?; + log::warn!("left out the custom item textures: {e}"); + Ok(layout) + } + layout => layout, + } + } /// Shelf placements of `images` (tallest first) in the narrowest atlas /// width from 4096 up that keeps the atlas, glyph rows included, within /// `max` texels a side; and the first row free for glyphs. High-resolution @@ -590,10 +738,23 @@ impl HudAtlas { named.insert(root.join(file), name.to_owned()); } if !named.is_empty() { + // A pack's variants can't grow the shared atlas past what a GPU takes: every variant + // (those beside their texture too) counts, in name order, and those past the budget + // stay out (their mobs wear the texture itself), not the whole pack. + let mut variants: Vec<&PathBuf> = named.keys().collect(); + variants.sort(); + let kept = within_budget(&variants, RANDOM_TEXELS); + if kept.len() < variants.len() { + log::warn!( + "{} random entity textures left out of the atlas: past its budget", + variants.len() - kept.len() + ); + } + files.retain(|p| !named.contains_key(p) || kept.contains(p)); // Those beside their texture are walked already. let walked: HashSet<&PathBuf> = files.iter().collect(); - let mut extra: Vec = named - .keys() + let mut extra: Vec = kept + .iter() .filter(|p| !walked.contains(p)) .cloned() .collect(); @@ -682,10 +843,21 @@ impl HudAtlas { } images.push(image); } + let mut cit_names = HashSet::new(); if full { let (more, more_animated) = atlas_sprites(root, &images); images.extend(more); animated.extend(more_animated); + for (image, image_flags, is_animated) in cit_sprites(root, &images) { + cit_names.insert(image.0.clone()); + if let Some(f) = image_flags { + flags.insert(image.0.clone(), f); + } + if is_animated { + animated.push(image.0.clone()); + } + images.push(image); + } } // The title screen's "JAVA EDITION" banner reads "RUST EDITION". if let Some(edition) = images @@ -720,7 +892,8 @@ impl HudAtlas { } } images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1))); - let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?; + let (width, placements, glyph_region) = + Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?; // A multiple of 1024 rather than a power of two: the default resources // need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384). let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024); @@ -2320,6 +2493,49 @@ mod tests { assert!(!atlas.sprites.contains_key("gems:item/ruby")); fs::remove_dir_all(&root).unwrap(); } + /// OptiFine random entity variants outside `textures/` pack under the names + /// `RandomTextures` gives them; one beside its texture packs once, under its own. + #[test] + fn random_entity_variants_pack_under_their_scan_names() { + let root = + std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let random = root.join("assets/minecraft/optifine/random/entity/pig"); + fs::create_dir_all(&random).unwrap(); + write_strip(&random.join("pig2.png"), 64, 64, &[[0, 200, 0, 255]], None); + let beside = root.join("assets/minecraft/textures/entity/cat"); + fs::create_dir_all(&beside).unwrap(); + fs::write(beside.join("cat.properties"), "textures.1=2").unwrap(); + write_strip(&beside.join("cat.png"), 64, 32, &[[1, 1, 1, 255]], None); + write_strip(&beside.join("cat2.png"), 64, 32, &[[0, 0, 200, 255]], None); + let atlas = HudAtlas::build(&root).unwrap(); + let rect = atlas.sprites["minecraft:optifine/random/entity/pig/pig2"]; + assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 200, 0, 255]); + let rect = atlas.sprites["entity/cat/cat2"]; + assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]); + assert!(atlas.random_textures.get("entity/pig/pig").is_some()); + fs::remove_dir_all(&root).unwrap(); + } + /// Variants past the budget, in name order, stay out of the atlas. + #[test] + fn random_variants_stop_at_their_budget() { + let dir = std::env::temp_dir().join(format!("horizon-atlas-budget-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let files: Vec = ["a2.png", "a3.png", "a4.png"] + .iter() + .map(|n| dir.join(n)) + .collect(); + for f in &files { + write_strip(f, 16, 16, &[[1, 2, 3, 255]], None); + } + let refs: Vec<&PathBuf> = files.iter().collect(); + // 256 texels each: 600 holds two. + let kept = within_budget(&refs, 600); + assert_eq!(kept, files[..2].iter().cloned().collect()); + assert_eq!(within_budget(&refs, 768).len(), 3); + fs::remove_dir_all(&dir).unwrap(); + } #[test] fn content_mod_effect_icons_pack_under_their_namespace() { let root = @@ -2464,6 +2680,209 @@ mod tests { .expect("the namespaced sprite animates"); assert_eq!(animation.frame_count(), 2); } + /// A resource root with one plain item texture and these CIT files (relative path, contents). + fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf { + let root = + std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let item = root.join("assets/minecraft/textures/item"); + fs::create_dir_all(&item).unwrap(); + write_strip(&item.join("stick.png"), 16, 16, &[[90, 60, 30, 255]], None); + let cit = root.join("assets/minecraft/optifine/cit"); + fs::create_dir_all(&cit).unwrap(); + for (rel, text) in files { + fs::write(cit.join(rel), text).unwrap(); + } + root + } + #[test] + fn a_cit_item_rules_texture_is_packed_under_its_id() { + let root = cit_root("plain", &[("a.properties", "items=stick\ntexture=a")]); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None); + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"]; + assert_eq!(rect.texels((atlas.width, atlas.height)), (2, 2)); + assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]); + } + #[test] + fn a_still_cit_texture_is_packed_whole() { + // Not cut to a square as an unmarked strip would be: armour layers are 2:1. + let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip(&cit.join("a.png"), 4, 2, &[[7, 8, 9, 255]], None); + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"]; + assert_eq!(rect.texels((atlas.width, atlas.height)), (4, 2)); + } + #[test] + fn an_animated_cit_texture_packs_one_frame_and_animates() { + let root = cit_root("anim", &[("a.properties", "items=stick\ntexture=a")]); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip( + &cit.join("a.png"), + 16, + 48, + &[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]], + Some(r#"{"animation": {"frametime": 2}}"#), + ); + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"]; + assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16)); + assert_eq!(texel_at(&atlas, rect, 0, 0), [255, 0, 0, 255]); + let animations: Vec<_> = atlas + .sprite_animations + .iter() + .filter(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0) + .collect(); + assert_eq!(animations.len(), 1); + assert_eq!(animations[0].1.frame_count(), 3); + } + #[test] + fn a_texture_only_an_armor_rule_names_is_not_packed() { + let root = cit_root( + "armor", + &[( + "a.properties", + "type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=a", + )], + ); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None); + // The rule loads; only its kind keeps the texture out. + assert_eq!(crate::cit::load_rules(&root).len(), 1); + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:"))); + } + #[test] + fn named_cit_textures_pack_once_and_bad_ones_are_skipped() { + let root = cit_root( + "mixed", + &[ + // Vanilla's own texture: already packed, under its usual name. + ( + "v.properties", + "items=stick +texture=item/stick", + ), + ( + "sub.properties", + "items=bow +texture.bow_pulling_0=b", + ), + ( + "broken.properties", + "items=apple +texture=broken", + ), + ( + "wide.properties", + "items=egg +texture=wide", + ), + ], + ); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip(&cit.join("b.png"), 2, 2, &[[5, 6, 7, 255]], None); + fs::write(cit.join("broken.png"), b"not a png").unwrap(); + // A still image past the 1024x512 limit. + write_strip(&cit.join("wide.png"), 1040, 1040, &[[1, 1, 1, 255]], None); + // Vanilla's stick is already packed when the CIT pass runs. + let stick = ("item/stick".to_owned(), 16, 16, vec![0; 16 * 16 * 4]); + let added = cit_sprites(&root, &[stick]); + let names: Vec<_> = added.iter().map(|(image, _, _)| image.0.as_str()).collect(); + assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]); + // And the atlas still builds around the broken and the too-wide texture. + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + assert!( + atlas + .sprites + .contains_key("horizon_cit:minecraft/optifine/cit/b") + ); + assert!(atlas.sprites.contains_key("item/stick")); + } + #[test] + fn one_oversized_texture_leaves_the_others_in() { + // `a` sorts first and claims more than any atlas; `b` is small and must still pack. + let root = cit_root( + "oversized", + &[ + ("a.properties", "items=stick\ntexture=a"), + ("b.properties", "items=apple\ntexture=b"), + ], + ); + let cit = root.join("assets/minecraft/optifine/cit"); + // Only the header is read for `a`: a PNG signature and an IHDR of 20000x20000. + let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + header.extend(20000u32.to_be_bytes()); + header.extend(20000u32.to_be_bytes()); + fs::write(cit.join("a.png"), header).unwrap(); + write_strip(&cit.join("b.png"), 2, 2, &[[4, 5, 6, 255]], None); + let added = cit_sprites(&root, &[]); + let _ = fs::remove_dir_all(&root); + let names: Vec<_> = added.iter().map(|(i, _, _)| i.0.as_str()).collect(); + assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]); + } + #[test] + fn a_wide_grid_animation_packs_its_frame() { + // 1104 wide is past the limit for a still texture, not for a row of 16x16 frames. + let root = cit_root("grid", &[("a.properties", "items=stick\ntexture=a")]); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip(&cit.join("a.png"), 1104, 16, &[[9, 9, 9, 255]], None); + let mcmeta = r#"{"animation":{"width":16,"height":16}}"#; + fs::write(cit.join("a.png.mcmeta"), mcmeta).unwrap(); + let added = cit_sprites(&root, &[]); + let _ = fs::remove_dir_all(&root); + let sizes: Vec<_> = added.iter().map(|(i, _, _)| (i.1, i.2)).collect(); + assert_eq!(sizes, [(16, 16)]); + } + #[test] + fn too_many_custom_item_textures_leave_them_out() { + // A 1024 atlas holds the stick and the glyph rows, not an image 1100 wide. + let image = |name: &str, side: u32| { + let pixels = vec![0; (side * side * 4) as usize]; + (name.to_owned(), side, side, pixels) + }; + let mut images = vec![image("item/stick", 16), image("horizon_cit:big", 1100)]; + let mut animated = vec!["horizon_cit:big".to_owned()]; + let extras = HashSet::from(["horizon_cit:big".to_owned()]); + assert!(HudAtlas::layout(&images, 1024).is_err()); + HudAtlas::layout_leaving_out(&mut images, &mut animated, &extras, 1024).unwrap(); + let names: Vec<_> = images.iter().map(|i| i.0.as_str()).collect(); + assert_eq!(names, ["item/stick"]); + assert!(animated.is_empty()); + // Without extras to leave out, it fails as before. + let mut alone = vec![image("item/big", 1100)]; + let none = HashSet::new(); + assert!(HudAtlas::layout_leaving_out(&mut alone, &mut animated, &none, 1024).is_err()); + } + #[test] + fn a_root_without_cit_rules_packs_the_same_sprites() { + let root = cit_root("none", &[("a.properties", "items=stick\ntexture=a")]); + let cit = root.join("assets/minecraft/optifine/cit"); + write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None); + let with: HashSet = HudAtlas::build(&root) + .unwrap() + .sprites + .into_keys() + .collect(); + fs::remove_dir_all(root.join("assets/minecraft/optifine")).unwrap(); + let without: HashSet = HudAtlas::build(&root) + .unwrap() + .sprites + .into_keys() + .collect(); + let _ = fs::remove_dir_all(&root); + assert!(without.is_subset(&with)); + let added: Vec<_> = with.difference(&without).collect(); + assert_eq!(added, ["horizon_cit:minecraft/optifine/cit/a"]); + assert!(without.contains("item/stick")); + } /// `count` images of `w x h`; layout reads only their sizes. fn sized(count: usize, w: u32, h: u32) -> Vec { (0..count) diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index 85b16ee1ce..e5e169c479 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -20,6 +20,7 @@ pub mod chat; pub mod chunk; pub mod chunk_map; pub mod cit; +pub mod cit_assets; pub mod clouds; pub mod collisions; pub mod command_block; diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index 85a614954d..8b1531a712 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -1333,8 +1333,11 @@ impl Mesher<'_> { ([lighting::gray(directional); 4], [coords; 4]) }; let tint = Self::tint(state, q.tint_index, tints, pos); - // OptiFine connected textures: another sprite, or one per quarter. + // OptiFine connected textures: another sprite, or one per quarter, and the overlays + // drawn over it (a few per face at most; more are dropped). let connected = &self.render.connected; + let mut overlays = [None; 8]; + let mut count = 0; let pick = if connected.is_empty() { Pick::Keep } else { @@ -1346,27 +1349,35 @@ 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])), + |overlay| { + if let Some(slot) = overlays.get_mut(count) { + *slot = Some(overlay); + count += 1; + } + }, ) }; - let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4]; - for (i, corner) in corners.iter_mut().enumerate() { - let g = gray[i] as u32; - let rgb = match tint { - Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255), - None => [g; 3], - }; - let p = q.positions[i]; - *corner = ( - TerrainVertex::new( - [p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]], + let vertices = |tint: Option| { + std::array::from_fn::<_, 4, _>(|i| { + let g = gray[i] as u32; + let rgb = match tint { + Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255), + None => [g; 3], + }; + let p = q.positions[i]; + ( + TerrainVertex::new( + [p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]], + q.uv[i], + 0, + emitting(pack_rgb(rgb), tier), + pack_coords(lighting::with_emission(coords[i], q.light_emission)), + ), q.uv[i], - 0, - emitting(pack_rgb(rgb), tier), - pack_coords(lighting::with_emission(coords[i], q.light_emission)), - ), - q.uv[i], - ); - } + ) + }) + }; + let corners = vertices(tint); // Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the // solid layer whatever their sprite. let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf; @@ -1403,15 +1414,69 @@ impl Mesher<'_> { } } } + // Overlays: the face again, after it (equal depth passes), tinted as their rule says. + // `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent + // face it must be in the translucent pass too, or the face would draw over it. + // Most faces have none: nothing below runs for them. + if count == 0 { + return; + } + let face_translucent = match pick { + Pick::Keep => self.translucent(own), + Pick::Tile(t) => self.translucent(tile(t)), + Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))), + }; + for overlay in overlays[..count].iter().flatten().copied() { + let tint = overlay.tint.and_then(|(index, block)| { + let info = block + .and_then(|b| self.tables.info.get(b as usize)) + .unwrap_or(state); + Self::tint(info, index, tints, pos) + }); + let tile = TileLayer::Layer { + layer: overlay.layer, + translucent: overlay.translucent || face_translucent, + opaque: false, + }; + self.quad_as(out, false, tile, vertices(tint).map(|c| c.0), false); + } } /// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no /// alpha cutout whatever the sprite). fn quad( + &self, + out: &mut SectionMesh, + solid_leaves: bool, + tile: TileLayer, + v: [TerrainVertex; 4], + ) { + self.quad_as(out, solid_leaves, tile, v, true); + } + /// Whether [`Mesher::quad`] draws `tile` in the translucent pass. + fn translucent(&self, tile: TileLayer) -> bool { + match tile { + TileLayer::Layer { + layer, translucent, .. + } => { + translucent + || self + .translucent_layers + .get(usize::from(layer)) + .copied() + .unwrap_or(false) + } + TileLayer::Skip | TileLayer::Default => false, + } + } + /// [`Mesher::quad`], with the sprite's own translucency (`translucent_layers`) counted only + /// when `by_sprite`: an overlay's pass is its rule's. + fn quad_as( &self, out: &mut SectionMesh, solid_leaves: bool, tile: TileLayer, mut v: [TerrainVertex; 4], + by_sprite: bool, ) { let TileLayer::Layer { layer, @@ -1424,12 +1489,11 @@ impl Mesher<'_> { if self.tables.is_animated(layer) { out.note_sprite_layer(layer); } - let translucent = translucent - || self - .translucent_layers - .get(usize::from(layer)) - .copied() - .unwrap_or(false); + let translucent = if by_sprite { + self.translucent(tile) + } else { + translucent + }; // Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side // skips its clear top row). All four vertices share the layer. let layer = if opaque && !translucent || solid_leaves { diff --git a/crates/horizon-client-render/src/models.rs b/crates/horizon-client-render/src/models.rs index ddbcfd31c5..b2404f92a5 100644 --- a/crates/horizon-client-render/src/models.rs +++ b/crates/horizon-client-render/src/models.rs @@ -10,6 +10,29 @@ use horizon_world::registry::{BlockRegistry, BlockStateId}; use serde_json::{Value, json}; use std::path::Path; +/// The standalone models native mods asked for (`native_standalone_models`). +static STANDALONE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// Sets the models no blockstate names that every resource load bakes +/// beside the block models ([`StateModels::standalone`]): once, at start, +/// as vanilla's `ModelEvent.RegisterStandalone` is posted once per model +/// discovery. Names are sorted and deduplicated; one without a namespace +/// (`mymod:block/shaft_half`) is dropped. False when already set. +pub fn register_standalone(mut names: Vec) -> bool { + names.retain(|n| { + n.split_once(':') + .is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty()) + }); + names.sort(); + names.dedup(); + STANDALONE.set(names).is_ok() +} + +/// What [`register_standalone`] set; none before. +pub fn standalone() -> &'static [String] { + STANDALONE.get().map_or(&[], Vec::as_slice) +} + type Placement = (&'static str, &'static [(&'static str, &'static str)]); const GALLERY: [Placement; 16] = [ ( @@ -76,8 +99,9 @@ pub(crate) fn prepare_with_models( .filter(|name| registry.default_state(name).is_some()) .map(|name| name.strip_prefix("minecraft:").unwrap_or(name)) .collect(); - let catalog = + let mut catalog = StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?; + catalog.bake_standalone(root, sprites, models, standalone())?; let mut placements = Vec::new(); let mut states = Vec::new(); for (index, (name, properties)) in GALLERY.iter().enumerate() { @@ -149,3 +173,23 @@ pub fn load_visibility( .collect::>()?; Ok(FaceVisibility::new(&occlusion, &definitions)?) } + +#[cfg(test)] +mod tests { + #[test] + fn standalone_names_are_namespaced_sorted_and_set_once() { + assert!(super::standalone().is_empty()); + let names = [ + "lanef:block/b", + "nonamespace", + ":x", + "lanef:", + "lanef:block/a", + "lanef:block/b", + ]; + assert!(super::register_standalone(names.map(str::to_owned).into())); + assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]); + assert!(!super::register_standalone(vec!["other:block/c".into()])); + assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]); + } +} diff --git a/crates/horizon-client-render/src/models/catalog.rs b/crates/horizon-client-render/src/models/catalog.rs index db249cd8bc..2457b8b3c4 100644 --- a/crates/horizon-client-render/src/models/catalog.rs +++ b/crates/horizon-client-render/src/models/catalog.rs @@ -52,10 +52,55 @@ pub struct StateModels { states: Vec>, /// Every transformed model once, with the variant it was baked from. baked: Vec<(Variant, BakedModel)>, + /// Standalone models by id: their index in `baked`. + standalone: BTreeMap, } fn key(variant: &Variant) -> VariantKey { (variant.model.clone(), variant.rotations, variant.uv_lock) } +/// Bakes model `name` of `library`, turned and UV-locked as a variant says. +fn bake_variant( + root: &Path, + sprites: &mut Sprites, + library: &horizon_assets::resolve::ModelLibrary, + name: &str, + turns: [u8; 3], + lock: bool, +) -> Result { + bake_resolved(root, sprites, &library.resolve(name)?, turns, lock) +} +/// Bakes a resolved `model`, turned and UV-locked as a variant says. +fn bake_resolved( + root: &Path, + sprites: &mut Sprites, + model: &horizon_assets::resolve::ResolvedCuboidModel, + turns: [u8; 3], + lock: bool, +) -> Result { + let particle = model + .material("particle")? + .map(|material| sprites.insert(root, &material.sprite)) + .transpose()?; + let faces: Vec = bake_block_model(model, BlockModelTransform::new(turns, lock))? + .into_iter() + .map(|quad| { + let layer = sprites.insert(root, &quad.material.sprite)?; + Ok(BakedFace { + translucent: quad.material.force_translucent || sprites.translucent[layer as usize], + opaque: sprites.region_opaque(layer, &quad.uv), + quad, + layer, + }) + }) + .collect::>()?; + let cube = super::cube::cube_styles(&faces); + Ok(BakedModel { + ambient_occlusion: model.ambient_occlusion, + particle, + faces, + cube, + }) +} impl StateModels { pub fn load( root: &Path, @@ -219,41 +264,30 @@ impl StateModels { models.block_library_or_missing(root, names)? } }; + let resolved: Vec<_> = variants + .iter() + .map(|(name, ..)| library.resolve(name)) + .collect(); + // Every texture the models name, decoded side by side before the bake adds them one + // by one in its own order. + sprites.prefetch( + root, + resolved + .iter() + .flatten() + .flat_map(|model| model.textures.values()) + .map(|material| material.sprite.as_str()), + ); let mut baked = Vec::with_capacity(variants.len()); let mut index = BTreeMap::new(); - for (name, turns, lock) in variants { - let model = library.resolve(&name)?; - let particle = model - .material("particle")? - .map(|material| sprites.insert(root, &material.sprite)) - .transpose()?; - let faces: Vec = - bake_block_model(&model, BlockModelTransform::new(turns, lock))? - .into_iter() - .map(|quad| { - let layer = sprites.insert(root, &quad.material.sprite)?; - Ok(BakedFace { - translucent: quad.material.force_translucent - || sprites.translucent[layer as usize], - opaque: sprites.region_opaque(layer, &quad.uv), - quad, - layer, - }) - }) - .collect::>()?; - let cube = super::cube::cube_styles(&faces); + for ((name, turns, lock), model) in variants.into_iter().zip(resolved) { + let model = bake_resolved(root, sprites, &model?, turns, lock)?; index.insert((name.clone(), turns, lock), baked.len() as u32); let variant = Variant { model: name, rotations: turns, uv_lock: lock, }; - let model = BakedModel { - ambient_occlusion: model.ambient_occlusion, - particle, - faces, - cube, - }; baked.push((variant, model)); } // Every variant of every definition was baked above. @@ -262,8 +296,58 @@ impl StateModels { choices, states, baked, + standalone: BTreeMap::new(), }) } + /// Bakes `names`, models no blockstate needs to name (vanilla's + /// standalone models, NeoForge's `ModelEvent.RegisterStandalone`), as + /// they are, unturned, for [`Self::standalone`]. One a block state + /// already bakes unturned is shared. A model that doesn't read (missing, + /// or a parent missing) is logged and left out, as a missing blockstate + /// is; the others still bake. + pub(crate) fn bake_standalone( + &mut self, + root: &Path, + sprites: &mut Sprites, + models: &crate::model_library::ParsedModels, + names: &[String], + ) -> Result<()> { + for name in names { + if self.standalone.contains_key(name) { + continue; + } + if let Some(i) = self + .baked + .iter() + .position(|(v, _)| v.model == *name && v.rotations == [0; 3] && !v.uv_lock) + { + self.standalone.insert(name.clone(), i as u32); + continue; + } + let model = models + .block_library(root, vec![name.clone()]) + .and_then(|library| bake_variant(root, sprites, &library, name, [0; 3], false)); + match model { + Ok(model) => { + self.standalone + .insert(name.clone(), self.baked.len() as u32); + let variant = Variant { + model: name.clone(), + rotations: [0; 3], + uv_lock: false, + }; + self.baked.push((variant, model)); + } + Err(e) => log::warn!("Horizon: standalone model {name} not loaded: {e}"), + } + } + Ok(()) + } + /// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`). + pub fn standalone(&self, name: &str) -> Option<&BakedModel> { + let &i = self.standalone.get(name)?; + Some(&self.baked[i as usize].1) + } pub fn collect( &self, state: BlockStateId, @@ -328,3 +412,103 @@ pub fn descriptor(name: &str, overrides: &[(&str, &str)]) -> BlockState { .collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model_library::ParsedModels; + + struct Fixture(std::path::PathBuf); + impl Fixture { + fn write(&self, name: &str, json: &str) { + let (namespace, path) = name.split_once(':').unwrap(); + let path = self + .0 + .join(format!("assets/{namespace}/models/{path}.json")); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, json).unwrap(); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn standalone_models_bake_once_and_skip_what_does_not_read() -> Result<()> { + let fixture = Fixture( + std::env::temp_dir().join(format!("horizon-standalone-{}", std::process::id())), + ); + // A half shaft: one element, two faces; a child with its parent's elements. + fixture.write( + "lanef:block/shaft_half", + r##"{"textures":{"side":"lanef:block/shaft"},"elements":[{"from":[6,6,0],"to":[10,10,8],"faces":{"north":{"texture":"#side"},"up":{"texture":"#side"}}}]}"##, + ); + fixture.write( + "lanef:block/cog", + r#"{"parent":"lanef:block/shaft_half","textures":{"side":"lanef:block/cog"}}"#, + ); + fixture.write("lanef:block/orphan", r#"{"parent":"lanef:block/absent"}"#); + let mut models = StateModels::default(); + let mut sprites = Sprites::default(); + let names: Vec = [ + "lanef:block/shaft_half", + "lanef:block/cog", + "lanef:block/orphan", + "lanef:block/absent", + "lanef:block/shaft_half", + ] + .map(str::to_owned) + .into(); + let parsed = ParsedModels::load(&fixture.0); + models.bake_standalone(&fixture.0, &mut sprites, &parsed, &names)?; + let shaft = models.standalone("lanef:block/shaft_half").expect("baked"); + assert_eq!(shaft.faces.len(), 2); + let cog = models.standalone("lanef:block/cog").expect("baked"); + assert_eq!(cog.faces.len(), 2); + assert_ne!(cog.faces[0].layer, shaft.faces[0].layer, "its own texture"); + assert!(models.standalone("lanef:block/orphan").is_none()); + assert!(models.standalone("lanef:block/absent").is_none()); + assert!(models.standalone("lanef:block/unnamed").is_none()); + // Named twice, baked once. + assert_eq!(models.baked.len(), 2); + // Without a parsed library, the files are read directly. + let mut direct = StateModels::default(); + direct.bake_standalone(&fixture.0, &mut sprites, &ParsedModels::default(), &names)?; + assert_eq!( + direct.standalone("lanef:block/cog").map(|m| m.faces.len()), + Some(2) + ); + Ok(()) + } + + #[test] + fn a_standalone_model_a_state_bakes_unturned_is_shared() -> Result<()> { + let mut models = StateModels::default(); + let variant = |rotations| Variant { + model: "lanef:block/shared".into(), + rotations, + uv_lock: false, + }; + let empty = || BakedModel { + ambient_occlusion: true, + particle: None, + faces: Vec::new(), + cube: None, + }; + models.baked.push((variant([0, 90, 0]), empty())); + models.baked.push((variant([0; 3]), empty())); + let names = vec!["lanef:block/shared".to_owned()]; + let root = std::env::temp_dir().join("horizon-standalone-none"); + models.bake_standalone( + &root, + &mut Sprites::default(), + &ParsedModels::default(), + &names, + )?; + assert_eq!(models.baked.len(), 2); + assert_eq!(models.standalone.get("lanef:block/shared"), Some(&1)); + Ok(()) + } +} diff --git a/crates/horizon-client-render/src/packs.rs b/crates/horizon-client-render/src/packs.rs index 6849e518d9..ca2e91f2a2 100644 --- a/crates/horizon-client-render/src/packs.rs +++ b/crates/horizon-client-render/src/packs.rs @@ -695,18 +695,20 @@ fn build_overlay_uncached( merged.insert(name.clone(), bytes); } } + /// A zip entry to write: its pack, its name in the zip and where it goes. + type Extract<'a> = (usize, &'a str, PathBuf); #[allow(clippy::too_many_arguments)] - fn mirror( + fn mirror<'a>( rel: &str, vanilla: &Path, out: &Path, touched: &BTreeSet, hidden: &BTreeSet, - files: &BTreeMap, + files: &'a BTreeMap, merged: &BTreeMap>, filters: &[&Filter], - zips: &[Option], dependencies: &mut cache::Dependencies, + extract: &mut Vec>, ) -> io::Result<()> { let src = if rel.is_empty() { vanilla.to_path_buf() @@ -733,7 +735,13 @@ fn build_overlay_uncached( } else { format!("{rel}/") }; - for f in files.keys().filter_map(|f| f.strip_prefix(&prefix)) { + // The files under this directory are one run of the sorted map: walking only that run + // keeps a pack of tens of thousands of files in thousands of folders from being + // scanned once per folder. + for f in files + .range(prefix.clone()..) + .map_while(|(f, _)| f.strip_prefix(&prefix)) + { names.insert(f.split('/').next().unwrap_or("").to_owned()); } for name in names { @@ -747,19 +755,8 @@ fn build_overlay_uncached( let target = out.join(&child); match path { Some(p) => link(p, &target)?, - None => { - if let Some(Some(z)) = zips.get(*owner) { - // One unreadable entry costs that file, not the - // pack: an error here failed the build and the - // reload dropped every server pack in the stack. - match z.read(inner) { - Ok(bytes) => fs::write(&target, bytes)?, - Err(e) => log::warn!( - "Horizon: skipping unreadable pack entry {inner}: {e}" - ), - } - } - } + // Inflated and written after the walk, on every thread. + None => extract.push((*owner, inner.as_str(), target)), } } else if filters.is_empty() && !touched.contains(&child) @@ -783,8 +780,8 @@ fn build_overlay_uncached( files, merged, filters, - zips, dependencies, + extract, )?; } else if filters.iter().any(|f| f.blocks(&child)) { // Hidden by a pack's filter. @@ -798,6 +795,7 @@ fn build_overlay_uncached( } Ok(()) } + let mut extract = Vec::new(); mirror( "", vanilla, @@ -807,9 +805,27 @@ fn build_overlay_uncached( &files, &merged, &filters, - &zips, dependencies, - ) + &mut extract, + )?; + // Inflating and writing a big pack's files one by one was most of a cold build; its + // folders all exist by now. + crate::par::par_map(&extract, |(owner, inner, target)| { + let Some(Some(z)) = zips.get(*owner) else { + return Ok(()); + }; + // One unreadable entry costs that file, not the pack: an error here + // failed the build and the reload dropped every server pack in the stack. + match z.read(inner) { + Ok(bytes) => fs::write(target, bytes), + Err(e) => { + log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}"); + Ok(()) + } + } + }) + .into_iter() + .collect() } /// Files vanilla loads from every pack in turn (`getResourceStack`) rather /// than from the top pack only. @@ -1489,6 +1505,72 @@ mod tests { let _ = fs::remove_dir_all(base); } #[test] + fn every_file_of_a_big_stack_comes_from_its_top_pack() { + use std::io::Write as _; + let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id())); + let _ = fs::remove_dir_all(&base); + let vanilla = base.join("vanilla"); + // Folders whose names start alike (`item`, `item_extra`, `item2`) and files beside + // folders of almost their name: what a prefix lookup could mix up. + let names: Vec = (0..400) + .map(|i| { + let folder = ["item", "item_extra", "item2", "item/sub", "block"][i % 5]; + format!("assets/ns{}/textures/{folder}/f{i}.png", i % 3) + }) + .chain(["assets/ns0/textures/item.png".to_owned()]) + .collect(); + let mut expected: BTreeMap> = BTreeMap::new(); + for name in names.iter().step_by(4) { + let path = vanilla.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("vanilla {name}")).unwrap(); + expected.insert(name.clone(), format!("vanilla {name}").into_bytes()); + } + let meta = br#"{"pack":{"description":"d","pack_format":97}}"#; + // Packs a and b are zips, c a folder; each provides a different part of the names. + fs::create_dir_all(base.join("packs/c")).unwrap(); + fs::write(base.join("packs/c/pack.mcmeta"), meta).unwrap(); + for (pack, every) in [("a", 2), ("b", 3)] { + let file = fs::File::create(base.join(format!("packs/{pack}.zip"))).unwrap(); + let mut zip = zip::ZipWriter::new(file); + zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(meta).unwrap(); + for name in names.iter().step_by(every) { + zip.start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(format!("{pack} {name}").as_bytes()).unwrap(); + expected.insert(name.clone(), format!("{pack} {name}").into_bytes()); + } + zip.finish().unwrap(); + } + for name in names.iter().skip(1).step_by(5) { + let path = base.join("packs/c").join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("c {name}")).unwrap(); + expected.insert(name.clone(), format!("c {name}").into_bytes()); + } + let all = folder(&base.join("packs"), RESOURCE_FORMAT); + let ids = ["file/a.zip", "file/b.zip", "file/c"].map(String::from); + let out = base.join("overlay"); + build_overlay(&vanilla, &enabled(&all, &ids), &out).unwrap(); + fn walk(dir: &Path, root: &Path, out: &mut BTreeMap>) { + for entry in fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + walk(&path, root, out); + } else { + let name = path.strip_prefix(root).unwrap().to_string_lossy(); + out.insert(name.replace('\\', "/"), fs::read(&path).unwrap()); + } + } + } + let mut built = BTreeMap::new(); + walk(&out.join("assets"), &out, &mut built); + assert_eq!(built, expected); + let _ = fs::remove_dir_all(base); + } + #[test] fn zip_entries_never_leave_the_overlay() { use std::io::Write as _; let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id())); diff --git a/crates/horizon-client-render/src/sprites.rs b/crates/horizon-client-render/src/sprites.rs index 3845b580b6..ca38f33f87 100644 --- a/crates/horizon-client-render/src/sprites.rs +++ b/crates/horizon-client-render/src/sprites.rs @@ -99,6 +99,9 @@ pub struct Sprites { still: std::collections::HashMap<(Vec, u64, u32, [u32; 2], [bool; 4]), u32>, /// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]). disk: Option, + /// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or + /// resource path. + ready: std::collections::HashMap, } /// Edge length of vanilla's block textures: the smallest array layer. pub const SPRITE_SIZE: u32 = 16; @@ -242,6 +245,8 @@ impl Sprites { self.lay_out(false); } fn lay_out(&mut self, pixels: bool) { + // Prefetched textures nothing inserted. + self.ready = Default::default(); let layers = self.native.len().max(1) as u64; let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE); self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE); @@ -335,13 +340,50 @@ impl Sprites { } } } + /// Decode on every thread the textures about to be inserted: sprite names, or resource + /// paths outside `textures/` (as [`Sprites::insert_file`] takes them). A loader that adds + /// many (the block models, an OptiFine pack's tiles) calls this first and then inserts + /// them in its own order, so the layers are the same as without it. What is already a + /// layer or in the disk cache is skipped; what fails to decode is left for the insert, + /// which reports it. + pub fn prefetch<'a>(&mut self, root: &Path, names: impl IntoIterator) { + let names: Vec<&str> = names + .into_iter() + .filter(|name| { + !self.layers.contains_key(*name) + && !self.ready.contains_key(*name) + && self.disk.as_ref().is_none_or(|d| d.get(name).is_none()) + }) + .collect::>() + .into_iter() + .collect(); + let decoded = crate::par::par_map(&names, |name| { + if name.contains(':') { + Self::decode(root, name).ok() + } else { + Self::decode_file(&root.join(name), name).ok() + } + }); + for (name, decoded) in names.into_iter().zip(decoded) { + if let Some(decoded) = decoded { + self.ready.insert(name.to_owned(), decoded); + } + } + } fn load(&mut self, root: &Path, name: &str) -> Result { - let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned(); + // Custom Item Textures' files are outside `textures/`, which the disk cache's + // fingerprint covers: like `insert_file`'s, they skip it. + let cit = name.starts_with("horizon_cit:"); + let disk = self.disk.as_mut().filter(|_| !cit); + let cached = disk.as_ref().and_then(|d| d.get(name)).cloned(); let decoded = match cached { Some(decoded) => decoded, None => { - let decoded = Self::decode(root, name)?; - if let Some(disk) = &mut self.disk { + let decoded = match self.ready.remove(name) { + Some(decoded) => decoded, + None => Self::decode(root, name)?, + }; + if let Some(disk) = disk { disk.insert(name, decoded.clone()); } decoded @@ -357,16 +399,20 @@ impl Sprites { if let Some(layer) = self.layers.get(resource) { return Ok(*layer); } - let decoded = Self::decode_file(&root.join(resource), resource)?; + let decoded = match self.ready.remove(resource) { + Some(decoded) => decoded, + None => Self::decode_file(&root.join(resource), resource)?, + }; self.register(root, resource, decoded) } /// Read and decode one texture and its `.mcmeta`. fn decode(root: &Path, name: &str) -> Result { - let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?; - Self::decode_file( - &root.join(format!("assets/{namespace}/textures/{path}.png")), - name, - ) + if !name.contains(':') { + return Err("invalid sprite identifier".into()); + } + // `texture_path`, so Custom Item Textures' `horizon_cit:` sprites find their file. + let path = crate::atlas_sources::texture_path(root, name, "textures"); + Self::decode_file(&path, name) } fn decode_file(path: &Path, name: &str) -> Result { let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) { @@ -462,8 +508,8 @@ impl Sprites { let count = frames.len() as u32; let layer = u32::try_from(self.native.len())?; let pbr = match name.split_once(':') { - Some((namespace, path)) if labpbr_maps() => { - let path = root.join(format!("assets/{namespace}/textures/{path}.png")); + Some(_) if labpbr_maps() => { + let path = crate::atlas_sources::texture_path(root, name, "textures"); ["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count)) } _ => [None, None], @@ -473,9 +519,11 @@ impl Sprites { strategy, alpha_cutoff_bias, has_transparent, - item: name - .split_once(':') - .is_some_and(|(_, p)| p.starts_with("item/")), + // Custom Item Textures' ids are all item textures. + item: name.starts_with("horizon_cit:") + || name + .split_once(':') + .is_some_and(|(_, p)| p.starts_with("item/")), dither_cutouts: crate::mipmap::dither_cutouts(), }; let still = (animation.is_none() && frames.len() == 1).then(|| { @@ -706,6 +754,172 @@ mod tests { 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"); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + let mut encoder = png::Encoder::new(File::create(&file).unwrap(), 16, 48); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let pixels: Vec = (0..16 * 48).flat_map(|_| [200, 10, 10, 255]).collect(); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap(); + let mut sprites = Sprites::default(); + sprites + .insert(&root, "horizon_cit:minecraft/optifine/cit/a") + .unwrap(); + fs::remove_dir_all(&root).unwrap(); + // The missing texture would not animate: this one is the file, in three frames. + assert_eq!(sprites.animations.len(), 1); + assert_eq!(sprites.animations[0].frame_count(), 3); + } + #[test] + fn custom_item_textures_skip_the_disk_cache() { + let root = + std::env::temp_dir().join(format!("horizon-sprite-cit-disk-{}", std::process::id())); + // A realistic root: the cache's fingerprint walks the `textures` trees. + let vanilla = root.join("assets/minecraft/textures/item/stick.png"); + let file = root.join("assets/minecraft/optifine/cit/a.png"); + let write = |path: &Path, red: u8| { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let pixels: Vec = (0..256).flat_map(|_| [red, 0, 0, 255]).collect(); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + }; + write(&vanilla, 1); + write(&file, 10); + let id = "horizon_cit:minecraft/optifine/cit/a"; + let red = |sprites: &Sprites, layer: u32| sprites.native[layer as usize].1[0][0]; + let mut first = Sprites::with_disk_cache(&root); + let layer = first.insert(&root, id).unwrap(); + assert_eq!(red(&first, layer), 10); + first.save_disk_cache(); + // The pack's file changes; `textures/` does not, so a cached copy would still match. + write(&file, 20); + let mut second = Sprites::with_disk_cache(&root); + let layer = second.insert(&root, id).unwrap(); + fs::remove_dir_all(&root).unwrap(); + assert_eq!(red(&second, layer), 20); + } + #[test] + fn custom_item_textures_mip_as_items() { + let root = + std::env::temp_dir().join(format!("horizon-sprite-cit-mip-{}", std::process::id())); + let write = |rel: &str| { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + // Any pixels: whether a texture mips as an item is part of the shared-layer key. + let pixels: Vec = (0..256) + .flat_map(|i| [9, 9, 9, if i % 2 == 0 { 255 } else { 0 }]) + .collect(); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + }; + write("assets/a/textures/block/x.png"); + write("assets/a/textures/item/x.png"); + write("assets/minecraft/optifine/cit/x.png"); + let mut sprites = Sprites::default(); + let block = sprites.insert(&root, "a:block/x").unwrap(); + let item = sprites.insert(&root, "a:item/x").unwrap(); + let cit = sprites + .insert(&root, "horizon_cit:minecraft/optifine/cit/x") + .unwrap(); + fs::remove_dir_all(&root).unwrap(); + // The same pixels share a layer only with the same mipmap settings: the CIT texture's + // are the item's. + assert_ne!(block, item); + assert_eq!(cit, item); + } + #[test] + fn prefetched_textures_take_the_layers_inserting_alone_gives() { + let root = + std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id())); + let png = |path: &str, side: u32, seed: u8| { + let file = root.join(path); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + let mut encoder = png::Encoder::new(File::create(file).unwrap(), side, side); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let pixels: Vec = (0..side * side) + .flat_map(|i| { + [ + seed, + i as u8, + (i >> 8) as u8, + if i % 3 == 0 { 0 } else { 255 }, + ] + }) + .collect(); + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + }; + for i in 0..40u8 { + png( + &format!("assets/minecraft/textures/block/b{i}.png"), + 16 << (i % 3), + i, + ); + } + // Two textures alike share a layer; a tile outside `textures/`, a broken file and a + // missing one take the paths they take without prefetching. + png("assets/minecraft/textures/block/same.png", 16, 3); + png("assets/minecraft/optifine/ctm/glass/0.png", 32, 77); + fs::write( + root.join("assets/minecraft/textures/block/broken.png"), + b"no png", + ) + .unwrap(); + let names: Vec = (0..40) + .map(|i| format!("minecraft:block/b{}", (i * 7) % 40)) + .chain(["minecraft:block/same", "minecraft:block/broken"].map(String::from)) + .chain(["minecraft:block/missing", "minecraft:block/b3"].map(String::from)) + .collect(); + let tile = "assets/minecraft/optifine/ctm/glass/0.png"; + let load = |prefetch: bool| { + let mut sprites = Sprites::default(); + if prefetch { + sprites.prefetch(&root, names.iter().map(String::as_str).chain([tile])); + } + let mut layers: Vec = names + .iter() + .map(|name| sprites.insert(&root, name).unwrap()) + .collect(); + layers.push(sprites.insert_file(&root, tile).unwrap()); + sprites.finish(); + ( + layers, + sprites.layers, + sprites.pixels, + sprites.translucent, + sprites.size, + ) + }; + let alone = load(false); + assert_eq!(load(true), alone); + // The textures alike and the broken one's missing texture: fewer layers than names. + assert!(alone.1.values().max().unwrap() + 1 < names.len() as u32); + fs::remove_dir_all(&root).unwrap(); + } + #[test] fn identical_still_textures_share_a_layer() { let root = std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id())); diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index 5777d91f57..c23ccd4338 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -19,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" } horizon-command = { path = "../horizon-command" } horizon-items = { path = "../horizon-items" } horizon-core = { path = "../horizon-core" } +# OptiFine-format pack files: custom entity models, random entity textures. +horizon-optifine = { path = "../horizon-optifine" } # The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority). horizon-threads = { path = "../horizon-threads" } horizon-hdr = { path = "../horizon-hdr" } @@ -40,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" } # Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md). # Their runtime is the `mods` feature. horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] } -horizon-optifine = { path = "../horizon-optifine" } wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] } winit = "0.30.13" pollster = "0.4" diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index d5fb9eaa59..63c87c91ab 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -505,6 +505,24 @@ fn signum(v: f64) -> f64 { /// entity stops there and is not kept: the game hands it to /// `finish_entity_hit` once it has hurt the entity. pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) { + tick_with(p, scene, targets, true) +} +/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow +/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves +/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit. +pub fn tick_through( + p: &mut Projectile, + scene: &ArrowScene, + targets: &[(Who, Aabb)], +) -> (bool, Hit) { + tick_with(p, scene, targets, false) +} +fn tick_with( + p: &mut Projectile, + scene: &ArrowScene, + targets: &[(Who, Aabb)], + impact: bool, +) -> (bool, Hit) { let Some(mut st) = p.arrow.take() else { return (false, Hit::None); }; @@ -517,7 +535,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> st: &mut st, scene, } - .tick(targets); + .tick(targets, impact); p.stuck = st.in_ground; let keep = !st.removed && !matches!(hit, Hit::Entity { .. }); p.arrow = Some(st); @@ -600,7 +618,7 @@ impl Ticker<'_, '_, '_, '_> { fn trident(&self) -> bool { matches!(self.p.kind, ProjectileKind::Trident) } - fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit { + fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit { // ThrownTrident.tick. if self.trident() && self.st.in_ground_time > 4 { self.st.dealt_damage = true; @@ -711,7 +729,7 @@ impl Ticker<'_, '_, '_, '_> { original.z + movement.z, ); if physics { - hit = self.step_move_and_hit(original, end, targets); + hit = self.step_move_and_hit(original, end, targets, impact); if matches!(hit, Hit::Entity { .. }) { return hit; } @@ -795,7 +813,13 @@ impl Ticker<'_, '_, '_, '_> { /// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the /// block. A piercing arrow that hits an entity here goes on from it next /// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick. - fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit { + fn step_move_and_hit( + &mut self, + from: Vec3, + to: Vec3, + targets: &[(Who, Aabb)], + impact: bool, + ) -> Hit { let block = self.scene.clip(from, to); let end = block.map_or(to, |b| b.0); // findHitEntities: ProjectileUtil.computeMargin and the search box. @@ -828,6 +852,10 @@ impl Ticker<'_, '_, '_, '_> { self.p.position = next; self.apply_effects_from_blocks(from, next); if let Some((who, at, _)) = best { + // A canceled impact ends the search, the arrow where it would have hit. + if !impact { + return Hit::None; + } // AbstractArrow.onHitEntity: past pierce level + 1 entities the // arrow is discarded before it hurts the next one. if self.st.pierce > 0 { @@ -849,6 +877,9 @@ impl Ticker<'_, '_, '_, '_> { if self.scene.is_air(state) { return Hit::None; } + if !impact { + return Hit::None; + } self.on_hit_block(pos, state); Hit::Block { pos: [pos.x, pos.y, pos.z], diff --git a/crates/horizon-client/src/breathing.rs b/crates/horizon-client/src/breathing.rs new file mode 100644 index 0000000000..eb3b9d0d15 --- /dev/null +++ b/crates/horizon-client/src/breathing.rs @@ -0,0 +1,130 @@ +//! Breathing for native mods: `LivingEntity.baseTick`'s air supply, the part +//! that raises or lowers it. A player (`motion/environment.rs`) and a mob +//! (`mobs/ai.rs`) work out what vanilla does this tick, `breathe` lets the +//! mods change it (`native_living_breathe`, NeoForge's `LivingBreatheEvent`), +//! and the caller applies the answer. Servers only: the callers keep it off a +//! client's prediction. + +/// One tick of breathing: whether the entity can breathe, how much air it +/// loses when it cannot and how much it gains when it can. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Breath { + pub can_breathe: bool, + /// `decreaseAirSupply`: 1, or 0 by the oxygen bonus's chance, for an + /// entity that drowns; 1 for one that would not. + pub consume: i32, + /// `increaseAirSupply`: 4 for one that can breathe, 0 for one that drowns + /// or is held under by the nautilus' breath. + pub refill: i32, +} + +/// An entity's breathing this tick (`LivingBreatheEvent`, posted from +/// `LivingEntity.baseTick` every tick for a living entity with the generic +/// air logic): `entity` is its ID and `kind` its type (`minecraft:player`, +/// `minecraft:zombie`). `can_breathe`, `consume` and `refill` come in as +/// Horizon does it today (a mod-free game changes nothing); set them to +/// change this tick. With `can_breathe` the air rises by `refill`, up to its +/// maximum (300); without, it falls by `consume`, and at -20 it is set to 0 +/// and the entity takes 2 drowning damage. Negative amounts count as 0. +/// Asked on servers for players and for mobs, except those with a +/// `WaterAnimal`'s or an axolotl's own air logic. API @API:living_breathe@. +#[inline(never)] +pub fn native_living_breathe( + entity: u32, + kind: &str, + can_breathe: &mut bool, + consume: &mut i32, + refill: &mut i32, +) { + let _ = (entity, kind, can_breathe, consume, refill); +} + +/// `vanilla`'s breathing after the mods have had their say. +pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath { + let mut now = vanilla; + native_living_breathe( + entity, + kind, + &mut now.can_breathe, + &mut now.consume, + &mut now.refill, + ); + Breath { + can_breathe: now.can_breathe, + consume: now.consume.max(0), + refill: now.refill.max(0), + } +} + +/// The air supply after `breath`: raised up to `max` when it can breathe, +/// lowered otherwise. The second is whether the entity now drowns +/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is +/// then 0. +pub fn apply(air: i32, max: i32, breath: Breath) -> (i32, bool) { + if breath.can_breathe { + return (air.max((air + breath.refill).min(max)), false); + } + let air = air - breath.consume; + if air <= -20 { (0, true) } else { (air, false) } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VANILLA: Breath = Breath { + can_breathe: true, + consume: 1, + refill: 4, + }; + + #[test] + fn air_refills_up_to_the_maximum() { + assert_eq!(apply(100, 300, VANILLA), (104, false)); + assert_eq!(apply(299, 300, VANILLA), (300, false)); + assert_eq!(apply(300, 300, VANILLA), (300, false)); + // A refill of 0 leaves it, as does one that would pass the maximum. + let held = Breath { + refill: 0, + ..VANILLA + }; + assert_eq!(apply(120, 300, held), (120, false)); + } + + #[test] + fn air_runs_down_and_drowns_at_minus_twenty() { + let drowning = Breath { + can_breathe: false, + consume: 1, + refill: 0, + }; + assert_eq!(apply(300, 300, drowning), (299, false)); + assert_eq!(apply(-18, 300, drowning), (-19, false)); + assert_eq!(apply(-19, 300, drowning), (0, true)); + // The oxygen bonus can spare a tick, and a mod can take more. + let spared = Breath { + consume: 0, + ..drowning + }; + assert_eq!(apply(-19, 300, spared), (-19, false)); + let gas = Breath { + consume: 30, + ..drowning + }; + assert_eq!(apply(20, 300, gas), (-10, false)); + assert_eq!(apply(10, 300, gas), (0, true)); + } + + #[test] + fn nothing_changes_without_a_mod() { + assert_eq!(breathe(1, "minecraft:zombie", VANILLA), VANILLA); + let negative = Breath { + can_breathe: false, + consume: -3, + refill: -2, + }; + // Mods only ever answer with their own values, so what comes back is clamped. + assert_eq!(breathe(1, "minecraft:player", negative).consume, 0); + assert_eq!(breathe(1, "minecraft:player", negative).refill, 0); + } +} diff --git a/crates/horizon-client/src/client_lib.rs b/crates/horizon-client/src/client_lib.rs index 2e6bb8d0a4..d68334d3cf 100644 --- a/crates/horizon-client/src/client_lib.rs +++ b/crates/horizon-client/src/client_lib.rs @@ -10,17 +10,17 @@ pub mod block_contact; pub use horizon_client_render::{ assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities, block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk, - chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light, - effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, - hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, - level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, - model_library, models, multiline_edit, native_emissive, options_file, options_fixes, - options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain, - potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings, - shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, - spans, split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list, - telemetry, test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, - widgets, world, worldborder, zip, + chunk_map, cit, cit_assets, clouds, collisions, command_block, containers, crash, datapack, + dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics, + graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, + java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, mesher, minimap, + mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file, + options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections, + post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, + section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine, + sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites, + streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, + title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip, }; pub mod attack_strength; pub mod attribute_updates; @@ -32,6 +32,7 @@ pub mod block_entity_item; pub mod block_pattern; pub mod block_transform; pub mod boss_overlay; +pub mod breathing; pub mod cauldron; pub mod chunk_io; pub mod clock; diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 49a722dd24..ba5f7755ed 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -2322,6 +2322,12 @@ mod armor_stand_motion_tests { let start = Vec3::new(20.5, 20.0, 20.5); let mut stand = ArmorStand::new(1, start, 0.0); stand.on_ground = false; + // A summoned stand has no motion yet: the first tick only builds it + // (vanilla moves by the velocity, then applies gravity), so it falls + // from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks). + tick_stand(&mut stand, &world, &facts, &collision); + assert_eq!(stand.position, start); + assert!((stand.velocity.y + 0.0784).abs() < 1e-6); tick_stand(&mut stand, &world, &facts, &collision); assert_eq!(stand.previous, start); assert!( diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 696f4558cc..42f685d06c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -196,22 +196,27 @@ mod native_block_entity_draw; mod native_claimed_items; mod native_components; mod native_criteria; +mod native_data_maps; mod native_entities; mod native_explosions; mod native_falling; mod native_hooks; mod native_inventories; +mod native_item_fluids; mod native_lifecycle; mod native_menus; mod native_mob_draw; mod native_mobs; mod native_motion; +mod native_mounts; mod native_net; mod native_panel; mod native_panel_recipes; +mod native_projectiles; mod native_recipes; mod native_server; mod native_shaders; +mod native_ticks; mod native_visuals; mod native_world_columns; mod native_world_edit; @@ -1790,6 +1795,9 @@ struct WorldTables { items: Items, recipes: Recipes, stations: horizon_client::stations::StationData, + /// The values of the data map types native mods registered + /// (`native_data_map_types`). + data_maps: native_data_maps::DataMaps, } impl Resources { @@ -2417,6 +2425,10 @@ struct Game<'a> { use_duration: i32, /// A mod's `Start` or `Tick` set that use's ticks left (`game/item_use.rs`). use_ticks_set: bool, + /// The crossbow charge's start and middle loading sounds played so far + /// (`game/crossbow.rs`): each charge plays each of them once, however + /// far a mod's tick moved the charge at a time. + crossbow_sounds: [bool; 2], /// Camera.oldFovModifier / fovModifier. /// `Camera` (eye height and field-of-view easing, view placement). view: horizon_client::camera::Camera, @@ -3760,6 +3772,29 @@ impl Game<'_> { } } fn tick_items(&mut self) { + // EntityTickEvent.Pre for the items about to tick, and the Post after them. + let mut round = self.native_tick_round(); + if round.listening() { + let ticking = self.entity_ticking_area(); + let merging = self.remote.is_none(); + let mut i = 0; + while let Some(item) = self.dropped.get(i) { + let (id, x, z) = ( + item.id, + item.position.x.floor() as i32, + item.position.z.floor() as i32, + ); + if !(merging && id > self.ticked_entities_through) && ticking(x, z) { + let kind = native_ticks::TickKind::Fixed("minecraft:item"); + self.native_tick_pre_batch(&mut round, id, kind); + } + i += 1; + } + } + self.tick_dropped_items(&mut round); + self.native_tick_post_batch(round); + } + fn tick_dropped_items(&mut self, round: &mut native_ticks::TickRound) { let mut items = std::mem::take(&mut self.dropped); // ServerLevel.tick: only entities in entity-ticking chunks tick. let ticking = self.entity_ticking_area(); @@ -3774,6 +3809,8 @@ impl Game<'_> { let mut nearby = Vec::new(); for i in 0..items.len() { if removed[i] { + // Merged into an earlier item before its own tick: it never ticked. + round.not_ticked(items[i].id); continue; } let item = &mut items[i]; @@ -3786,7 +3823,8 @@ impl Game<'_> { if !ticking( item.position.x.floor() as i32, item.position.z.floor() as i32, - ) { + ) || round.canceled(item.id) + { continue; } // ItemEntity.tick's noPhysics (servers only): out of a block @@ -8131,42 +8169,50 @@ impl Game<'_> { hud_layout::full_attack_indicator(a, delay, self.crosshair_living()) }); let draws = hud_layout::crosshair(wi, hi, attack, full); - hud_layout::paint(&mut batch, &self.gui.atlas, &draws); + self.gui_layer("minecraft:crosshair", &mut batch, |g, batch| { + hud_layout::paint(batch, &g.gui.atlas, &draws); + }); } - if spectator { - self.draw_spectator_hotbar(&mut batch); - } else { - let partial = self.clock.fraction() as f32; - let inputs = hud_layout::HotbarInputs { - selected: self.selected as i32, - right_handed: !self.settings.main_hand_left, - offhand: self.offhand.is_some(), - slots: std::array::from_fn(|i| { - self.inventory[i] - .as_ref() - .map(|_| (f32::from(self.hotbar_pop[i]) - partial).max(0.0)) - }), - offhand_pop: (f32::from(self.hotbar_pop[9]) - partial).max(0.0), - attack: (indicator == 2).then_some(strength), - }; - let draws = hud_layout::hotbar(wi, hi, &inputs); - hud_layout::paint(&mut batch, &self.gui.atlas, &draws); - for d in &draws { - if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d { - let stack = if slot == 40 { - self.offhand.as_ref() - } else { - self.inventory[slot as usize].as_ref() - }; - if let Some(stack) = stack { - self.draw_item(&mut batch, stack, x as f32, y as f32); + self.gui_layer("minecraft:hotbar", &mut batch, |g, batch| { + if spectator { + g.draw_spectator_hotbar(batch); + } else { + let partial = g.clock.fraction() as f32; + let inputs = hud_layout::HotbarInputs { + selected: g.selected as i32, + right_handed: !g.settings.main_hand_left, + offhand: g.offhand.is_some(), + slots: std::array::from_fn(|i| { + g.inventory[i] + .as_ref() + .map(|_| (f32::from(g.hotbar_pop[i]) - partial).max(0.0)) + }), + offhand_pop: (f32::from(g.hotbar_pop[9]) - partial).max(0.0), + attack: (indicator == 2).then_some(strength), + }; + let draws = hud_layout::hotbar(wi, hi, &inputs); + hud_layout::paint(batch, &g.gui.atlas, &draws); + for d in &draws { + if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d { + let stack = if slot == 40 { + g.offhand.as_ref() + } else { + g.inventory[slot as usize].as_ref() + }; + if let Some(stack) = stack { + g.draw_item(batch, stack, x as f32, y as f32); + } } } } - } - self.draw_effect_icons(&mut batch); + }); + self.gui_layer("minecraft:effects", &mut batch, |g, batch| { + g.draw_effect_icons(batch); + }); // Hud: the action bar and titles draw over the hotbar (nextStratum). - self.draw_boss_bars(&mut batch); + self.gui_layer("minecraft:boss_overlay", &mut batch, |g, batch| { + g.draw_boss_bars(batch); + }); self.draw_extras(&mut batch); } if show_hud { @@ -8181,47 +8227,56 @@ impl Game<'_> { self.experience_recent(), ); use horizon_client::locator_bar::ContextualBar; - // The mount's hearts (and the jump bar). + // The mount's hearts. let vehicle_hearts = if spectator { 0 } else { - self.draw_mount_hud( - &mut batch, - wi, - hi, - contextual == ContextualBar::JumpableVehicle, - ) + self.draw_mount_hud(&mut batch, wi, hi) }; // Without the survival bars only the mount's hearts raise it. bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10); - if contextual == ContextualBar::Locator { - let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi); - horizon_client::ui::nine_slice( - &mut batch, - &self.gui.atlas, - sprite, - x as f32, - y as f32, - w as f32, - h as f32, - u32::MAX, - ); - } + // The one bar above the hotbar: the mount's jump bar, the locator + // bar (with its background) or the experience bar, with the level + // number that a survival player always has. + self.gui_layer("minecraft:contextual_info_bar", &mut batch, |g, batch| { + if contextual == ContextualBar::JumpableVehicle { + g.draw_jump_bar(batch, wi, hi); + } + if contextual == ContextualBar::Locator { + let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi); + horizon_client::ui::nine_slice( + batch, + &g.gui.atlas, + sprite, + x as f32, + y as f32, + w as f32, + h as f32, + u32::MAX, + ); + } + if has_experience && g.player.is_some() { + let atlas = &g.gui.atlas; + let width = |s: &str| HudBatch::text_width(atlas, s) as i32; + let draws = hud_layout::experience( + wi, + hi, + g.experience.progress, + g.experience.level, + contextual == ContextualBar::Experience, + &width, + ); + hud_layout::paint(batch, atlas, &draws); + } + if contextual == ContextualBar::Locator { + let draws = g.locator_bar_draws(wi, hi); + hud_layout::paint(batch, &g.gui.atlas, &draws); + } + }); if !self.creative && !spectator && let Some(p) = &mut self.player { - let atlas = &self.gui.atlas; - let width = |s: &str| HudBatch::text_width(atlas, s) as i32; - let draws = hud_layout::experience( - wi, - hi, - self.experience.progress, - self.experience.level, - contextual == ContextualBar::Experience, - &width, - ); - hud_layout::paint(&mut batch, atlas, &draws); let effects = &self.potions.effects; let kind = if effects.get("poison").is_some() { hud_layout::HeartKind::Poisoned @@ -8266,9 +8321,11 @@ impl Game<'_> { Some(draws) => draws, None => self.health_hud.layout(&inputs, wi, hi), }; - // A fresh borrow: the hook ran with all of `self`. - let atlas = &self.gui.atlas; - hud_layout::paint(&mut batch, atlas, &draws); + // The rows draw in one pass here, so a mod's `player_health` + // layer stands for the armor, food and air rows too. + self.gui_layer("minecraft:player_health", &mut batch, |g, batch| { + hud_layout::paint(batch, &g.gui.atlas, &draws); + }); bar_height = self .health_hud .left_height @@ -8286,21 +8343,30 @@ impl Game<'_> { self.audio.capture = capture; } } - if contextual == ContextualBar::Locator { - let draws = self.locator_bar_draws(wi, hi); - hud_layout::paint(&mut batch, &self.gui.atlas, &draws); - } if !spectator { - self.draw_selected_name(&mut batch, bar_height, !self.creative); + self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| { + g.draw_selected_name(batch, bar_height, !g.creative); + }); } } // Hud.extractSleepOverlay: over all health/food bars, under titles and chat. let draws = hud_overlays::sleep_overlay(wi, hi, self.sleep_timer as i32); - hud_layout::paint(&mut batch, &self.gui.atlas, &draws); + self.gui_layer("minecraft:sleep_overlay", &mut batch, |g, batch| { + hud_layout::paint(batch, &g.gui.atlas, &draws); + }); if show_hud { - self.draw_titles(&mut batch, bar_height); - self.draw_scoreboard(&mut batch); - self.draw_subtitles(&mut batch); + self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| { + g.draw_overlay_message(batch, bar_height); + }); + self.gui_layer("minecraft:title", &mut batch, |g, batch| { + g.draw_title(batch); + }); + self.gui_layer("minecraft:scoreboard_sidebar", &mut batch, |g, batch| { + g.draw_scoreboard(batch); + }); + self.gui_layer("minecraft:subtitle_overlay", &mut batch, |g, batch| { + g.draw_subtitles(batch); + }); } let atlas = &self.gui.atlas; if show_hud @@ -8310,8 +8376,16 @@ impl Game<'_> { let tw = HudBatch::text_width(atlas, text); batch.text(atlas, text, (w - tw) / 2.0, h - 59.0, u32::MAX, true); } - self.draw_chat(&mut batch); - self.draw_tab_list(&mut batch); + // The chat screen draws the focused chat itself; F1 hides the rest. + if show_hud && self.screen != Screen::Chat { + self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch)); + self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| { + g.draw_tab_list(batch); + }); + } else { + self.draw_chat(&mut batch); + self.draw_tab_list(&mut batch); + } // Screen.extractBlurredBackground blurs the world and HUD, while the // screen's background, widgets, toasts and debug overlay stay sharp. batch.blur_at = batch.vertices.len() as u32; @@ -11352,6 +11426,7 @@ impl Game<'_> { } fn tick_mobs(&mut self) -> Result<()> { self.tick_creatures()?; + self.native_mobs_equipment(); self.position_rider(); self.tick_stats(); self.tick_scoreboard(); @@ -17982,6 +18057,7 @@ impl<'a> Game<'a> { using: None, use_duration: 0, use_ticks_set: false, + crossbow_sounds: [false; 2], view: Default::default(), limiter: horizon_client::window::FramerateLimiter::new(0), frame_clock: Default::default(), @@ -18598,11 +18674,20 @@ impl Game<'_> { } }; let stations = horizon_client::stations::StationData::load(root, &items, &overlay); + let mut data_map_types = Vec::new(); + self.native_data_map_types(&mut data_map_types); + let data_maps = native_data_maps::DataMaps::load( + &data_map_types, + &files, + &items.item_tags, + &items.block_tags, + ); Some(WorldTables { files, items, recipes, stations, + data_maps, }) } /// Reload the content tables with the world's packs, as @@ -20222,6 +20307,7 @@ pub fn run() -> Result<()> { } } mods::register_components(&vanilla_root)?; + mods::register_standalone_models(); let early_settings = Settings::load(&Game::options_path()); // Minecraft's constructor opens the narrator; a server has none. if options.execution_role.is_client() { diff --git a/crates/horizon-client/src/game/block_edits.rs b/crates/horizon-client/src/game/block_edits.rs index 47c4f64daa..6bb1bf26a6 100644 --- a/crates/horizon-client/src/game/block_edits.rs +++ b/crates/horizon-client/src/game/block_edits.rs @@ -560,10 +560,13 @@ mod tests { // a mod block's, as `Game::new_block_entity` makes it let made = |n: &str| { (n == "machines:press").then(|| { - BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity { - id: "machines:press".into(), - ..Default::default() - })) + BlockEntity::Mod(Box::new( + horizon_client::containers::ModBlockEntityFields { + id: "machines:press".into(), + ..Default::default() + } + .into(), + )) }) }; replace_block_entities(&mut w, &out.tracked, names, made); @@ -685,10 +688,13 @@ mod tests { |n| { calls.push(n.to_string()); (n == "machines:press").then(|| { - BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity { - id: format!("press-{}", calls.len()), - ..Default::default() - })) + BlockEntity::Mod(Box::new( + horizon_client::containers::ModBlockEntityFields { + id: format!("press-{}", calls.len()), + ..Default::default() + } + .into(), + )) }) }, ); diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index 57e6acb037..6c78a2a707 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -28,6 +28,8 @@ impl Game<'_> { self.world.block_entities.set_mod_containers(containers); let fluids = self.native_fluid_tables(); self.world.block_entities.set_mod_fluids(fluids); + let energy = self.native_energy_tables(); + self.world.block_entities.set_mod_energy(energy); } // Only lingering cooldowns are dropped; a hopper that ticks checks // its own block. Once a second is soon enough for the rest. diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index 6fbc3db6cf..342a31b621 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -720,9 +720,14 @@ impl Game<'_> { pub(super) fn tick_boats(&mut self) { // ServerLevel.tick: only entities in entity-ticking chunks tick. let ticking = self.entity_ticking_area(); + let mut round = self.native_tick_round(); // A server player's boat is driven by its client (lan_vehicles.rs). let driven = self.guest_vehicles(); for i in 0..self.boats.len() { + // A mod's hook may have removed boats. + if i >= self.boats.len() { + break; + } let p = self.boats[i].position; if Some(self.boats[i].id) == self.riding || driven.contains(&self.boats[i].id) @@ -736,8 +741,25 @@ impl Game<'_> { } continue; } + let id = self.boats[i].id; + let kind = if round.listening() { + self.boats[i].entity_type() + } else { + String::new() + }; + if !self.native_tick_pre_one(&mut round, id, &kind) { + continue; + } + // The hook may have removed boats. + let Some(i) = Some(i) + .filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id)) + .or_else(|| self.boats.iter().position(|b| b.id == id)) + else { + continue; + }; let b = self.boats[i].clone(); self.boats[i] = self.tick_boat(b, false); + self.native_tick_post_one(&round, id, &kind); } // AbstractBoat.tick's applyEffectsFromBlocks, on the server for every // boat, the client-driven ones too. @@ -745,6 +767,7 @@ impl Game<'_> { while i < self.boats.len() { let p = self.boats[i].position; let skip = Some(self.boats[i].id) == self.riding + || round.canceled(self.boats[i].id) || !ticking(p.x.floor() as i32, p.z.floor() as i32); if skip || !self.boat_lava_tick(i) { i += 1; @@ -755,7 +778,8 @@ impl Game<'_> { // pushes what it touches. for i in 0..self.boats.len() { let p = self.boats[i].position; - if !ticking(p.x.floor() as i32, p.z.floor() as i32) { + if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(self.boats[i].id) + { continue; } // `AbstractBoat.tick` counts submersion on the server even when @@ -1046,7 +1070,18 @@ impl Game<'_> { /// `getDismountLocationForPassenger`: in front of the player if there is /// floor there, else on top of the boat. pub(super) fn dismount(&mut self, i: usize) { + let id = self.boats[i].id; + if self.riding == Some(id) && !self.native_may_leave(id) { + return; + } self.riding = None; + // `AbstractBoat.tick`'s non-authoritative branch: the copy of a boat + // that no player of this side drives drops its cached motion + // (`setDeltaMovement(Vec3.ZERO)`, `deltaRotation = 0`) and is not + // moved, so a boat is at a standstill when its rider gets out instead + // of coasting on the velocity of the ride that just ended. + self.boats[i].velocity = Vec3::ZERO; + self.boats[i].delta_rotation = 0.0; let target = self.boat_dismount_target(i, 0.6, self.yaw); if let Some(p) = self.player.as_mut() { p.position = target; @@ -1235,6 +1270,9 @@ impl Game<'_> { { return false; } + if !self.native_may_board(self.boats[i].id) { + return true; + } self.riding = Some(self.boats[i].id); self.yaw = self.boats[i].yaw; // ServerPlayer.startRiding. diff --git a/crates/horizon-client/src/game/cartography.rs b/crates/horizon-client/src/game/cartography.rs index e6fb3d3fc5..eabe022665 100644 --- a/crates/horizon-client/src/game/cartography.rs +++ b/crates/horizon-client/src/game/cartography.rs @@ -162,6 +162,22 @@ impl Game<'_> { u32::MAX, ); } + // A native mod's own icons (`native_map_decorations`). + for d in self.mod_map_decorations(id, true) { + let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else { + continue; + }; + batch.quad( + pose_corners( + super::maps::decoration_pose_at(d.x, d.y, d.rot, 0.0), + x, + y, + scale, + ), + [[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]], + u32::MAX, + ); + } } } @@ -169,9 +185,14 @@ impl Game<'_> { /// `scale` GUI pixels per map pixel: (-1, 1), (1, 1), (1, -1), (-1, -1) /// under `MapRenderer.render`'s pose. fn decoration_corners(d: &super::maps::Decoration, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] { + pose_corners(super::maps::decoration_pose(d, 0.0), x, y, scale) +} +/// The corners of a decoration drawn under `pose` (`decoration_pose`), like +/// [`decoration_corners`]. +fn pose_corners(pose: glam::Mat4, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] { let m = glam::Mat4::from_translation(glam::Vec3::new(x, y, 0.0)) * glam::Mat4::from_scale(glam::Vec3::new(scale, scale, 1.0)) - * super::maps::decoration_pose(d, 0.0); + * pose; [(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)].map(|(cx, cy)| { let p = m.transform_point3(glam::Vec3::new(cx, cy, 0.0)); [p.x, p.y] diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index c3e4c7b4bd..cc75047148 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -142,6 +142,9 @@ pub(super) struct CombatTracker { /// The mob that owns the projectile exploding now (`getIndirectSourceEntity`): /// the mobs the blast hurts record it as their attacker. pub(super) explosion_owner: Option, + /// `WitherSkull.getBlockExplosionResistance`: a dangerous skull's blast + /// caps every destroyable block's resistance at this value. + pub(super) blast_resistance_cap: Option, /// Charged creepers whose blast just went off and that have not dropped /// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list, /// but the mobs they killed still ask for their loot. diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 873171a1ea..c806fadfce 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -1234,8 +1234,13 @@ impl Game<'_> { return Err(generic()); } if let Some(rider) = rider { - self.mount_mob(rider, id); + if !self.try_mount_mob(rider, id) { + return Err(generic()); + } } else { + if !self.native_may_board(id) { + return Err(generic()); + } self.riding = Some(id); self.yaw = self.boats[i].yaw; // sendEventAndTriggers: START_RIDING_TRIGGER. @@ -1248,7 +1253,10 @@ impl Game<'_> { return Ok(1); } Target::Cart(id) => { - if target != Target::Player || self.ridden_by_guest(id) { + if target != Target::Player + || self.ridden_by_guest(id) + || !self.native_may_board(id) + { return Err(generic()); } self.riding = Some(id); @@ -1270,13 +1278,18 @@ impl Game<'_> { } if let Some(rider) = rider { // Entity.startRiding. - self.mount_mob(rider, mob_id); + if !self.try_mount_mob(rider, mob_id) { + return Err(generic()); + } self.feedback(Text::translate( "commands.ride.mount.success", &[name, vehicle_name], )); return Ok(1); } + if !self.native_may_board(mob_id) { + return Err(generic()); + } if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) { m.extra.flag = false; m.extra.counter = 0; @@ -1314,9 +1327,13 @@ impl Game<'_> { let Some(i) = self.mobs.iter().position(|m| m.id == rider) else { return; }; - let Some(v) = self.mobs[i].extra.mount.take() else { + let Some(v) = self.mobs[i].extra.mount else { return; }; + if !self.native_mob_may_ride(rider, v, false) { + return; + } + self.mobs[i].extra.mount = None; let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw); let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) { self.boat_dismount_target(b, width, yaw) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index da690377fd..bcf29ab484 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2,7 +2,10 @@ //! chunk-generation spawning (`NaturalSpawner`), mob events (attacks, //! breeding, conversions, drops), projectiles, player interactions //! (feeding, shearing, milking, spawn eggs, throwing) and rendering. -use super::{Dim, Game, InteractTarget, dispenser, view_vector}; +use super::{ + Dim, Game, InteractTarget, dispenser, native_mob_draw::NativePlayerRender, + native_projectiles::Impact, view_vector, +}; use horizon_assets::item_model::DisplayContext; use horizon_client::{ Result, @@ -56,6 +59,9 @@ pub(super) struct NativeSound { #[derive(Default)] pub(super) struct Creatures { pub projectiles: Vec, + /// The entities a family tells the mods ticked, kept between ticks + /// (game/native_ticks.rs). + pub tick_buffer: Vec<(u32, super::native_ticks::TickKind)>, /// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs). pub wind_bursts: Vec, /// The players a fishing hook can catch, taken as the hooks tick, and @@ -147,7 +153,7 @@ impl ProjectileWorld for ProjWorld<'_, '_> { const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000; /// The index of the wind charge a `wind_charge_hitboxes` id stands for. -fn wind_charge_hit(id: u32) -> Option { +pub(super) fn wind_charge_hit(id: u32) -> Option { (WIND_CHARGE_IDS..super::dragon::SPECIAL) .contains(&id) .then(|| (id - WIND_CHARGE_IDS) as usize) @@ -885,6 +891,20 @@ impl Game<'_> { // waiting any more. self.combat.charged_exploders.clear(); self.combat.exploded.clear(); + // EntityTickEvent.Pre for the mobs about to tick, before the loop holds them. + let mut round = self.native_tick_round(); + if round.listening() { + let ticking = self.entity_ticking_area(); + let mut i = 0; + while let Some(m) = self.mobs.get(i) { + let (x, z) = (m.position.x.floor() as i32, m.position.z.floor() as i32); + if self.world.loaded(x, z) && ticking(x, z) { + let (id, kind) = (m.id, super::native_ticks::TickKind::Mob(m.kind.clone())); + self.native_tick_pre_batch(&mut round, id, kind); + } + i += 1; + } + } let uuid = self.level.player_uuid; let mut snapshot: Vec = self.mobs.iter().map(|m| seen(m, uuid)).collect(); let mut events = Vec::new(); @@ -997,6 +1017,11 @@ impl Game<'_> { keep.push(kept); continue; } + // A mod canceled its tick: it stays as it is. + if round.canceled(m.id) { + keep.push(true); + continue; + } horizon_client::mobs::apply_mob_pushes(m, &mut pushes); horizon_client::mobs::blocks_changed(m, &path_changes); let from = events.len(); @@ -1077,6 +1102,7 @@ impl Game<'_> { self.mobs.append(&mut mobs); } self.died = died; + self.native_tick_post_batch(round); // ... and entity event 60 goes out as it is: the poof. for (id, at, width, height) in gone { let at = [at.x, at.y, at.z]; @@ -3411,24 +3437,12 @@ impl Game<'_> { let mut deflections: Vec<[f64; 3]> = Vec::new(); let mut arrow_events = Vec::new(); let mut trails = Vec::new(); + let mut keep_flags = vec![true; projectiles.len()]; + // The impacts the mods may cancel (`native_projectile_impact`), on a server. + let hear = self.native_hears_impacts(); + let mut impacts: Vec = Vec::new(); { - let mut targets: Vec<(Who, Aabb)> = self - .mobs - .iter() - .filter(|m| m.alive()) - .map(|m| (Who::Mob(m.id), m.bounds())) - .collect(); - if let Some(p) = self.player_view() - && p.alive - { - targets.push((Who::Player, p.bounds)); - } - // Dragon parts and end crystals under reserved ids (game/dragon.rs). - targets.extend( - self.special_targets() - .into_iter() - .map(|(id, b)| (Who::Mob(id), b)), - ); + let targets = self.projectile_targets(); let breezes: Vec = self .mobs .iter() @@ -3472,7 +3486,6 @@ impl Game<'_> { .map(|g| g.conn) .collect() }); - let mut keep_flags = vec![true; projectiles.len()]; for i in 0..projectiles.len() { // A wind charge is pickable, so a pearl, an arrow or a trident can // strike it (`Projectile.isPickable`, `Projectile.onHit`). @@ -3492,6 +3505,8 @@ impl Game<'_> { keep_flags[i] = false; continue; } + // The projectile as it was, to fly on if a mod cancels its impact. + let before = (hear && !p.stuck).then(|| p.clone()); let mob_shot = !matches!(p.owner, Some(Who::Player)); // A player's arrows, snowballs and eggs meet every player; a // guest's flies from that guest (Projectile.getOwner), not @@ -3556,6 +3571,7 @@ impl Game<'_> { &mut p.velocity, ); } + let events_from = arrow_events.len(); if let Some(a) = p.arrow.as_mut() { arrow_events.extend(a.events.drain(..).map(|e| (p.position, e))); } @@ -3594,6 +3610,15 @@ impl Game<'_> { continue; } if hit != Hit::None { + if let Some(before) = before { + impacts.push(Impact { + index: i, + before, + hit, + hit_slot: hits.len(), + events: events_from..arrow_events.len(), + }); + } hits.push((p.clone(), hit)); } keep_flags[i] = keep; @@ -3609,12 +3634,22 @@ impl Game<'_> { super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest); } } - let mut idx = 0; - projectiles.retain(|_| { - let k = keep_flags[idx]; - idx += 1; - k - }); + } + let listed = keep_flags.clone(); + let mut idx = 0; + projectiles.retain(|_| { + let k = keep_flags[idx]; + idx += 1; + k + }); + if !impacts.is_empty() { + self.native_settle_impacts( + &mut projectiles, + &listed, + impacts, + &mut hits, + &mut arrow_events, + ); } self.smoke_trails(trails); for at in deflections { @@ -3885,11 +3920,32 @@ impl Game<'_> { } } /// `Level.isRainingAt` for arrows. - fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ { + /// What a projectile can hit: the mobs, the player, the dragon's parts and + /// the End crystals (under reserved ids, game/dragon.rs). + pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> { + let mut targets: Vec<(Who, Aabb)> = self + .mobs + .iter() + .filter(|m| m.alive()) + .map(|m| (Who::Mob(m.id), m.bounds())) + .collect(); + if let Some(p) = self.player_view() + && p.alive + { + targets.push((Who::Player, p.bounds)); + } + targets.extend( + self.special_targets() + .into_iter() + .map(|(id, b)| (Who::Mob(id), b)), + ); + targets + } + pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ { move |p: BlockPos| self.raining_at(p) } /// The level as arrows see it. - fn arrow_scene<'s>( + pub(super) fn arrow_scene<'s>( &'s self, rain: &'s dyn Fn(BlockPos) -> bool, ) -> horizon_client::arrows::ArrowScene<'s, 's> { @@ -6062,6 +6118,10 @@ impl Game<'_> { [p.position.x, p.position.y, p.position.z] }), player_rot: [self.pitch, self.yaw], + paused: self.pause, + first_person: self.camera_type.first_person(), + third_person_reversed: self.camera_type + == super::perspective::CameraType::ThirdPersonFront, ..Default::default() }); for mob in &self.mobs { @@ -6143,10 +6203,20 @@ impl Game<'_> { // A native mod's own model in place of the vanilla one // (`native_mob_submit`), before any of the vanilla model's parts // (a sulfur cube's swallowed block) are drawn. - if !is_player - && !layers_only - && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out) - { + // A player's own hook (`native_player_submit`) is asked for the + // other players in view. + let who = is_player + .then(|| self.remote_player_name(mob.id)) + .flatten() + .map(|name| NativePlayerRender { name, local: false }); + let modded = if layers_only { + false + } else if let Some(who) = who { + self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out) + } else { + !is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out) + }; + if modded { if let Some(rgb) = outline { self.mark_outline(&out[entity_start..], rgb); if mob.invisible { @@ -6225,6 +6295,8 @@ impl Game<'_> { } // CustomHeadLayer (a pumpkin, skull or head item) included. self.mob_block_geometry(mob, camera, alpha, packed, out); + // `native_mob_layer`: a native mod's layer, over the vanilla ones. + self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out); if let Some(rgb) = outline { self.mark_outline(&out[entity_start..], rgb); if mob.invisible { diff --git a/crates/horizon-client/src/game/crossbow.rs b/crates/horizon-client/src/game/crossbow.rs index b8597edd9c..c13396e7d4 100644 --- a/crates/horizon-client/src/game/crossbow.rs +++ b/crates/horizon-client/src/game/crossbow.rs @@ -43,6 +43,29 @@ fn projectile_slot( }) } +/// `CrossbowItem.onUseTick`'s loading sounds for a charge now at `charge` +/// (of `getChargeDuration`) that has played `played` so far: the start and +/// middle sounds each play once the charge reaches their point, and both +/// count as unheard again while the charge is under the first one. A tick +/// that jumped the charge past a point still plays that point's sound, as +/// vanilla's two flags do. Returns the flags for the next tick and the +/// sounds to play now. +fn charge_sounds( + charge: f32, + played: [bool; 2], + sounds: [&'static str; 3], +) -> ([bool; 2], Option<&'static str>, Option<&'static str>) { + let mut played = if charge < 0.2 { [false; 2] } else { played }; + let mut play = [None; 2]; + for (i, point) in [0.2, 0.5].into_iter().enumerate() { + if charge >= point && !played[i] && !sounds[i].is_empty() { + played[i] = true; + play[i] = Some(sounds[i]); + } + } + (played, play[0], play[1]) +} + impl Game<'_> { pub(super) fn charge_ticks(stack: &ItemStack) -> u32 { // crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level. @@ -100,17 +123,19 @@ impl Game<'_> { let held = self.ticks_held(stack, ticks); self.using = Some((slot, ticks + 1)); let charge = Self::charge_ticks(stack).max(1) as f32; - let (before, now) = (held as f32 / charge, (held + 1) as f32 / charge); + let now = (held + 1) as f32 / charge; let sounds = Self::charging_sounds(stack); let at = self .player .as_ref() .map(|p| [p.position.x, p.position.y, p.position.z]); - if before < 0.2 && now >= 0.2 && !sounds[0].is_empty() { - self.audio.play(sounds[0], at, 0.5, 1.0); + let (played, start, middle) = charge_sounds(now, self.crossbow_sounds, sounds); + self.crossbow_sounds = played; + if let Some(sound) = start { + self.audio.play(sound, at, 0.5, 1.0); } - if before < 0.5 && now >= 0.5 && !sounds[1].is_empty() { - self.audio.play(sounds[1], at, 0.5, 1.0); + if let Some(sound) = middle { + self.audio.play(sound, at, 0.5, 1.0); } let charged = stack.data().is_some_and(|d| !d.charged.is_empty()); if now >= 1.0 && !charged { @@ -254,6 +279,44 @@ impl Game<'_> { mod tests { use super::*; + /// A charge that crosses a point between two ticks plays that point's + /// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick` + /// cutting the ticks left) still plays every point it passed, as + /// `CrossbowItem.onUseTick`'s two flags do. + #[test] + fn a_jumped_charge_still_plays_the_loading_sounds_it_passed() { + let sounds = [ + "minecraft:item.crossbow.loading_start", + "minecraft:item.crossbow.loading_middle", + "minecraft:item.crossbow.loading_end", + ]; + // Each step: the charge and the sounds it plays, from the flags the + // step before left. + let mut played = [false; 2]; + let mut step = |charge: f32| { + let (next, start, middle) = charge_sounds(charge, played, sounds); + played = next; + [start, middle] + }; + assert_eq!(step(0.1), [None, None]); + assert_eq!(step(0.25), [Some(sounds[0]), None]); + assert_eq!(step(0.25), [None, None]); + assert_eq!(step(0.55), [None, Some(sounds[1])]); + assert_eq!(step(1.0), [None, None]); + // A charge below the first point is a new charge: both sound again. + assert_eq!(step(0.1), [None, None]); + assert_eq!(step(0.6), [Some(sounds[0]), Some(sounds[1])]); + // One tick from the start past every point, as a mod's tick makes it. + assert_eq!(step(0.1), [None, None]); + assert_eq!(step(0.9), [Some(sounds[0]), Some(sounds[1])]); + // Quick Charge 1 and up have no middle sound. + let quick = [sounds[0], "", sounds[2]]; + assert_eq!( + charge_sounds(0.9, [false; 2], quick), + ([true, false], Some(sounds[0]), None) + ); + } + #[test] fn held_projectiles_come_before_the_inventory() { let stack = |item: &str| Some(ItemStack::new(item, 1)); diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs index 39311da8fa..9b8759bd46 100644 --- a/crates/horizon-client/src/game/cushions.rs +++ b/crates/horizon-client/src/game/cushions.rs @@ -338,6 +338,9 @@ impl Game<'_> { } // `Entity.startRiding`: a passenger first gets off what it rides // (`stopRiding`), so a seated player moves to this cushion. + if !self.native_may_board(id) { + return true; + } self.stop_riding(); self.riding = Some(id); let p = self.cushions[i].position; @@ -454,6 +457,9 @@ impl Game<'_> { /// and `Cushion.removePassenger`'s get-up sound for a rider that left /// rather than one whose cushion was killed. pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) { + if self.riding == Some(c.id) && !self.native_may_leave(c.id) { + return; + } let at = c.dismount(); self.riding = None; if let Some(p) = self.player.as_mut() { diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index ccb2e201d2..c4294c7e74 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -27,7 +27,7 @@ use horizon_world::nbt::Tag; /// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals /// (`SPECIAL + 16 + crystal`), so projectiles can target them. pub(super) const SPECIAL: u32 = u32::MAX - 4096; -const CRYSTAL_IDS: u32 = SPECIAL + 16; +pub(super) const CRYSTAL_IDS: u32 = SPECIAL + 16; const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat"; const MAX_HEALTH: f32 = 200.0; /// `Mob.getAmbientSoundInterval`. diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 7755f53f3a..7ea364f5ab 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -812,7 +812,30 @@ impl Game<'_> { if !moved && !landed && !changed.iter().any(|c| *c) { return; } + let id = stand.id; + let told: Vec<_> = (0..6) + .filter(|&i| changed[i]) + .map(|i| { + let slot = horizon_client::entities::ArmorStand::SLOTS[i]; + ( + slot, + stand.enchantment_equipment[i].clone(), + stand.equipment[i].clone(), + ) + }) + .collect(); stand.enchantment_equipment.clone_from(&stand.equipment); + if self.remote.is_none() && !self.options.execution_role.is_client() { + for (slot, from, to) in told { + self.native_living_equipment_change( + id, + "minecraft:armor_stand", + slot, + from.as_ref(), + to.as_ref(), + ); + } + } let stand = &self.armor_stands[index]; let at = [stand.position.x, stand.position.y, stand.position.z]; let who = Who::Mob(stand.id); diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index eaa9fd900d..6b8c714a82 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -100,6 +100,15 @@ pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bo d / diameter <= 1.0 } +/// `ItemEntity.ignoreExplosion` through +/// `ServerExplosion.shouldAffectBlocklikeEntities`: an explosion that does not +/// affect blocklike entities is ignored entirely by a dropped item. With +/// `mobGriefing` off that is every blast that keeps blocks; with it on, none +/// (no wind charge goes through `server_explode`). +fn items_ignore_explosion(mob_griefing: bool, interaction: BlockInteraction) -> bool { + !mob_griefing && interaction == BlockInteraction::Keep +} + /// Whether a blast leaves item frames, paintings, cushions and armour stands /// alone. Without `mobGriefing`, a blast that keeps blocks is ignored /// (`ServerExplosion.shouldAffectBlocklikeEntities` through their @@ -259,6 +268,16 @@ impl Game<'_> { use horizon_world::level::LevelAccess; (level.min_y(), level.max_y()) }); + // WitherSkull.getBlockExplosionResistance: a dangerous skull's blast + // caps a block the wither may destroy at 0.8. + let cap = self.combat.blast_resistance_cap; + let wither_immune = cap.and_then(|_| { + self.res + .items() + .block_tags + .get("minecraft:wither_immune") + .cloned() + }); let mut set: JavaHashMap = JavaHashMap::default(); for xx in 0..16 { for yy in 0..16 { @@ -301,12 +320,14 @@ impl Game<'_> { power -= (resistance + 0.3) * 0.3; } _ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => { - let resistance = if spared { + let base = if spared { 0.0 } else { f.explosion_resistance.max(fluid) }; - power -= (resistance + 0.3) * 0.3; + let immune = + wither_immune.as_ref().is_some_and(|t| t.contains(&*f.name)); + power -= (blast_resistance(base, cap, immune) + 0.3) * 0.3; } _ => {} } @@ -593,9 +614,15 @@ impl Game<'_> { } self.mobs = mobs; // Dropped items: 5 health, destroyed by the explosion damage (nether stars resist). + // `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities` + // first, so a blast that keeps blocks leaves every item untouched. + let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction); let before = self.dropped_ids(); let mut items = std::mem::take(&mut self.dropped); items.retain_mut(|item| { + if items_ignore { + return true; + } let feet = [item.position.x, item.position.y, item.position.z]; let dist = distance(feet) / diameter; if dist > 1.0 { @@ -1164,10 +1191,45 @@ fn add_or_append_stack( stacks.push((pos, stack)); } +/// `WitherSkull.getBlockExplosionResistance`: `isDangerous() && +/// WitherBoss.canDestroy(state) ? min(0.8, resistance) : resistance`. Air has +/// no resistance to begin with, and `#wither_immune` blocks keep theirs. +fn blast_resistance(base: f32, cap: Option, wither_immune: bool) -> f32 { + match cap { + Some(cap) if !wither_immune => base.min(cap), + _ => base, + } +} + #[cfg(test)] mod tests { use super::*; + /// A dangerous wither skull's blast treats a block as at most 0.8 harder to + /// break, but a `#wither_immune` block keeps its own resistance and any + /// other blast is unchanged. + #[test] + fn a_dangerous_wither_skull_caps_block_resistance() { + assert_eq!(blast_resistance(4.0, Some(0.8), false), 0.8); + assert_eq!(blast_resistance(0.3, Some(0.8), false), 0.3, "already low"); + assert_eq!(blast_resistance(4.0, Some(0.8), true), 4.0, "wither immune"); + assert_eq!(blast_resistance(4.0, None, false), 4.0, "another blast"); + } + + /// A dropped item survives a mob's blast untouched while `mobGriefing` is + /// off, and is thrown and hurt as usual by one that destroys blocks or by + /// any blast with the rule back on. + #[test] + fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() { + assert!(items_ignore_explosion(false, BlockInteraction::Keep)); + assert!(!items_ignore_explosion(false, BlockInteraction::Destroy)); + assert!(!items_ignore_explosion( + false, + BlockInteraction::DestroyWithDecay + )); + assert!(!items_ignore_explosion(true, BlockInteraction::Keep)); + } + #[test] fn blasts_reach_entities_within_their_diameter() { // A TNT blast: radius 4, so the entity pass reaches 8 blocks out. diff --git a/crates/horizon-client/src/game/ghasts.rs b/crates/horizon-client/src/game/ghasts.rs index ae8f6fb994..646d0ab28b 100644 --- a/crates/horizon-client/src/game/ghasts.rs +++ b/crates/horizon-client/src/game/ghasts.rs @@ -90,6 +90,9 @@ impl Game<'_> { if harnessed && !sneaking { // doPlayerRide. let id = self.mobs[index].id; + if !self.native_may_board(id) { + return true; + } self.break_leash(id, true, false); let was_empty = self.mobs[index].rider.is_none(); // addPassenger: the rider is not standing on top, so no still diff --git a/crates/horizon-client/src/game/item_use.rs b/crates/horizon-client/src/game/item_use.rs index f9023d3117..18a273396d 100644 --- a/crates/horizon-client/src/game/item_use.rs +++ b/crates/horizon-client/src/game/item_use.rs @@ -32,6 +32,9 @@ impl Game<'_> { self.using = Some((self.held_slot(), 0)); self.use_duration = duration; self.use_ticks_set = ticks_set; + // A new charge hears the crossbow's loading sounds again + // (`game/crossbow.rs`). + self.crossbow_sounds = [false; 2]; true } /// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 9f6fa9bf8b..c78279b737 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1113,54 +1113,11 @@ fn mob_equipment( hub: &mut Hub, out: &mut Vec<(u8, Option)>, ) { - let e = &m.extra; - // A horse's armour, a llama's carpet, a harness: Horizon keeps the body - // slot's item (6) in `head`. - let body = horizon_client::mobs::drops::head_is_body(m.species()); - let slots = [ - (0u8, &e.main_hand, e.main_hand_stack()), - (1, &e.off_hand, None), - (2, &e.feet, e.armor[3].as_ref()), - (3, &e.legs, e.armor[2].as_ref()), - (4, &e.chest, e.armor[1].as_ref()), - // The body slot's item is no worn armour. - ( - if body { 6 } else { 5 }, - &e.head, - e.armor[0].as_ref().filter(|_| !body), - ), - ]; - for (slot, item, worn) in slots { - if let Some(item) = item { - // The worn armour keeps its stack: a dyed leather piece keeps - // its colour. - let stack = worn.cloned().unwrap_or_else(|| ItemStack { - item: item.clone(), - count: 1, - // A wolf's armor shows its cracks. - damage: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf { - e.body_damage.max(0) as u32 - } else { - 0 - }, - extra: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf { - e.body_extra.clone() - } else { - None - }, - }); - out.push((slot, hub.stack(&Some(stack)))); + for (slot, stack) in m.equipment_stacks().into_iter().enumerate() { + if stack.is_some() { + out.push((slot as u8, hub.stack(&stack))); } } - if e.saddled { - let stack = e.saddle_stack.clone().unwrap_or(ItemStack { - item: Arc::from("minecraft:saddle"), - count: 1, - damage: 0, - extra: None, - }); - out.push((7, hub.stack(&Some(stack)))); - } } pub const HELP: &[&str] = &[ @@ -2334,6 +2291,15 @@ impl Game<'_> { .take() .expect("the hub is back after the menu events"); } + host.hub.collect_tracking(); + if !host.hub.native_tracking.is_empty() { + self.lan = Some(host); + self.run_native_tracking(); + host = self + .lan + .take() + .expect("the hub is back after the tracking events"); + } if !paused { self.tick_guests(&mut host); } diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 165ecd67d7..65a3df55f1 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -93,6 +93,8 @@ pub(super) struct GuestPlay { pub(super) mount_window: Option, /// The lectern the guest reads (game/lan_stations.rs). pub(super) lectern: Option, + /// The menu the mods last heard this guest open (`tick_player_menus`). + pub(super) menu_heard: Option, /// What the guest's body was given for its effects and enchantments. potions: super::potions::BodyPotions, /// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw. @@ -1305,6 +1307,8 @@ impl Game<'_> { self.lan_station_closed(host, conn, None); self.lan_mount_closed(host, conn, None); self.guest_carried_back(host, gi); + // `PlayerContainerEvent.Close`, after the menu's `removed`. + self.player_menu_closing(host, conn); } /// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes @@ -3263,6 +3267,7 @@ impl Game<'_> { self.lan_tables_tick(host); self.lan_containers_tick(host); self.tick_lan_mount_menus(host); + self.tick_player_menus(host); self.announce_guest_advancements(host); let conns: Vec = host .hub diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 0bf154343f..2d922a861c 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -83,6 +83,42 @@ fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value) } impl Game<'_> { + /// The entities players started and stopped tracking since the last + /// tick, told to the mods in order with the hub back in `self.lan` and + /// no player's state swapped in (`ServerEntity.addPairing` and + /// `removePairing`). + pub(super) fn run_native_tracking(&mut self) { + let batches = match self.lan.as_mut() { + Some(host) => std::mem::take(&mut host.hub.native_tracking), + None => return, + }; + if self.remote.is_some() || self.options.execution_role.is_client() { + return; + } + for batch in batches { + for e in batch.events { + let (id, kind) = (e.entity as u32, &*e.tracked.kind); + if e.start { + self.native_player_start_tracking( + batch.player, + &batch.name, + id, + e.tracked.uuid, + kind, + ); + } else { + self.native_player_stop_tracking( + batch.player, + &batch.name, + id, + e.tracked.uuid, + kind, + ); + } + } + } + } + /// Who rides what (`Entity.getPassengers`): mobs on mobs, and the /// host in its boat or minecart. pub(super) fn lan_riding( diff --git a/crates/horizon-client/src/game/lan_stations.rs b/crates/horizon-client/src/game/lan_stations.rs index a3b2d7f72b..16a613ff81 100644 --- a/crates/horizon-client/src/game/lan_stations.rs +++ b/crates/horizon-client/src/game/lan_stations.rs @@ -22,8 +22,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C}; pub(super) struct GuestStation { pub(super) window: i32, state: i32, - kind: StationKind, - pos: [i32; 3], + pub(super) kind: StationKind, + pub(super) pos: [i32; 3], menu: Box

, /// The data slots the client was last sent. sent_data: Vec, @@ -43,7 +43,7 @@ pub(super) struct GuestLectern { /// The `MenuType`, the engine's menu and the input slots /// (`clearContainer`'s container) of a station a guest can use. -fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range)> { +pub(super) fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range)> { Some(match kind { StationKind::Loom => ("minecraft:loom", Kind::Loom, 0..3), StationKind::Stonecutter => ("minecraft:stonecutter", Kind::Stonecutter, 0..1), diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index 9e43d00e48..f67223b98c 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -11,8 +11,9 @@ //! is teleported where the player lands (`ServerPlayer.dismountTo`). use super::{Game, Keys, lan::LanHost, minecarts::SEAT}; use horizon_client::collisions::SceneCollisions; +use horizon_client::lan::hub::no_blocks_in; use horizon_client::mobs::equine; -use horizon_core::geometry::{Aabb, Vec3}; +use horizon_core::geometry::{Aabb, Axis, Vec3}; use horizon_protocol::{ packets::play::{self, Clientbound as C}, text::translatable, @@ -367,7 +368,31 @@ impl Game<'_> { back(host, old, yaw); return; } - host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]); + // clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto + // something on its last move (`verticalCollisionBelow` before this one), and nothing but air is + // around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or + // no-gravity mount, and a server that allows flight, never float. + let (gravity, no_gravity) = match mount { + Some(i) => (0.08, self.mobs[i].no_gravity), + None => (0.04, false), + }; + let around = moved_box + .inflate(Vec3::new(0.0625, 0.0625, 0.0625)) + .expand_towards(Vec3::new(0.0, -0.55, 0.0)); + let corner = |max: bool| { + [Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) }) + }; + let no_blocks = no_blocks_in(&self.world, corner(false), corner(true)); + let allow_flight = host.hub.config.allow_flight; + let g = &mut host.hub.guests[gi]; + g.vehicle_good = Some([target.x, target.y, target.z]); + let rested = std::mem::replace( + &mut g.vehicle_rests, + wanted.y < 0.0 && resolved.y != wanted.y, + ); + g.vehicle_floating = + wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks; + g.vehicle_gravity = gravity; if let Some(i) = mount { // absSnapTo: where and how its rider's client turned it; the // rider sits on it from its own ride tick. diff --git a/crates/horizon-client/src/game/llamas.rs b/crates/horizon-client/src/game/llamas.rs index 2af6464244..0b16651adf 100644 --- a/crates/horizon-client/src/game/llamas.rs +++ b/crates/horizon-client/src/game/llamas.rs @@ -130,6 +130,9 @@ impl Game<'_> { } // doPlayerRide; mounting drops a lead. let id = self.mobs[index].id; + if !self.native_may_board(id) { + return true; + } self.break_leash(id, true, false); let m = &mut self.mobs[index]; m.extra.flag = false; diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index e82b6ca18c..1380602592 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -5,6 +5,7 @@ use super::{Game, storage::is_shulker_box}; mod crafter; +pub(in crate::game) mod native_energy; pub(in crate::game) mod native_fluids; pub(in crate::game) mod native_items; use horizon_client::{ diff --git a/crates/horizon-client/src/game/machines/native_energy.rs b/crates/horizon-client/src/game/machines/native_energy.rs new file mode 100644 index 0000000000..80b1147390 --- /dev/null +++ b/crates/horizon-client/src/game/machines/native_energy.rs @@ -0,0 +1,275 @@ +//! Energy in a block's buffer for native server mods (docs/native-mods.md): +//! the buffer a mod block declared through `native_block_entity_energy`, read +//! through a side, filled, drained and moved to another block. Vanilla has no +//! energy; the contract is NeoForge's `EnergyHandler` (`SimpleEnergyHandler` +//! and `EnergyHandlerUtil.move`). Like the fluid helpers, they act where the +//! world is simulated, never on a client's copy, and only on the level in +//! place, which the readers name by their `dimension` +//! (`minecraft:overworld`), as `native_set_block` does. +use super::Game; +use horizon_client::containers::ModEnergyBuffer; +use horizon_core::geometry::Direction; +use horizon_world::nbt::{Compound, JavaString, Tag}; +use std::sync::Arc; + +/// A block's energy as a side reaches it (`native_energy`). +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NativeEnergy { + pub(crate) amount: i32, + pub(crate) capacity: i32, + /// The most one insert moves. + pub(crate) max_insert: i32, + /// The most one extract moves. + pub(crate) max_extract: i32, + /// Whether a fill may come through this side (and moves more than 0). + pub(crate) can_insert: bool, + /// Whether a drain may leave by this side (and moves more than 0). + pub(crate) can_extract: bool, +} + +/// The amount in `nbt`: the int `energy`, as `SimpleEnergyHandler` saves it; +/// anything else, or a negative, reads as empty. +fn read_energy(nbt: &Compound) -> i32 { + match nbt.get(&JavaString::from("energy")) { + Some(Tag::Int(energy)) => (*energy).max(0), + _ => 0, + } +} + +/// `SimpleEnergyHandler.insert`: the room left (none when the amount is +/// over the capacity), the call's limit and what was offered, whichever is +/// least. +fn insert_amount(buffer: &ModEnergyBuffer, energy: i32, offered: i32) -> i32 { + (buffer.capacity.saturating_sub(energy)) + .min(offered) + .min(buffer.max_insert) + .max(0) +} + +/// `SimpleEnergyHandler.extract`: what is stored, the call's limit and what +/// was asked, whichever is least. +fn extract_amount(buffer: &ModEnergyBuffer, energy: i32, asked: i32) -> i32 { + energy.min(asked).min(buffer.max_extract).max(0) +} + +// Native mods use these; nothing in Horizon does. +#[allow(dead_code)] +impl Game<'_> { + /// The buffer of the mod block at `pos` in the level in place, + /// `dimension`, and the energy it holds (its block entity's, and not the + /// placeholder standing in while it ticks). + fn native_energy_at( + &self, + dimension: &str, + pos: [i32; 3], + ) -> Option<(Arc, i32)> { + if !self.native_simulates() || dimension != self.dim.id() { + return None; + } + let key = (pos[0], pos[1], pos[2]); + let state = self.world.block(pos[0], pos[1], pos[2]); + let buffer = self.world.block_entities.mod_energy(state.0)?; + // The block entity is out of the world while it ticks. + match self.world.block_entities.get(&key) { + Some(horizon_client::containers::BlockEntity::Mod(m)) if !m.id.is_empty() => { + Some((Arc::clone(buffer), read_energy(&m.nbt))) + } + _ => None, + } + } + /// Writes `energy` into the block entity at `pos`, saved with its chunk. + fn native_energy_store(&mut self, pos: [i32; 3], energy: i32) { + let key = (pos[0], pos[1], pos[2]); + if let Some(horizon_client::containers::BlockEntity::Mod(m)) = + self.world.block_entities.get_mut(&key) + { + m.nbt + .insert(JavaString::from("energy"), Tag::Int(energy.max(0))); + self.world.modified.insert((key.0 >> 4, key.2 >> 4)); + } + } + /// What the buffer at `pos` takes (`insert`) or gives through `face` of + /// up to `amount`, written unless `simulate`. Nothing where the side + /// doesn't reach the buffer for that. + fn native_energy_transfer( + &mut self, + dimension: &str, + pos: [i32; 3], + face: Direction, + amount: i32, + insert: bool, + simulate: bool, + ) -> i32 { + if amount <= 0 { + return 0; + } + let Some((buffer, energy)) = self.native_energy_at(dimension, pos) else { + return 0; + }; + let moved = if insert { + if !buffer.insert_faces[face as usize] { + return 0; + } + insert_amount(&buffer, energy, amount) + } else { + if !buffer.extract_faces[face as usize] { + return 0; + } + extract_amount(&buffer, energy, amount) + }; + if moved > 0 && !simulate { + let after = if insert { + energy.saturating_add(moved) + } else { + energy - moved + }; + self.native_energy_store(pos, after); + } + moved + } + /// The energy buffer of the block at `pos` in the level in place, + /// `dimension`, as its side `face` reaches it: what it holds, its + /// capacity and its limits per call. None where no buffer is: another + /// dimension, a chunk that isn't loaded, a block without one, a side + /// that reaches neither a fill nor a drain, or the mod block entity + /// ticking now (it's out of the world). `Capabilities.Energy.BLOCK`. + pub(in crate::game) fn native_energy( + &self, + dimension: &str, + pos: [i32; 3], + face: Direction, + ) -> Option { + let (buffer, amount) = self.native_energy_at(dimension, pos)?; + let (fill, drain) = ( + buffer.insert_faces[face as usize], + buffer.extract_faces[face as usize], + ); + (fill || drain).then(|| NativeEnergy { + amount, + capacity: buffer.capacity, + max_insert: buffer.max_insert, + max_extract: buffer.max_extract, + can_insert: fill && buffer.max_insert > 0, + can_extract: drain && buffer.max_extract > 0, + }) + } + /// `EnergyHandler.insert`: up to `amount` into the buffer of the block at + /// `pos` through its side `face`, in the level in place: no more than + /// its room and its limit per call. With `simulate` nothing changes and + /// the answer says what would. The amount that went in. + pub(in crate::game) fn native_energy_insert( + &mut self, + pos: [i32; 3], + face: Direction, + amount: i32, + simulate: bool, + ) -> i32 { + let dimension = self.dim.id(); + self.native_energy_transfer(dimension, pos, face, amount, true, simulate) + } + /// `EnergyHandler.extract`: up to `amount` out of the buffer of the block + /// at `pos` in the level in place, `dimension`, through its side `face`: + /// no more than it holds and its limit per call. With `simulate` nothing + /// changes and the answer says what would. The amount that came out. + pub(in crate::game) fn native_energy_extract( + &mut self, + dimension: &str, + pos: [i32; 3], + face: Direction, + amount: i32, + simulate: bool, + ) -> i32 { + self.native_energy_transfer(dimension, pos, face, amount, false, simulate) + } + /// `EnergyHandlerUtil.move`: up to `max` from the block at `from`, + /// through its side `from_face`, into the one at `to`, through + /// `to_face`, in the level in place, `dimension`. It asks what the + /// source can give, offers that to the target, and takes from the source + /// exactly what the target accepted, so none is lost. The amount moved; + /// 0 where either has no buffer for it, and within one block. + pub(in crate::game) fn native_move_energy( + &mut self, + dimension: &str, + from: [i32; 3], + from_face: Direction, + to: [i32; 3], + to_face: Direction, + max: i32, + ) -> i32 { + if from == to || max <= 0 { + return 0; + } + let offered = self.native_energy_transfer(dimension, from, from_face, max, false, true); + let accepted = self.native_energy_transfer(dimension, to, to_face, offered, true, true); + if accepted == 0 { + return 0; + } + let taken = self.native_energy_transfer(dimension, from, from_face, accepted, false, false); + let put = self.native_energy_transfer(dimension, to, to_face, taken, true, false); + if put < taken { + // Both were just simulated and nothing ran between; a buffer + // that answers differently is a bug, and its energy goes back. + log::warn!("Horizon: an energy move lost {} energy", taken - put); + let _ = + self.native_energy_transfer(dimension, from, from_face, taken - put, true, false); + } + put + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buffer(capacity: i32, max_insert: i32, max_extract: i32) -> ModEnergyBuffer { + ModEnergyBuffer { + capacity, + max_insert, + max_extract, + insert_faces: [true; 6], + extract_faces: [true; 6], + } + } + + #[test] + fn an_insert_stops_at_the_room_the_limit_and_the_offer() { + let b = buffer(1000, 100, 50); + assert_eq!(insert_amount(&b, 0, 40), 40); + assert_eq!(insert_amount(&b, 0, 500), 100); + assert_eq!(insert_amount(&b, 950, 500), 50); + assert_eq!(insert_amount(&b, 1000, 500), 0); + // Over the capacity after a mod update shrank it: nothing goes in. + assert_eq!(insert_amount(&b, 1500, 500), 0); + assert_eq!(insert_amount(&b, 0, i32::MAX), 100); + assert_eq!( + insert_amount(&buffer(i32::MAX, i32::MAX, 0), 5, i32::MAX), + i32::MAX - 5 + ); + } + + #[test] + fn an_extract_stops_at_what_is_stored_the_limit_and_the_ask() { + let b = buffer(1000, 100, 50); + assert_eq!(extract_amount(&b, 800, 30), 30); + assert_eq!(extract_amount(&b, 800, 500), 50); + assert_eq!(extract_amount(&b, 20, 500), 20); + assert_eq!(extract_amount(&b, 0, 500), 0); + assert_eq!(extract_amount(&buffer(1000, 100, 0), 800, 500), 0); + } + + #[test] + fn the_amount_reads_as_the_saved_int_and_anything_else_as_empty() { + let mut nbt = Compound::new(); + assert_eq!(read_energy(&nbt), 0); + nbt.insert(JavaString::from("energy"), Tag::Int(750)); + assert_eq!(read_energy(&nbt), 750); + nbt.insert(JavaString::from("energy"), Tag::Int(-5)); + assert_eq!(read_energy(&nbt), 0); + nbt.insert( + JavaString::from("energy"), + Tag::String(JavaString::from("lots")), + ); + assert_eq!(read_energy(&nbt), 0); + } +} diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 39ac17e61e..dea4c4ebdb 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -38,6 +38,42 @@ pub(super) struct Decoration { pub name: Option, pub key: Key, } +/// A map as a native mod sees it when `native_map_decorations` is asked. +// Native mods read it; nothing in Horizon does. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub struct NativeMapInfo<'a> { + /// The map's id (`map_`). + pub id: i32, + /// The map's centre block `[x, z]`. + pub center: [i32; 2], + /// `MapItemSavedData.scale`: a map pixel is `1 << scale` blocks. + pub scale: u8, + /// The dimension the map shows (`minecraft:overworld`). + pub dimension: &'a str, + pub locked: bool, + /// Whether it is drawn in an item frame, not held or on the cartography screen. + pub in_frame: bool, +} +/// An icon a native mod puts on a map: the same place and pose as vanilla's +/// decorations (`MapDecoration`). +#[derive(Clone, Debug, PartialEq)] +pub struct NativeMapDecoration { + /// An atlas sprite: vanilla's `map/decorations/`, or a content + /// mod's texture. An unknown sprite is left out. + pub sprite: String, + /// The position on the map in half map pixels, -128 (left, top) to 127. + /// For the world position `(wx, wz)`: `(wx - center_x) / (1 << scale) * 2`, + /// and the same for z, floored and clamped. + pub x: i8, + pub y: i8, + /// 0 to 15, in 22.5 degree steps clockwise (only the low four bits count). + pub rot: u8, + /// Whether it shows in an item frame and on the cartography screen + /// (`MapDecorationType.showOnItemFrame`); a held map shows all. + pub shown_on_frame: bool, +} + /// `MapBanner`: a banner a map marks, as `banners` saves it. #[derive(Clone, Debug, PartialEq)] pub(super) struct MapBanner { @@ -675,11 +711,15 @@ const DECORATION_TYPES: [(&str, &str); 40] = [ /// `MapRenderer.render`'s pose for decoration `d` in map pixels (0..128, /// y down) at depth `z`: its quad is (-1, -1) to (1, 1) under it. pub(super) fn decoration_pose(d: &Decoration, z: f32) -> glam::Mat4 { + decoration_pose_at(d.x, d.y, d.rot, z) +} +/// [`decoration_pose`] for a decoration at `x`, `y` (half map pixels) turned `rot`. +pub(super) fn decoration_pose_at(x: i8, y: i8, rot: u8, z: f32) -> glam::Mat4 { glam::Mat4::from_translation(glam::Vec3::new( - f32::from(d.x) / 2.0 + 64.0, - f32::from(d.y) / 2.0 + 64.0, + f32::from(x) / 2.0 + 64.0, + f32::from(y) / 2.0 + 64.0, z, - )) * glam::Mat4::from_rotation_z((f32::from(d.rot) * 360.0 / 16.0).to_radians()) + )) * glam::Mat4::from_rotation_z((f32::from(rot) * 360.0 / 16.0).to_radians()) * glam::Mat4::from_scale(glam::Vec3::splat(4.0)) * glam::Mat4::from_translation(glam::Vec3::new(-0.125, 0.125, 0.0)) } @@ -1736,6 +1776,54 @@ impl Game<'_> { ); count += 1; } + // A native mod's own icons, over the vanilla ones. + for d in self.mod_map_decorations(id, show_only_frame) { + let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else { + continue; + }; + let dm = decoration_pose_at(d.x, d.y, d.rot, -0.02 - count as f32 * 0.001); + let p = |x: f32, y: f32| dm.transform_point3(glam::Vec3::new(x, y, 0.0)).to_array(); + let corners = [p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)]; + let uvs = [[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]]; + for (c, uv) in corners.into_iter().zip(uvs) { + out.push(Vertex { + normal: [0; 4], + pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(), + uv, + layer: 0, + color: 0xFFFF_FFFF, + light, + glint_uv: [0.0, 0.0], + }); + } + count += 1; + } + } + /// The icons `native_map_decorations` adds to map `id`, as it is drawn + /// (`frame`: in an item frame or on a screen, which show only what + /// `shown_on_frame` allows). + pub(super) fn mod_map_decorations(&self, id: i32, frame: bool) -> Vec { + let Some(map) = self.maps.data.get(&id) else { + return Vec::new(); + }; + let info = NativeMapInfo { + id, + center: map.center, + scale: map.scale, + dimension: &map.dimension, + locked: map.locked, + in_frame: frame, + }; + let mut decorations = Vec::new(); + self.native_map_decorations(&info, &mut decorations); + decorations + .into_iter() + .filter(|d| !frame || d.shown_on_frame) + .map(|d| NativeMapDecoration { + rot: d.rot & 15, + ..d + }) + .collect() } /// `renderTwoHandedMap`: map `id` held in both hands, in view space (the /// arms under it: `first_person::map_arm_poses`). @@ -1803,6 +1891,28 @@ impl Game<'_> { mod tests { use super::*; + #[test] + fn a_mod_icon_sits_where_a_vanilla_decoration_would() { + // Half pixels from the map's centre: x 20 is 10 map pixels right of it. + let vanilla = Decoration { + sprite: "map/decorations/frame", + x: 20, + y: -30, + rot: 4, + name: None, + key: Key::Item, + }; + assert_eq!( + decoration_pose(&vanilla, -0.02), + decoration_pose_at(20, -30, 4, -0.02) + ); + let at = decoration_pose_at(20, -30, 0, 0.0).transform_point3(glam::Vec3::ZERO); + assert!( + (at.x - 74.0).abs() < 0.6 && (at.y - 49.0).abs() < 0.6, + "{at:?}" + ); + } + #[test] fn every_target_decoration_type_has_its_sprite() { // MapDecorationTypes: (name, asset). diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 0af35fc56d..54682169fd 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -1292,6 +1292,7 @@ impl Game<'_> { self.push_carts(); // ServerLevel.tick: only entities in entity-ticking chunks tick. let ticking = self.entity_ticking_area(); + let mut round = self.native_tick_round(); // A server player's minecart moves with its rider (lan_vehicles.rs). let ridden = self.guest_vehicles(); // By id: an exploding cart can destroy or prime others mid-loop. @@ -1307,12 +1308,20 @@ impl Game<'_> { { continue; } + let kind = self.carts[i].kind.id(); + if !self.native_tick_pre_one(&mut round, id, kind) { + continue; + } + let Some(i) = self.carts.iter().position(|c| c.id == id) else { + continue; + }; let mut c = self.carts[i].clone(); let alive = self.tick_cart(&mut c, [0.0; 2]); if c.kind == CartKind::Hopper && c.enabled { self.hopper_cart_suck(&mut c); } let Some(i) = self.carts.iter().position(|c| c.id == id) else { + self.native_tick_post_one(&round, id, kind); continue; }; if alive { @@ -1327,9 +1336,14 @@ impl Game<'_> { self.release_vehicle_container(id); self.carts.remove(i); } + // After the ticked cart is stored: a mod reads and changes the cart as it is now. + self.native_tick_post_one(&round, id, kind); } // On the server for every cart, the ridden ones too. for id in ids { + if round.canceled(id) { + continue; + } if let Some(i) = self.carts.iter().position(|c| c.id == id) { let p = self.carts[i].position; if ticking(p.x.floor() as i32, p.z.floor() as i32) { @@ -1444,6 +1458,10 @@ impl Game<'_> { } /// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top. pub(super) fn dismount_cart(&mut self, i: usize) { + let id = self.carts[i].id; + if self.riding == Some(id) && !self.native_may_leave(id) { + return; + } self.riding = None; let c = &self.carts[i]; let (bx, by, bz) = ( @@ -1603,7 +1621,10 @@ impl Game<'_> { if sneaking || self.riding.is_some() || self.ridden_by_guest(id) || has_mob { return false; } - self.riding = Some(self.carts[i].id); + if !self.native_may_board(id) { + return true; + } + self.riding = Some(id); } // AbstractMinecartContainer.interact: no sneak check. CartKind::Chest | CartKind::Hopper => self.open_cart_container(i), @@ -1847,7 +1868,21 @@ impl Game<'_> { let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else { return; }; - for (_, model) in &models { + let models = models.into_iter().map(|(_, model)| model); + self.baked_model_vertices(models, m, light, diffuse, tint, out); + } + /// `tinted_block_model_vertices` for baked models given as they are (a + /// state's, or a standalone model). + pub(super) fn baked_model_vertices<'m>( + &self, + models: impl IntoIterator, + m: glam::Mat4, + light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32, + diffuse: bool, + tint: impl Fn(i32) -> Option, + out: &mut Vec, + ) { + for model in models { for face in &model.faces { let Some(s) = self .pack diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index a9ed403222..8ecbae5865 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -37,6 +37,32 @@ pub(super) fn register_components(root: &std::path::Path) -> horizon_client::Res Ok(()) } +/// The blocks and items native mods declare in code (`native_blocks`, +/// `native_items`): asked once, so every content root of this run, a pack +/// reload's included, has the same ones. +fn native_declared() -> &'static ( + Vec, + Vec, +) { + static DECLARED: std::sync::OnceLock<( + Vec, + Vec, + )> = std::sync::OnceLock::new(); + DECLARED.get_or_init(|| { + let (mut blocks, mut items) = (Vec::new(), Vec::new()); + Game::native_blocks(&mut blocks); + Game::native_items(&mut items); + (blocks, items) + }) +} +/// The native mods' standalone models (`native_standalone_models`), set once +/// before the resources first load. +pub(super) fn register_standalone_models() { + let mut names = Vec::new(); + Game::native_standalone_models(&mut names); + horizon_client::models::register_standalone(names); +} + /// `root` with the content mods that are on layered over it /// (docs/modpacks.md): their blocks, items, data and assets. Read once at /// start, before the resources load; a mod whose content is refused is left @@ -92,6 +118,20 @@ pub(super) fn content_root( log::warn!("Horizon: mod {} content not loaded: {e}", m.id); } } + let (blocks, items) = native_declared(); + if !blocks.is_empty() || !items.is_empty() { + if known.is_none() { + match mod_content::known_components(root) { + Ok(types) => known = Some(types), + Err(e) => log::warn!("Horizon: native mods' blocks and items not loaded: {e}"), + } + } + if let Some(known) = &known { + for e in mod_content::accept_declared(&mut mods, blocks, items, known) { + log::warn!("Horizon: {e}"); + } + } + } match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) { Ok(out) => { if out != root { diff --git a/crates/horizon-client/src/game/mount_menu.rs b/crates/horizon-client/src/game/mount_menu.rs index 2304072c3e..47b6586f9f 100644 --- a/crates/horizon-client/src/game/mount_menu.rs +++ b/crates/horizon-client/src/game/mount_menu.rs @@ -541,16 +541,19 @@ impl Game<'_> { let atlas = &self.gui.atlas; let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); let mut verts = Vec::new(); - horizon_client::mobs::render::render_with( - &m, - 1.0, - camera, - &lookup, - 240 | 240 << 8, - self.res.mob_data(), - &[], - &mut verts, - ); + // A screen preview: custom entity model animations see is_in_gui = 1. + horizon_client::mobs::cem_anim::in_gui(|| { + horizon_client::mobs::render::render_with( + &m, + 1.0, + camera, + &lookup, + 240 | 240 << 8, + self.res.mob_data(), + &[], + &mut verts, + ); + }); let height = m.kind.height as f32; let gui = glam::Mat4::from_translation(glam::Vec3::new(cx, cy, 0.0)) * glam::Mat4::from_scale(glam::Vec3::new(size, size, -size)) diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index 82978d9569..1eb6972674 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -86,7 +86,11 @@ impl Game<'_> { if !m.extra.saddled || food || self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft) { return false; } - self.riding = Some(m.id); + let id = m.id; + if !self.native_may_board(id) { + return true; + } + self.riding = Some(id); self.trigger_player("minecraft:started_riding"); true } @@ -243,6 +247,10 @@ impl Game<'_> { return true; } // doPlayerRide + let id = self.mobs[index].id; + if !self.native_may_board(id) { + return true; + } let m = &mut self.mobs[index]; m.extra.flag = false; m.extra.counter = 0; @@ -480,9 +488,13 @@ impl Game<'_> { } /// `removePassenger` + `getDismountLocationForPassenger`: beside the mount. pub(super) fn dismount_mount(&mut self) { - let Some(id) = self.riding.take() else { + let Some(id) = self.riding else { return; }; + if !self.native_may_leave(id) { + return; + } + self.riding = None; let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { return; }; @@ -640,38 +652,37 @@ impl Game<'_> { pub(super) fn mount_jumping(&self) -> bool { self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0 } - /// `Hud.extractVehicleHealth`, and the jump bar when it is the - /// contextual bar (`jump_bar`). Returns the mount's heart count + /// `Hud.extractVehicleHealth` (a native mod's `minecraft:vehicle_health` + /// layer, only while riding a mount). Returns the mount's heart count /// (`getVehicleMaxHearts`). - pub(super) fn draw_mount_hud( - &self, - batch: &mut HudBatch, - w: i32, - h: i32, - jump_bar: bool, - ) -> i32 { + pub(super) fn draw_mount_hud(&mut self, batch: &mut HudBatch, w: i32, h: i32) -> i32 { let Some(i) = self.mount_index() else { return 0; }; let m = &self.mobs[i]; - let atlas = &self.gui.atlas; let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health); let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health); - horizon_client::hud_layout::paint(batch, atlas, &draws); - if jump_bar { - // JumpableVehicleBar: `getJumpCooldown`, which only the camel's and - // the nautilus' dash have; a horse always shows the progress. - let cooldown = i32::from( - self.mount.jump_ticks < 0 - && (matches!(m.species(), Species::Camel | Species::CamelHusk) - || horizon_client::mobs::nautilus::is_nautilus(m.species())), - ); - let draws = - horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale); - horizon_client::hud_layout::paint(batch, atlas, &draws); - } + self.gui_layer("minecraft:vehicle_health", batch, |g, batch| { + horizon_client::hud_layout::paint(batch, &g.gui.atlas, &draws); + }); hearts } + /// The jump bar when it is the contextual bar. + pub(super) fn draw_jump_bar(&self, batch: &mut HudBatch, w: i32, h: i32) { + let Some(i) = self.mount_index() else { + return; + }; + let m = &self.mobs[i]; + // JumpableVehicleBar: `getJumpCooldown`, which only the camel's and + // the nautilus' dash have; a horse always shows the progress. + let cooldown = i32::from( + self.mount.jump_ticks < 0 + && (matches!(m.species(), Species::Camel | Species::CamelHusk) + || horizon_client::mobs::nautilus::is_nautilus(m.species())), + ); + let draws = horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale); + horizon_client::hud_layout::paint(batch, &self.gui.atlas, &draws); + } } /// The mount a rider gets off: its kind, where it stands, its box, 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 ff312375a6..1863413e84 100644 --- a/crates/horizon-client/src/game/native_block_entity_draw.rs +++ b/crates/horizon-client/src/game/native_block_entity_draw.rs @@ -12,7 +12,7 @@ use horizon_assets::{ }; use horizon_client::{ block_entity_render::{diffuse, diffuse_lights, vertex_light}, - containers::{BlockEntity, ModBlockEntity}, + containers::{BlockEntity, ModRevision}, fast_hash::FxHashMap, gfx::Cull, hud::Rect, @@ -201,7 +201,8 @@ struct KeptKey { struct KeptEntry { key: KeptKey, - entity: ModBlockEntity, + /// Of the block entity it was drawn from: equal, nothing changed it. + revision: ModRevision, bounds: [f32; 6], /// Game time it was last drawn. used: i64, @@ -228,19 +229,19 @@ impl KeptGeometry { .retain(|_, e| (now - KEPT_FOR..=now).contains(&e.used)); } } - /// What is kept at `pos` when it was drawn with `key` and `entity`, - /// marked drawn at `now`. + /// What is kept at `pos` when it was drawn with `key` and its block + /// entity at `revision`, marked drawn at `now`. fn get( &mut self, pos: [i32; 3], key: KeptKey, - entity: &ModBlockEntity, + revision: ModRevision, now: i64, ) -> Option<&KeptEntry> { let entry = self .entries .get_mut(&pos) - .filter(|e| e.key == key && e.entity == *entity)?; + .filter(|e| e.key == key && e.revision == revision)?; entry.used = now; Some(entry) } @@ -248,7 +249,7 @@ impl KeptGeometry { &mut self, pos: [i32; 3], key: KeptKey, - entity: &ModBlockEntity, + revision: ModRevision, bounds: [f32; 6], vertices: &[Vertex], now: i64, @@ -263,7 +264,7 @@ impl KeptGeometry { pos, KeptEntry { key, - entity: entity.clone(), + revision, bounds, used: now, vertices: kept, @@ -336,7 +337,7 @@ impl Game<'_> { state, light, }; - if let Some(entry) = kept.get(pos, key, entity, now) { + if let Some(entry) = kept.get(pos, key, entity.revision(), now) { if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) { let start = draw.vertices.len(); self.mark_block_entity(start, state); @@ -366,7 +367,14 @@ impl Game<'_> { draw.kept = false; self.native_block_entity_submit(pos, block, id, entity, &mut draw); if draw.kept { - kept.keep(pos, key, entity, bounds, &draw.vertices[start..], now); + kept.keep( + pos, + key, + entity.revision(), + bounds, + &draw.vertices[start..], + now, + ); } else { kept.forget(pos); } @@ -447,8 +455,8 @@ impl Game<'_> { /// the state with `properties` (`[("axis", "x")]`, the rest default) /// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in /// an entity's place: a block's baked model, with its default tints. A - /// moving part is often a block of the mod's own, never placed. False for - /// an unknown block or property. API 71. + /// moving part that is no block is a standalone model + /// (`native_draw_model`). False for an unknown block or property. API 71. pub(super) fn native_draw_block( &self, draw: &mut BlockEntityDraw, @@ -477,6 +485,30 @@ impl Game<'_> { true } + /// Draws standalone model `model` (`mymod:block/shaft_half`, one a mod + /// named in `native_standalone_models`) under `draw`'s pose, as + /// `native_draw_block` draws a block's: unturned, from the block's lower + /// north-west corner, lit by `draw.light`. Its faces with a tint index + /// are multiplied by the 0xRRGGBB `tint` (0xFFFFFF for none), the + /// others drawn as they are. False when the model isn't baked: not + /// named, missing from the resources, or on a side that draws nothing. + /// API @API:standalone_models@. + pub(super) fn native_draw_model( + &self, + draw: &mut BlockEntityDraw, + model: &str, + tint: u32, + ) -> bool { + let Some(baked) = self.pack.render.model_catalog.standalone(model) else { + return false; + }; + let pose = draw.pose(); + let light = draw.light; + let tint = |index: i32| (index >= 0).then_some(tint & 0xFF_FFFF); + self.baked_model_vertices([baked], pose, |_| light, true, tint, &mut draw.vertices); + true + } + /// Draws `stack` under `draw`'s pose as an item is drawn in the world /// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the /// pose's origin, in its model's `display` transform: `Fixed` as a @@ -532,6 +564,7 @@ impl Game<'_> { #[cfg(test)] mod tests { use super::*; + use horizon_client::containers::{ModBlockEntity, ModBlockEntityFields}; fn draw() -> BlockEntityDraw { BlockEntityDraw { @@ -628,12 +661,12 @@ mod tests { assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10); } - fn machine(progress: i32) -> ModBlockEntity { - ModBlockEntity { + fn machine() -> ModRevision { + ModBlockEntity::new(ModBlockEntityFields { id: "mymod:machine".into(), - data: vec![progress], ..Default::default() - } + }) + .revision() } fn vertex(x: f32) -> Vertex { @@ -653,14 +686,21 @@ mod tests { fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() { let mut kept = KeptGeometry::default(); let pos = [1, 2, 3]; - kept.keep(pos, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.5)], 0); - let entry = kept.get(pos, KEY, &machine(0), 1).expect("kept"); + let mut entity = ModBlockEntity::new(ModBlockEntityFields { + id: "mymod:machine".into(), + data: vec![0], + ..Default::default() + }); + let drawn = entity.revision(); + kept.keep(pos, KEY, drawn, DEFAULT_BOUNDS, &[vertex(0.5)], 0); + let entry = kept.get(pos, KEY, entity.revision(), 1).expect("kept"); assert_eq!(entry.vertices.len(), 1); assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]); assert_eq!(entry.bounds, DEFAULT_BOUNDS); // New data from the server, another state, light or dimension, or // another position: none of them is the kept geometry. - assert!(kept.get(pos, KEY, &machine(1), 1).is_none()); + entity.data[0] = 1; + assert!(kept.get(pos, KEY, entity.revision(), 1).is_none()); for changed in [ KeptKey { state: 8, ..KEY }, KeptKey { @@ -672,36 +712,53 @@ mod tests { ..KEY }, ] { - assert!(kept.get(pos, changed, &machine(0), 1).is_none()); + assert!(kept.get(pos, changed, drawn, 1).is_none()); } - assert!(kept.get([1, 2, 4], KEY, &machine(0), 1).is_none()); + assert!(kept.get([1, 2, 4], KEY, drawn, 1).is_none()); + // Another block entity in its place, as the old one was: not it. + let replaced = ModBlockEntity::new(ModBlockEntityFields { + id: "mymod:machine".into(), + data: vec![0], + ..Default::default() + }); + assert!(kept.get(pos, KEY, replaced.revision(), 1).is_none()); // Drawn again and kept with the new data, it replaces the old. - kept.keep(pos, KEY, &machine(1), DEFAULT_BOUNDS, &[vertex(0.25)], 2); - assert!(kept.get(pos, KEY, &machine(0), 2).is_none()); - let entry = kept.get(pos, KEY, &machine(1), 2).expect("kept again"); + kept.keep( + pos, + KEY, + entity.revision(), + DEFAULT_BOUNDS, + &[vertex(0.25)], + 2, + ); + assert!(kept.get(pos, KEY, drawn, 2).is_none()); + let entry = kept + .get(pos, KEY, entity.revision(), 2) + .expect("kept again"); assert_eq!(entry.vertices[0].pos, [0.25, 0.0, 0.0]); // Drawn without keeping: forgotten. kept.forget(pos); - assert!(kept.get(pos, KEY, &machine(1), 3).is_none()); + assert!(kept.get(pos, KEY, entity.revision(), 3).is_none()); } #[test] fn kept_geometry_out_of_view_for_its_ticks_is_swept() { let mut kept = KeptGeometry::default(); let (seen, gone) = ([0, 0, 0], [5, 0, 0]); - kept.keep(seen, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0); - kept.keep(gone, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0); - kept.get(gone, KEY, &machine(0), 50); + let m = machine(); + kept.keep(seen, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], 0); + kept.keep(gone, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], 0); + kept.get(gone, KEY, m, 50); // A sweep at 100 keeps both: each was drawn in the last 100 ticks. kept.sweep(100); assert!(kept.entries.contains_key(&gone)); assert!(kept.entries.contains_key(&seen)); - kept.get(seen, KEY, &machine(0), 150); + kept.get(seen, KEY, m, 150); // At 199 no sweep is due yet; at 200 the one last drawn at 50 goes. kept.sweep(199); assert!(kept.entries.contains_key(&gone)); kept.sweep(200); assert!(!kept.entries.contains_key(&gone)); - assert!(kept.get(seen, KEY, &machine(0), 200).is_some()); + assert!(kept.get(seen, KEY, m, 200).is_some()); } } diff --git a/crates/horizon-client/src/game/native_data_maps.rs b/crates/horizon-client/src/game/native_data_maps.rs new file mode 100644 index 0000000000..8a451cf408 --- /dev/null +++ b/crates/horizon-client/src/game/native_data_maps.rs @@ -0,0 +1,408 @@ +//! Data maps for native server mods (docs/native-mods.md): values a data pack +//! attaches to the entries of a registry, under the types a mod registered +//! (`native_data_map_types`). Vanilla has no data maps; the contract is +//! NeoForge's `DataMapType`: files at +//! `data//data_maps//.json` hold +//! `replace`, `values` (entry ids and `#tags` to values) and `remove`, and a +//! newer value overrides an older one for the same entry. The files are the +//! world's data packs' and what mods contribute through +//! `native_data_pack_files` (key `data_maps/:/`), +//! merged each time the server's tables are built: a world's first open and +//! every `/reload`. +use super::Game; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap, HashSet}; + +/// A data map type a mod registers (`RegisterDataMapTypesEvent`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NativeDataMapType { + /// `mod_id:name`: not in the `minecraft` namespace. Its files are + /// `data//data_maps//.json`. + pub(crate) id: String, + /// The registry it is for: `minecraft:item` or `minecraft:block`. + pub(crate) registry: String, +} + +/// The registries data maps can be attached to so far, with their folder +/// (the registry's path: `minecraft` registries have no namespace folder). +const REGISTRIES: [(&str, &str); 2] = [("minecraft:item", "item"), ("minecraft:block", "block")]; + +/// The merged values of every registered type: `(registry, type id)` to the +/// value of each entry that has one. +#[derive(Default)] +pub(crate) struct DataMaps { + maps: HashMap<(String, String), BTreeMap>, +} + +/// `minecraft:` for an id written without a namespace. +fn full_id(id: &str) -> String { + if id.contains(':') { + id.to_owned() + } else { + format!("minecraft:{id}") + } +} + +/// A file's value for an entry: the data itself, or the wrapper +/// `{"value": ..., "replace": false}` (`DataMapEntry`'s codec). `replace` only +/// matters to mergers, which types don't have yet: a newer value overrides. +fn entry_value(raw: &Value) -> Option { + if let Value::Object(map) = raw + && let Some(value) = map.get("value") + && map + .iter() + .all(|(k, v)| k == "value" || (k == "replace" && v.is_boolean())) + { + return Some(value.clone()); + } + (!raw.is_null()).then(|| raw.clone()) +} + +/// One file over what earlier files made of the type: `replace` clears it, +/// `values` attach (a tag to every entry in it), then `remove` takes entries +/// away, as `DataMapFile` is applied. False when the file isn't a data map. +fn apply_file( + map: &mut BTreeMap, + file: &Value, + tags: &HashMap>, +) -> bool { + let (Some(root), Some(values)) = (file.as_object(), file.get("values")) else { + return false; + }; + let Some(values) = values.as_object() else { + return false; + }; + if root.get("replace").and_then(Value::as_bool) == Some(true) { + map.clear(); + } + // The members of a tag or the entry itself. + let members = |key: &str| -> Vec { + match key.strip_prefix('#') { + Some(tag) => tags + .get(&full_id(tag)) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default(), + None => vec![full_id(key)], + } + }; + for (key, raw) in values { + let Some(value) = entry_value(raw) else { + continue; + }; + for entry in members(key) { + map.insert(entry, value.clone()); + } + } + for removal in root + .get("remove") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + // An entry or `{"key": entry}`, as `DataMapEntry.Removal` reads it. + let key = removal.as_str().or_else(|| removal["key"].as_str()); + for entry in key.map(members).unwrap_or_default() { + map.remove(&entry); + } + } + true +} + +impl DataMaps { + /// The types' values from `files` (the server's tables' pack files, in + /// pack order, mods' below the packs'), tags resolved against the + /// loaded `item_tags` and `block_tags`. A type with an unsupported + /// registry or a bad id is left out with a warning; a file that isn't a + /// data map is skipped alone. + pub(crate) fn load( + types: &[NativeDataMapType], + files: &[(String, Vec>)], + item_tags: &HashMap>, + block_tags: &HashMap>, + ) -> Self { + let mut maps: HashMap<(String, String), BTreeMap> = HashMap::new(); + for ty in types { + let Some((_, dir)) = REGISTRIES.iter().find(|(r, _)| *r == ty.registry) else { + log::warn!( + "Horizon: data map {} is for {}, which has no data maps yet: left out", + ty.id, + ty.registry + ); + continue; + }; + let valid = |s: &str| { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "_-./".contains(c)) + }; + let ok = ty + .id + .split_once(':') + .is_some_and(|(ns, path)| ns != "minecraft" && valid(ns) && valid(path)); + if !ok { + log::warn!( + "Horizon: data map type {} is not a mod's id: left out", + ty.id + ); + continue; + } + let key = (ty.registry.clone(), ty.id.clone()); + if maps.contains_key(&key) { + log::warn!( + "Horizon: data map {} registered twice: the first is kept", + ty.id + ); + continue; + } + let tags = if *dir == "item" { + item_tags + } else { + block_tags + }; + let (ns, path) = ty.id.split_once(':').unwrap_or_default(); + let wanted = format!("data_maps/{ns}:{dir}/{path}"); + let mut map = BTreeMap::new(); + if let Some((_, versions)) = files.iter().find(|(k, _)| *k == wanted) { + for bytes in versions { + let applied = serde_json::from_slice::(bytes) + .ok() + .is_some_and(|file| apply_file(&mut map, &file, tags)); + if !applied { + log::warn!( + "Horizon: a data map file of {} is not a data map, skipped", + ty.id + ); + } + } + } + maps.insert(key, map); + } + Self { maps } + } +} + +// Native mods use these; nothing in Horizon does. +#[allow(dead_code)] +impl Game<'_> { + /// The merged data map of `type_id` for `registry`, none unless this is a + /// server whose tables were built from packs or mods' files. + fn native_data_map_of( + &self, + registry: &str, + type_id: &str, + ) -> Option<&BTreeMap> { + if self.serving.is_none() && self.options.dedicated.is_none() { + return None; + } + let key = (registry.to_owned(), type_id.to_owned()); + self.world_tables.as_ref()?.data_maps.maps.get(&key) + } + /// `Holder.getData(type)`: the value the data pack and mod files gave + /// `entry` (`minecraft:coal`) in data map `type_id` for `registry` + /// (`minecraft:item`, `minecraft:block`), as the JSON the file held. None + /// when the entry has none, the type isn't registered for that registry + /// (`native_data_map_types`), or this isn't a server whose tables hold + /// files. + pub(in crate::game) fn native_data_map( + &self, + registry: &str, + type_id: &str, + entry: &str, + ) -> Option<&Value> { + self.native_data_map_of(registry, type_id)? + .get(&full_id(entry)) + } + /// Every entry that has a value in data map `type_id` for `registry`, in + /// id order. Empty where `native_data_map` is none for all. + pub(in crate::game) fn native_data_map_entries( + &self, + registry: &str, + type_id: &str, + ) -> Vec<(&str, &Value)> { + self.native_data_map_of(registry, type_id) + .map(|map| map.iter().map(|(k, v)| (k.as_str(), v)).collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tags(list: &[(&str, &[&str])]) -> HashMap> { + list.iter() + .map(|(tag, members)| { + ( + (*tag).to_owned(), + members.iter().map(|m| (*m).to_owned()).collect(), + ) + }) + .collect() + } + + fn ty(id: &str, registry: &str) -> NativeDataMapType { + NativeDataMapType { + id: id.to_owned(), + registry: registry.to_owned(), + } + } + + fn files(list: &[(&str, &[Value])]) -> Vec<(String, Vec>)> { + list.iter() + .map(|(key, versions)| { + ( + (*key).to_owned(), + versions + .iter() + .map(|v| v.to_string().into_bytes()) + .collect(), + ) + }) + .collect() + } + + fn load(files: &[(String, Vec>)]) -> DataMaps { + let items = tags(&[( + "minecraft:logs", + &["minecraft:oak_log", "minecraft:birch_log"], + )]); + DataMaps::load( + &[ty("mymod:fuel", "minecraft:item")], + files, + &items, + &HashMap::new(), + ) + } + + fn get<'a>(maps: &'a DataMaps, entry: &str) -> Option<&'a Value> { + maps.maps[&("minecraft:item".to_owned(), "mymod:fuel".to_owned())].get(entry) + } + + #[test] + fn values_attach_to_entries_and_to_every_member_of_a_tag() { + let f = files(&[( + "data_maps/mymod:item/fuel", + &[json!({"values": { + "minecraft:coal": {"burn": 1600}, + "stick": 100, + "#minecraft:logs": {"burn": 300} + }})], + )]); + let maps = load(&f); + assert_eq!(get(&maps, "minecraft:coal"), Some(&json!({"burn": 1600}))); + // An id without a namespace is a `minecraft` one. + assert_eq!(get(&maps, "minecraft:stick"), Some(&json!(100))); + assert_eq!(get(&maps, "minecraft:oak_log"), Some(&json!({"burn": 300}))); + assert_eq!( + get(&maps, "minecraft:birch_log"), + Some(&json!({"burn": 300})) + ); + assert_eq!(get(&maps, "minecraft:stone"), None); + } + + #[test] + fn a_newer_file_overrides_and_replace_clears_what_came_before() { + let f = files(&[( + "data_maps/mymod:item/fuel", + &[ + json!({"values": {"minecraft:coal": 1, "minecraft:stick": 2}}), + json!({"values": {"minecraft:coal": 10}}), + ], + )]); + let maps = load(&f); + assert_eq!(get(&maps, "minecraft:coal"), Some(&json!(10))); + assert_eq!(get(&maps, "minecraft:stick"), Some(&json!(2))); + let f = files(&[( + "data_maps/mymod:item/fuel", + &[ + json!({"values": {"minecraft:coal": 1, "minecraft:stick": 2}}), + json!({"replace": true, "values": {"minecraft:coal": 10}}), + ], + )]); + let maps = load(&f); + assert_eq!(get(&maps, "minecraft:coal"), Some(&json!(10))); + assert_eq!(get(&maps, "minecraft:stick"), None); + } + + #[test] + fn remove_takes_entries_and_tags_away_after_the_files_own_values() { + let f = files(&[( + "data_maps/mymod:item/fuel", + &[ + json!({"values": {"#minecraft:logs": 5, "minecraft:coal": 1}}), + // The file's own value for coal is attached, then removed. + json!({"values": {"minecraft:coal": 9}, "remove": ["minecraft:coal", "#minecraft:logs"]}), + ], + )]); + let maps = load(&f); + assert_eq!(get(&maps, "minecraft:coal"), None); + assert_eq!(get(&maps, "minecraft:oak_log"), None); + } + + #[test] + fn the_wrapper_form_gives_its_value_and_a_plain_object_is_a_value() { + let f = files(&[( + "data_maps/mymod:item/fuel", + &[json!({"values": { + "minecraft:coal": {"value": {"burn": 3}, "replace": true}, + "minecraft:stick": {"value": 1, "other": 2} + }})], + )]); + let maps = load(&f); + assert_eq!(get(&maps, "minecraft:coal"), Some(&json!({"burn": 3}))); + // More than `value` and `replace`: the object is the data. + assert_eq!( + get(&maps, "minecraft:stick"), + Some(&json!({"value": 1, "other": 2})) + ); + } + + #[test] + fn a_bad_file_is_skipped_alone_and_bad_types_are_left_out() { + let mut f = files(&[( + "data_maps/mymod:item/fuel", + &[json!({"values": {"minecraft:coal": 1}})], + )]); + f[0].1.insert(0, b"{nope".to_vec()); + f[0].1 + .push(json!({"nothing": true}).to_string().into_bytes()); + let maps = load(&f); + assert_eq!(get(&maps, "minecraft:coal"), Some(&json!(1))); + let maps = DataMaps::load( + &[ + ty("minecraft:fuel", "minecraft:item"), + ty("mymod:x", "minecraft:biome"), + ty("nonamespace", "minecraft:item"), + ty("mymod:a", "minecraft:block"), + ty("mymod:a", "minecraft:block"), + ], + &[], + &HashMap::new(), + &HashMap::new(), + ); + // Only the one good type, registered once, empty. + assert_eq!(maps.maps.len(), 1); + assert!( + maps.maps + .contains_key(&("minecraft:block".to_owned(), "mymod:a".to_owned())) + ); + } + + #[test] + fn block_maps_read_the_block_tags() { + let blocks = tags(&[("minecraft:logs", &["minecraft:oak_log"])]); + let f = files(&[( + "data_maps/mymod:block/hardness", + &[json!({"values": {"#minecraft:logs": 2}})], + )]); + let maps = DataMaps::load( + &[ty("mymod:hardness", "minecraft:block")], + &f, + &HashMap::new(), + &blocks, + ); + let map = &maps.maps[&("minecraft:block".to_owned(), "mymod:hardness".to_owned())]; + assert_eq!(map.get("minecraft:oak_log"), Some(&json!(2))); + } +} diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 5f0e81f0d9..eeb14be494 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -272,12 +272,14 @@ //! that would break such a bundle is not made. //! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with //! either (`tests::hook_points_stay_hookable`). +use super::native_data_maps::NativeDataMapType; use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase}; use super::{ Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole, + maps::{NativeMapDecoration, NativeMapInfo}, native_block_entity_draw::BlockEntityDraw, - native_mob_draw::NativeMobRender, + native_mob_draw::{NativeMobRender, NativePlayerRender}, native_panel_recipes::RecipePage, native_server::{NativeCommand, NativeServerPlayer}, }; @@ -286,7 +288,10 @@ use horizon_client::{ biome::BiomeColors, block_entities::Pos, chat::ChatSource, - containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModFluidTanks}, + containers::{ + BlockEntity, MOD_MENUS, ModBlockEntity, ModBlockEntityFields, ModContainer, + ModEnergyBuffer, ModFluidTanks, + }, crafting::{CustomRecipe, Ingredient, recipe_result}, extras::Corner, extras_hud::Corners, @@ -1264,6 +1269,22 @@ impl Game<'_> { pub(super) fn native_data_pack_files(&self, files: &mut Vec<(String, Vec)>) { let _ = files; } + /// The server is assembling its data tables, as for + /// `native_data_pack_files` (a world's first open and every `/reload`): + /// push the data map types your mod registers (NeoForge's + /// `RegisterDataMapTypesEvent`). A type is an `id` (`mod_id:name`, not in + /// `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` + /// so far. Its values come from the world's data packs' + /// `data//data_maps//.json`, and from files + /// you push through `native_data_pack_files` under the key + /// `data_maps/:/`: `replace`, `values` + /// (entry ids and `#tags` to JSON values) and `remove`, a newer value + /// overriding an older. Read them with `native_data_map` and + /// `native_data_map_entries`, in `native_data_maps.rs`. Server-side, not + /// synced to clients. API @API:data_map_types@. + pub(super) fn native_data_map_types(&self, types: &mut Vec) { + let _ = types; + } /// The server is about to open its worlds: nothing of the world is /// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the /// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel` @@ -1312,6 +1333,37 @@ impl Game<'_> { pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) { let _ = (batch, corners); } + /// Before one of vanilla's HUD layers is drawn (`RenderGuiLayerEvent.Pre`): + /// draw here to put something below the layer, or set `canceled` to hide + /// it. `layer` is the layer's id, in the order Horizon draws them: + /// `minecraft:crosshair`, `hotbar`, `effects`, `boss_overlay`, + /// `vehicle_health` (only riding a mount), `contextual_info_bar` (the + /// mount's jump bar, the locator bar or the experience bar, with the level + /// number), `player_health` (the health, armor, food and air rows, drawn in + /// one pass), `selected_item_name`, `sleep_overlay`, `overlay_message` + /// (the action bar), `title`, `scoreboard_sidebar`, `subtitle_overlay`, + /// `chat` and `tab_list`. A layer F1 hides fires nothing, and the chat + /// screen draws the focused chat itself, so `chat` is only the unfocused + /// lines. The camera overlays and the debug screen are not layers here: + /// they are not drawn in this pass. Draw with `native_fill`, `native_text` + /// and the sprite helpers; `batch.width` and `batch.height` are the GUI + /// size. Together with `native_gui_layer_post` this is NeoForge's + /// `RegisterGuiLayersEvent`: a mod keeps its own list of what it draws + /// above or below which layer. API @API:gui_layers@. + pub(super) fn native_gui_layer_pre( + &mut self, + layer: &str, + batch: &mut HudBatch, + canceled: &mut bool, + ) { + let _ = (layer, batch, canceled); + } + /// After one of vanilla's HUD layers (or after the layer a mod hid), with + /// the ids of `native_gui_layer_pre`: draw here to put something above + /// the layer (`RenderGuiLayerEvent.Post`). API @API:gui_layers@. + pub(super) fn native_gui_layer_post(&mut self, layer: &str, batch: &mut HudBatch) { + let _ = (layer, batch); + } /// A player right-clicks the entity `entity` (`Entity.getId`) of type /// `kind` (`minecraft:cow`) with `self.hand`, holding `stack` (none in an /// empty hand): where `Player.interactOn` starts, before the entity's own @@ -1412,6 +1464,27 @@ impl Game<'_> { ) { let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled); } + /// An interaction key acts on what the crosshair points at + /// (`InputEvent.InteractionKeyMappingTriggered`), while playing: `button` 0 is + /// the attack key (`key_mapping` `key.attack`), 1 the use key (`key.use`, once + /// for each hand it tries, the main hand first), 2 pick block (`key.pickItem`, + /// only with something aimed at). The attack fires when an attack is allowed + /// and again each tick a held attack key keeps digging a block. Set `canceled` + /// to skip the action: a cancelled use also skips the other hand. A cancelled + /// attack or use still swings `hand` unless the mod clears `swing_hand` (it + /// starts true). A server's player and a single player's window only; a mod + /// that claims an item's use (`mod_use_start`) sees the event first. API + /// @API:interaction_key@. + pub(super) fn native_interaction_key( + &mut self, + button: i32, + key_mapping: &str, + hand: horizon_client::first_person::Hand, + swing_hand: &mut bool, + canceled: &mut bool, + ) { + let _ = (button, key_mapping, hand, swing_hand, canceled); + } /// A command typed or clicked in chat, without its slash, before the /// server and Horizon's commands see it: set `ran` when the mod ran it. pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) { @@ -1548,6 +1621,48 @@ impl Game<'_> { pub(super) fn native_component_types(types: &mut Vec) { let _ = types; } + /// Blocks this mod adds, declared in code rather than one file each + /// (NeoForge's `DeferredRegister.Blocks` drained by `RegisterEvent`): + /// push a `horizon_mods::content::Block` per block, the same declaration + /// a content mod's `data//horizon/block/.json` holds (a + /// vanilla `template`, `properties`, `state_properties`, `shapes`...), + /// with its id in your mod's namespace. `item: true` gives it a block + /// item, as a file does unless told not to (`..Default::default()` + /// leaves it false). They join the content mods' blocks: the one whose + /// id is that namespace when it is on (your models, textures, loot and + /// recipes are its files), else one of their own, with the template's + /// look. Ids, saves and the check that a joining client has the same + /// blocks are the files' (docs/mods.md). Asked once at start, on every + /// side, before any resource loads: push the same ones every time, from + /// nothing but your code. Declarations that don't pass the files' checks + /// are logged and that namespace's are left out. API + /// @API:native_blocks_items@. + pub(super) fn native_blocks(blocks: &mut Vec) { + let _ = blocks; + } + /// Items this mod adds, declared in code (NeoForge's + /// `DeferredRegister.Items`), as `native_blocks` declares blocks: a + /// `horizon_mods::content::Item` per item, a content mod's + /// `data//horizon/item/.json` (a vanilla `template`, + /// `max_stack_size`, `rarity`, `components`, `crafting_remainder`). A + /// block's item comes with its block (`item: true`), not from here. + /// Asked once at start, on every side. API @API:native_blocks_items@. + pub(super) fn native_items(items: &mut Vec) { + let _ = items; + } + /// Models no blockstate names that this client bakes beside the block + /// models (NeoForge's `ModelEvent.RegisterStandalone`): push their ids + /// (`mymod:block/shaft_half`, the model at + /// `assets/mymod/models/block/shaft_half.json`). Parts a block entity or + /// a mob draws, with `native_draw_model`, so that no block is registered + /// only to bake its model. Asked once at start, before the resources + /// load; every resource load, a pack reload included, bakes them again + /// from that pack's files. A model that doesn't read is logged and left + /// out; an id without a namespace is dropped. Every side asks it, but + /// only a window bakes models. API @API:standalone_models@. + pub(super) fn native_standalone_models(models: &mut Vec) { + let _ = models; + } /// Options > Mods lists the native mods: push yours. pub(super) fn native_mods(&mut self, mods: &mut Vec) { let _ = mods; @@ -1638,6 +1753,33 @@ impl Game<'_> { ) { let _ = (block, fluids); } + /// Block `block`'s block entity has an energy buffer + /// (`Capabilities.Energy.BLOCK`): set `energy` when the block is yours, + /// as `SimpleEnergyHandler` is built: its capacity, the most one insert + /// and one extract moves, and the faces a fill may come through and a + /// drain may leave by. Asked once for each mod block when the ticking + /// table is built; a capacity under 1 leaves the buffer out, and the + /// limits are clamped to 0..=capacity. The amount is kept in the entity's + /// saved compound, as the int `energy`, and the energy helpers below read + /// and write it. API @API:block_entity_energy@. + pub(super) fn native_block_entity_energy( + &self, + block: &str, + energy: &mut Option, + ) { + let _ = (block, energy); + } + /// Item `item` (`mod_id:name`) is a fluid container + /// (`Capabilities.FluidHandler.ITEM`): set `capacity`, in millibuckets, + /// when the item is yours (clamped to 1..=1 000 000; vanilla's buckets + /// are built in and cannot be changed). Asked at each call of the item + /// fluid helpers (`native_item_fluid`, `native_item_fluid_fill`, + /// `native_item_fluid_drain`, in `native_item_fluids.rs`). What the + /// container holds is kept in the stack's `minecraft:custom_data`, under + /// `horizon_fluid`. API @API:item_fluid_tank@. + pub(super) fn native_item_fluid_tank(&self, item: &str, capacity: &mut Option) { + let _ = (item, capacity); + } /// A server tick of the block entity at `pos`, in vanilla's order among /// the others (`Level.tickBlockEntities`), when its `id` is yours: read /// and change its saved compound `nbt`, its item slots `items` (keep @@ -1736,6 +1878,67 @@ impl Game<'_> { ) { let _ = (mob, draw, drawn); } + /// Whether a mod listens to `native_player_submit` or `native_mob_layer`: a + /// mod that hooks either hooks this one too and sets `listened` (HEAD, + /// `*listened = true`). Without it the game poses no player or mob for + /// them, each frame. Client-side. API @API:player_render@. + pub(super) fn native_player_render_listened(&self, listened: &mut bool) { + let _ = listened; + } + /// Draw the player `player` (`who`: its profile name, and whether it is + /// this window's own player) with a model of your own, once a frame while + /// it is in view (`RenderPlayerEvent.Pre`), and set `drawn` to leave out its + /// vanilla model, armour, held items, cape and the other layers. This + /// window's player is drawn only in third person (and F5 front view); an + /// invisible player is not asked. `draw` is the frame of `native_mob_submit`: + /// at the player's feet, turned with the body. The name tag, shadow, + /// flames and glowing outline draw as before. API @API:player_render@. + pub(super) fn native_player_submit( + &self, + player: &NativeMobRender<'_>, + who: &NativePlayerRender<'_>, + draw: &mut BlockEntityDraw, + drawn: &mut bool, + ) { + let _ = (player, who, draw, drawn); + } + /// A layer on every mob and player in view, once a frame after its + /// vanilla model and layers (`EntityRenderersEvent.AddLayers`, with + /// `RenderLivingEvent.Post` and `RenderPlayerEvent.Post`): add geometry + /// in the entity's frame, which starts at its feet turned with the body + /// (`mob.head_yaw`, `mob.pitch` and the walk cycle place a part, the eyes + /// being at the entity type's eye height). `who` is set for a player. + /// Not asked for what a mod drew itself (`native_mob_submit`, + /// `native_player_submit`) nor for a mob that isn't drawn at all. + /// API @API:player_render@. + pub(super) fn native_mob_layer( + &self, + mob: &NativeMobRender<'_>, + who: Option<&NativePlayerRender<'_>>, + draw: &mut BlockEntityDraw, + ) { + let _ = (mob, who, draw); + } + /// Icons a mod puts on map `map` each time it is drawn: held, in an + /// item frame or on the cartography table's screen (a map's pixels are + /// its own; nothing is saved in the map). Push `NativeMapDecoration`s + /// (an atlas sprite, a position in half map pixels, a rotation, and + /// whether it shows in a frame and on the screen like vanilla's markers + /// do); they are drawn after the vanilla ones with the same pose, and a + /// sprite the atlas lacks is left out. `map.center`, `map.scale` and + /// `map.dimension` say which blocks the map shows, and + /// `NativeMapDecoration::x` has the formula. Vanilla's decorations are + /// not passed in. Asked every frame a map is drawn, so keep it cheap. + /// The client half of `MapDecorationType` and `addDecoration`: a type + /// registered on the server and saved in the map's data is not here. + /// API @API:map_decorations@. + pub(super) fn native_map_decorations( + &self, + map: &NativeMapInfo<'_>, + decorations: &mut Vec, + ) { + let _ = (map, decorations); + } /// A menu opens on this client for block `block`, a mod's: set `screen` /// when the block is yours, to lay its screen out (`native_screen_draw` /// draws it). The server asks it too: a slot it hides is not in the menu @@ -2202,6 +2405,34 @@ impl Game<'_> { ) { let _ = (player, command, ran); } + /// A player finished editing a sign: `ServerGamePacketListenerImpl.updateSignText` + /// has taken their update packet and every check has passed (they were + /// editing that sign, within reach, the sign is not waxed, the chat filter + /// let the text through), right before the lines go on the sign. `pos` in + /// `dimension`, the `front` or back side, and the four `lines` as written, + /// plain text with the formatting codes still in (vanilla strips them + /// afterwards). Rewrite `lines` (made four lines again) and that is what + /// the sign gets, without a second look from the chat filter, or set + /// `cancel` and the sign keeps its old text. With the player's state + /// swapped in, so `native_tell` and the helpers that act on the player + /// reach them. Once for each update a player sends on a server. Not told: + /// the host's own editing of a sign on a LAN or single-player world + /// (the editor writes into their world directly), and the sign changes + /// that are no edit of the text (dyeing, glow ink, waxing, `/data`, + /// `/setblock`, mods' `native_with_mod_block_entity`). + /// @API:native_sign_changed@. + pub(super) fn native_sign_changed( + &mut self, + player: u128, + name: &str, + dimension: &str, + pos: [i32; 3], + front: bool, + lines: &mut Vec, + cancel: &mut bool, + ) { + let _ = (player, name, dimension, pos, front, lines, cancel); + } /// A command a player typed (NeoForge's `CommandEvent`, before /// `Commands.performCommand` runs it), without its slash: any command, /// vanilla's and the mods' own, also one the player may not run. Set @@ -2691,6 +2922,118 @@ impl Game<'_> { ) { let _ = (dimension, pos, viewers, previous); } + /// `player` (`name`) opened a menu (NeoForge's `PlayerContainerEvent.Open`, + /// posted when `ServerPlayer.openMenu` sets the new `containerMenu`): any + /// menu, vanilla or a mod's. `menu` is the menu type's name + /// (`minecraft:generic_9x3`, `minecraft:furnace`, `minecraft:merchant`, + /// `minecraft:crafting`, `minecraft:lectern`, a mod's own), `pos` the + /// block it is of in `dimension` (none for a trade, a vehicle's, a mod's + /// native menu or a mount's inventory, which has no menu type in vanilla + /// and is told as `horizon:mount_inventory`). The player's own inventory + /// is not one. Heard once a tick, so a menu opened and closed again + /// inside one tick is not; one that replaces another comes after that + /// one's close. The player's state is swapped in. `native_player_container_close` + /// follows. Server-side. API @API:player_container_events@. + pub(super) fn native_player_container_open( + &mut self, + player: u128, + name: &str, + dimension: &str, + menu: &str, + pos: Option<[i32; 3]>, + ) { + let _ = (player, name, dimension, menu, pos); + } + /// `player` (`name`) closed the menu `native_player_container_open` told + /// of (NeoForge's `PlayerContainerEvent.Close`, posted in + /// `ServerPlayer.doCloseContainer` after the menu's `removed`): they + /// closed it, walked out of reach, died, changed level, or another menu + /// opened. A player who leaves closes it before `native_player_logged_out` + /// (vanilla posts nothing for a disconnect; Horizon does, so each open has + /// its close). Once for each open; not told when the server stops. The + /// menu's items are where it left them: the block's, and the cursor stack + /// back in the inventory. State swapped in, when the player has a body. + /// Server-side. API @API:player_container_events@. + pub(super) fn native_player_container_close( + &mut self, + player: u128, + name: &str, + dimension: &str, + menu: &str, + pos: Option<[i32; 3]>, + ) { + let _ = (player, name, dimension, menu, pos); + } + /// A client started tracking an entity (NeoForge's + /// `PlayerEvent.StartTracking`, posted at the end of + /// `ServerEntity.addPairing`): `player` (`name`) is now sent `entity` + /// (its ID, `uuid` its UUID, `kind` its type, `minecraft:zombie`) and its + /// changes. It comes into range, its client's chunks reach it, or it is + /// put in the level; another player is an entity too, and a player never + /// tracks their own. A mod sends the player what they need to show it + /// (`native_net_send_to_player`). The entity is as it was last tick: it + /// may be gone by now. Heard after the tick's players and entities have + /// moved, in order, with no player's state swapped in. Server-side. + /// API @API:player_tracking@. + pub(super) fn native_player_start_tracking( + &mut self, + player: u128, + name: &str, + entity: u32, + uuid: u128, + kind: &str, + ) { + let _ = (player, name, entity, uuid, kind); + } + /// A client stopped tracking an entity (NeoForge's + /// `PlayerEvent.StopTracking`, posted at the end of + /// `ServerEntity.removePairing`): it went out of range, left the level or + /// died, or `player` (`name`) left, changed level or respawned, which + /// stops everything they tracked. `entity`, `uuid` and `kind` are as + /// `native_player_start_tracking` told. Once for each start, a leaving + /// player's after `native_player_logged_out`. The entity is not + /// necessarily still there, and a player who left is gone: no player's + /// state is swapped in. Server-side. API @API:player_tracking@. + pub(super) fn native_player_stop_tracking( + &mut self, + player: u128, + name: &str, + entity: u32, + uuid: u128, + kind: &str, + ) { + let _ = (player, name, entity, uuid, kind); + } + /// Whether a mod listens to the mobs' `native_living_equipment_change`: a + /// mod that hooks it hooks this one too and sets `listened` (HEAD, + /// `*listened = true`). Without it the game does not read every mob's + /// equipment each tick, and mobs are not told (armor stands still are). A + /// mob that ticks once it is set counts from empty. Asked once a tick, after + /// the mobs' tick. Server-side. API @API:living_equipment_change@. + pub(super) fn native_living_equipment_listened(&mut self, listened: &mut bool) { + let _ = listened; + } + /// A mob's or an armor stand's equipment slot took a different stack + /// (NeoForge's `LivingEquipmentChangeEvent`, posted in + /// `LivingEntity.collectEquipmentChanges`): `entity` is its ID and `kind` + /// its type (`minecraft:zombie`), `slot` the `EquipmentSlot` (`mainhand`, + /// `offhand`, `feet`, `legs`, `chest`, `head`, `body`, `saddle`), `from` + /// what the slot held when this was last heard (none for empty) and `to` + /// what it holds now. Once for each slot that changed, in that order, + /// after the mobs' tick, so a mob that joins the level wearing something + /// counts from empty on its first tick. It only hears: change the mob with + /// its own helpers. Players are heard by `native_on_equip`. Server-side. + /// API @API:living_equipment_change@. + pub(super) fn native_living_equipment_change( + &mut self, + entity: u32, + kind: &str, + slot: &str, + from: Option<&ItemStack>, + to: Option<&ItemStack>, + ) { + let _ = (entity, kind, slot, from, to); + } /// `player` died: where `ServerPlayer.die` starts, with the death /// message's key when the game knows it (`death.attack.lava`; none for a /// message it builds from the combat). It only hears. Server-side, for @@ -2748,6 +3091,75 @@ impl Game<'_> { ) { let _ = (dimension, entity, kind, pos, loaded_from_disk, canceled); } + /// An entity mounts or dismounts a vehicle (NeoForge's `EntityMountEvent`, posted from + /// `Entity.startRiding` and `removeVehicle`): `entity` of type `kind` (`minecraft:player`, + /// `minecraft:zombie`) in the server level `dimension`, getting on (`mounting`) or off the + /// `vehicle` of type `vehicle_kind` (`minecraft:oak_boat`, `minecraft:minecart`, + /// `minecraft:horse`, `minecraft:cushion`). Set `canceled` and it does not: a player stays + /// where they were, a mob does not board, a `/ride` fails, and a rider whose dismount is + /// canceled keeps riding. Told for a player boarding a boat, minecart, cushion or a mount (pig, + /// strider, horse and its kin, llama, nautilus, happy ghast) and getting off one by their + /// own input, `/ride`, a stab, a buck, a teleport or spectator mode; and for the mobs + /// Horizon seats on a vehicle (jockeys, raiders on a ravager, `/ride`) and lets off one + /// (`/ride`, a stab). It is not told for a rider whose vehicle was removed from the + /// level (a boat or minecart destroyed, a broken cushion), nor for riders loaded with a + /// save. The acting player is the one whose action runs, a guest on a server. Server-side. + /// API @API:entity_mount@. + pub(super) fn native_entity_mount( + &mut self, + dimension: &str, + entity: u32, + kind: &str, + vehicle: u32, + vehicle_kind: &str, + mounting: bool, + canceled: &mut bool, + ) { + let _ = ( + dimension, + entity, + kind, + vehicle, + vehicle_kind, + mounting, + canceled, + ); + } + /// Whether a mod listens to `native_entity_tick_pre` and `native_entity_tick_post`: a mod + /// that hooks them hooks this one too and sets `listened` (HEAD, `*listened = true`). + /// Without it the game tells no entity's tick, and keeps no list of them. Asked once for + /// each family (mobs, items, minecarts, boats) each tick. Server-side. API @API:entity_tick@. + pub(super) fn native_entity_tick_listened(&mut self, listened: &mut bool) { + let _ = listened; + } + /// An entity of type `kind` (`minecraft:zombie`, `minecraft:item`) with ID `entity` is + /// about to tick in the server level `dimension` (NeoForge's `EntityTickEvent.Pre`, posted + /// around `Entity.commonTick` and `tick` in `ServerLevel.tickNonPassenger`). Set `canceled` + /// and it does not tick this tick: a mob has no AI, physics or effects, an item no movement, + /// ageing or merging, a minecart or boat no motion, and `native_entity_tick_post` is not told. + /// Horizon ticks a family of entities together, so mobs and items hear every Pre before the + /// first of them ticks; minecarts and boats hear theirs one by one. Told for the mobs, item + /// entities, minecarts and boats that tick: those in an entity-ticking chunk, not a player's + /// boat or cart (it ticks with the player). Later APIs add other kinds, so check `kind`. It + /// runs for every ticking entity each tick, so keep it cheap. Server-side. API + /// @API:entity_tick@. + pub(super) fn native_entity_tick_pre( + &mut self, + dimension: &str, + entity: u32, + kind: &str, + canceled: &mut bool, + ) { + let _ = (dimension, entity, kind, canceled); + } + /// The entity `entity` of type `kind` ticked in the server level `dimension` (NeoForge's + /// `EntityTickEvent.Post`): for each entity `native_entity_tick_pre` let through, after it + /// ticked, also one that died or was removed in its tick. Mobs and items hear their Posts + /// once the whole family has ticked; minecarts and boats right after their own tick. Server-side. + /// API @API:entity_tick@. + pub(super) fn native_entity_tick_post(&mut self, dimension: &str, entity: u32, kind: &str) { + let _ = (dimension, entity, kind); + } /// Whether the experience orb `into` takes the orb `from` in (vanilla's /// `ExperienceOrb.canMerge`): the same value, and ids a multiple of 40 apart. Asked /// for each pair whose boxes meet, when an orb spawns (`tryMergeToExisting`: `from` is @@ -2987,6 +3399,61 @@ impl Game<'_> { canceled, ); } + /// Whether a mod hears projectiles' impacts (`native_projectile_impact`): + /// set `hears`. Asked once a server tick before the projectiles fly; only + /// then does the tick keep the copy of each flying projectile that a + /// canceled impact flies on from, so a game without such a mod pays + /// nothing. Server-side. API @API:projectile_impact@. + pub(super) fn native_hears_projectile_impacts(&self, hears: &mut bool) { + let _ = hears; + } + /// A projectile hit something (NeoForge's `ProjectileImpactEvent`, posted + /// where vanilla's projectile has found what it meets, before the hit + /// acts): `projectile` (its entity id) of type `kind` (`minecraft:arrow`), + /// shot by `owner` (a mob's or a player's entity id, 0 for none) in the level + /// `dimension`. It hit the entity `entity` of type `entity_kind` (a player is + /// `minecraft:player`; a dragon's part is `minecraft:ender_dragon`), or, with + /// `entity` None, the block at `block` on its `face` (the face's normal, + /// `[0, 1, 0]` for the top), at the point `at`. Set `canceled` and the impact + /// does not happen: no damage, no knockback, no sound, no block reaction, the + /// arrow does not stick, a pearl does not teleport, a potion or a fireball + /// does not burst. The projectile flies on and is not discarded, so a mod + /// that cancels also decides what becomes of it: it hits again the next tick + /// unless the mod moves, turns or removes it. An arrow stays where it would + /// have hit, and the other projectiles go on through the whole of their + /// motion. Covers what `tick_projectiles` flies: arrows, tridents, snowballs, + /// eggs, pearls, potions, fireballs, wither skulls, wind charges, llama spit and + /// shulker bullets, from players, mobs and dispensers. It does not fire for + /// a breeze's deflection, for a hit on a wind charge, for fireworks or for + /// fishing bobbers. A dragon part or End crystal is told by the id of the + /// dragon or crystal. Heard only when a mod also sets + /// `native_hears_projectile_impacts`. Server-side. API @API:projectile_impact@. + pub(super) fn native_projectile_impact( + &mut self, + dimension: &str, + projectile: u32, + kind: &str, + owner: u32, + entity: Option, + entity_kind: &str, + block: [i32; 3], + face: [i32; 3], + at: [f64; 3], + canceled: &mut bool, + ) { + let _ = ( + dimension, + projectile, + kind, + owner, + entity, + entity_kind, + block, + face, + at, + canceled, + ); + } /// `Block.stepOn` for a mod block, on its authoritative server level. /// No self: the simulation owns the entity during this callback. @@ -3781,6 +4248,21 @@ impl Game<'_> { self.audio.play_after(sound, at, volume, pitch, delay); true } + /// Draws one vanilla HUD layer between the mods' hooks: `native_gui_layer_pre` + /// (which may hide it), the layer, then `native_gui_layer_post`. `None` when + /// a mod hid the layer. + pub(super) fn gui_layer( + &mut self, + layer: &str, + batch: &mut HudBatch, + draw: impl FnOnce(&mut Self, &mut HudBatch) -> R, + ) -> Option { + let mut canceled = false; + self.native_gui_layer_pre(layer, batch, &mut canceled); + let drawn = (!canceled).then(|| draw(self, batch)); + self.native_gui_layer_post(layer, batch); + drawn + } /// Whether a box in `corner` shows: F3's debug screen takes both top /// corners (its left and right columns), and a bottom corner while one /// of its charts is drawn there. @@ -4470,11 +4952,14 @@ impl Game<'_> { let mut entity = None; self.native_block_entity(name, &mut entity); let (id, nbt) = entity?; - Some(BlockEntity::Mod(Box::new(ModBlockEntity { - id, - nbt, - ..ModBlockEntity::default() - }))) + Some(BlockEntity::Mod(Box::new( + ModBlockEntityFields { + id, + nbt, + ..Default::default() + } + .into(), + ))) } /// The containers of the mod blocks' block entities, by block state /// (`native_block_entity_container`), checked. @@ -4546,6 +5031,34 @@ impl Game<'_> { } out } + /// The energy buffers of the mod blocks' block entities, by block state + /// (`native_block_entity_energy`), checked as the containers are. + pub(super) fn native_energy_tables(&self) -> FxHashMap> { + let registry = self.pack.assets.registry; + let mut by_block: FxHashMap>> = FxHashMap::default(); + let mut out = FxHashMap::default(); + for i in 0..registry.len() as u32 { + let Some(state) = registry.state(horizon_world::registry::BlockStateId(i)) else { + continue; + }; + if state.name.starts_with("minecraft:") { + continue; + } + let buffer = by_block.entry(state.name.clone()).or_insert_with(|| { + let mut energy = None; + self.native_block_entity_energy(&state.name, &mut energy); + energy.filter(|e| e.capacity >= 1).map(|mut e| { + e.max_insert = e.max_insert.clamp(0, e.capacity); + e.max_extract = e.max_extract.clamp(0, e.capacity); + Arc::new(e) + }) + }); + if let Some(b) = buffer { + out.insert(i, Arc::clone(b)); + } + } + out + } /// Whether a native mod's block `name` has a ticking block entity. pub(super) fn native_ticks(&self, name: &str) -> bool { let mut ticks = false; @@ -4993,6 +5506,23 @@ impl Game<'_> { self.native_key(name, pressed); } } + /// `ClientHooks.onClickInput`: tells `native_interaction_key` that `button` + /// (0 attack, 1 use, 2 pick) is about to act with `hand`. `None` when the + /// action goes on; when a mod cancelled it, `Some` of whether the hand swings. + pub(super) fn native_interaction( + &mut self, + button: i32, + hand: horizon_client::first_person::Hand, + ) -> Option { + let key_mapping = match button { + 0 => "key.attack", + 1 => "key.use", + _ => "key.pickItem", + }; + let (mut swing_hand, mut canceled) = (true, false); + self.native_interaction_key(button, key_mapping, hand, &mut swing_hand, &mut canceled); + canceled.then_some(swing_hand) + } /// A mouse button while playing, for `native_key`. pub(super) fn native_mouse_event(&mut self, button: MouseButton, pressed: bool) { let name = keybinds::mouse_name(button); @@ -5471,6 +6001,22 @@ mod tests { } } + #[test] + fn native_breathing_stays_hookable() { + let source = include_str!("../breathing.rs"); + 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(); + let body = &source[start..hook + source[hook..].find("\n}\n").unwrap()]; + assert!(body.contains("#[inline(never)]")); + for line in body.lines() { + assert!(!line.contains("cfg"), "a cfg in the breathing hook: {line}"); + for mac in ["!(", "![", "! {", "!{"] { + assert!(!line.contains(mac), "a macro in the breathing hook: {line}"); + } + } + } + #[test] fn native_loot_kinds_stay_hookable() { let source = include_str!("../../../horizon-world/src/loot.rs"); @@ -5592,6 +6138,8 @@ mod tests { let loot_hooks = free_hooks(include_str!("../../../horizon-world/src/loot.rs")); // The menu engine's (`horizon_client::menu`). 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")); let declared: BTreeSet<_> = section .lines() .chain(level_section.lines()) @@ -5605,6 +6153,7 @@ mod tests { .chain(item_hooks.iter().copied()) .chain(loot_hooks.iter().copied()) .chain(menu_hooks.iter().copied()) + .chain(breath_hooks.iter().copied()) .collect(); let mut names = BTreeSet::new(); let mut contracts = BTreeSet::new(); @@ -5624,6 +6173,8 @@ mod tests { format!("horizon_world::loot::native_{name}") } else if menu_hooks.contains(name) { format!("horizon_client::menu::native_{name}") + } else if breath_hooks.contains(name) { + format!("horizon_client::breathing::native_{name}") } else if level_hooks.contains(name) { format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}") } else { diff --git a/crates/horizon-client/src/game/native_item_fluids.rs b/crates/horizon-client/src/game/native_item_fluids.rs new file mode 100644 index 0000000000..4be8b73b84 --- /dev/null +++ b/crates/horizon-client/src/game/native_item_fluids.rs @@ -0,0 +1,357 @@ +//! Fluids held by item stacks for native mods (docs/native-mods.md): vanilla's +//! buckets, and a mod's own containers (`native_item_fluid_tank`), read, +//! filled and drained in millibuckets. NeoForge's +//! `Capabilities.FluidHandler.ITEM` acts on one item of a stack at a time and +//! may swap the item for another; so do these. They take a stack and answer +//! without changing it: the caller shrinks its stack by one and hands out the +//! changed container they return. They hold no world state, so they work on +//! any side. +use super::Game; +use horizon_client::items::ItemStack; +use horizon_world::nbt::{Compound, JavaString, Tag}; + +/// What one item of a stack holds (`native_item_fluid`). +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NativeItemFluid { + /// `minecraft:water` or `minecraft:lava`; none when it is empty. + pub(crate) fluid: Option, + /// In millibuckets: a bucket is a thousand. + pub(crate) amount: i32, + pub(crate) capacity: i32, +} + +const CUSTOM_DATA: &str = "minecraft:custom_data"; +/// The key under `minecraft:custom_data` that keeps a mod container's +/// contents: `{Fluid: "minecraft:water", Amount: 250}`. +const TANK: &str = "horizon_fluid"; +/// A bucket's capacity, vanilla's `FluidType.BUCKET_VOLUME`. +const BUCKET: i32 = 1000; +const MOD_CAPACITY: std::ops::RangeInclusive = 1..=1_000_000; + +/// The bucket items: (empty, full), by the fluid they hold. A bucket is all +/// or nothing, as `BucketItem` is. Milk, powder snow and fish buckets hold +/// no fluid Horizon models. +const BUCKETS: [(&str, &str); 2] = [ + ("minecraft:water", "minecraft:water_bucket"), + ("minecraft:lava", "minecraft:lava_bucket"), +]; + +fn is_fluid(name: &str) -> bool { + BUCKETS.iter().any(|(fluid, _)| *fluid == name) +} + +/// The contents a mod container's stack keeps in its `custom_data`: none and +/// 0 when empty, or when what is there isn't a tank. +fn read_tank(stack: &ItemStack) -> (Option, i32) { + let Some(Tag::Compound(data)) = stack.data().and_then(|d| d.other(CUSTOM_DATA)) else { + return (None, 0); + }; + let Some(Tag::Compound(tank)) = data.get(&JavaString::from(TANK)) else { + return (None, 0); + }; + let amount = match tank.get(&JavaString::from("Amount")) { + Some(Tag::Int(amount)) => (*amount).max(0), + _ => 0, + }; + match tank.get(&JavaString::from("Fluid")) { + Some(Tag::String(fluid)) if amount > 0 => (Some(fluid.to_string_lossy()), amount), + _ => (None, 0), + } +} + +/// One item of `stack` holding `amount` of `fluid`, its other data kept. An +/// empty tank carries nothing, so empty containers stack. +fn with_tank(stack: &ItemStack, fluid: &str, amount: i32) -> ItemStack { + let mut one = stack.clone(); + one.count = 1; + let mut data = match one.data().and_then(|d| d.other(CUSTOM_DATA)) { + Some(Tag::Compound(data)) => data.clone(), + _ => Compound::new(), + }; + if amount > 0 { + data.insert( + JavaString::from(TANK), + Tag::compound([ + ("Fluid", Tag::String(JavaString::from(fluid))), + ("Amount", Tag::Int(amount)), + ]), + ); + } else { + data.remove(&JavaString::from(TANK)); + } + let value = (!data.is_empty()).then_some(Tag::Compound(data)); + one.data_mut().set_other(CUSTOM_DATA, value); + // What a server sent for the stack no longer describes it; kept, an + // emptied container would not stack with a plain one. + one.data_mut().wire = Default::default(); + one.tidy(); + one +} + +/// `stack` as a fresh single `item`, as `BucketItem` makes its result. +fn bucket(item: &str) -> ItemStack { + ItemStack::new(item, 1) +} + +/// What one item of `stack` holds, if it is a container: a bucket, or an item +/// whose mod gave it `capacity`. +fn contents(stack: &ItemStack, capacity: Option) -> Option { + if stack.count == 0 { + return None; + } + if &*stack.item == "minecraft:bucket" { + return Some(NativeItemFluid { + fluid: None, + amount: 0, + capacity: BUCKET, + }); + } + if let Some((fluid, _)) = BUCKETS.iter().find(|(_, full)| *full == &*stack.item) { + return Some(NativeItemFluid { + fluid: Some((*fluid).to_owned()), + amount: BUCKET, + capacity: BUCKET, + }); + } + let capacity = capacity?; + let (fluid, amount) = read_tank(stack); + Some(NativeItemFluid { + fluid, + amount, + capacity, + }) +} + +/// How much of `amount` of `fluid` one item of `stack` takes, and the item +/// it becomes. +fn fill_one( + stack: &ItemStack, + capacity: Option, + fluid: &str, + amount: i32, +) -> Option<(i32, ItemStack)> { + let held = contents(stack, capacity)?; + let mut unchanged = stack.clone(); + unchanged.count = 1; + if !is_fluid(fluid) || amount <= 0 { + return Some((0, unchanged)); + } + if &*stack.item == "minecraft:bucket" { + // All or nothing: a bucket takes a whole bucket. + let full = BUCKETS.iter().find(|(f, _)| *f == fluid)?.1; + return Some(if amount >= BUCKET { + (BUCKET, bucket(full)) + } else { + (0, unchanged) + }); + } + if capacity.is_none() || held.fluid.as_deref().is_some_and(|f| f != fluid) { + return Some((0, unchanged)); + } + let take = (held.capacity - held.amount).max(0).min(amount); + if take == 0 { + return Some((0, unchanged)); + } + Some((take, with_tank(stack, fluid, held.amount + take))) +} + +/// The fluid and how much one item of `stack` gives of at most `max`, and +/// the item it becomes. +fn drain_one( + stack: &ItemStack, + capacity: Option, + max: i32, +) -> Option<(String, i32, ItemStack)> { + let held = contents(stack, capacity)?; + let fluid = held.fluid?; + if max <= 0 { + return None; + } + if capacity.is_none() || BUCKETS.iter().any(|(_, full)| *full == &*stack.item) { + // A full bucket gives its whole bucket or nothing. + return (max >= BUCKET).then(|| (fluid, BUCKET, bucket("minecraft:bucket"))); + } + let give = held.amount.min(max); + Some(( + fluid.clone(), + give, + with_tank(stack, &fluid, held.amount - give), + )) +} + +// Native mods use these; nothing in Horizon does. +#[allow(dead_code)] +impl Game<'_> { + /// The capacity a mod gave item `item`, clamped; none for any other item. + fn native_item_tank_capacity(&self, item: &str) -> Option { + if item.starts_with("minecraft:") { + return None; + } + let mut capacity = None; + self.native_item_fluid_tank(item, &mut capacity); + capacity.map(|c| c.clamp(*MOD_CAPACITY.start(), *MOD_CAPACITY.end())) + } + /// `Capabilities.FluidHandler.ITEM`, read: the fluid, amount and capacity + /// of one item of `stack`. A bucket holds a bucket of water or lava or + /// nothing; a mod's container what it keeps in its `custom_data`. None + /// for any other item, and for an empty stack. + pub(in crate::game) fn native_item_fluid(&self, stack: &ItemStack) -> Option { + contents(stack, self.native_item_tank_capacity(&stack.item)) + } + /// Fills one item of `stack` with up to `amount` millibuckets of `fluid` + /// (`minecraft:water`, `minecraft:lava`), as `IFluidHandler.fill`: a + /// bucket takes a whole bucket or nothing and becomes the full one; a + /// mod's container takes what fits of one fluid. The amount it takes, and + /// the single item it becomes (the item itself when it took nothing). The + /// stack given is not changed: shrink it by one and hand out the result. + /// None where the stack is no container. + pub(in crate::game) fn native_item_fluid_fill( + &self, + stack: &ItemStack, + fluid: &str, + amount: i32, + ) -> Option<(i32, ItemStack)> { + fill_one( + stack, + self.native_item_tank_capacity(&stack.item), + fluid, + amount, + ) + } + /// Drains up to `max` millibuckets out of one item of `stack`, as + /// `IFluidHandler.drain`: a full bucket gives its whole bucket or nothing + /// and becomes an empty one; a mod's container gives what it holds, up to + /// `max`. The fluid, the amount, and the single item it becomes. The stack + /// given is not changed. None where it is no container, holds nothing, or + /// `max` is less than a bucket's for a bucket. + pub(in crate::game) fn native_item_fluid_drain( + &self, + stack: &ItemStack, + max: i32, + ) -> Option<(String, i32, ItemStack)> { + drain_one(stack, self.native_item_tank_capacity(&stack.item), max) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + const W: &str = "minecraft:water"; + const L: &str = "minecraft:lava"; + + fn canister(count: u32) -> ItemStack { + ItemStack::new("mymod:canister", count) + } + + #[test] + fn an_empty_bucket_takes_a_whole_bucket_or_nothing() { + let empty = ItemStack::new("minecraft:bucket", 3); + let (took, result) = fill_one(&empty, None, W, 1500).unwrap(); + assert_eq!( + (took, &*result.item, result.count), + (1000, "minecraft:water_bucket", 1) + ); + let (took, result) = fill_one(&empty, None, L, 1000).unwrap(); + assert_eq!((took, &*result.item), (1000, "minecraft:lava_bucket")); + let (took, result) = fill_one(&empty, None, W, 999).unwrap(); + assert_eq!( + (took, &*result.item, result.count), + (0, "minecraft:bucket", 1) + ); + // Not a fluid Horizon models. + assert_eq!(fill_one(&empty, None, "minecraft:milk", 1000).unwrap().0, 0); + } + + #[test] + fn a_full_bucket_gives_a_whole_bucket_and_takes_nothing() { + let full = ItemStack::new("minecraft:lava_bucket", 1); + assert_eq!(fill_one(&full, None, L, 1000).unwrap().0, 0); + assert_eq!(drain_one(&full, None, 999), None); + let (fluid, took, result) = drain_one(&full, None, 5000).unwrap(); + assert_eq!( + (fluid.as_str(), took, &*result.item), + (L, 1000, "minecraft:bucket") + ); + let empty = ItemStack::new("minecraft:bucket", 1); + assert_eq!(drain_one(&empty, None, 1000), None); + } + + #[test] + fn other_items_are_no_containers() { + let stone = ItemStack::new("minecraft:stone", 1); + assert_eq!(contents(&stone, None), None); + assert_eq!(fill_one(&stone, None, W, 1000), None); + assert_eq!(drain_one(&stone, None, 1000), None); + assert_eq!(contents(&ItemStack::new("minecraft:bucket", 0), None), None); + // A mod item nobody gave a capacity is none either. + assert_eq!(contents(&canister(1), None), None); + } + + #[test] + fn a_mod_container_keeps_one_fluid_up_to_its_capacity() { + let stack = canister(4); + let (took, filled) = fill_one(&stack, Some(750), W, 500).unwrap(); + assert_eq!((took, filled.count), (500, 1)); + assert_eq!(read_tank(&filled), (Some(W.to_owned()), 500)); + // Only the room is taken, and only of the same fluid. + let (took, full) = fill_one(&filled, Some(750), W, 500).unwrap(); + assert_eq!(took, 250); + assert_eq!(read_tank(&full), (Some(W.to_owned()), 750)); + assert_eq!(fill_one(&full, Some(750), W, 1).unwrap().0, 0); + assert_eq!(fill_one(&filled, Some(750), L, 100).unwrap().0, 0); + assert_eq!( + contents(&full, Some(750)), + Some(NativeItemFluid { + fluid: Some(W.to_owned()), + amount: 750, + capacity: 750 + }) + ); + } + + #[test] + fn draining_a_mod_container_empties_it_back_to_a_plain_stack() { + let (_, filled) = fill_one(&canister(1), Some(1000), L, 300).unwrap(); + let (fluid, took, rest) = drain_one(&filled, Some(1000), 100).unwrap(); + assert_eq!((fluid.as_str(), took), (L, 100)); + assert_eq!(read_tank(&rest), (Some(L.to_owned()), 200)); + let (_, took, empty) = drain_one(&rest, Some(1000), 5000).unwrap(); + assert_eq!(took, 200); + // Nothing left over, so it stacks with a container never filled. + assert_eq!(empty, canister(1)); + assert_eq!(drain_one(&empty, Some(1000), 100), None); + } + + #[test] + fn an_emptied_container_from_a_server_stacks_with_a_plain_one() { + let mut sent = canister(2); + sent.data_mut().wire = + horizon_client::item_data::Wire::new(canister(2), Arc::from(vec![1u8])); + let (_, filled) = fill_one(&sent, Some(100), W, 40).unwrap(); + let (_, _, emptied) = drain_one(&filled, Some(100), 40).unwrap(); + assert!(emptied.same_item_same_components(&canister(1))); + } + + #[test] + fn a_container_keeps_the_rest_of_its_custom_data() { + let mut stack = canister(1); + stack.data_mut().set_other( + CUSTOM_DATA, + Some(Tag::compound([( + "owner", + Tag::String(JavaString::from("Alex")), + )])), + ); + let (_, filled) = fill_one(&stack, Some(1000), W, 10).unwrap(); + let Some(Tag::Compound(data)) = filled.data().and_then(|d| d.other(CUSTOM_DATA)) else { + panic!("no custom data"); + }; + assert!(data.contains_key(&JavaString::from("owner"))); + assert!(data.contains_key(&JavaString::from(TANK))); + let (_, _, emptied) = drain_one(&filled, Some(1000), 10).unwrap(); + assert_eq!(emptied, stack); + } +} diff --git a/crates/horizon-client/src/game/native_menus.rs b/crates/horizon-client/src/game/native_menus.rs index 19e4cf8603..4aa9fcc99c 100644 --- a/crates/horizon-client/src/game/native_menus.rs +++ b/crates/horizon-client/src/game/native_menus.rs @@ -7,7 +7,7 @@ //! `native_exchange_items` swaps two players' stacks in one step, checked //! first, so a trade built on these menus never moves an item before both //! sides are settled. -use super::Game; +use super::{Game, lan::LanHost}; use horizon_client::{ items::ItemStack, lan::{data::component_nbt, menu::NativeMenuEvent}, @@ -216,8 +216,148 @@ impl Game<'_> { } } +/// A menu a player has open, as the mods last heard it +/// (`native_player_container_open`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct HeardMenu { + /// The window id the player's client knows it by. + window: i32, + /// The menu type's name; a mount's inventory has none in vanilla. + menu: String, + /// The block the menu is of. + pos: Option<[i32; 3]>, +} + +/// What a mount's inventory is called, as vanilla's `HorseInventoryMenu` has +/// no menu type. +const MOUNT_MENU: &str = "horizon:mount_inventory"; + +/// The menu `conn` has open, whichever way the game keeps it: a block's +/// container, a trade, a crafting table, a workstation, a lectern or a +/// mount's inventory. Not the player's own inventory menu. +fn open_menu_of(host: &LanHost, conn: u32) -> Option<(i32, &str, Option<[i32; 3]>)> { + let g = host.hub.guest(conn)?; + let play = host.play.get(&conn); + if let Some(m) = &g.menu { + let menu = host.hub.registry_name("minecraft:menu", m.kind)?; + // A vehicle's menu is keyed by a stand-in, not a block. + let pos = m + .pos + .filter(|p| p.1 != horizon_client::lan::menu::VEHICLE_KEY_Y) + .map(|p| [p.0, p.1, p.2]); + return Some((m.window, menu, pos)); + } + let play = play?; + let (window, menu, pos) = if let Some(gm) = &play.merchant { + (gm.window, "minecraft:merchant", None) + } else if let Some((window, _, _, pos)) = &play.crafting.table { + (*window, "minecraft:crafting", Some(*pos)) + } else if let Some(st) = &play.station { + ( + st.window, + super::lan_stations::menu_of(st.kind)?.0, + Some(st.pos), + ) + } else if let Some(l) = &play.lectern { + ( + l.window, + "minecraft:lectern", + Some([l.key.0, l.key.1, l.key.2]), + ) + } else if let Some(m) = &play.mount_window { + (m.window, MOUNT_MENU, None) + } else { + return None; + }; + Some((window, menu, pos)) +} + /// Where the game calls the menu hook points. impl Game<'_> { + /// The mods hear the menus the players here opened or closed since the + /// last tick (`PlayerContainerEvent`), in that order for a menu another + /// replaced: what each player has open is compared with what the mods last + /// heard. A menu opened and closed again inside one tick is not heard. + pub(super) fn tick_player_menus(&mut self, host: &mut LanHost) { + let conns: Vec = host + .hub + .guests + .iter() + .filter(|g| host.hub.here(g)) + .map(|g| g.conn) + .collect(); + for conn in conns { + // Compared as borrowed: nothing is built each tick while it stays open. + let now = open_menu_of(host, conn); + let heard = host.play.get(&conn).and_then(|p| p.menu_heard.as_ref()); + if now == heard.map(|h| (h.window, h.menu.as_str(), h.pos)) { + continue; + } + let now = now.map(|(window, menu, pos)| HeardMenu { + window, + menu: menu.to_owned(), + pos, + }); + let heard = host.play.get(&conn).and_then(|p| p.menu_heard.clone()); + // Remembered first: a hook can run commands that close menus. + if let Some(p) = host.play.get_mut(&conn) { + p.menu_heard.clone_from(&now); + } + if let Some(old) = heard { + self.player_menu_closed(host, conn, old); + } + if let Some(new) = now { + self.player_menu_opened(host, conn, new); + } + } + } + + /// `conn`'s menu closes with the guest in the level in place + /// (`ServerPlayer.doCloseContainer`): the mods hear it, if they heard it + /// open. + pub(super) fn player_menu_closing(&mut self, host: &mut LanHost, conn: u32) { + let heard = host.play.get_mut(&conn).and_then(|p| p.menu_heard.take()); + if let Some(old) = heard { + self.player_menu_closed(host, conn, old); + } + } + + fn player_menu_opened(&mut self, host: &mut LanHost, conn: u32, menu: HeardMenu) { + let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else { + return; + }; + let dimension = self.dim.id(); + self.player_hook(host, conn, move |g| { + g.native_player_container_open(uuid, &name, dimension, &menu.menu, menu.pos); + }); + } + + fn player_menu_closed(&mut self, host: &mut LanHost, conn: u32, menu: HeardMenu) { + let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else { + return; + }; + let dimension = self.dim.id(); + self.player_hook(host, conn, move |g| { + g.native_player_container_close(uuid, &name, dimension, &menu.menu, menu.pos); + }); + } + + /// Runs a player hook with the guest's state in place, or as it is when + /// the guest has no body to swap in. + fn player_hook(&mut self, host: &mut LanHost, conn: u32, f: impl FnOnce(&mut Self)) { + let mut f = Some(f); + if self.lan_acting != Some(conn) { + self.as_guest(host, conn, |g| { + if let Some(f) = f.take() { + f(g); + } + }); + } + if let Some(f) = f { + f(self); + } + } + /// The clicks on and closings of native menus the hub collected, each /// told to the mods with the hub back in `self.lan`, no player's state /// swapped in. diff --git a/crates/horizon-client/src/game/native_mob_draw.rs b/crates/horizon-client/src/game/native_mob_draw.rs index 2661d95c89..ffb4982cff 100644 --- a/crates/horizon-client/src/game/native_mob_draw.rs +++ b/crates/horizon-client/src/game/native_mob_draw.rs @@ -3,7 +3,10 @@ //! it cancels) gets the mob's render state and a [`BlockEntityDraw`] posed at //! its feet and turned with its body, so a mod draws a model of its own with //! the helpers block entities draw with (`native_draw_cube`, -//! `native_draw_quad`, `native_draw_block`, `native_draw_item`). +//! `native_draw_quad`, `native_draw_block`, `native_draw_item`). A player +//! has `native_player_submit` (`RenderPlayerEvent.Pre`), and every mob and +//! player in view gets `native_mob_layer` after its vanilla model +//! (`EntityRenderersEvent.AddLayers`), in the same frame. use super::{Dim, Game, native_block_entity_draw::BlockEntityDraw}; use glam::{DVec3, Mat4}; @@ -77,6 +80,18 @@ impl<'a> NativeMobRender<'a> { } } +/// Which player a [`NativeMobRender`] is, for `native_player_submit` and +/// `native_mob_layer`. +// Native mods read it; nothing in Horizon does. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub struct NativePlayerRender<'a> { + /// The player's profile name. + pub name: &'a str, + /// Whether it is the player of this window, drawn in third person. + pub local: bool, +} + /// The pose a mod's mob model starts from: at the feet, `at` (relative to the /// camera), turned with the body so +z is the way it faces, +x its left and +y /// up, in blocks. @@ -96,6 +111,69 @@ impl Game<'_> { camera: DVec3, light: u32, out: &mut Vec, + ) -> bool { + self.mod_entity_draw(mob, partial, camera, light, out, |render, draw| { + let mut drawn = false; + self.native_mob_submit(render, draw, &mut drawn); + drawn + }) + } + /// `native_player_submit` for the player `mob` (`who` says which): true + /// when a mod drew it and the vanilla model, armour, held items, cape and + /// other layers are left out. + pub(super) fn mod_player_geometry( + &self, + mob: &Mob, + who: NativePlayerRender<'_>, + partial: f32, + camera: DVec3, + light: u32, + out: &mut Vec, + ) -> bool { + // Posing a player for the mods costs, each frame: only when one listens. + let mut listened = false; + self.native_player_render_listened(&mut listened); + if !listened { + return false; + } + self.mod_entity_draw(mob, partial, camera, light, out, |render, draw| { + let mut drawn = false; + self.native_player_submit(render, &who, draw, &mut drawn); + drawn + }) + } + /// `native_mob_layer` for `mob` (`who` set for a player): what a mod adds + /// is kept, drawn after the vanilla model and its layers. + pub(super) fn mod_mob_layer_geometry( + &self, + mob: &Mob, + who: Option>, + partial: f32, + camera: DVec3, + light: u32, + out: &mut Vec, + ) { + // Every mob in view each frame: only when a mod listens. + let mut listened = false; + self.native_player_render_listened(&mut listened); + if !listened { + return; + } + self.mod_entity_draw(mob, partial, camera, light, out, |render, draw| { + self.native_mob_layer(render, who.as_ref(), draw); + true + }); + } + /// Gives `hook` the frame of `mob` and a draw posed at its feet; what it + /// added to `out` stays when it answers true. + fn mod_entity_draw( + &self, + mob: &Mob, + partial: f32, + camera: DVec3, + light: u32, + out: &mut Vec, + hook: impl FnOnce(&NativeMobRender<'_>, &mut BlockEntityDraw) -> bool, ) -> bool { let render = NativeMobRender::of(mob, partial); let a = f64::from(partial); @@ -111,13 +189,12 @@ impl Game<'_> { diffuse_lights(self.dim == Dim::Nether), std::mem::take(out), ); - let mut drawn = false; - self.native_mob_submit(&render, &mut draw, &mut drawn); + let keep = hook(&render, &mut draw); *out = draw.into_vertices(); - if !drawn { + if !keep { out.truncate(start); } - drawn + keep } } diff --git a/crates/horizon-client/src/game/native_mobs.rs b/crates/horizon-client/src/game/native_mobs.rs index 0ed8e18a78..7a23b6f755 100644 --- a/crates/horizon-client/src/game/native_mobs.rs +++ b/crates/horizon-client/src/game/native_mobs.rs @@ -8,6 +8,53 @@ use super::{Game, commands::Target}; use horizon_core::geometry::Vec3; +/// The `EquipmentSlot` names of `Mob::equipment_stacks`, in its order. +const MOB_SLOTS: [&str; 8] = [ + "mainhand", "offhand", "feet", "legs", "chest", "head", "body", "saddle", +]; + +impl Game<'_> { + /// `LivingEntity.collectEquipmentChanges` for the mobs that tick: each + /// slot that holds another stack than the mods last heard is told + /// (`native_living_equipment_change`). A mob new to the level counts from + /// empty, as vanilla's `lastEquipmentItems` does. Only while a mod + /// listens (`native_living_equipment_listened`): a mob's first tick after + /// that counts from empty too. + pub(super) fn native_mobs_equipment(&mut self) { + if self.remote.is_some() || self.options.execution_role.is_client() { + return; + } + // Reading every mob's equipment each tick costs: only when a mod listens. + let mut listened = false; + self.native_living_equipment_listened(&mut listened); + if !listened { + return; + } + let ticking = self.entity_ticking_area(); + let mut changes = Vec::new(); + for m in self.mobs.iter_mut().filter(|m| !m.removed) { + if !ticking(m.position.x.floor() as i32, m.position.z.floor() as i32) { + continue; + } + let now = m.equipment_stacks(); + let heard = m.extra.equipment_heard.get_or_insert_with(Default::default); + if **heard == now { + continue; + } + for (slot, name) in MOB_SLOTS.into_iter().enumerate() { + if now[slot] != heard[slot] { + let from = heard[slot].take(); + changes.push((m.id, m.kind.id.clone(), name, from, now[slot].clone())); + } + } + **heard = now; + } + for (id, kind, slot, from, to) in changes { + self.native_living_equipment_change(id, &kind, slot, from.as_ref(), to.as_ref()); + } + } +} + /// A mob as a native mod reads it. #[allow(dead_code)] #[derive(Clone, Debug, PartialEq)] diff --git a/crates/horizon-client/src/game/native_mounts.rs b/crates/horizon-client/src/game/native_mounts.rs new file mode 100644 index 0000000000..a98b25c77d --- /dev/null +++ b/crates/horizon-client/src/game/native_mounts.rs @@ -0,0 +1,82 @@ +//! Mounting for native mods (`native_entity_mount`: NeoForge's `EntityMountEvent`). Each place +//! the acting player or a mob gets on or off a vehicle asks `native_may_board` or +//! `native_may_leave` first, and does nothing when a mod says no. +use super::{Game, native_hooks::mob_kind}; + +impl Game<'_> { + /// Whether this game is a server whose mounts the mods hear: the server thread or a + /// dedicated server, never a client nor the deprecated in-frame path. + fn native_hears_mounts(&self) -> bool { + self.serving.is_some() || self.options.dedicated.is_some() + } + + /// The entity id of the acting player: a guest's while their action runs, else the host's. + fn native_acting_entity(&self) -> u32 { + let guests = self.lan_guest_entities(); + match self.lan_acting { + Some(conn) => guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1), + None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32), + } + } + + /// The type of the vehicle with entity id `id`. + fn native_vehicle_kind(&self, id: u32) -> String { + if let Some(b) = self.boats.iter().find(|b| b.id == id) { + b.entity_type() + } else if let Some(c) = self.carts.iter().find(|c| c.id == id) { + c.kind.id().into() + } else if self.cushions.iter().any(|c| c.id == id) { + "minecraft:cushion".into() + } else if let Some(m) = self.mobs.iter().find(|m| m.id == id) { + mob_kind(m) + } else { + String::new() + } + } + + /// `native_entity_mount` for `rider` (the acting player when `None`) and `vehicle`: whether + /// the mounting or dismounting goes ahead. + fn native_mount_allowed(&mut self, rider: Option, vehicle: u32, mounting: bool) -> bool { + if !self.native_hears_mounts() { + return true; + } + let (entity, kind) = match rider { + Some(id) => ( + id, + self.mobs + .iter() + .find(|m| m.id == id) + .map_or_else(String::new, mob_kind), + ), + None => (self.native_acting_entity(), "minecraft:player".into()), + }; + let vehicle_kind = self.native_vehicle_kind(vehicle); + let mut canceled = false; + self.native_entity_mount( + self.dim.id(), + entity, + &kind, + vehicle, + &vehicle_kind, + mounting, + &mut canceled, + ); + !canceled + } + + /// The acting player is about to board `vehicle`: false when a mod canceled it. + pub(super) fn native_may_board(&mut self, vehicle: u32) -> bool { + self.native_mount_allowed(None, vehicle, true) + } + + /// The acting player is about to get off `vehicle`: false when a mod canceled it. + pub(super) fn native_may_leave(&mut self, vehicle: u32) -> bool { + self.native_mount_allowed(None, vehicle, false) + } + + /// The mob `rider` is about to board (`mounting`) or leave `vehicle`: false when a mod + /// canceled it. + pub(super) fn native_mob_may_ride(&mut self, rider: u32, vehicle: u32, mounting: bool) -> bool { + self.native_mount_allowed(Some(rider), vehicle, mounting) + } +} diff --git a/crates/horizon-client/src/game/native_projectiles.rs b/crates/horizon-client/src/game/native_projectiles.rs new file mode 100644 index 0000000000..a46dd9ccce --- /dev/null +++ b/crates/horizon-client/src/game/native_projectiles.rs @@ -0,0 +1,255 @@ +//! Projectile impacts for native mods (`native_projectile_impact`, NeoForge's +//! `ProjectileImpactEvent`). `tick_projectiles` flies every projectile first and +//! collects the impacts; once its worlds are let go, the mods hear each one, and a +//! projectile whose impact a mod canceled flies the tick again from where it was, +//! through whatever it hit. +use super::{ + Game, + creatures::{ProjWorld, wind_charge_hit}, + dragon::{CRYSTAL_IDS, SPECIAL}, + lan_pvp::meets_players, + native_hooks::mob_kind, +}; +use horizon_client::{ + arrows::ArrowEvent, + mobs::Who, + projectiles::{Hit, Projectile}, +}; +use horizon_core::geometry::Vec3; +use std::ops::Range; + +/// A projectile's impact in one tick of `tick_projectiles`. +pub(super) struct Impact { + /// Where the projectile was in the tick's list. + pub index: usize, + /// The projectile before the tick. + pub before: Projectile, + pub hit: Hit, + /// Where its hit is in the tick's `hits`. + pub hit_slot: usize, + /// Where its arrow events are in the tick's `arrow_events`. + pub events: Range, +} + +/// The type the mods know a projectile by: a spectral arrow is its own. +fn projectile_type(p: &Projectile) -> &'static str { + let spectral = p + .arrow + .as_ref() + .is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow"); + if spectral { + "minecraft:spectral_arrow" + } else { + p.kind.id() + } +} + +impl Game<'_> { + /// Whether this game is a server that flies projectiles for the mods (the + /// server thread or a dedicated server, never the deprecated in-frame path + /// nor a client) and a mod asked to hear their impacts + /// (`native_hears_projectile_impacts`). Without one, the tick keeps no copy + /// of its projectiles: it costs nothing. + pub(super) fn native_hears_impacts(&self) -> bool { + if self.serving.is_none() && self.options.dedicated.is_none() { + return false; + } + let mut hears = false; + self.native_hears_projectile_impacts(&mut hears); + hears + } + + /// The entity id of the one who shot `p`: a mob's, a player's, else 0. + fn native_impact_owner(&self, p: &Projectile) -> u32 { + match p.owner { + Some(Who::Mob(id)) => id, + Some(Who::Player) => match p.guest { + Some(conn) => self + .lan_guest_entities() + .into_iter() + .find(|&(c, _)| c == conn) + .map_or(0, |(_, id)| id), + None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32), + }, + None => 0, + } + } + + /// The entity id and type a projectile's hit on the reserved or ordinary + /// `id` stands for: a dragon part is the dragon, an End crystal its own id. + fn native_impact_entity(&self, id: u32) -> (u32, String) { + if id >= SPECIAL { + return if id < CRYSTAL_IDS { + let dragon = self.dragon.as_ref().map_or(0, |d| d.id); + (dragon, "minecraft:ender_dragon".into()) + } else { + let crystal = self.crystals.get((id - CRYSTAL_IDS) as usize); + (crystal.map_or(0, |c| c.id), "minecraft:end_crystal".into()) + }; + } + if let Some(mob) = self.mobs.iter().find(|m| m.id == id) { + return (id, mob_kind(mob)); + } + if let Some(cart) = self.carts.iter().find(|c| c.id == id) { + return (id, cart.kind.id().into()); + } + if self.lan_guest_entities().iter().any(|&(_, e)| e == id) { + return (id, "minecraft:player".into()); + } + (id, String::new()) + } + + /// `native_projectile_impact` for each of `impacts`: whether the mods + /// canceled it. A hit on a wind charge is not told. + fn native_projectile_impacts(&mut self, impacts: &[Impact]) -> Vec { + let dimension = self.dim.id(); + let mut canceled = Vec::with_capacity(impacts.len()); + for impact in impacts { + let p = &impact.before; + let (entity, entity_kind, block, face, at) = match impact.hit { + Hit::Block { pos, at, face } => (None, String::new(), pos, face, at), + Hit::Entity { + who: Who::Mob(id), + at, + } => { + if wind_charge_hit(id).is_some() { + canceled.push(false); + continue; + } + let (entity, kind) = self.native_impact_entity(id); + (Some(entity), kind, [0; 3], [0; 3], at) + } + Hit::Entity { + who: Who::Player, + at, + } => { + let Some(host) = self.lan.as_ref().map(|h| h.host_entity as u32) else { + canceled.push(false); + continue; + }; + (Some(host), "minecraft:player".into(), [0; 3], [0; 3], at) + } + Hit::None => { + canceled.push(false); + continue; + } + }; + let owner = self.native_impact_owner(p); + let mut stop = false; + self.native_projectile_impact( + dimension, + p.id, + projectile_type(p), + owner, + entity, + &entity_kind, + block, + face, + [at.x, at.y, at.z], + &mut stop, + ); + canceled.push(stop); + } + canceled + } + + /// The end of a tick of `tick_projectiles` that met `impacts`, with + /// `projectiles` in place (those it keeps, as `listed` says of the tick's + /// list): the mods hear each, and a projectile one canceled flies the tick + /// again from before it, through what it hit. Its hit and its arrow events are + /// taken out of `hits` and `arrow_events`, and it goes back among the + /// projectiles. One a mod removed in its hook stays removed. + pub(super) fn native_settle_impacts( + &mut self, + projectiles: &mut Vec, + listed: &[bool], + impacts: Vec, + hits: &mut Vec<(Projectile, Hit)>, + arrow_events: &mut Vec<(Vec3, ArrowEvent)>, + ) { + // The mods see the projectiles where they will be, as `projectile_hit` does. + self.creatures.projectiles = std::mem::take(projectiles); + let canceled = self.native_projectile_impacts(&impacts); + *projectiles = std::mem::take(&mut self.creatures.projectiles); + if !canceled.contains(&true) { + return; + } + let mut drop_hits = vec![false; hits.len()]; + let mut drop_events = vec![false; arrow_events.len()]; + let mut flown = Vec::new(); + for (impact, _) in impacts.into_iter().zip(canceled).filter(|(_, c)| *c) { + drop_hits[impact.hit_slot] = true; + drop_events[impact.events.clone()].fill(true); + let present = projectiles.iter().position(|q| q.id == impact.before.id); + if present.is_none() && listed[impact.index] { + continue; + } + let (p, keep, events) = self.native_projectile_through(impact.before); + flown.extend(events); + match (present, keep) { + (Some(i), true) => projectiles[i] = p, + (Some(i), false) => { + projectiles.remove(i); + } + (None, true) => projectiles.push(p), + (None, false) => {} + } + } + let mut n = 0; + hits.retain(|_| { + n += 1; + !drop_hits[n - 1] + }); + let mut n = 0; + arrow_events.retain(|_| { + n += 1; + !drop_events[n - 1] + }); + arrow_events.extend(flown); + } + + /// `p`, as it was before its tick, flown that tick through what it hit + /// (`Projectile::tick_through`, `arrows::tick_through`): the projectile after + /// it, whether it stays, and the arrow events it made. + fn native_projectile_through( + &self, + mut p: Projectile, + ) -> (Projectile, bool, Vec<(Vec3, ArrowEvent)>) { + let mob_shot = !matches!(p.owner, Some(Who::Player)); + let pvp = !mob_shot && meets_players(&p.kind); + // A guest's shot flies from that guest, as in `tick_projectiles`. + let shooter = p + .guest + .filter(|_| pvp) + .and_then(|c| self.lan_guest_entities().into_iter().find(|&(g, _)| g == c)) + .map(|(_, id)| Who::Mob(id)); + if shooter.is_some() { + p.owner = shooter; + } + let mut targets = self.projectile_targets(); + if p.arrow.is_some() { + targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()))); + } + if mob_shot || pvp { + targets.extend(self.lan_projectile_targets()); + } + let rain = self.arrow_rain(); + let scene = self.arrow_scene(&rain); + let (keep, _) = if p.arrow.is_some() { + horizon_client::arrows::tick_through(&mut p, &scene, &targets) + } else { + let w = self.mob_world_base(&[]); + p.tick_through(&ProjWorld { w: &w, targets }) + }; + if shooter.is_some() { + p.owner = Some(Who::Player); + } + let at = p.position; + let events = p + .arrow + .as_mut() + .map(|a| a.events.drain(..).map(|e| (at, e)).collect()) + .unwrap_or_default(); + (p, keep, events) + } +} diff --git a/crates/horizon-client/src/game/native_ticks.rs b/crates/horizon-client/src/game/native_ticks.rs new file mode 100644 index 0000000000..b3bc33acc6 --- /dev/null +++ b/crates/horizon-client/src/game/native_ticks.rs @@ -0,0 +1,169 @@ +//! Entities ticking for native mods (`native_entity_tick_pre`, `native_entity_tick_post`: +//! NeoForge's `EntityTickEvent.Pre` and `.Post`). A family of entities ticks in its own loop, +//! and the loops of the mobs and the items hold their lists out of `Game`, so a family hands +//! its ticking entities to a `TickRound` before the loop and hears the Posts after it; the +//! minecarts and boats, whose loops can call the hooks, ask for each one in turn. +use super::Game; +use horizon_client::mobs::MobType; +use std::sync::Arc; + +/// The type of an entity in a round, as the hooks name it. +pub(super) enum TickKind { + Fixed(&'static str), + Mob(Arc), +} + +impl TickKind { + fn id(&self) -> &str { + match self { + Self::Fixed(id) => id, + Self::Mob(kind) => &kind.id, + } + } +} + +/// The entities of one family in one tick. +#[derive(Default)] +pub(super) struct TickRound { + /// Whether this game is a server the mods hear: otherwise nothing is told or kept. + listening: bool, + /// The entities the Pre let through, in order, for the Post. + ticking: Vec<(u32, TickKind)>, + /// The entities a Pre canceled. + canceled: Vec, +} + +impl TickRound { + /// Whether a mod canceled the tick of entity `id`. + pub(super) fn canceled(&self, id: u32) -> bool { + !self.canceled.is_empty() && self.canceled.contains(&id) + } + /// Whether the mods hear this round. + pub(super) fn listening(&self) -> bool { + self.listening + } + /// The entity `id`, told its Pre, went before it ticked (an item merged into an earlier + /// one): it hears no Post. + pub(super) fn not_ticked(&mut self, id: u32) { + if self.listening { + self.ticking.retain(|(other, _)| *other != id); + } + } +} + +impl Game<'_> { + /// A round for a family about to tick: on a server only (the server thread or a dedicated + /// server, never a client nor the deprecated in-frame path), and only while a mod listens + /// (`native_entity_tick_listened`). Its list is kept between ticks. + pub(super) fn native_tick_round(&mut self) -> TickRound { + let server = self.serving.is_some() || self.options.dedicated.is_some(); + let mut listened = false; + if server { + self.native_entity_tick_listened(&mut listened); + } + let listening = server && listened; + TickRound { + listening, + ticking: if listening { + std::mem::take(&mut self.creatures.tick_buffer) + } else { + Vec::new() + }, + canceled: Vec::new(), + } + } + + /// `native_entity_tick_pre` for the entity `id` of `kind` about to tick in a family that + /// hears its Posts together: whether it ticks. + pub(super) fn native_tick_pre_batch( + &mut self, + round: &mut TickRound, + id: u32, + kind: TickKind, + ) -> bool { + if !round.listening { + return true; + } + let mut canceled = false; + self.native_entity_tick_pre(self.dim.id(), id, kind.id(), &mut canceled); + if canceled { + round.canceled.push(id); + } else { + round.ticking.push((id, kind)); + } + !canceled + } + + /// `native_entity_tick_post` for each entity the round let through, and the round is over. + pub(super) fn native_tick_post_batch(&mut self, mut round: TickRound) { + if !round.listening { + return; + } + let dimension = self.dim.id(); + for (id, kind) in &round.ticking { + self.native_entity_tick_post(dimension, *id, kind.id()); + } + round.ticking.clear(); + self.creatures.tick_buffer = round.ticking; + } + + /// `native_entity_tick_pre` for one entity about to tick: whether it does. + pub(super) fn native_tick_pre_one( + &mut self, + round: &mut TickRound, + id: u32, + kind: &str, + ) -> bool { + if !round.listening { + return true; + } + let mut canceled = false; + self.native_entity_tick_pre(self.dim.id(), id, kind, &mut canceled); + if canceled { + round.canceled.push(id); + } + !canceled + } + + /// `native_entity_tick_post` for one entity that ticked. + pub(super) fn native_tick_post_one(&mut self, round: &TickRound, id: u32, kind: &str) { + if round.listening { + self.native_entity_tick_post(self.dim.id(), id, kind); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round() -> TickRound { + TickRound { + listening: true, + ..TickRound::default() + } + } + + /// A canceled entity is skipped, and one that went before its tick (an item merged into an + /// earlier one) hears no Post; a round nobody listens to keeps nothing. + #[test] + fn a_round_keeps_the_entities_that_tick() { + let mut r = round(); + r.ticking.push((1, TickKind::Fixed("minecraft:item"))); + r.ticking.push((2, TickKind::Fixed("minecraft:item"))); + r.canceled.push(3); + assert!(r.canceled(3)); + assert!(!r.canceled(1)); + r.not_ticked(2); + let left: Vec<_> = r + .ticking + .iter() + .map(|(id, kind)| (*id, kind.id())) + .collect(); + assert_eq!(left, [(1, "minecraft:item")]); + let mut quiet = TickRound::default(); + assert!(!quiet.listening()); + quiet.not_ticked(1); + assert!(quiet.ticking.is_empty() && !quiet.canceled(1)); + } +} diff --git a/crates/horizon-client/src/game/nautili.rs b/crates/horizon-client/src/game/nautili.rs index edc0e35da4..10636a2566 100644 --- a/crates/horizon-client/src/game/nautili.rs +++ b/crates/horizon-client/src/game/nautili.rs @@ -127,6 +127,9 @@ impl Game<'_> { if tame && !food { // doPlayerRide. let id = self.mobs[index].id; + if !self.native_may_board(id) { + return true; + } self.break_leash(id, true, false); self.yaw = self.mobs[index].yaw; self.riding = Some(id); diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 19b286b6ad..cc88d5fcc9 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -372,7 +372,8 @@ impl Game<'_> { .cem .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = cem; - // The random entity textures the new atlas packs: every mob chooses again. + // The random entity textures the new atlas packs. A new scan is a new generation: + // every mob (every splitscreen player's too) chooses again on its next tick. *self .res .mob_data() @@ -380,9 +381,6 @@ impl Game<'_> { .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = std::mem::take(&mut self.gui.atlas.random_textures); - for m in &mut self.mobs { - m.random_textures.forget_picks(); - } horizon_client::ui::load_metadata(&root); lang::set(lang::Language::load(&[&root], &lang::code())); self.refresh_remote_language(); diff --git a/crates/horizon-client/src/game/passengers.rs b/crates/horizon-client/src/game/passengers.rs index 69b52c757d..ccd725bd0d 100644 --- a/crates/horizon-client/src/game/passengers.rs +++ b/crates/horizon-client/src/game/passengers.rs @@ -27,7 +27,15 @@ fn zombie_kind(s: Species) -> bool { impl Game<'_> { /// `Entity.startRiding` for two mobs. pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) { + self.try_mount_mob(rider, vehicle); + } + /// `Entity.startRiding` for two mobs: false when a mod canceled it. + pub(super) fn try_mount_mob(&mut self, rider: u32, vehicle: u32) -> bool { + if !self.native_mob_may_ride(rider, vehicle, true) { + return false; + } riders::link(&mut self.mobs, rider, vehicle); + true } /// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats, /// and let go when the vehicle dies or is gone. diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index d0be5dcd71..9b920998c8 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -2,7 +2,7 @@ //! in front, cycled by F5), the detached camera (`Camera.setup` with //! `getMaxZoom`), and the local player drawn as a model: in third person //! and in the inventory (`InventoryScreen.extractEntityInInventoryFollowsMouse`). -use super::{Game, Screen, shoulders::shoulder_variants}; +use super::{Game, Screen, native_mob_draw::NativePlayerRender, shoulders::shoulder_variants}; use horizon_client::{ blocks::BlockFacts, camera::{CameraEntity, LivingCamera}, @@ -625,6 +625,17 @@ impl Game<'_> { let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); let start = out.len(); let body = self.own_body_visible(); + // `native_player_submit`: a native mod's own model for this player, + // in place of the vanilla one and its layers (an invisible player is + // not asked, as for the others). + let name = self.own_profile_name(); + let who = NativePlayerRender { + name: &name, + local: true, + }; + if body && self.mod_player_geometry(m, who, alpha as f32, camera, packed, out) { + return; + } // A spectator still draws its own model in third person: the poses' // visibility (set by player_model_visibility) hides body parts, and // translucent_body marks the body OIT for the ghostly look. Only first @@ -664,6 +675,8 @@ impl Game<'_> { self.held_item_geometry(m, camera, alpha, packed, true, out); // CustomHeadLayer: a pumpkin, skull or other head item. self.head_item_geometry(m, camera, alpha, packed, true, out); + // `native_mob_layer`: a native mod's layer, over the vanilla ones. + self.mod_mob_layer_geometry(m, Some(who), alpha as f32, camera, packed, out); // LivingEntity.isCurrentlyGlowing: the Glowing effect. if self.potions.effects.get("glowing").is_some() { let color = super::glowing::team_color(self.own_team()); @@ -737,16 +750,19 @@ impl Game<'_> { } else { horizon_client::mobs::render::render_layers }; - render( - &m, - 1.0, - camera, - &lookup, - 240 | 240 << 8, - self.res.mob_data(), - &self.player_armor_layers(), - &mut verts, - ); + // A screen preview: custom entity model animations see is_in_gui = 1. + horizon_client::mobs::cem_anim::in_gui(|| { + render( + &m, + 1.0, + camera, + &lookup, + 240 | 240 << 8, + self.res.mob_data(), + &self.player_armor_layers(), + &mut verts, + ); + }); let chest = self.armor[1].as_ref().map(|s| &*s.item); if !self.own_invisible() { self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts); diff --git a/crates/horizon-client/src/game/random_textures.rs b/crates/horizon-client/src/game/random_textures.rs index 843d6efdc9..c80deba735 100644 --- a/crates/horizon-client/src/game/random_textures.rs +++ b/crates/horizon-client/src/game/random_textures.rs @@ -1,5 +1,6 @@ //! OptiFine random entity textures: each mob's variants, chosen when this window first sees it -//! and checked again once a second (`horizon_client::mobs::random_textures`). +//! and checked again once a second, counted in this window's ticks so a frozen world still +//! checks (`horizon_client::mobs::random_textures`). use super::Game; use horizon_client::mobs::random_textures::{Seen, choose, seed}; @@ -17,15 +18,16 @@ impl Game<'_> { if textures.is_empty() { return; } - let tick = self.world.game_time; for i in 0..self.mobs.len() { - let m = &self.mobs[i]; - if !m.random_textures.due(tick, m.id) { + let m = &mut self.mobs[i]; + let id = m.id; + if !m.random_textures.due(&textures, id) { continue; } - let seed = seed(m, self.persist.uuids.get(&m.id).copied()); + let m = &self.mobs[i]; + let seed = seed(m, self.persist.uuids.get(&id).copied()); let seen = (!m.random_textures.has_seen()).then(|| self.seen_at(m.position)); - choose(&mut self.mobs[i], &textures, seen, seed); + choose(&mut self.mobs[i], &textures, res.mob_data(), seen, seed); } } diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 4c849a5d19..71ee9f47a5 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -709,7 +709,9 @@ impl Game<'_> { if let Some(search) = self.recipe_ui.search.as_mut() { search.x = xo + 25.0; search.y = yo + 13.0; - let magnifier = inside(cursor, xo + 8.0, yo + 13.0, 17.0, 14.0); + // RecipeBookComponent's magnifierIconPlacement: from the book's + // left edge plus eight to the search box's own left edge. + let magnifier = inside(cursor, xo + 8.0, yo + 13.0, search.x - xo, 14.0); if magnifier || inside(cursor, search.x, search.y, search.width, search.height) { self.recipe_ui.search_drag = true; search.focused = true; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5aa11cb4f3..808d4aa078 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2188,8 +2188,7 @@ impl Game<'_> { let block = self.world.block(pos[0], pos[1], pos[2]); if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&key) { if *self.res.facts().get(block).name == *name { - m.nbt - .extend(fields.iter().map(|(k, v)| (k.clone(), v.clone()))); + m.merge_update_tag(fields); } return; } @@ -6917,6 +6916,14 @@ impl Game<'_> { if self.hands_busy() { return; } + // InputEvent.InteractionKeyMappingTriggered for the main hand: a cancel + // ends the whole click, the off hand gets no turn. + if let Some(swing) = self.native_interaction(1, Hand::Main) { + if swing { + self.swing_hand(Hand::Main, SwingAnimation::DEFAULT); + } + return; + } // An item a mod claims: its use is the mod's, and the server never hears of it. if self.mod_use_start() { return; @@ -7877,7 +7884,13 @@ impl Game<'_> { self.heights.item_used(Hand::Main); return; } - // The off hand's turn: the entity again, or the block. + // The off hand's turn: the event again, then the entity or the block. + if let Some(swing) = self.native_interaction(1, Hand::Off) { + if swing { + self.swing_hand(Hand::Off, SwingAnimation::DEFAULT); + } + return; + } let off_target = if self.entity_targeted() { self.remote_use_entity(Hand::Off) } else { @@ -8158,16 +8171,25 @@ impl Game<'_> { self.send(S::SpectatorAction(play::SpectatorAction { target })); return true; } + let spear = self + .held() + .and_then(|s| horizon_client::spear::Kinetic::of(&s.item)); + if spear.is_some() && self.cannot_attack_with_item(self.hand_strength.ticker, 0) { + return false; + } + // InputEvent.InteractionKeyMappingTriggered, after the checks that stop + // an attack: a cancel ends it there, swinging unless the mod said not to. + if let Some(swing) = self.native_interaction(0, Hand::Main) { + if swing { + let animation = spear.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation()); + self.swing_hand(Hand::Main, animation); + } + return false; + } // A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server // stabs, this client swings, plays the sound and starts its charge // again. - if let Some(kinetic) = self - .held() - .and_then(|s| horizon_client::spear::Kinetic::of(&s.item)) - { - if self.cannot_attack_with_item(self.hand_strength.ticker, 0) { - return false; - } + if let Some(kinetic) = spear { // ServerboundPlayerActionPacket.Action.STAB. self.send(S::PlayerAction(play::PlayerAction { action: 8, @@ -8305,9 +8327,22 @@ impl Game<'_> { && let Some(hit) = self.pick() { let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); - if self.remote_break_restricted() { + let air = self.res.facts().is_air(state); + // InputEvent.InteractionKeyMappingTriggered, before the dig goes on: + // a cancel digs nothing and swings unless the mod said not to. + let canceled = if air { + None + } else { + self.native_interaction(0, Hand::Main) + }; + if let Some(swing) = canceled { + if swing { + self.swing_arm(); + self.send(S::Punch(Empty)); + } + } else if self.remote_break_restricted() { // continueDestroyBlock returned false: no swing. - } else if !self.res.facts().is_air(state) { + } else if !air { self.remote_mining(false); self.swing_arm(); self.send(S::Punch(Empty)); @@ -8894,10 +8929,11 @@ impl Game<'_> { // A content mod's menu type: its container's slots, which its // mod's screen places. _ if mod_type.is_some() => Some(BlockEntity::Mod(Box::new( - horizon_client::containers::ModBlockEntity { + horizon_client::containers::ModBlockEntityFields { items: vec![None; mod_type.map_or(0, |t| t.slots)], ..Default::default() - }, + } + .into(), ))), _ => { // Unsupported menu: tell the server we closed it. @@ -9731,12 +9767,18 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_ // for (`handlePickItemFromEntity`), else the block. if let Some(entity) = self.0.pick_entity_hit() { if let Some(id) = entity { + if self.0.native_interaction(2, Hand::Main).is_some() { + return; + } self.0.send(S::PickItemFromEntity(play::PickItemFromEntity { id, include_data: self.0.has_control_down(), })); } } else if let Some(hit) = self.0.pick() { + if self.0.native_interaction(2, Hand::Main).is_some() { + return; + } self.0.send(S::PickItemFromBlock(play::PickItemFromBlock { pos: hit.pos, include_data: self.0.has_control_down(), diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 35d47ead53..ce29e81b1d 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -391,7 +391,12 @@ impl Game<'_> { /// codes stripped, go on the sign the guest may write on unless it is /// waxed (`SignBlockEntity.updateSignText`); a guest gone out of reach /// may no longer write (`playerIsTooFarAwayToEdit`). - pub(super) fn lan_sign_update(&mut self, host: &mut LanHost, conn: u32, p: play::SignUpdate) { + pub(super) fn lan_sign_update( + &mut self, + host: &mut LanHost, + conn: u32, + mut p: play::SignUpdate, + ) { let Some(g) = host.hub.guest(conn) else { return; }; @@ -422,9 +427,51 @@ impl Game<'_> { self.mark_block_entity_updated(key); return; } + if !matches!( + self.world.block_entities.get(&key), + Some(BlockEntity::Sign(sign)) if !sign.waxed + ) { + return; + } + // Everything vanilla checks has passed: the mods hear what is about to + // go on the sign, with the player's state swapped in, and may rewrite + // or refuse it. + if let Some((uuid, name, dimension)) = host + .hub + .guest(conn) + .map(|g| (g.uuid, g.name.clone(), g.dimension.clone())) + { + let mut lines = p.lines.to_vec(); + let mut cancel = false; + self.as_guest_messaged(host, conn, |game| { + game.native_sign_changed( + uuid, + &name, + &dimension, + p.pos, + p.front, + &mut lines, + &mut cancel, + ); + }); + if cancel { + // The sign keeps its text: the editor closes, and the player's + // client, which shows what it sent, gets the sign as it is. + if let Some(play) = host.play.get_mut(&conn) { + play.sign_editing = None; + } + self.mark_block_entity_updated(key); + return; + } + lines.resize(4, String::new()); + for (line, new) in p.lines.iter_mut().zip(lines) { + *line = new; + } + } let Some(play) = host.play.get_mut(&conn) else { return; }; + // A hook may have broken the sign. let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else { return; }; diff --git a/crates/horizon-client/src/game/titles.rs b/crates/horizon-client/src/game/titles.rs index 41a22bb645..bf3ecead5d 100644 --- a/crates/horizon-client/src/game/titles.rs +++ b/crates/horizon-client/src/game/titles.rs @@ -11,16 +11,24 @@ use horizon_client::{ }; impl Game<'_> { - /// `Hud.extractOverlayMessage` and `extractTitle`. - pub(super) fn draw_titles(&self, batch: &mut HudBatch, bar_height: i32) { + /// `Hud.extractOverlayMessage`: the action bar. + pub(super) fn draw_overlay_message(&self, batch: &mut HudBatch, bar_height: i32) { let (w, h) = (batch.width as i32, batch.height as i32); let partial = self.clock.fraction() as f32; let background = self.settings.text_background(0.0); let font = &self.gui.atlas.font; - let mut draws = self + let draws = self .titles .overlay_message(w, h, bar_height, partial, font, background); - draws.extend(self.titles.title(w, h, partial, font, background)); + hud_layout::paint(batch, &self.gui.atlas, &draws); + } + /// `Hud.extractTitle`. + pub(super) fn draw_title(&self, batch: &mut HudBatch) { + let (w, h) = (batch.width as i32, batch.height as i32); + let partial = self.clock.fraction() as f32; + let background = self.settings.text_background(0.0); + let font = &self.gui.atlas.font; + let draws = self.titles.title(w, h, partial, font, background); hud_layout::paint(batch, &self.gui.atlas, &draws); } /// `Hud.extractSavingIndicator`, under the screens: the option, then the diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index 5594dd4673..3534be8905 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -624,7 +624,7 @@ mod tests { } world.set(20, 4, 20, water).unwrap(); world.set(21, 4, 20, flowing).unwrap(); - let at = Vec3::new(20.5, 6.0, 20.5); + let at = Vec3::new(20.5, 4.0, 20.5); let mut tnt = PrimedTnt { id: 0, position: at, @@ -702,10 +702,15 @@ mod tests { .registry .default_state("minecraft:soul_sand") .unwrap(); - let column = assets + // Soul sand makes an up-pushing column (`drag=false`); the block's + // default state is the down-dragging one. + let drag_down = assets .registry .default_state("minecraft:bubble_column") .unwrap(); + let mut up = assets.registry.state(drag_down).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(); } diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 8d35326b29..6b6df106b1 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -632,7 +632,7 @@ impl Game<'_> { }; if m.session.offers.len() > 7 { let max = m.session.offers.len() - 7; - m.scroll = (m.scroll as i32 - d.signum() as i32).clamp(0, max as i32) as usize; + m.scroll = scrolled(m.scroll, max, d); } } /// `MerchantScreen` rendering. @@ -930,10 +930,30 @@ fn drag_scroll(y: f32, offers: usize) -> usize { ((offs * max as f32 + 0.5) as i32).clamp(0, max) as usize } +/// `MerchantScreen.mouseScrolled`: `Mth.clamp((int)(scrollOff - scrollY), 0, max)`, +/// so a fractional wheel step that truncates to zero does not move the list. +fn scrolled(scroll: usize, max: usize, d: f32) -> usize { + ((scroll as f64 - f64::from(d)) as i32).clamp(0, max as i32) as usize +} + #[cfg(test)] mod tests { use super::*; + /// `MerchantScreen.mouseScrolled`: the offset is `(int)(scrollOff - scrollY)` + /// clamped, so the list moves by as many rows as the event's delta and a + /// fractional delta still moves one row (the truncation is after the + /// subtraction). Horizon moved exactly one row per event. + #[test] + fn the_offers_scroll_by_the_wheel_delta() { + assert_eq!(scrolled(3, 5, 1.0), 2, "one notch down"); + assert_eq!(scrolled(3, 5, -1.0), 4, "one notch up"); + assert_eq!(scrolled(3, 5, 0.25), 2, "a fraction still moves a row"); + assert_eq!(scrolled(3, 5, 2.5), 0, "a two-and-a-half notch event"); + assert_eq!(scrolled(0, 5, 1.0), 0, "clamped at the top"); + assert_eq!(scrolled(5, 5, -1.0), 5, "clamped at the bottom"); + } + #[test] fn scroller_takes_presses_only_with_more_than_seven_offers() { let x = SHIFT + 97.0; diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 2daa0f0dc8..4edf44c13b 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -228,7 +228,16 @@ impl Game<'_> { self.combat.explosion_in_water = p.touching_water; // The blast's source names a player owner, for the dragon's kill credit. self.set_blast_by_player(live_owner == Some(Who::Player)); + // WitherSkull.getBlockExplosionResistance: only a blue skull's blast + // caps a destroyable block's resistance at 0.8 (`isDangerous`). + if matches!( + p.kind, + horizon_client::projectiles::ProjectileKind::WitherSkull { dangerous: true } + ) { + self.combat.blast_resistance_cap = Some(0.8); + } let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0)); + self.combat.blast_resistance_cap = None; self.set_blast_by_player(false); self.combat.explosion_owner = None; self.combat.explosion_in_water = false; diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index 04a8fd8e2a..ae5c5e4acd 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -1479,10 +1479,10 @@ mod tests { let mut chunk = Chunk::empty(0, 0); chunk.set(1, MIN_Y + 2, 3, 1); chunk.set(5, MIN_Y + 2, 5, 2); - let mut machine = ModBlockEntity { + let mut machine = ModBlockEntity::new(crate::containers::ModBlockEntityFields { id: "test:machine".into(), - ..ModBlockEntity::default() - }; + ..Default::default() + }); machine.nbt.insert("active".into(), Tag::Byte(1)); machine.nbt.insert("recipe".into(), Tag::Int(7)); let block_entities = diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index c81b0e399f..f61400b7f2 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -16,6 +16,7 @@ use horizon_protocol::{ SetEntityMotion, SetEquipment, SetPassengers, UpdateAttributes, VecDelta, }, }; +use std::sync::Arc; /// The attribute instances of `now` that differ from what `sent` held (an /// instance is never removed: one that disappears is left as it was sent). @@ -203,6 +204,8 @@ impl Riding { /// What a level's clients were last told about one entity (ServerEntity's /// fields), shared by every client that tracks it. struct ServerEntity { + /// What the mods hear of it when a client starts or stops tracking it. + tracked: Arc, /// The registry id of its type (None: a type the clients don't know, /// never paired). kind: Option, @@ -265,6 +268,10 @@ impl ServerEntity { fn new(e: &EntityState, kind: Option, riding: &Riding) -> Self { let (range, interval, track_deltas) = tracking::settings(&e.kind); Self { + tracked: Arc::new(Tracked { + uuid: e.uuid, + kind: Arc::from(e.kind.as_str()), + }), kind, range: i32::from(range), interval, @@ -583,27 +590,61 @@ impl ServerEntity { } } +/// An entity a client tracks, as the mods hear of it. +#[derive(Debug)] +pub struct Tracked { + pub uuid: u128, + /// Its type's registry id (`minecraft:zombie`). + pub kind: Arc, +} + +/// A client started or stopped tracking an entity (`ServerEntity.addPairing` +/// and `removePairing`). +#[derive(Debug)] +pub struct TrackEvent { + pub start: bool, + pub entity: i32, + pub tracked: Arc, +} + /// The entities one client tracks (`TrackedEntity.seenBy`, from the -/// client's side). +/// client's side), and what started and stopped since the game last took +/// the events. #[derive(Default)] pub struct Tracker { - seen: crate::fast_hash::FxHashSet, + seen: crate::fast_hash::FxHashMap>, + events: Vec, } impl Tracker { pub fn is_tracking(&self, id: i32) -> bool { - self.seen.contains(&id) + self.seen.contains_key(&id) } pub fn ids(&self) -> Vec { - self.seen.iter().copied().collect() + self.seen.keys().copied().collect() } + /// Stop tracking everything without a packet: the caller removes them + /// from the client. pub fn clear(&mut self) { - self.seen.clear(); + let events = &mut self.events; + events.extend(self.seen.drain().map(|(entity, tracked)| TrackEvent { + start: false, + entity, + tracked, + })); } /// Stop tracking `id` without a packet: the caller removes it from the /// client, and the next tick pairs it again if it is still in range. pub fn forget(&mut self, id: i32) -> bool { - self.seen.remove(&id) + let Some(tracked) = self.seen.remove(&id) else { + return false; + }; + self.events.push(TrackEvent { + start: false, + entity: id, + tracked, + }); + true } pub fn len(&self) -> usize { self.seen.len() @@ -611,6 +652,10 @@ impl Tracker { pub fn is_empty(&self) -> bool { self.seen.is_empty() } + /// The starts and stops since the last call, in order. + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } } /// Who a level's entities are tracked by this tick: a client's position, @@ -829,7 +874,7 @@ impl Entities { flags.extend(self.probes.iter().zip(pairs.iter_mut()).map(|(p, pairs)| { if p.id == v.entity_id || !sees(v, p, chunks, held) { 0 - } else if v.tracker.seen.contains(&p.id) { + } else if v.tracker.seen.contains_key(&p.id) { kept += 1; SEES | TRACKS } else { @@ -844,7 +889,7 @@ impl Entities { let is_moved = &self.is_moved; flags.extend(self.probes.iter().zip(pairs.iter_mut()).zip(is_moved).map( |((p, pairs), &moved)| { - let tracks = v.tracker.seen.contains(&p.id); + let tracks = v.tracker.seen.contains_key(&p.id); if p.id == v.entity_id || (moved && !sees(v, p, chunks, held)) { 0 } else if tracks { @@ -861,7 +906,7 @@ impl Entities { } else { // One tracking little of it, from what it tracks. flags.resize(self.probes.len(), 0); - for id in &v.tracker.seen { + for id in v.tracker.seen.keys() { if let Some(s) = self.states.get(id).filter(|s| s.seen == now) { flags[s.index] = SEES | TRACKS; kept += 1; @@ -885,13 +930,19 @@ impl Entities { let mut removed = Vec::new(); if kept != v.tracker.seen.len() { let states = &self.states; - v.tracker.seen.retain(|id| match states.get(id) { + let Tracker { seen, events } = &mut *v.tracker; + seen.retain(|id, tracked| match states.get(id) { Some(s) if s.seen == now && flags[s.index] & TRACKS != 0 => true, other => { if let (Some(gone), Some(s)) = (v.gone.as_deref_mut(), other) { gone.push(s.base); } removed.push(*id); + events.push(TrackEvent { + start: false, + entity: *id, + tracked: Arc::clone(tracked), + }); false } }); @@ -938,7 +989,17 @@ impl Entities { packets.iter().map(play::Clientbound::encode).collect() }); v.out.extend(bodies.iter().cloned()); - v.tracker.seen.insert(e.id); + if v.tracker + .seen + .insert(e.id, Arc::clone(&s.tracked)) + .is_none() + { + v.tracker.events.push(TrackEvent { + start: true, + entity: e.id, + tracked: Arc::clone(&s.tracked), + }); + } } } } @@ -1594,6 +1655,47 @@ mod tests { assert!(c.trackers[0].is_tracking(2)); } + #[test] + fn pairing_and_unpairing_are_kept_for_the_game() { + let mut level = Entities::default(); + let mut c = Clients::new(); + let zombie = state(0.0); + c.tick(&mut level, &[&zombie]); + c.tick(&mut level, &[&zombie]); + // Both clients were paired with it, once. + for t in &mut c.trackers { + let events = t.take_events(); + assert_eq!(events.len(), 1, "{events:?}"); + assert!(events[0].start && events[0].entity == 7); + assert_eq!(&*events[0].tracked.kind, "minecraft:zombie"); + assert_eq!(events[0].tracked.uuid, 1); + assert!(t.take_events().is_empty()); + } + // It leaves the level: both clients lose it. + c.tick(&mut level, &[]); + for t in &mut c.trackers { + let events = t.take_events(); + assert!( + matches!(&events[..], [e] if !e.start && e.entity == 7), + "{events:?}" + ); + } + // Back, then forgotten and cleared without a tick. + c.tick(&mut level, &[&zombie]); + c.tick(&mut level, &[&zombie]); + c.trackers[0].take_events(); + c.trackers[1].take_events(); + assert!(c.trackers[0].forget(7)); + c.trackers[1].clear(); + for t in &mut c.trackers { + let events = t.take_events(); + assert!( + matches!(&events[..], [e] if !e.start && e.entity == 7), + "{events:?}" + ); + } + } + #[test] fn a_forgotten_entity_is_paired_again_at_the_next_tick() { // A respawned player: the hub removes it from the other clients and diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index c95ace4ff3..2507590001 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -159,6 +159,14 @@ pub struct Guest { /// (`clientIsFloating`), and the ticks it has (`aboveGroundTickCount`). pub client_is_floating: bool, pub above_ground_ticks: i32, + /// The same for the vehicle the player drives (`clientVehicleIsFloating`, + /// `aboveGroundVehicleTickCount`), with that vehicle's gravity + /// (`getMaximumFlyingTicks(lastVehicle)`) and whether its last accepted + /// move came down onto something (`verticalCollisionBelow`). + pub vehicle_floating: bool, + pub above_ground_vehicle_ticks: i32, + pub vehicle_gravity: f64, + pub vehicle_rests: bool, /// A position move arrived since the client's last ClientTickEnd /// (receivedPositionThisTick): a second one is a protocol error. pub position_this_tick: bool, @@ -371,6 +379,10 @@ impl Guest { known_moves: 0, client_is_floating: false, above_ground_ticks: 0, + vehicle_floating: false, + above_ground_vehicle_ticks: 0, + vehicle_gravity: 0.0, + vehicle_rests: false, position_this_tick: false, anticheat: Default::default(), replay: None, diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index da7ee488d6..4957aabbb1 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -198,9 +198,32 @@ pub enum HostEvent { }, } +/// What one player started and stopped tracking, in order, for the game to +/// tell the mods. +pub struct TrackingBatch { + pub player: u128, + pub name: String, + pub events: Vec, +} + +/// Moves a guest's tracking events into `out`, one batch. +fn take_tracking(g: &mut Guest, out: &mut Vec) { + let events = g.tracker.take_events(); + if !events.is_empty() { + out.push(TrackingBatch { + player: g.uuid, + name: g.name.clone(), + events, + }); + } +} + pub struct Hub { /// Clicks on and closings of native mods' menus, for the game. pub native_menu_events: Vec, + /// The entities players started and stopped tracking, collected by + /// `collect_tracking`, for the game to tell the mods. + pub native_tracking: Vec, pub lan: Lan, pub config: HubConfig, pub encoder: ChunkEncoder, @@ -539,6 +562,7 @@ impl Hub { entity_gone: Vec::new(), mod_commands: (serde_json::Value::Null, None), native_menu_events: Vec::new(), + native_tracking: Vec::new(), teams: Vec::new(), nicknames: std::collections::HashMap::new(), } @@ -1551,6 +1575,14 @@ impl Hub { } } + /// The starts and stops of tracking the guests had since the last call + /// are kept in `native_tracking`. + pub fn collect_tracking(&mut self) { + for g in &mut self.guests { + take_tracking(g, &mut self.native_tracking); + } + } + fn remove_guest(&mut self, i: usize, reason: &str) -> Guest { let mut g = self.guests.remove(i); if let Some(m) = g @@ -1573,6 +1605,9 @@ impl Hub { if !g.placed { return g; } + // `PlayerList.remove`: the entities it tracked stop (`removePairing`). + g.tracker.clear(); + take_tracking(&mut g, &mut self.native_tracking); self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove { uuids: vec![g.uuid], })); @@ -3177,8 +3212,7 @@ impl Hub { /// move after joining, before the spawn chunks are generated) is not /// known to be air, so it never makes the player float. fn no_blocks_around(&self, i: usize, at: [f64; 3], world: &World) -> bool { - let g = &self.guests[i]; - let d = g.dimensions(); + let d = self.guests[i].dimensions(); let half = f64::from(d.width) / 2.0 + 0.0625; let lo = [at[0] - half, at[1] - 0.0625 - 0.55, at[2] - half]; let hi = [ @@ -3186,13 +3220,7 @@ impl Hub { at[1] + f64::from(d.height) + 0.0625, at[2] + half, ]; - let cell = |v: f64| v.floor() as i32; - (cell(lo[0])..=cell(hi[0])).all(|x| { - (cell(lo[2])..=cell(hi[2])).all(|z| { - world.loaded(x, z) - && (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z))) - }) - }) + no_blocks_in(world, lo, hi) } /// `ServerGamePacketListenerImpl.tickPlayer`'s flying check: a player the @@ -3212,6 +3240,20 @@ impl Hub { g.client_is_floating = false; g.above_ground_ticks = 0; } + // The vehicle the player drives, by its own gravity: a boat may hover 160 ticks, a horse 80. + let g = &mut self.guests[i]; + if g.vehicle_floating && g.vehicle.is_some() { + g.above_ground_vehicle_ticks += 1; + if g.above_ground_vehicle_ticks > maximum_flying_ticks(g.vehicle_gravity) { + let line = format!("{} was kicked for floating a vehicle too long!", g.name); + let conn = g.conn; + events.push(HostEvent::Warn(line)); + self.kick(conn, translatable("multiplayer.disconnect.flying", &[])); + } + } else { + g.vehicle_floating = false; + g.above_ground_vehicle_ticks = 0; + } } /// The collision part of handlePlayerPositionChange: whether the move @@ -3703,6 +3745,18 @@ const PLAYER_GRAVITY: f64 = 0.08; /// `ServerGamePacketListenerImpl.getMaximumFlyingTicks`: 80 ticks, more where /// gravity is weaker, none at all (never kicked) without gravity. +/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is air, in +/// loaded chunks (a chunk not loaded yet is not known to be air: nothing floats there). +pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3]) -> bool { + let cell = |v: f64| v.floor() as i32; + (cell(lo[0])..=cell(hi[0])).all(|x| { + (cell(lo[2])..=cell(hi[2])).all(|z| { + world.loaded(x, z) + && (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z))) + }) + }) +} + fn maximum_flying_ticks(gravity: f64) -> i32 { if gravity < f64::from(1.0E-5_f32) { return i32::MAX; diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index bf35e18ef9..58a985c4b7 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -865,7 +865,7 @@ mod tests { /// menu reads and writes only its count, the rest stay saved. #[test] fn mod_menu_shows_only_its_container_slots() { - use crate::containers::{ModBlockEntity, ModContainer}; + use crate::containers::{ModBlockEntityFields, ModContainer}; let mut world = World::new(horizon_world::registry::BlockStateId(0), 0); let container = ModContainer { slots: 5, @@ -880,11 +880,14 @@ mod tests { let k = (0, 64, 0); world.block_entities.insert( k, - BlockEntity::Mod(Box::new(ModBlockEntity { - id: "gtmod:machine".into(), - items, - ..Default::default() - })), + BlockEntity::Mod(Box::new( + ModBlockEntityFields { + id: "gtmod:machine".into(), + items, + ..Default::default() + } + .into(), + )), ); let mut g = guest(); let slots = Slots::Block(k); diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index b8cbffdbcf..3421a683a1 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1073,6 +1073,10 @@ pub struct Extra { /// What a piglin's or hoglin's brain remembers: its avoid target, the /// adults its sensor sees and the activity it is in. pub piglin: piglin::Memories, + /// What the native mods last heard it hold and wear + /// (`LivingEntity.lastEquipmentItems`, empty at first), by + /// `EquipmentSlot` ordinal: see `Mob::equipment_stacks`. + pub equipment_heard: Option; 8]>>, } impl Extra { pub fn set_main_hand(&mut self, item: Option>) { @@ -1991,6 +1995,62 @@ impl Seen { } } impl Mob { + /// What the mob holds and wears, by `EquipmentSlot` ordinal: main hand, + /// off hand, feet, legs, chest, head, body and saddle + /// (`ClientboundSetEquipmentPacket`'s slots). + pub fn equipment_stacks(&self) -> [Option; 8] { + use crate::items::ItemStack; + let e = &self.extra; + // A horse's armour, a llama's carpet, a harness: Horizon keeps the body + // slot's item (6) in `head`. + let body = drops::head_is_body(self.species()); + let wolf = self.species() == Species::Wolf; + let slots = [ + (0usize, &e.main_hand, e.main_hand_stack()), + (1, &e.off_hand, None), + (2, &e.feet, e.armor[3].as_ref()), + (3, &e.legs, e.armor[2].as_ref()), + (4, &e.chest, e.armor[1].as_ref()), + // The body slot's item is no worn armour. + ( + if body { 6 } else { 5 }, + &e.head, + e.armor[0].as_ref().filter(|_| !body), + ), + ]; + let mut out: [Option; 8] = Default::default(); + for (slot, item, worn) in slots { + let Some(item) = item else { + continue; + }; + // The worn armour keeps its stack: a dyed leather piece keeps + // its colour. + out[slot] = Some(worn.cloned().unwrap_or_else(|| ItemStack { + item: item.clone(), + count: 1, + // A wolf's armor shows its cracks. + damage: if slot == 6 && wolf { + e.body_damage.max(0) as u32 + } else { + 0 + }, + extra: if slot == 6 && wolf { + e.body_extra.clone() + } else { + None + }, + })); + } + if e.saddled { + out[7] = Some(e.saddle_stack.clone().unwrap_or(ItemStack { + item: Arc::from("minecraft:saddle"), + count: 1, + damage: 0, + extra: None, + })); + } + out + } pub fn burning_time(&self) -> f64 { let base = finalize::value(self, finalize::Attr::BurningTime); let protection: u32 = gear::ARMOR diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 0c669210db..2f4056299a 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -12,6 +12,7 @@ use super::{ undead, }; use crate::blocks::Fluid; +use crate::breathing::{Breath, apply, breathe}; use horizon_core::{ geometry::Vec3, random::{LegacyRandom, RandomSource}, @@ -1013,18 +1014,26 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } else { m.air = 300; } - } else if m.fluids.eye_in_water && !m.kind.flags.breathes_water { - m.air -= 1; - if m.air == -20 { - m.air = 0; + } else { + // LivingEntity.baseTick's air supply (LivingBreatheEvent): an + // entity with its eyes in water that can't breathe there loses + // air, any other gains it. + let drowns = m.fluids.eye_in_water && !m.kind.flags.breathes_water; + let vanilla = Breath { + can_breathe: !drowns, + consume: 1, + refill: if drowns { 0 } else { 4 }, + }; + let breath = breathe(m.id, &m.kind.id, vanilla); + let (air, drowned) = apply(m.air, 300, breath); + m.air = air; + if drowned { events.push(MobEvent::Drown { mob: m.id, at: [m.position.x, m.position.y, m.position.z], }); m.damage(2.0, Damage::of(DamageKind::Drown)); } - } else if m.air < 300 { - m.air = (m.air + 4).min(300); } if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) { @@ -1184,6 +1193,12 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec) { Species::Guardian if !m.fluids.in_water => "entity.guardian.ambient_land", Species::ElderGuardian if !m.fluids.in_water => "entity.elder_guardian.ambient_land", Species::Axolotl if !m.fluids.in_water => "entity.axolotl.idle_air", + // Villager.getAmbientSound: silent while asleep, the trade sound + // while a player has its menu open. + Species::Villager if super::villager::sleeping(m) => return, + Species::Villager if m.extra.flag2 => "entity.villager.trade", + // WanderingTrader.getAmbientSound: only the trade branch, it never sleeps. + Species::WanderingTrader if m.extra.flag2 => "entity.wandering_trader.trade", Species::Bat if m.extra.flag && m.random.next_int_bound(4).unwrap_or(1) != 0 => return, Species::Allay if m.extra.main_hand.is_some() => "entity.allay.ambient_with_item", // Strider.getAmbientSound: quiet while panicking or tempted. @@ -2899,6 +2914,49 @@ mod same_vehicle_push_tests { use super::*; use crate::mobs::world::with_test_world; + /// What a mob holds and wears, by `EquipmentSlot`: the hand's item, the + /// worn armour's stack (a dyed piece keeps its colour), a horse's + /// armour in the body slot and its saddle, and nothing else. + #[test] + fn a_mobs_equipment_is_listed_by_slot() { + with_test_world(&[], |_, types| { + let mob = |name: &str| { + Mob::new( + 1, + std::sync::Arc::new(types.types[name].clone()), + Vec3::new(0.5, 64.0, 0.5), + 0.0, + ) + }; + let mut zombie = mob("minecraft:zombie"); + assert!(zombie.equipment_stacks().iter().all(Option::is_none)); + zombie + .extra + .set_main_hand(Some(std::sync::Arc::from("minecraft:iron_sword"))); + zombie.extra.feet = Some(std::sync::Arc::from("minecraft:iron_boots")); + let worn = zombie.equipment_stacks(); + assert_eq!( + worn[0].as_ref().unwrap().item.as_ref(), + "minecraft:iron_sword" + ); + assert_eq!( + worn[2].as_ref().unwrap().item.as_ref(), + "minecraft:iron_boots" + ); + assert_eq!(worn.iter().flatten().count(), 2); + let mut horse = mob("minecraft:horse"); + horse.extra.head = Some(std::sync::Arc::from("minecraft:iron_horse_armor")); + horse.set_saddle(Some(crate::items::ItemStack::new("minecraft:saddle", 1))); + let gear = horse.equipment_stacks(); + assert_eq!( + gear[6].as_ref().unwrap().item.as_ref(), + "minecraft:iron_horse_armor" + ); + assert_eq!(gear[7].as_ref().unwrap().item.as_ref(), "minecraft:saddle"); + assert!(gear[5].is_none()); + }); + } + /// `Entity.push(Entity)` returns at once for `isPassengerOfSameVehicle` /// (the same `getRootVehicle`): a camel and the husk on its back, seated /// half a block toward its front and so inside its box, never push each @@ -3153,6 +3211,69 @@ mod endermite_goal_tests { } } +#[cfg(test)] +mod villager_ambient_tests { + use super::*; + use crate::mobs::world::with_test_world; + + /// The ambient sound one tick of `play_ambient` makes, after `setup`. + fn sound( + w: &MobWorld<'_>, + types: &crate::mobs::MobTypes, + kind: &str, + setup: impl FnOnce(&mut Mob), + ) -> Option<&'static str> { + let mut m = Mob::new( + 1, + std::sync::Arc::new(types.types[kind].clone()), + Vec3::new(0.5, 64.0, 0.5), + 0.0, + ); + setup(&mut m); + let mut events = Vec::new(); + play_ambient(&mut m, w, &mut events); + events.into_iter().find_map(|e| match e { + MobEvent::Sound(s, ..) => Some(s), + _ => None, + }) + } + + /// `Villager.getAmbientSound`: silent while asleep, the trade sound while a + /// player has its menu open, ambient otherwise. `WanderingTrader` only has + /// the trade branch. + #[test] + fn a_villager_is_silent_asleep_and_noisy_while_trading() { + with_test_world(&[], |w, types| { + assert_eq!( + sound(w, types, "minecraft:villager", |_| {}), + Some("entity.villager.ambient") + ); + assert_eq!( + sound(w, types, "minecraft:villager", |m| m.extra.flag2 = true), + Some("entity.villager.trade") + ); + assert_eq!( + sound(w, types, "minecraft:villager", |m| m.extra.ints[1] = 1), + None, + "asleep" + ); + assert_eq!( + sound(w, types, "minecraft:villager", |m| { + m.extra.ints[1] = 1; + m.extra.flag2 = true; + }), + None, + "sleeping wins over trading" + ); + let trading = |m: &mut Mob| m.extra.flag2 = true; + assert_eq!( + sound(w, types, "minecraft:wandering_trader", trading), + Some("entity.wandering_trader.trade") + ); + }); + } +} + #[cfg(test)] mod fox_screech_tests { use super::*; diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index c075f6c75c..92215315f6 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -146,7 +146,11 @@ impl Poses { self.skipped.insert(name.to_owned()); } pub fn skips(&self, part: &Part) -> bool { - self.skipped.contains(&part.name) + self.skips_named(&part.name) + } + /// `skips` by the part's name. + pub fn skips_named(&self, name: &str) -> bool { + self.skipped.contains(name) } pub fn has(&self, name: &str) -> bool { self.map.contains_key(name) @@ -169,7 +173,11 @@ impl Poses { } } pub fn is_hidden(&self, part: &Part) -> bool { - self.hidden.contains(&part.name) + self.is_hidden_named(&part.name) + } + /// `is_hidden` by the part's name. + pub fn is_hidden_named(&self, name: &str) -> bool { + self.hidden.contains(name) } } /// The LivingEntityRenderState values setupAnim reads. diff --git a/crates/horizon-client/src/mobs/cem.rs b/crates/horizon-client/src/mobs/cem.rs index 485c012d35..4e9f8c04de 100644 --- a/crates/horizon-client/src/mobs/cem.rs +++ b/crates/horizon-client/src/mobs/cem.rs @@ -1,18 +1,30 @@ //! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/.jem` (or //! `cem//.jem`, and Entity Model Features' `emf/cem/`, which wins) maps to one layer //! (`zombie.jem` to `zombie#main`, `sheep_wool.jem` to `sheep#wool`), its parts to the layer's -//! vanilla parts, and its boxes join or replace theirs. A baby layer without its own model -//! takes the adult's (OptiFine's fallbacks, and Entity Model Features' for horses too). +//! vanilla parts, and its boxes join or replace theirs. A layer without its own model takes the +//! one it falls back to: a baby's adult (OptiFine's fallbacks, and Entity Model Features' for +//! horses too), and the few of [`FALLBACKS`], following their chains (`cat_baby_collar` to +//! `cat_collar` to `cat`). //! //! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files -//! nothing is built and every model stays vanilla's. +//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed. //! //! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's -//! vanilla texture name, and the texture selection then works on that name as on vanilla's. +//! vanilla texture name, and the random texture lookup then gets that name. Variants without +//! rules (`green2.png` beside `green.png`) follow it; a `.properties` with rules for it is not +//! matched yet, because the random-texture picks are made for the vanilla names. That half +//! belongs with random textures. The `.jem` texture paints the layer the file is for, not the overlays drawn on that layer's model (a wolf's +//! collar, a spider's eyes, the cracks on a wolf's armour): those keep their textures over the +//! new geometry. use super::{MobType, Part, Polygon}; use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem}; -use std::{collections::HashMap, fs, path::Path, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + fs, + path::Path, + sync::Arc, +}; /// The CEM models of the enabled packs, by model layer (`zombie#main`). #[derive(Default)] @@ -30,62 +42,85 @@ pub struct CemLayer { pub texture: Option, } +/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two). +const FALLBACK_STEPS: usize = 3; + impl CemModels { /// The `.jem` files under `root` applied to the layers of `types`. pub fn load(root: &Path, types: &HashMap>) -> Self { - let vanilla: HashMap<&str, &Arc> = types - .values() - .flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v))) - .collect(); - Self::over(root, &vanilla) - } - - /// The `.jem` files under `root` applied to the `vanilla` layers. - fn over(root: &Path, vanilla: &HashMap<&str, &Arc>) -> Self { let files = jem_files(root); if files.is_empty() { return Self::default(); } - let mut read = |path: &str| read_small(&root.join(path)); - let mut layers = HashMap::new(); - // A baby layer without its own file takes the adult's: those go after every own file. - let mut fallbacks = Vec::new(); - for (name, path) in &files { - let Some(text) = read_small(&root.join(path)) else { - log::warn!( - "Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes" - ); - continue; - }; - let jem = match parse_jem(path, &text, &mut read) { - Ok(jem) => jem, - Err(e) => { - log::warn!("Horizon: custom entity model {e}"); - continue; - } - }; - for warning in &jem.warnings { - log::warn!("Horizon: custom entity model {warning}"); - } - let jem = Arc::new(jem); + Self::from_files(root, &files, &vanilla_layers(types)) + } + + /// `files` ([`jem_files`]) applied to the `vanilla` layers. + fn from_files( + root: &Path, + files: &BTreeMap>, + vanilla: &HashMap<&str, &Arc>, + ) -> Self { + // Every model name a file can shape: the files' own, and the names that fall back to + // them, a chain of fallbacks deep. + let mut names: BTreeSet = files.keys().cloned().collect(); + let mut frontier: Vec = names.iter().cloned().collect(); + for _ in 0..FALLBACK_STEPS { + frontier = frontier + .iter() + .flat_map(|name| fallback_names(name)) + .filter(|name| names.insert(name.clone())) + .collect(); + } + // The files each layer may take, best first: the first that parses wins. Every name of + // the layer's own file in name order, then the files its names fall back to, nearest + // first. Two names of one layer (`llama_decor` and `trader_llama_decor` are both + // `llama#decor`, `skeleton_horse_armor` and `zombie_horse_armor` both + // `undead_horse_armor#main`) give it the first's, and the second's if the first is + // broken; within one name, EMF's file before OptiFine's. + let mut plan: HashMap> = HashMap::new(); + for name in &names { let key = layer_key(name); - if let Some(part) = vanilla.get(key.as_str()) { - layers.insert(key, CemLayer::build(part, &jem, name)); + if !vanilla.contains_key(key.as_str()) { + continue; } - if !name.contains("_baby") { - fallbacks.extend(baby_names(name).map(|baby| (baby, name, jem.clone()))); + let chain = std::iter::successors(Some(name.clone()), |n| falls_back_to(n)); + let candidates = plan.entry(key).or_default(); + for (depth, target) in chain.take(FALLBACK_STEPS + 1).enumerate() { + if let Some((file, _)) = files.get_key_value(&target) { + candidates.push((depth, file.as_str())); + } } } - for (baby, adult, jem) in fallbacks { - if files.contains_key(&baby) { + // Each file parsed once, when a layer first tries it. + let mut parsed: HashMap<&str, Option> = HashMap::new(); + let mut layers = HashMap::new(); + for (key, mut candidates) in plan { + candidates.sort_by_key(|&(depth, _)| depth); + let found = candidates + .iter() + .flat_map(move |&(_, name)| { + files[name].iter().map(move |path| (name, path.as_str())) + }) + .find(|&(_, path)| { + parsed + .entry(path) + .or_insert_with(|| load_jem(root, path)) + .is_some() + }); + let Some((name, path)) = found else { continue; + }; + let Some(Some(jem)) = parsed.get(path) else { + continue; + }; + let mut layer = CemLayer::build(vanilla[key.as_str()], jem, name); + // A fallback onto another layer (the cat's collar from `cat`) takes the geometry + // only: the layer keeps its own texture. + if layer_suffix(&key) != layer_suffix(&layer_key(name)) { + layer.texture = None; } - let key = layer_key(&baby); - if let Some(part) = vanilla.get(key.as_str()) { - layers - .entry(key) - .or_insert_with(|| CemLayer::build(part, &jem, adult)); - } + layers.insert(key, layer); } Self { layers } } @@ -95,6 +130,47 @@ 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 + .values() + .flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v))) + .collect() +} + /// 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 { @@ -105,26 +181,65 @@ fn read_small(path: &Path) -> Option { fs::read_to_string(path).ok() } -/// Every `.jem` of the CEM folders by model name, `emf/cem` over `optifine/cem`. -fn jem_files(root: &Path) -> HashMap { - let mut out = HashMap::new(); - for base in ["assets/minecraft/optifine/cem", "assets/minecraft/emf/cem"] { +/// 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") { - out.insert(model.to_owned(), format!("{base}/{name}")); - } else if root.join(format!("{base}/{name}/{name}.jem")).is_file() { - out.entry(name.clone()) - .or_insert_with(|| format!("{base}/{name}/{name}.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"), + ("creeper_charge", "creeper#armor"), + ("slime_outer", "slime#outer"), + ("drowned_outer", "drowned#outer"), + ("stray_outer", "stray#outer"), + ("bogged_outer", "bogged#outer"), + ("wither_armor", "wither#armor"), + ("breeze_wind", "breeze#wind"), + // Horizon draws both llamas' decor on one layer, and both undead horses' armour. + ("llama_decor", "llama#decor"), + ("trader_llama_decor", "llama#decor"), + ("cat_collar", "cat#collar"), + ("pig_saddle", "pig#saddle"), + ("strider_saddle", "strider#saddle"), + ("nautilus_saddle", "nautilus#saddle"), + ("tropical_fish_a", "tropical_fish_small#main"), + ("tropical_fish_b", "tropical_fish_large#main"), + ("tropical_fish_pattern_a", "tropical_fish_small#pattern"), + ("tropical_fish_pattern_b", "tropical_fish_large#pattern"), + ("puffer_fish_big", "pufferfish_big#main"), + ("puffer_fish_medium", "pufferfish_medium#main"), + ("puffer_fish_small", "pufferfish_small#main"), + ("skeleton_horse_armor", "undead_horse_armor#main"), + ("zombie_horse_armor", "undead_horse_armor#main"), +]; + /// The model layer a CEM model name reshapes. fn layer_key(name: &str) -> String { // `_baby[_]`: the adult's layer on the baby model. @@ -133,31 +248,49 @@ fn layer_key(name: &str) -> String { let (model, layer) = adult.split_once('#').unwrap_or((&adult, "main")); return format!("{model}_baby#{layer}"); } - match name { - "sheep_wool" => "sheep#wool", - "creeper_charge" => "creeper#armor", - "slime_outer" => "slime#outer", - "drowned_outer" => "drowned#outer", - "stray_outer" => "stray#outer", - "bogged_outer" => "bogged#outer", - "wither_armor" => "wither#armor", - "breeze_wind" => "breeze#wind", - "llama_decor" | "trader_llama_decor" => "llama#decor", - "cat_collar" => "cat#collar", - "pig_saddle" => "pig#saddle", - "strider_saddle" => "strider#saddle", - "nautilus_saddle" => "nautilus#saddle", - "tropical_fish_a" => "tropical_fish_small#main", - "tropical_fish_b" => "tropical_fish_large#main", - "tropical_fish_pattern_a" => "tropical_fish_small#pattern", - "tropical_fish_pattern_b" => "tropical_fish_large#pattern", - "puffer_fish_big" => "pufferfish_big#main", - "puffer_fish_medium" => "pufferfish_medium#main", - "puffer_fish_small" => "pufferfish_small#main", - "zombie_horse_armor" | "skeleton_horse_armor" => "undead_horse_armor#main", - _ => return format!("{name}#main"), + match LAYERS.iter().find(|(n, _)| *n == name) { + Some((_, key)) => (*key).to_owned(), + None => format!("{name}#main"), } - .to_owned() +} + +/// The layer of a model layer key: `wool` of `sheep#wool`. +fn layer_suffix(key: &str) -> &str { + key.split_once('#').map_or(key, |(_, layer)| layer) +} + +/// Models without a file of their own that take another's (OptiFine's and Entity Model +/// Features' tables), besides the babies: (model, the model it takes). +const FALLBACKS: &[(&str, &str)] = &[ + ("cat_collar", "cat"), + ("player_slim", "player"), + ("salmon_large", "salmon"), + ("salmon_small", "salmon"), +]; + +/// The model `name` takes when it has no file: [`FALLBACKS`], or a baby's adult (`sheep_wool` +/// for `sheep_baby_wool`). The baby nautilus has none (OptiFine's and Entity Model Features' +/// tables). +fn falls_back_to(name: &str) -> Option { + if let Some((_, to)) = FALLBACKS.iter().find(|(from, _)| *from == name) { + return Some((*to).to_owned()); + } + (name.contains("_baby") && !name.starts_with("nautilus_baby")) + .then(|| name.replacen("_baby", "", 1)) +} + +/// The model names that take `name`'s file when they have none ([`falls_back_to`] the other +/// way): the babies of an adult and [`FALLBACKS`]. +fn fallback_names(name: &str) -> Vec { + let mut out: Vec = FALLBACKS + .iter() + .filter(|(_, to)| *to == name) + .map(|(from, _)| (*from).to_owned()) + .collect(); + if !name.contains("_baby") { + out.extend(baby_names(name).filter(|baby| !baby.starts_with("nautilus_baby"))); + } + out } /// The baby model names that fall back to the adult `name`: `_baby` before any of its @@ -286,8 +419,13 @@ fn vanilla_part(model: &str, part: &str) -> String { ("cod" | "tropical_fish_a" | "tropical_fish_b", "fin_back" | "fin_top") => { Some("top_fin".into()) } - (_, "fin_right") if model.starts_with("puffer") => Some("right_blue_fin".into()), - (_, "fin_left") if model.starts_with("puffer") => Some("left_blue_fin".into()), + // The big and medium pufferfish have blue fins; the small one has plain `right_fin`s + // (the fin animation in aquatic.rs). + ("puffer_fish_big" | "puffer_fish_medium", "fin_right") => Some("right_blue_fin".into()), + ("puffer_fish_big" | "puffer_fish_medium", "fin_left") => Some("left_blue_fin".into()), + // RabbitModel's hind feet (rabbit.rs). + ("rabbit", "left_foot") => Some("left_hind_foot".into()), + ("rabbit", "right_foot") => Some("right_hind_foot".into()), (_, "fin_right") => Some("right_fin".into()), (_, "fin_left") => Some("left_fin".into()), (_, "fin_bottom") => Some("bottom_fin".into()), @@ -325,7 +463,7 @@ impl CemLayer { let mut entries = Vec::new(); for p in &jem.parts { let target = vanilla_part(&model, &p.part); - let target = if find(&out, &target) { + let target = if super::render::find_part(&out, &target).is_some() { target } else { p.part.clone() @@ -369,10 +507,6 @@ fn atlas_name(path: &str) -> Option { Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned()) } -fn find(part: &Part, name: &str) -> bool { - part.name == name || part.children.iter().any(|c| find(c, name)) -} - /// 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 { @@ -468,6 +602,9 @@ mod tests { ("strider", "hair_left_top", "left_top_bristle"), ("puffer_fish_big", "spikes_front_top", "top_front_fin"), ("squid", "tentacle0", "tentacle0"), + ("puffer_fish_medium", "fin_left", "left_blue_fin"), + ("puffer_fish_small", "fin_right", "right_fin"), + ("rabbit", "left_foot", "left_hind_foot"), ] { assert_eq!(vanilla_part(model, part), vanilla, "{model} {part}"); } @@ -510,7 +647,7 @@ mod tests { ] .into_iter() .collect(); - let models = CemModels::over(&root, &vanilla); + let models = CemModels::from_files(&root, &jem_files(&root), &vanilla); fs::remove_dir_all(&root).unwrap(); let adult = models.layer("zombie#main").unwrap(); @@ -530,4 +667,511 @@ mod tests { assert_eq!((leg.polygons.len(), leg.children.len()), (0, 1)); assert!(models.layer("sheep#main").is_none()); } + + const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#; + + /// A `.jem` with one entry on `part`, attached or replacing it. + fn jem(part: &str, attach: bool) -> String { + format!(r#"{{"models": [{{"part": "{part}", "attach": {attach}, {BOX}}}]}}"#) + } + + /// A fresh pack folder for the test `test` holding `files` (paths under `assets/minecraft/`). + fn pack(test: &str, files: &[(&str, &str)]) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!("horizon-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 + } + + /// The models of the pack `files` over the `layers`, the same tree for each. + fn models(test: &str, files: &[(&str, &str)], layers: &[&str], tree: &Part) -> CemModels { + let root = pack(test, files); + let tree = Arc::new(tree.clone()); + let vanilla: HashMap<&str, &Arc> = layers.iter().map(|k| (*k, &tree)).collect(); + let models = CemModels::from_files(&root, &jem_files(&root), &vanilla); + fs::remove_dir_all(&root).unwrap(); + models + } + + /// Whether the layer `key`'s part `name` was replaced (lost its own face) or had a box + /// attached (kept it); None without the layer. Either way the box joins as a child. + fn attached(models: &CemModels, key: &str, name: &str) -> Option { + let part = get(&models.layer(key)?.model, name); + assert_eq!(part.children.len(), 1, "{key} {name}: one custom box"); + 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)); + let files = [ + ("optifine/cem/zombie.jem", adult.as_str()), + ("optifine/cem/zombie_baby.jem", baby.as_str()), + ]; + let tree = plane("root", vec![plane("head", vec![])]); + let models = models("baby", &files, &["zombie#main", "zombie_baby#main"], &tree); + assert_eq!(attached(&models, "zombie#main", "head"), Some(false)); + assert_eq!(attached(&models, "zombie_baby#main", "head"), Some(true)); + } + + #[test] + fn two_names_of_one_layer_give_it_the_first_by_name() { + let (llama, trader) = (jem("body", false), jem("body", true)); + let files = [ + ("optifine/cem/trader_llama_decor.jem", trader.as_str()), + ("optifine/cem/llama_decor.jem", llama.as_str()), + ]; + let tree = plane("root", vec![plane("body", vec![])]); + // Written trader first, `llama_decor` still shapes the layer, and the baby's (both + // fall back to the adult files). + let models = models("decor", &files, &["llama#decor", "llama_baby#decor"], &tree); + assert_eq!(attached(&models, "llama#decor", "body"), Some(false)); + assert_eq!(attached(&models, "llama_baby#decor", "body"), Some(false)); + } + + #[test] + fn other_models_fall_back_but_the_baby_nautilus_does_not() { + let (replace, attach) = (jem("body", false), jem("body", true)); + let cat = format!( + r#"{{"texture": "textures/entity/cat/custom.png", "models": [ + {{"part": "body", {BOX}}}]}}"# + ); + let files = [ + ("optifine/cem/salmon.jem", replace.as_str()), + ("optifine/cem/salmon_large.jem", attach.as_str()), + ("optifine/cem/nautilus.jem", replace.as_str()), + ("optifine/cem/cat.jem", cat.as_str()), + ]; + let tree = plane("root", vec![plane("body", vec![])]); + let models = models( + "fallbacks", + &files, + &[ + "salmon#main", + "salmon_small#main", + "salmon_large#main", + "nautilus#main", + "nautilus_baby#main", + "cat#main", + "cat#collar", + "cat_baby#collar", + ], + &tree, + ); + // The small salmon has no file: salmon.jem's. The large one has its own. + assert_eq!(attached(&models, "salmon_small#main", "body"), Some(false)); + assert_eq!(attached(&models, "salmon_large#main", "body"), Some(true)); + assert_eq!(attached(&models, "nautilus#main", "body"), Some(false)); + assert!(models.layer("nautilus_baby#main").is_none()); + // The collar takes the cat's geometry, but its own collar texture stays. + let texture = |key: &str| models.layer(key).unwrap().texture.clone(); + assert_eq!(texture("cat#main").as_deref(), Some("entity/cat/custom")); + assert_eq!(attached(&models, "cat#collar", "body"), Some(false)); + assert_eq!(texture("cat#collar"), None); + // So does the baby's, two fallbacks down: `cat_baby_collar`, `cat_collar`, `cat`. + assert_eq!(attached(&models, "cat_baby#collar", "body"), Some(false)); + assert_eq!(texture("cat_baby#collar"), None); + } + + #[test] + fn a_broken_file_gives_way_to_the_next() { + let (replace, attach) = (jem("body", false), jem("body", true)); + let files = [ + // The first name of the layer is broken: the second shapes it. + ("optifine/cem/llama_decor.jem", "{"), + ("optifine/cem/trader_llama_decor.jem", attach.as_str()), + // EMF's file is broken: OptiFine's stands. + ("emf/cem/cow.jem", "{"), + ("optifine/cem/cow.jem", replace.as_str()), + // The baby's own file is broken: the adult's falls back onto it. + ("optifine/cem/zombie_baby.jem", "{"), + ("optifine/cem/zombie.jem", attach.as_str()), + ]; + let tree = plane("root", vec![plane("body", vec![])]); + let models = models( + "broken", + &files, + &["llama#decor", "cow#main", "zombie_baby#main"], + &tree, + ); + assert_eq!(attached(&models, "llama#decor", "body"), Some(true)); + assert_eq!(attached(&models, "cow#main", "body"), Some(false)); + assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true)); + } + + #[test] + fn an_unknown_part_is_skipped_and_the_rest_applies() { + let file = + format!(r#"{{"models": [{{"part": "wings", {BOX}}}, {{"part": "head", {BOX}}}]}}"#); + let tree = plane("root", vec![plane("head", vec![])]); + let models = models( + "unknown", + &[("optifine/cem/zombie.jem", file.as_str())], + &["zombie#main"], + &tree, + ); + assert_eq!(attached(&models, "zombie#main", "head"), Some(false)); + fn custom(p: &Part) -> usize { + usize::from(p.name.starts_with("cem#")) + p.children.iter().map(custom).sum::() + } + assert_eq!(custom(&models.layer("zombie#main").unwrap().model), 1); + } + + /// A mob type `id` of `species` whose body and `layers` are all `tree`, with the vanilla + /// `texture`. + fn mob_kind( + id: &str, + species: super::super::Species, + anim: super::super::Anim, + texture: &str, + layers: &[&str], + tree: &Part, + ) -> Arc { + let mut kind = (*super::super::sensor_scan_tests::kind()).clone(); + kind.id = id.into(); + kind.texture = texture.into(); + kind.species = species; + kind.anim = anim; + kind.model = Arc::new(tree.clone()); + kind.layers = layers + .iter() + .map(|k| ((*k).to_owned(), Arc::new(tree.clone()))) + .collect(); + Arc::new(kind) + } + + /// How many vertices `render` draws of `mob` in each of `sprites`, laid far apart in the + /// atlas: the sprite a vertex's UV falls in says what it drew with. `sprites[0]`, the + /// `.jem`'s texture, is in the atlas only with `custom`. + fn sprite_counts( + mob: &super::super::Mob, + models: &Arc, + sprites: &[&str], + custom: bool, + ) -> Vec { + let data = super::super::MobData { + states: Vec::new(), + placements: Default::default(), + heightmaps: Default::default(), + animations: Default::default(), + biome_tags: Default::default(), + entity_tags: Default::default(), + villager_hats: Default::default(), + cem: std::sync::RwLock::new(models.clone()), + random_textures: Default::default(), + pois: Default::default(), + species_placements: Default::default(), + }; + // Sprite i spans u and v from 0.2 i to 0.2 i + 0.1. + let atlas = |name: &str| -> Option<[f32; 4]> { + let i = sprites.iter().position(|s| *s == name)?; + let lo = i as f32 * 0.2; + (custom || i != 0).then_some([lo, lo, lo + 0.1, lo + 0.1]) + }; + let mut vertices = Vec::new(); + super::super::render::render( + mob, + 0.0, + glam::DVec3::ZERO, + &atlas, + 0xf0f0, + &data, + &mut vertices, + ); + let mut counts = vec![0; sprites.len()]; + for v in &vertices { + let u = v.uv[0]; + let i = ((u + 0.05) / 0.2).floor() as usize; + assert!( + i < sprites.len() && u - i as f32 * 0.2 <= 0.1 + 1e-4, + "a UV between the sprites: {u}" + ); + counts[i] += 1; + } + counts + } + + /// A spider whose `.jem` replaces its body: the body draws with the `.jem`'s texture, the + /// eyes (an overlay on the body's model) with theirs on the new geometry, and a texture the + /// atlas lacks leaves the vanilla one. A sheep's wool, a layer with a model of its own, + /// takes its `.jem`'s texture. + #[test] + fn a_jem_texture_paints_its_layer_and_leaves_the_overlays() { + use super::super::{Anim, Mob, Species}; + let tree = |part: &str| Part { + name: "root".into(), + pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + polygons: Vec::new(), + children: vec![plane(part, vec![])], + }; + let textured = |texture: &str| { + format!( + r#"{{"texture": "textures/{texture}.png", "models": [ + {{"part": "body", {BOX}}}]}}"# + ) + }; + let spider = textured("entity/spider/custom"); + let cem = Arc::new(models( + "render", + &[("optifine/cem/spider.jem", spider.as_str())], + &["spider#main"], + &tree("body1"), + )); + let mob = Mob::new( + 1, + mob_kind( + "minecraft:spider", + Species::Spider, + Anim::Spider, + "entity/spider/spider", + &["spider#main"], + &tree("body1"), + ), + horizon_core::geometry::Vec3::ZERO, + 0.0, + ); + let sprites = [ + "entity/spider/custom", + "entity/spider/spider_eyes", + "entity/spider/spider", + ]; + // The custom box's six faces, four corners each, for the body and again for the eyes; + // the replaced body part's own face is gone. + assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 24, 0]); + assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24]); + + let wool = textured("entity/sheep/custom_wool"); + let cem = Arc::new(models( + "render-sheep", + &[("optifine/cem/sheep_wool.jem", wool.as_str())], + &["sheep#main", "sheep#wool"], + &tree("body"), + )); + let mob = Mob::new( + 1, + mob_kind( + "minecraft:sheep", + Species::Sheep, + Anim::Sheep, + "entity/sheep/sheep", + &["sheep#main", "sheep#wool"], + &tree("body"), + ), + horizon_core::geometry::Vec3::ZERO, + 0.0, + ); + let sprites = [ + "entity/sheep/custom_wool", + "entity/sheep/sheep_wool", + "entity/sheep/sheep", + ]; + // The wool's custom box in the `.jem`'s texture; the vanilla body's one face. + assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 4]); + assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 4]); + } + + /// A wolf armour `.jem`'s texture paints the armour, the first layer on its model, and + /// not the cracks drawn over it on the same model. + #[test] + fn armour_cracks_keep_their_texture() { + use super::super::{Anim, Mob, Species, WOLF_ARMOR_DURABILITY}; + let tree = Part { + name: "root".into(), + pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + polygons: Vec::new(), + children: vec![plane("body", vec![])], + }; + let file = format!( + r#"{{"texture": "textures/entity/wolf/custom_armor.png", "models": [ + {{"part": "body", {BOX}}}]}}"# + ); + let layers = ["wolf#main", "wolf_armor#main"]; + let cem = Arc::new(models( + "cracks", + &[("optifine/cem/wolf_armor.jem", file.as_str())], + &layers, + &tree, + )); + let mut mob = Mob::new( + 1, + mob_kind( + "minecraft:wolf", + Species::Wolf, + Anim::Wolf, + "entity/wolf/wolf", + &layers, + &tree, + ), + horizon_core::geometry::Vec3::ZERO, + 0.0, + ); + mob.extra.tame = true; + mob.extra.head = Some("minecraft:wolf_armor".into()); + // Worn through: the high cracks. + mob.extra.body_damage = WOLF_ARMOR_DURABILITY; + let sprites = [ + "entity/wolf/custom_armor", + "entity/equipment/wolf_body/armadillo_scute", + "entity/wolf/wolf_armor_crackiness_high", + "entity/wolf/wolf_collar", + ]; + // The armour's custom box in the `.jem`'s texture, the cracks' over the same box in + // theirs, and the collar on the vanilla body (whose own texture isn't in the atlas). + assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 24, 4]); + assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24, 4]); + } + + /// Every layer of [`LAYERS`] and [`FALLBACKS`] is in Horizon's own models, every baby layer + /// there is one a baby model name maps to, and so are the parts a sample of part names is + /// renamed to. + #[test] + #[ignore = "requires prepared entity models in HORIZON_TEST_RESOURCES"] + fn names_reach_horizons_models() { + let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"); + let types: HashMap> = super::super::MobTypes::load(Path::new(&root)) + .unwrap() + .types + .into_iter() + .map(|(k, v)| (k, Arc::new(v))) + .collect(); + let vanilla = vanilla_layers(&types); + let mut missing = Vec::new(); + for (name, key) in LAYERS { + if !vanilla.contains_key(key) { + missing.push(format!("{name}: no layer {key}")); + } + } + for name in FALLBACKS.iter().flat_map(|(name, takes)| [*name, *takes]) { + let key = layer_key(name); + if !vanilla.contains_key(key.as_str()) { + missing.push(format!("{name}: no layer {key}")); + } + } + // `sheep_baby#wool` is `sheep_baby_wool`'s: the baby name of a name of `sheep#wool`. + for key in vanilla.keys() { + let Some((model, layer)) = key.split_once('#') else { + continue; + }; + let Some(adult) = model.strip_suffix("_baby") else { + continue; + }; + let adult_key = format!("{adult}#{layer}"); + let reached = LAYERS + .iter() + .map(|(name, _)| *name) + .chain([adult]) + .filter(|name| layer_key(name) == adult_key) + .flat_map(baby_names) + .any(|baby| layer_key(&baby) == *key); + if !reached { + missing.push(format!("{key}: no baby model name maps to it")); + } + } + // A sample of the renamed parts. + for (model, part) in [ + ("zombie", "headwear"), + ("cow", "leg1"), + ("cow", "leg4"), + ("camel", "back_left_leg"), + ("camel", "front_right_leg"), + ("spider", "leg1"), + ("spider", "leg8"), + ("spider", "neck"), + ("spider", "body"), + ("villager", "headwear2"), + ("villager", "bodywear"), + ("witch", "headwear2"), + ("witch", "bodywear"), + ("chicken", "bill"), + ("chicken", "chin"), + ("llama", "chest_right"), + ("llama", "chest_left"), + ("turtle", "body2"), + ("ravager", "jaw"), + ("bee", "body"), + ("bee", "torso"), + ("warden", "body"), + ("warden", "torso"), + ("snow_golem", "body"), + ("snow_golem", "body_bottom"), + ("snow_golem", "right_hand"), + ("snow_golem", "left_hand"), + ("slime", "body"), + ("magma_cube", "core"), + ("magma_cube", "segment1"), + ("blaze", "stick1"), + ("ghast", "tentacle1"), + ("squid", "tentacle1"), + ("silverfish", "body1"), + ("silverfish", "wing1"), + ("endermite", "body1"), + ("guardian", "spine1"), + ("guardian", "tail1"), + ("wither", "body1"), + ("wither", "head1"), + ("phantom", "left_wing"), + ("phantom", "tail2"), + ("cat", "tail"), + ("ocelot", "tail"), + ("bat", "outer_right_wing"), + ("cod", "fin_back"), + ("cod", "fin_right"), + ("cod", "tail"), + ("tropical_fish_b", "fin_top"), + ("tropical_fish_b", "fin_bottom"), + ("puffer_fish_big", "fin_right"), + ("puffer_fish_medium", "fin_left"), + ("puffer_fish_small", "fin_right"), + ("puffer_fish_big", "spikes_front_top"), + ("rabbit", "left_foot"), + ("rabbit", "right_foot"), + ("sniffer", "middle_left_leg"), + ("strider", "hair_left_top"), + ("horse", "neck"), + ("horse", "mouth"), + ] { + let key = layer_key(model); + let target = vanilla_part(model, part); + let found = vanilla + .get(key.as_str()) + .is_some_and(|tree| super::super::render::find_part(tree, &target).is_some()); + if !found { + missing.push(format!("{model} {part}: no part {target} in {key}")); + } + } + assert!(missing.is_empty(), "{missing:#?}"); + } } diff --git a/crates/horizon-client/src/mobs/cem_anim.rs b/crates/horizon-client/src/mobs/cem_anim.rs index 3542e8babe..10c7d9fe53 100644 --- a/crates/horizon-client/src/mobs/cem_anim.rs +++ b/crates/horizon-client/src/mobs/cem_anim.rs @@ -3,14 +3,19 @@ //! reads the vanilla pose and `head.rx = ...` overrides it. //! //! One slot array holds everything an expression reads: the entity's parameters -//! (`limb_swing`, `is_child`...), then 11 slots per model part an animation names (`tx ty tz rx -//! ry rz sx sy sz visible visible_boxes`, as `ModelPart` keeps them: pixels, radians, on -//! vanilla's axes), then the entity's `var.*` / `varb.*`, which persist from frame to frame, -//! then `render.*`, which nothing reads back yet. +//! (`limb_swing`, `is_child`...) at fixed slots, then, in the order the expressions first name +//! them, 11 slots per model part (`tx ty tz rx ry rz sx sy sz visible visible_boxes`, as +//! `ModelPart` keeps them: pixels, radians, on vanilla's axes) and one per variable: the +//! entity's `var.*` / `varb.*`, which persist from frame to frame, the `global_var.*` / +//! `global_varb.*` every entity shares, and `render.*`, which nothing reads back yet. use super::{Mob, Part, anim::Poses, anim::State}; use horizon_optifine::cem::expr::{Expr, compile}; -use std::{cell::RefCell, collections::HashMap, sync::Mutex}; +use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + sync::{Mutex, MutexGuard, PoisonError}, +}; /// The model variables of a part, in slot order. const PART_VARS: [&str; 11] = [ @@ -67,7 +72,7 @@ params! { } /// What every entity of a frame shares, set once per frame by the game. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug)] pub struct Frame { /// The world's game time and day time in ticks, with the partial tick. pub time: f64, @@ -83,51 +88,147 @@ pub struct Frame { pub third_person_reversed: bool, } -static FRAME: Mutex = Mutex::new(Frame { - time: 0.0, - day_time: 0.0, - frame_time: 0.0, - frame_counter: 0, - dimension: 0.0, - player: [0.0; 3], - player_rot: [0.0; 2], - paused: false, - first_person: true, - third_person_reversed: false, -}); +impl Frame { + /// Before the first frame: in the overworld at the origin, seen in first person. + pub const DEFAULT: Self = Self { + time: 0.0, + day_time: 0.0, + frame_time: 0.0, + frame_counter: 0, + dimension: 0.0, + player: [0.0; 3], + player_rot: [0.0; 2], + paused: false, + first_person: true, + third_person_reversed: false, + }; +} + +impl Default for Frame { + fn default() -> Self { + Self::DEFAULT + } +} + +static FRAME: Mutex = Mutex::new(Frame::DEFAULT); + +/// `m`'s value, even if a thread panicked holding it. +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} /// The frame the next animations see. Its `frame_time` and `frame_counter` come from the /// calls themselves: the time since the previous one, and how many there were. pub fn set_frame(mut frame: Frame) { static LAST: Mutex> = Mutex::new(None); let now = std::time::Instant::now(); - let mut last = LAST - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut last = lock(&LAST); frame.frame_time = last.map_or(0.0, |t| (now - t).as_secs_f32()); *last = Some(now); - let mut current = FRAME - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut current = lock(&FRAME); frame.frame_counter = current.frame_counter.wrapping_add(1); *current = frame; } +fn frame() -> Frame { + *lock(&FRAME) +} + +thread_local!(static IN_GUI: Cell = const { Cell::new(false) }); + +/// Runs `draw` as a screen's preview of an entity (the inventory's player, a mount's box): +/// its animations see `is_in_gui = 1` and store no variables, unless the world didn't draw +/// the entity this frame or the last. Screens draw before the world in a frame, so a +/// preview runs under the previous frame's counter and time. `set_frame` must not be +/// called inside it. +pub fn in_gui(draw: impl FnOnce() -> R) -> R { + /// Puts the previous value back, even if `draw` panics. + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + IN_GUI.set(self.0); + } + } + let _restore = Restore(IN_GUI.replace(true)); + draw() +} + /// A part an animation names: its `Poses` name and its first slot. struct Bound { name: String, base: u32, } +/// One compiled `target = expression`. +struct Step { + slot: u32, + expr: Expr, +} + /// The compiled `animations` of one model layer. pub struct Animation { - steps: Vec<(u32, Expr)>, + steps: Vec, parts: Vec, - /// `var.*` and `varb.*` slots, kept per entity. - vars: std::ops::Range, + /// The `var.*` and `varb.*` slots, kept per entity. + vars: Vec, + /// The `global_var.*` and `global_varb.*` slots and their index in `GLOBALS`. + globals: Vec<(u32, u32)>, slots: usize, - /// Each entity's variables, by entity id. - saved: Mutex>>, + saved: Mutex, +} + +/// Each entity's variables, by entity id. +#[derive(Default)] +struct Saved { + entities: HashMap, + /// The frame of the last eviction, so that a full table is scanned once a frame at most. + evicted: Option, +} + +/// One entity's variables. +struct Vars { + /// The frame counter of the last run. + frame: u32, + /// A run stored `now` in that frame. + stored: bool, + /// The last frame a draw in the world stored: a preview stores only when the world + /// didn't draw the entity this frame or the last (the inventory's own player in first + /// person, whom the world never draws). + world: Option, + /// The variables when that frame began: a model drawn again in the same frame (the + /// illusioner's copies, a preview, the glowing pass) starts from them, not from the + /// first draw's result. + start: Box<[f32]>, + /// The variables the frame's storing draw left. + now: Box<[f32]>, + /// The shared variables (`global_var.*`) when that frame began for this entity, read by + /// every run of the frame like `start`. + globals_start: Box<[f32]>, +} + +/// The variables every entity shares (`global_var.*`): their names, then their values. +struct Globals { + names: Vec, + values: Vec, +} + +static GLOBALS: Mutex = Mutex::new(Globals { + names: Vec::new(), + values: Vec::new(), +}); + +/// The index of the global variable `name`, registered on first sight. +fn global(name: &str) -> u32 { + let mut g = lock(&GLOBALS); + let i = match g.names.iter().position(|n| n == name) { + Some(i) => i, + None => { + g.names.push(name.to_owned()); + g.values.push(0.0); + g.names.len() - 1 + } + }; + i as u32 } /// How an animation finds the parts it names. @@ -166,6 +267,7 @@ impl Names<'_> { } } +/// The first part of the tree, depth first, that passes `test`. fn find<'a>(part: &'a Part, test: &dyn Fn(&Part) -> bool) -> Option<&'a Part> { if test(part) { return Some(part); @@ -173,61 +275,60 @@ fn find<'a>(part: &'a Part, test: &dyn Fn(&Part) -> bool) -> Option<&'a Part> { part.children.iter().find_map(|c| find(c, test)) } -/// Where a name an animation uses lives. -#[derive(Clone, Copy)] -enum Key { - Param(u32), - Part(u32, u32), - Var(u32), - Render(u32), +/// Hands out slots as the expressions name things: the parameters are fixed at +/// `0..PARAMS.len()`, then each new part takes the next 11, each variable the next one. +struct Layout<'a, 'n> { + names: &'a Names<'n>, + next: u32, + parts: Vec, + vars: Vec<(String, u32)>, + globals: Vec<(String, u32)>, + render: Vec<(String, u32)>, } -/// The parts and variables a model's animations name, in the order they first appear. -#[derive(Default)] -struct Layout { - parts: Vec, - vars: Vec, - render: Vec, +/// The slot of `name` in `list`, the next free one on first sight. +fn named(list: &mut Vec<(String, u32)>, next: &mut u32, name: &str) -> u32 { + if let Some((_, slot)) = list.iter().find(|(n, _)| n == name) { + return *slot; + } + let slot = *next; + *next += 1; + list.push((name.to_owned(), slot)); + slot } -fn index(list: &mut Vec, name: &str) -> u32 { - let i = list.iter().position(|v| v == name).unwrap_or_else(|| { - list.push(name.to_owned()); - list.len() - 1 - }); - i as u32 -} - -impl Layout { - fn key(&mut self, name: &str, names: &Names, this: &str, part: &str) -> Option { +impl Layout<'_, '_> { + fn slot(&mut self, name: &str, this: &str, part: &str) -> Option { if let Some(i) = PARAMS.iter().position(|(n, _)| *n == name) { - return Some(Key::Param(i as u32)); + return Some(i as u32); } let (model, var) = name.rsplit_once('.')?; Some(match model { - "var" | "varb" | "global_var" | "global_varb" => Key::Var(index(&mut self.vars, name)), - "render" => Key::Render(index(&mut self.render, name)), + "var" | "varb" => named(&mut self.vars, &mut self.next, name), + "global_var" | "global_varb" => named(&mut self.globals, &mut self.next, name), + "render" => named(&mut self.render, &mut self.next, name), _ => { let var = PART_VARS.iter().position(|v| *v == var)? as u32; - let found = names.find(model, this, part)?; - Key::Part(index(&mut self.parts, &found), var) + let found = self.names.find(model, this, part)?; + let known = self.parts.iter().find(|b| b.name == found).map(|b| b.base); + let base = match known { + Some(base) => base, + None => { + let base = self.next; + self.next += PART_VARS.len() as u32; + self.parts.push(Bound { name: found, base }); + base + } + }; + base + var } }) } - - fn slot(&self, key: Key) -> u32 { - let parts = PARAMS.len() as u32; - let vars = parts + (self.parts.len() * PART_VARS.len()) as u32; - let render = vars + self.vars.len() as u32; - match key { - Key::Param(i) => i, - Key::Part(i, var) => parts + i * PART_VARS.len() as u32 + var, - Key::Var(i) => vars + i, - Key::Render(i) => render + i, - } - } } +/// The most expressions one model's animations may have; the rest are left out. +const MAX_EXPRESSIONS: usize = 4096; + /// Builds an animation from `entries`: each model entry's `this` and `part` names and its /// (target, expression) pairs. An expression that doesn't compile is left out, with a warning. pub(super) fn compile_animation( @@ -235,29 +336,33 @@ pub(super) fn compile_animation( names: &Names, entries: &[(String, String, &[(String, String)])], ) -> Option { - // First the names every entry uses, which fixes the slots; then the expressions. - let mut layout = Layout::default(); - for (this, part, pairs) in entries { - for (target, src) in pairs.iter() { - let mut note = |name: &str| layout.key(name, names, this, part).map(|_| 0); - note(target); - let _ = compile(src, &mut note); - } - } + let mut layout = Layout { + names, + next: PARAMS.len() as u32, + parts: Vec::new(), + vars: Vec::new(), + globals: Vec::new(), + render: Vec::new(), + }; let mut steps = Vec::new(); - for (this, part, pairs) in entries { + let mut count = 0; + 'entries: for (this, part, pairs) in entries { for (target, src) in pairs.iter() { - // Every name is in the layout already: the slots don't move. - let mut resolve = |name: &str| { - let key = layout.key(name, names, this, part)?; - Some(layout.slot(key)) - }; - let Some(slot) = resolve(target) else { + count += 1; + if count > MAX_EXPRESSIONS { + log::warn!( + "Horizon: custom entity model {file}: more than {MAX_EXPRESSIONS} animation \ + expressions, the rest are left out" + ); + break 'entries; + } + let Some(slot) = layout.slot(target, this, part) else { log::warn!("Horizon: custom entity model {file}: unknown target {target}"); continue; }; - match compile(src, &mut resolve) { - Ok(expr) => steps.push((slot, expr)), + let compiled = compile(src, &mut |name: &str| layout.slot(name, this, part)); + match compiled { + Ok(expr) => steps.push(Step { slot, expr }), Err(e) => log::warn!("Horizon: custom entity model {file}: {target}: {e}"), } } @@ -265,73 +370,144 @@ pub(super) fn compile_animation( if steps.is_empty() { return None; } - let parts = layout - .parts - .iter() - .enumerate() - .map(|(i, name)| Bound { - name: name.clone(), - base: layout.slot(Key::Part(i as u32, 0)), - }) - .collect(); - let vars = layout.slot(Key::Var(0))..layout.slot(Key::Render(0)); Some(Animation { steps, - parts, - vars, - slots: layout.slot(Key::Render(layout.render.len() as u32)) as usize, + slots: layout.next as usize, + parts: layout.parts, + vars: layout.vars.into_iter().map(|(_, slot)| slot).collect(), + globals: layout + .globals + .into_iter() + .map(|(name, slot)| (slot, global(&name))) + .collect(), saved: Mutex::default(), }) } thread_local!(static SLOTS: RefCell> = const { RefCell::new(Vec::new()) }); -/// The most entities whose variables an animation keeps; past it they start over. +/// Past this many entities, an animation forgets those it hasn't drawn for `STALE` frames, +/// and starts over at twice as many. const SAVED: usize = 4096; +const STALE: u32 = 600; + +fn flag(b: bool) -> f32 { + f32::from(b) +} impl Animation { /// Runs the animation for `m` over `poses`, which the vanilla animation has set. pub fn run(&self, m: &Mob, s: &State, poses: &mut Poses) { - self.run_with(m.id, |slots| params(m, s, slots), poses); + self.run_in(&frame(), m, s, poses); } - /// `run` for the entity `id` whose parameters `fill` writes. - fn run_with(&self, id: u32, fill: impl FnOnce(&mut [f32]), poses: &mut Poses) { + /// `run` in `frame`. + fn run_in(&self, frame: &Frame, m: &Mob, s: &State, poses: &mut Poses) { + self.run_with( + m.id, + frame.frame_counter, + |slots| params(m, s, frame, slots), + poses, + ); + } + + /// `run` for the entity `id` in the frame `counter`, whose parameters `fill` writes. + fn run_with(&self, id: u32, counter: u32, fill: impl FnOnce(&mut [f32]), poses: &mut Poses) { + let preview = IN_GUI.get(); SLOTS.with_borrow_mut(|slots| { slots.clear(); slots.resize(self.slots, 0.0); fill(slots); + // What vanilla left: the pose, `visible` and `skipDraw`. for b in &self.parts { let base = b.base as usize; - let pose = *poses.get(&b.name); - slots[base..base + 9].copy_from_slice(&pose); - slots[base + 9] = 1.0; - slots[base + 10] = 1.0; + slots[base..base + 9].copy_from_slice(poses.get(&b.name)); + slots[base + 9] = flag(!poses.is_hidden_named(&b.name)); + slots[base + 10] = flag(!poses.skips_named(&b.name)); } - let vars = self.vars.start as usize..self.vars.end as usize; - let mut saved = self - .saved - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(v) = saved.get(&id) { - slots[vars.clone()].copy_from_slice(v); + let mut saved = lock(&self.saved); + // Each run of a frame starts from the variables the frame began with; only the + // first one in the world keeps its result, so that drawing again (or in a + // preview) changes nothing. A preview of an entity the world no longer draws + // keeps its result too. + let mut vars = None; + if !self.vars.is_empty() || !self.globals.is_empty() { + if saved.entities.len() >= SAVED && !saved.entities.contains_key(&id) { + saved.make_room(counter); + } + let globals = |into: &mut [f32]| { + if !self.globals.is_empty() { + let g = lock(&GLOBALS); + for (value, &(_, i)) in into.iter_mut().zip(&self.globals) { + *value = g.values[i as usize]; + } + } + }; + let v = saved.entities.entry(id).or_insert_with(|| { + // Seen for the first time: its variables began this frame at 0. + let zero: Box<[f32]> = vec![0.0; self.vars.len()].into_boxed_slice(); + let mut globals_start = vec![0.0; self.globals.len()].into_boxed_slice(); + globals(&mut globals_start); + Vars { + frame: counter, + stored: false, + world: None, + start: zero.clone(), + now: zero, + globals_start, + } + }); + if v.frame != counter { + v.start.copy_from_slice(&v.now); + globals(&mut v.globals_start); + v.frame = counter; + v.stored = false; + } + for (&slot, value) in self.vars.iter().zip(v.start.iter()) { + slots[slot as usize] = *value; + } + for (&(slot, _), value) in self.globals.iter().zip(v.globals_start.iter()) { + slots[slot as usize] = *value; + } + vars = Some(v); } - for (slot, expr) in &self.steps { - let v = expr.eval(slots); - slots[*slot as usize] = v; + for step in &self.steps { + let v = step.expr.eval(slots); + // A part keeps the pose vanilla gave it, a variable its last value. + if v.is_finite() { + slots[step.slot as usize] = v; + } } - if !vars.is_empty() { - if saved.len() >= SAVED && !saved.contains_key(&id) { - saved.clear(); + if let Some(v) = vars + && !v.stored + && (!preview || v.world.is_none_or(|w| counter.wrapping_sub(w) > 1)) + { + v.stored = true; + if !preview { + v.world = Some(counter); + } + for (&slot, value) in self.vars.iter().zip(v.now.iter_mut()) { + *value = slots[slot as usize]; + } + if !self.globals.is_empty() { + let mut g = lock(&GLOBALS); + for &(slot, i) in &self.globals { + g.values[i as usize] = slots[slot as usize]; + } } - saved.insert(id, slots[vars].into()); } drop(saved); for b in &self.parts { let base = b.base as usize; + // The slots began as the pose: writing back what no step changed keeps it. poses.get(&b.name).copy_from_slice(&slots[base..base + 9]); - poses.visible(&b.name, slots[base + 9] != 0.0); - if slots[base + 10] == 0.0 { + // Only a change is written: naming a part keeps what vanilla hid. + let visible = slots[base + 9] != 0.0; + if visible == poses.is_hidden_named(&b.name) { + poses.visible(&b.name, visible); + } + // `visible_boxes = 0` skips the part's own boxes; 1 leaves vanilla's skipDraw. + if slots[base + 10] == 0.0 && !poses.skips_named(&b.name) { poses.skip_draw(&b.name); } } @@ -339,11 +515,22 @@ impl Animation { } } -fn params(m: &Mob, s: &State, slots: &mut [f32]) { - let frame = *FRAME - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let t = |b: bool| if b { 1.0 } else { 0.0 }; +impl Saved { + /// Before a new entity joins a full table, forgets the entities not drawn for a while, + /// and everything if that is not enough. + fn make_room(&mut self, counter: u32) { + if self.evicted != Some(counter) { + self.evicted = Some(counter); + self.entities + .retain(|_, v| counter.wrapping_sub(v.frame) <= STALE); + } + if self.entities.len() >= 2 * SAVED { + self.entities.clear(); + } + } +} + +fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) { let max_health = super::finalize::max_health(m); let (dx, dy, dz) = ( m.position.x - frame.player[0], @@ -403,30 +590,35 @@ fn params(m: &Mob, s: &State, slots: &mut [f32]) { // once it touches the bottom. FluidDepth | FluidDepthDown => m.fluids.water.max(m.fluids.lava) as f32, HeightAboveGround | FluidDepthUp => 0.0, - Aggressive => t(m.ai.target.is_some()), - Alive => t(m.alive()), - Burning => t(m.fire_ticks > 0), - Child => t(s.baby), - Glowing => t(m.glowing), - Hurt => t(m.hurt_time > 0), - InWater => t(m.fluids.in_water), - InLava => t(m.fluids.in_lava), - Wet => t(m.fluids.in_water), - Invisible => t(m.invisible), - OnGround => t(m.on_ground), - Riding => t(m.extra.passenger), - Climbing => t(m.climbing), - Jumping => t(!m.on_ground && m.velocity.y > 0.0), - Paused => t(frame.paused), - FirstPerson => t(frame.first_person), - ThirdPerson => t(!frame.first_person && !frame.third_person_reversed), - ThirdPersonReversed => t(frame.third_person_reversed), - RightHanded => 1.0, - InHand | InItemFrame | InGround | InGui | OnHead | OnShoulder | Ridden | Sitting - | Sneaking | Sprinting | Tamed | Blocking | Crawling | Swimming | SwingingRight - | SwingingLeft | FirstPersonHand | UsingItem | HoldingRight | HoldingLeft | Hovered => { - 0.0 - } + // The synced `Mob.isAggressive`, which a client's mobs have too. + Aggressive => flag(m.ai.aggressive), + Alive => flag(m.alive()), + Burning => flag(m.fire_ticks > 0), + Child => flag(s.baby), + Glowing => flag(m.glowing), + Hurt => flag(m.hurt_time > 0), + InWater => flag(m.fluids.in_water), + InLava => flag(m.fluids.in_lava), + Wet => flag(m.fluids.in_water), + Invisible => flag(m.invisible), + OnGround => flag(m.on_ground), + Riding => flag(m.extra.passenger), + Climbing => flag(m.climbing), + Jumping => flag(!m.on_ground && m.velocity.y > 0.0), + Paused => flag(frame.paused), + InGui => flag(IN_GUI.get()), + FirstPerson => flag(frame.first_person), + 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), + 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, }; } } @@ -496,11 +688,16 @@ mod tests { ("cem#0".to_owned(), "real_head".to_owned(), hat.as_slice()), ]; let animation = compile_animation("test.jem", &names, &entries).unwrap(); - let frame = |id: u32, poses: &mut Poses| { - animation.run_with(id, |s| s[LIMB_SWING] = std::f32::consts::FRAC_PI_2, poses); + let frame = |id: u32, counter: u32, poses: &mut Poses| { + animation.run_with( + id, + counter, + |s| s[LIMB_SWING] = std::f32::consts::FRAC_PI_2, + poses, + ); }; let mut poses = Poses::new(&model); - frame(7, &mut poses); + frame(7, 1, &mut poses); assert_eq!(poses.get("real_head")[..4], [1.0, 20.0, 3.0, 0.5]); assert_eq!(poses.get("cem#1")[2], 1.0); assert!((poses.get("cem#0")[4] - std::f32::consts::FRAC_PI_2).abs() < 1e-6); @@ -509,12 +706,12 @@ mod tests { assert!(!poses.is_hidden(&bone("cem#1", vec![]))); // The next frame starts from the vanilla pose again, with this entity's counter. let mut poses = Poses::new(&model); - frame(7, &mut poses); + frame(7, 2, &mut poses); assert_eq!(poses.get("cem#1")[2], 2.0); assert!(poses.is_hidden(&bone("cem#1", vec![]))); // Another entity counts on its own. let mut poses = Poses::new(&model); - frame(8, &mut poses); + frame(8, 2, &mut poses); assert_eq!(poses.get("cem#1")[2], 1.0); } @@ -531,4 +728,363 @@ mod tests { let entries = [("root".to_owned(), "root".to_owned(), bad.as_slice())]; assert!(compile_animation("test.jem", &names, &entries).is_none()); } + + /// The animation `list` over a model whose parts keep their own names. + fn animate(model: &Part, list: &[(&str, &str)]) -> Animation { + let ids = HashMap::new(); + let names = Names { + model, + vanilla: &|p| p.to_owned(), + ids: &ids, + }; + let list = pairs(list); + let entries = [("root".to_owned(), "root".to_owned(), list.as_slice())]; + compile_animation("test.jem", &names, &entries).unwrap() + } + + #[test] + fn naming_a_part_keeps_what_vanilla_hid() { + let model = bone("root", vec![bone("head", vec![])]); + let hidden = || { + let mut poses = Poses::new(&model); + poses.visible("head", false); + poses.skip_draw("head"); + poses + }; + // Only the rotation changes: the part stays hidden and its boxes skipped. + let mut poses = hidden(); + animate(&model, &[("head.rx", "1")]).run_with(1, 1, |_| {}, &mut poses); + assert_eq!(poses.get("head")[3], 1.0); + assert!(poses.is_hidden_named("head") && poses.skips_named("head")); + // An animation that reads the flags sees vanilla's: hidden and skipped are 0. + let mut poses = hidden(); + animate( + &model, + &[("head.tx", "head.visible + head.visible_boxes * 2")], + ) + .run_with(1, 1, |_| {}, &mut poses); + assert_eq!(poses.get("head")[0], 0.0); + // visible 1 shows the part; visible_boxes 1 leaves vanilla's skipDraw. + let mut poses = hidden(); + animate( + &model, + &[("head.visible", "1"), ("head.visible_boxes", "1")], + ) + .run_with(1, 1, |_| {}, &mut poses); + assert!(!poses.is_hidden_named("head") && poses.skips_named("head")); + // visible_boxes 0 skips the part's own boxes and leaves it visible. + let mut poses = Poses::new(&model); + animate(&model, &[("head.visible_boxes", "0")]).run_with(1, 1, |_| {}, &mut poses); + assert!(poses.skips_named("head") && !poses.is_hidden_named("head")); + // visible 0 hides a part vanilla showed. + let mut poses = Poses::new(&model); + animate(&model, &[("head.visible", "0")]).run_with(1, 1, |_| {}, &mut poses); + assert!(poses.is_hidden_named("head") && !poses.skips_named("head")); + } + + #[test] + fn a_value_that_is_not_finite_keeps_the_vanilla_pose_and_the_variable() { + let model = bone("root", vec![]); + let mut poses = Poses::new(&model); + animate( + &model, + &[ + ("root.tx", "sqrt(-1)"), + ("root.ty", "5"), + ("root.rx", "1 / 0"), + ("var.k", "7"), + ("var.k", "0 / 0"), + ("var.k", "1 / 0"), + ("root.tz", "var.k"), + ], + ) + .run_with(1, 1, |_| {}, &mut poses); + assert_eq!(poses.get("root")[..4], [1.0, 5.0, 7.0, 0.0]); + } + + #[test] + fn the_parameters_come_from_the_mob_and_its_state() { + let mut m = Mob::new( + 1, + crate::mobs::sensor_scan_tests::kind(), + horizon_core::geometry::Vec3::new(3.0, 4.0, 0.0), + 0.0, + ); + m.hurt_time = 5; + m.fire_ticks = 3; + m.on_ground = false; + m.velocity = horizon_core::geometry::Vec3::new(0.0, 0.1, 0.0); + let s = State { + pos: 1.5, + speed: 0.0, + age: 0.0, + y_rot: 30.0, + x_rot: 0.0, + swing: 0.0, + baby: true, + partial: 0.25, + }; + let model = bone("root", vec![]); + let animation = animate( + &model, + &[ + ("root.tx", "hurt_time"), + ("root.ty", "limb_swing"), + ("root.tz", "head_yaw"), + ("root.rx", "is_burning + is_jumping * 2 + is_child * 4"), + ("root.ry", "distance"), + ( + "root.rz", + "is_player_third_person + is_player_first_person * 2", + ), + ], + ); + let frame = Frame { + player: [0.0, 0.0, 0.0], + ..Frame::default() + }; + let mut poses = Poses::new(&model); + animation.run_in(&frame, &m, &s, &mut poses); + assert_eq!(poses.get("root")[..6], [4.75, 1.5, 30.0, 7.0, 5.0, 2.0]); + } + + /// A mob at the origin of the test species. + fn mob() -> Mob { + Mob::new( + 1, + crate::mobs::sensor_scan_tests::kind(), + horizon_core::geometry::Vec3::ZERO, + 0.0, + ) + } + + /// A state with every value at rest. + fn still() -> State { + State { + pos: 0.0, + speed: 0.0, + age: 0.0, + y_rot: 0.0, + x_rot: 0.0, + swing: 0.0, + baby: false, + partial: 0.0, + } + } + + #[test] + fn the_flags_come_from_the_synced_mob_fields() { + let mut m = mob(); + m.ai.aggressive = true; + m.extra.sitting = true; + m.extra.tame = true; + m.extra.sprinting = true; + m.extra.crouching = true; + // The player's main hand option set to left. + m.extra.main_hand_left = true; + 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", + )], + ); + let mut poses = Poses::new(&model); + animation.run_in(&Frame::default(), &m, &still(), &mut poses); + assert_eq!(poses.get("root")[0], 31.0); + } + + #[test] + fn expressions_past_the_cap_are_left_out() { + let model = bone("root", vec![]); + let list = vec![("root.tx", "1"); MAX_EXPRESSIONS + 1]; + assert_eq!(animate(&model, &list).steps.len(), MAX_EXPRESSIONS); + } + + #[test] + fn a_preview_leaves_the_world_entity_variables_alone() { + let model = bone("root", vec![]); + let animation = animate( + &model, + &[ + ("var.n", "var.n + 1 + is_in_gui * 100"), + ("root.tx", "var.n"), + ], + ); + let (m, s) = (mob(), still()); + let tx = |counter: u32| { + let frame = Frame { + frame_counter: counter, + ..Frame::default() + }; + let mut poses = Poses::new(&model); + animation.run_in(&frame, &m, &s, &mut poses); + poses.get("root")[0] + }; + // A preview in a frame the world drew, then one in a frame it didn't: each starts from + // where its frame began (0, then the world's last 2) and stores nothing itself. + assert_eq!( + [tx(1), in_gui(|| tx(1)), tx(2), in_gui(|| tx(3)), tx(4)], + [1.0, 101.0, 2.0, 103.0, 3.0] + ); + } + + #[test] + fn drawing_again_in_the_same_frame_does_not_advance_variables() { + let model = bone("root", vec![]); + let animation = animate(&model, &[("var.n", "var.n + 1"), ("root.tx", "var.n")]); + let mut poses = Poses::new(&model); + let mut tx = |counter: u32| { + animation.run_with(1, counter, |_| {}, &mut poses); + poses.get("root")[0] + }; + assert_eq!( + [tx(10), tx(10), tx(11), tx(11), tx(12)], + [1.0, 1.0, 2.0, 2.0, 3.0] + ); + } + + #[test] + fn global_variables_are_shared_by_every_entity() { + let model = bone("root", vec![]); + let animation = animate( + &model, + &[ + ( + "global_var.cem_anim_test_shared", + "global_var.cem_anim_test_shared + 1", + ), + ("root.tx", "global_var.cem_anim_test_shared"), + ], + ); + let mut poses = Poses::new(&model); + animation.run_with(1, 1, |_| {}, &mut poses); + assert_eq!(poses.get("root")[0], 1.0); + animation.run_with(2, 1, |_| {}, &mut poses); + assert_eq!(poses.get("root")[0], 2.0); + // Another model reading the same name sees the same value. + let reader = animate(&model, &[("root.ty", "global_var.cem_anim_test_shared")]); + reader.run_with(3, 1, |_| {}, &mut poses); + assert_eq!(poses.get("root")[1], 2.0); + } + + #[test] + fn a_full_table_forgets_only_entities_not_seen_for_a_while() { + let model = bone("root", vec![]); + let animation = animate(&model, &[("var.n", "var.n + 1"), ("root.tx", "var.n")]); + let mut poses = Poses::new(&model); + let mut tx = |id: u32, counter: u32| { + animation.run_with(id, counter, |_| {}, &mut poses); + poses.get("root")[0] + }; + for id in 0..SAVED as u32 { + tx(id, 1); + } + // Entity 0 comes back 699 frames later; a new one then fills the table. + assert_eq!(tx(0, 700), 2.0); + assert_eq!(tx(100_000, 701), 1.0); + // Entity 0 was seen recently and keeps counting; entity 1 was forgotten. + assert_eq!(tx(0, 702), 3.0); + assert_eq!(tx(1, 702), 1.0); + } + + #[test] + fn drawing_an_entity_again_reads_and_stores_the_globals_once() { + let model = bone("root", vec![]); + let animation = animate( + &model, + &[ + ( + "global_var.cem_anim_test_redraw", + "global_var.cem_anim_test_redraw + 1 + is_in_gui * 100", + ), + ("root.tx", "global_var.cem_anim_test_redraw"), + ], + ); + let mut poses = Poses::new(&model); + let mut tx = |counter: u32| { + // Only `is_in_gui` matters here: the rest of the parameters stay 0. + let fill = |slots: &mut [f32]| slots[Param::InGui as usize] = flag(IN_GUI.get()); + animation.run_with(7, counter, fill, &mut poses); + poses.get("root")[0] + }; + // Two draws in the world and a preview in frame 1 read the same start; only the first + // stores, so frame 2 counts on from 1, not from the preview's 101. + assert_eq!( + [tx(1), tx(1), in_gui(|| tx(1)), tx(2)], + [1.0, 1.0, 101.0, 2.0] + ); + } + + #[test] + fn an_entity_only_a_preview_draws_still_counts() { + let model = bone("root", vec![]); + let animation = animate(&model, &[("var.n", "var.n + 1"), ("root.tx", "var.n")]); + let mut poses = Poses::new(&model); + let mut tx = |counter: u32| { + animation.run_with(5, counter, |_| {}, &mut poses); + poses.get("root")[0] + }; + // The inventory's own player in first person: the world never draws it. + assert_eq!( + [in_gui(|| tx(1)), in_gui(|| tx(2)), in_gui(|| tx(3))], + [1.0, 2.0, 3.0] + ); + } + + #[test] + fn a_table_of_recent_entities_starts_over_at_twice_its_size() { + let model = bone("root", vec![]); + let animation = animate(&model, &[("var.n", "var.n + 1"), ("root.tx", "var.n")]); + let mut poses = Poses::new(&model); + let mut tx = |id: u32, counter: u32| { + animation.run_with(id, counter, |_| {}, &mut poses); + poses.get("root")[0] + }; + // Every entity is recent, so none can be forgotten until the table is twice full. + for id in 0..2 * SAVED as u32 { + tx(id, 1); + } + assert_eq!(tx(0, 2), 2.0); + tx(1_000_000, 2); + assert_eq!(tx(0, 3), 1.0); + } + + /// Held by the tests that change `FRAME`. + static FRAME_TEST: Mutex<()> = Mutex::new(()); + + #[test] + fn a_preview_runs_in_the_gui_and_the_world_frame_comes_back() { + let _lock = lock(&FRAME_TEST); + set_frame(Frame { + time: 77.0, + ..Frame::default() + }); + let (m, s) = (mob(), still()); + let model = bone("root", vec![]); + let animation = animate(&model, &[("root.tx", "is_in_gui"), ("root.ty", "time")]); + let run = || { + let mut poses = Poses::new(&model); + animation.run(&m, &s, &mut poses); + let p = *poses.get("root"); + [p[0], p[1]] + }; + assert_eq!(run(), [0.0, 77.0]); + assert_eq!(in_gui(run), [1.0, 77.0]); + assert_eq!(run(), [0.0, 77.0]); + } + + #[test] + fn each_frame_counts_one_more() { + let _lock = lock(&FRAME_TEST); + let counter = || frame().frame_counter; + let a = counter(); + set_frame(Frame::default()); + let b = counter(); + set_frame(Frame::default()); + let c = counter(); + assert_eq!([b.wrapping_sub(a), c.wrapping_sub(b)], [1, 1]); + } } diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index ceed9cb34a..1343746c61 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -2590,19 +2590,27 @@ impl Goal for EatBlock { m.position.z.floor() as i32, ); let here = cx.w.block_name(x, y, z).to_owned(); + // EatBlockGoal.tick: both halves of the world change sit behind + // MOB_GRIEFING (`ifeq 89`, `ifeq 182` in the bytecode); `Mob.ate` + // runs either way, so a sheep still sheds its wool and ages up. if cx.w.has_tag("minecraft:edible_for_sheep", &here) { - cx.events.push(MobEvent::BreakBlock { - pos: [x, y, z], - drop: false, - }); + if cx.w.mob_griefing { + cx.events.push(MobEvent::BreakBlock { + pos: [x, y, z], + drop: false, + }); + } super::ai::ate(m); } else if cx.w.block_name(x, y - 1, z) == "minecraft:grass_block" { - cx.events.push(MobEvent::SetBlockName { - pos: [x, y - 1, z], - name: "minecraft:dirt", - effect: true, - flags: horizon_world::level::flags::UPDATE_CLIENTS, - }); + // The particles and the dirt `setBlock` are the same gate. + if cx.w.mob_griefing { + cx.events.push(MobEvent::SetBlockName { + pos: [x, y - 1, z], + name: "minecraft:dirt", + effect: true, + flags: horizon_world::level::flags::UPDATE_CLIENTS, + }); + } super::ai::ate(m); } } @@ -2821,6 +2829,114 @@ impl MoveToBlock { } } +#[cfg(test)] +mod eat_block_tests { + use super::*; + use crate::mobs::world::with_built_test_world_rules; + + /// A sheep on a wide grass shelf, ticked long enough for its `EatBlockGoal` + /// (1 in 1000 a tick) to fire several times: every event it pushed, with + /// the `mob_griefing` game rule at `griefing`. `tufts` puts short grass at + /// the sheep's own feet, so the goal takes the `destroyBlock` half; without + /// it the sheep stands on the grass and takes the grass-to-dirt half. + fn sheep_events(griefing: bool, tufts: bool) -> Option> { + let mut out = None; + let build = |world: &mut crate::world::World, + registry: &horizon_world::registry::BlockRegistry| { + let grass = registry.default_state("minecraft:grass_block").unwrap(); + let tuft = registry.default_state("minecraft:short_grass").unwrap(); + for cx in -1..1 { + for cz in -1..1 { + world + .chunks + .insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz))); + } + } + for x in -8..=8 { + for z in -8..=8 { + world.set(x, 63, z, grass).unwrap(); + if tufts { + world.set(x, 64, z, tuft).unwrap(); + } + } + } + }; + with_built_test_world_rules(&[], griefing, build, |w, types| { + let kind = Arc::new(types.types["minecraft:sheep"].clone()); + let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0); + let mut random = LegacyRandom::new(0); + let mut events = Vec::new(); + for _ in 0..6000 { + crate::mobs::ai::tick(&mut m, w, &mut random, &mut events); + } + out = Some(events); + }); + out + } + + /// `EatBlockGoal.tick` puts the `levelEvent` + `setBlock` behind + /// `MOB_GRIEFING` and leaves `Mob.ate` outside it, so with the rule off the + /// goal still runs (`EntityEvent` 10) and still shears the coat, and the + /// grass stays. + #[test] + fn the_eat_goal_turns_grass_to_dirt_only_with_mob_griefing() { + let Some(on) = sheep_events(true, false) else { + return; + }; + let Some(off) = sheep_events(false, false) else { + return; + }; + let dirt = |events: &[MobEvent]| { + events + .iter() + .filter(|e| matches!(e, MobEvent::SetBlockName { name, .. } if *name == "minecraft:dirt")) + .count() + }; + let ate = |events: &[MobEvent]| { + events + .iter() + .filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. })) + .count() + }; + assert!(dirt(&on) > 0, "mob_griefing on: {}", dirt(&on)); + assert_eq!(dirt(&off), 0, "mob_griefing off must leave the grass alone"); + assert!(ate(&off) > 0, "the goal still runs with the rule off"); + } + + /// The edible half of the same gate (`ifeq 89` skips + /// `Level.destroyBlock(pos, false)`): short grass at the sheep's feet + /// disappears with the rule on and stays with it off, while the goal eats + /// either way. + #[test] + fn the_eat_goal_breaks_short_grass_only_with_mob_griefing() { + let Some(on) = sheep_events(true, true) else { + return; + }; + let Some(off) = sheep_events(false, true) else { + return; + }; + let bitten = |events: &[MobEvent]| { + events + .iter() + .filter(|e| matches!(e, MobEvent::BreakBlock { drop: false, .. })) + .count() + }; + let ate = |events: &[MobEvent]| { + events + .iter() + .filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. })) + .count() + }; + assert!(bitten(&on) > 0, "mob_griefing on: {}", bitten(&on)); + assert_eq!( + bitten(&off), + 0, + "mob_griefing off must leave the tufts alone" + ); + assert!(ate(&off) > 0, "the goal still runs with the rule off"); + } +} + #[cfg(test)] mod built_by_player_tests { use super::*; diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 052747f879..e760a2a582 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -1703,6 +1703,21 @@ pub fn ai_step( if !w.nether && !m.extra.flag2 && m.alive() { m.extra.counter2 += 1; if m.extra.counter2 > CONVERSION { + // `Piglin.finishConversion` runs `PiglinAi.cancelAdmiring` first: + // the item the piglin is admiring leaves the off hand and lands + // where it stands, so the zombified piglin never carries it. + if m.species() == Species::Piglin { + let admiring = (m.extra.off_hand.is_some() && m.extra.using_item > 0) + || m.extra.piglin.seen_admire > 0; + if admiring && let Some(item) = m.extra.off_hand.take() { + events.push(MobEvent::DropItem { + item, + count: 1, + at: [m.position.x, m.position.y, m.position.z], + }); + m.extra.using_item = 0; + } + } // `Hoglin.finishConversion` is `ConversionParams.single(this, true, // false)`, `AbstractPiglin.finishConversion` `(this, true, true)`; // both leave the new mob nauseous (the game adds it). @@ -2172,3 +2187,47 @@ mod crossbow_tests { assert_eq!(f.seen_admire, 40); } } + +#[cfg(test)] +mod conversion_tests { + use super::*; + use crate::mobs::world::with_test_world; + + /// `Piglin.finishConversion` cancels the admire before it converts: the + /// item in the off hand is dropped at the piglin's feet and the zombified + /// piglin does not carry it, where Horizon used to hand it over. + #[test] + fn a_converted_piglin_drops_the_item_it_was_admiring() { + let ran = with_test_world(&[], |w, types| { + let mut m = Mob::new( + 1, + std::sync::Arc::new(types.types["minecraft:piglin"].clone()), + Vec3::new(0.5, 64.0, 0.5), + 0.0, + ); + m.extra.off_hand = Some("minecraft:gold_ingot".into()); + m.extra.using_item = 60; + m.extra.counter2 = CONVERSION + 1; + let mut lr = LegacyRandom::new(0); + let mut events = Vec::new(); + ai_step(&mut m, w, &mut lr, &mut events); + assert!( + events.iter().any(|e| matches!( + e, + MobEvent::DropItem { item, count, .. } + if &**item == "minecraft:gold_ingot" && *count == 1 + )), + "the admired item is dropped" + ); + let from = events + .iter() + .find_map(|e| match e { + MobEvent::Convert { from, .. } => Some(from), + _ => None, + }) + .expect("the piglin converts"); + assert_eq!(from.extra.off_hand, None, "the new mob carries nothing"); + }); + let _ = ran; + } +} diff --git a/crates/horizon-client/src/mobs/random_textures.rs b/crates/horizon-client/src/mobs/random_textures.rs index c6e9c00ca7..765d2fd1b9 100644 --- a/crates/horizon-client/src/mobs/random_textures.rs +++ b/crates/horizon-client/src/mobs/random_textures.rs @@ -1,10 +1,13 @@ //! OptiFine random entity textures on mobs: which variant of each of its textures a mob wears //! (`horizon_optifine::random_textures` finds them in the packs). //! -//! [`choose`] runs when the client first sees a mob and about once a second after that, never -//! per frame. The spawn-time facts (biome, height, blocks, moon, time, weather) are fixed when -//! the mob is first seen; the others (name, profession, colour, baby, health, size) are read -//! fresh each time. Drawing only looks the choice up ([`texture`]). +//! [`choose`] runs when the client first sees a mob, about once a second after that, and on the +//! next tick when the mob draws a texture with rules it hasn't chosen for (a wolf just tamed; +//! at most once between those checks), never per frame. The spawn-time facts (biome, height, blocks, moon, time, weather) are fixed +//! when the mob is first seen; the others (name, profession, colour, baby, health, size) are +//! read fresh each time. Drawing only looks the choice up ([`texture`]). It walks the textures +//! drawing does ([`super::render::drawn_textures`]): a custom model's own texture and the +//! warden's glowing layers included. //! //! Each texture with its own rules (or those of the name it extends) wears that rule file's //! pick. A texture without rules follows the body's variant, so a sheep wearing `sheep3` has @@ -12,9 +15,10 @@ //! choose their own, as ETF does. use super::render::{DYE_NAMES, PROFESSIONS}; -use super::{Mob, Species}; +use super::{Mob, MobData, Species}; use horizon_optifine::random::{self, Block, Facts, Weather}; use horizon_optifine::random_textures::RandomTextures; +use std::sync::atomic::{AtomicBool, Ordering}; /// Where the mob was when the client first saw it. #[derive(Clone, Debug)] @@ -30,33 +34,74 @@ pub struct Seen { } /// A mob's variants. -#[derive(Clone, Debug, Default)] +#[derive(Debug, Default)] pub struct Choice { seen: Option, seed: i32, + /// The [`RandomTextures::generation`] the picks are from: another (a pack reload) makes + /// them stale. + generation: u64, /// The variant each rule file chose, by its index in [`RandomTextures::rules`]. picks: Vec<(usize, u32)>, /// The body texture's variant. primary: Option, - /// The packs changed since the picks. - stale: bool, + /// Ticks until the next check of the facts that change. + countdown: u8, + /// Drawing found a texture with rules and no pick: choose on the next tick, unless a miss + /// was answered since the last regular check. + missed: AtomicBool, + /// A miss was answered since the last regular check: one per check, so a texture that + /// drawing keeps missing (one `drawn_textures` doesn't list) can't make the mob choose + /// every tick. + answered: bool, } +impl Clone for Choice { + fn clone(&self) -> Self { + Self { + seen: self.seen.clone(), + seed: self.seed, + generation: self.generation, + picks: self.picks.clone(), + primary: self.primary, + countdown: self.countdown, + missed: AtomicBool::new(self.missed.load(Ordering::Relaxed)), + answered: self.answered, + } + } +} + +/// Ticks between checks of the facts that change. +const RECHECK: u8 = 20; + impl Choice { /// Whether the mob's first sighting is recorded. pub fn has_seen(&self) -> bool { self.seen.is_some() } - /// Whether [`choose`] should run now: on first sight, after the packs changed, and on - /// one tick in 20 (staggered by `stagger`, the entity id) for the facts that change. - pub fn due(&self, tick: i64, stagger: u32) -> bool { - !self.has_seen() || self.stale || (tick + i64::from(stagger)).rem_euclid(20) == 0 - } - - /// The packs changed: choose again, from the same first sighting. - pub fn forget_picks(&mut self) { - self.stale = true; + /// Called once a tick: whether [`choose`] should run now. It does on first sight, after the + /// packs changed, every [`RECHECK`] ticks (the first spread by `stagger`, the entity id, so + /// a crowd seen at once doesn't check at once), and when drawing missed a pick, once + /// between those checks. + pub fn due(&mut self, textures: &RandomTextures, stagger: u32) -> bool { + let fresh = !self.has_seen() || self.generation != textures.generation(); + if fresh || self.countdown == 0 { + self.countdown = if fresh { + (stagger % u32::from(RECHECK)) as u8 + } else { + RECHECK - 1 + }; + self.answered = false; + self.missed.store(false, Ordering::Relaxed); + return true; + } + self.countdown -= 1; + if self.missed.swap(false, Ordering::Relaxed) && !self.answered { + self.answered = true; + return true; + } + false } } @@ -69,9 +114,17 @@ pub fn seed(m: &Mob, uuid: Option<[i32; 4]>) -> i32 { }) } -/// Chooses the variant of each texture `m` draws now. `seen` describes its surroundings the -/// first time (when [`Choice::has_seen`] is false); later calls keep that. -pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option, seed: i32) { +/// Chooses the variant of each texture `m` draws now (its custom models' from `data`). +/// `seen` describes its surroundings the first time (when [`Choice::has_seen`] is false); +/// later calls keep that. +pub fn choose( + m: &mut Mob, + textures: &RandomTextures, + data: &MobData, + seen: Option, + seed: i32, +) { + let names = super::render::drawn_textures(m, data); let mut choice = std::mem::take(&mut m.random_textures); if choice.seen.is_none() { choice.seen = seen; @@ -81,9 +134,9 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option, seed: return; }; choice.seed = seed; + choice.generation = textures.generation(); choice.picks.clear(); choice.primary = None; - choice.stale = false; let states: Vec> = seen .blocks .iter() @@ -114,10 +167,7 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option, seed: weather: Some(seen.weather), size: size(m), }; - let (_, body) = super::render::body(m); - let layers = super::render::layers(m, m.age as f32); - let names = std::iter::once(body.as_str()).chain(layers.iter().map(|l| l.texture.as_str())); - for (i, name) in names.enumerate() { + for (i, name) in names.iter().enumerate() { let Some(entry) = textures.get(name) else { continue; }; @@ -125,8 +175,8 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option, seed: Some(id) => match choice.picks.iter().find(|p| p.0 == id) { Some(&(_, index)) => index, None => { - // ETF's 0 is the default texture too. - let index = textures.rules(id).select(&facts, seed).max(1); + // No rule matching is the texture itself, as is ETF's 0. + let index = textures.rules(id).matching(&facts, seed).unwrap_or(0); choice.picks.push((id, index)); index } @@ -145,11 +195,17 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option, seed: pub fn texture<'a>(m: &Mob, textures: &'a RandomTextures, texture: &str) -> Option<&'a str> { let entry = textures.get(texture)?; let choice = &m.random_textures; - if !choice.has_seen() || choice.stale { + if !choice.has_seen() || choice.generation != textures.generation() { return None; } let index = match entry.rules { - Some(id) => choice.picks.iter().find(|p| p.0 == id)?.1, + Some(id) => match choice.picks.iter().find(|p| p.0 == id) { + Some(&(_, index)) => index, + None => { + choice.missed.store(true, Ordering::Relaxed); + return None; + } + }, None => match choice.primary { Some(primary) if !matches!(m.species(), Species::Villager | Species::ZombieVillager) => @@ -163,16 +219,13 @@ pub fn texture<'a>(m: &Mob, textures: &'a RandomTextures, texture: &str) -> Opti } /// `colors`: a sheep's wool, a wolf's or cat's collar, a llama's carpet, a shulker's shell. -fn color(m: &Mob) -> Option<&'static str> { +fn color(m: &Mob) -> Option<&str> { match m.species() { Species::Sheep | Species::Wolf | Species::Cat => { Some(DYE_NAMES[usize::from(m.extra.color & 15)]) } Species::Shulker => DYE_NAMES.get(usize::from(m.extra.color)).copied(), - Species::Llama | Species::TraderLlama => { - let carpet = super::llama::carpet(m)?; - DYE_NAMES.iter().find(|&&d| d == carpet).copied() - } + Species::Llama | Species::TraderLlama => super::llama::carpet(m), _ => None, } } @@ -199,6 +252,23 @@ mod tests { Mob::new(1, kind, Vec3::ZERO, 0.0) } + /// No custom models. + fn data() -> MobData { + MobData { + states: Vec::new(), + placements: Default::default(), + heightmaps: Default::default(), + animations: Default::default(), + villager_hats: Default::default(), + cem: Default::default(), + random_textures: Default::default(), + biome_tags: Default::default(), + entity_tags: Default::default(), + pois: Default::default(), + species_placements: Default::default(), + } + } + fn seen(biome: &str) -> Seen { Seen { biome: Some(biome.into()), @@ -213,15 +283,19 @@ mod tests { } } - /// A temp pack of `files` (path, contents), scanned. + /// A temp pack of `files` (path under `optifine/random/entity`, or from the root when it + /// starts with `assets/`; contents), scanned. fn pack(name: &str, files: &[(&str, &str)]) -> RandomTextures { let root = std::env::temp_dir().join(format!("horizon-mob-random-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); for (path, text) in files { - let path = root - .join("assets/minecraft/optifine/random/entity") - .join(path); + let path = if path.starts_with("assets/") { + root.join(path) + } else { + root.join("assets/minecraft/optifine/random/entity") + .join(path) + }; std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, text).unwrap(); } @@ -251,32 +325,62 @@ mod tests { ("sheep/sheep_wool3.png", ""), ], ); + let data = data(); let mut m = mob(Species::Sheep); assert_eq!(texture(&m, &t, SHEEP), None, "not chosen yet"); - assert!(m.random_textures.due(5, m.id)); + // Entity 1: chosen at first sight, then 1 tick later (the stagger), then every 20. + assert!(m.random_textures.due(&t, m.id)); m.extra.color = 14; // red - choose(&mut m, &t, Some(seen("minecraft:plains")), 0); - assert_eq!( - texture(&m, &t, SHEEP), - Some(variant("entity/sheep/sheep3").as_str()) - ); - assert_eq!( - texture(&m, &t, WOOL), - Some(variant("entity/sheep/sheep_wool3").as_str()) - ); - // Checked again on one tick in 20 (tick + id), the dyed sheep changes. - assert!(!m.random_textures.due(5, m.id)); - assert!(m.random_textures.due(19, m.id)); + choose(&mut m, &t, &data, Some(seen("minecraft:plains")), 0); + let sheep3 = variant("entity/sheep/sheep3"); + assert_eq!(texture(&m, &t, SHEEP), Some(sheep3.as_str())); + let wool3 = variant("entity/sheep/sheep_wool3"); + assert_eq!(texture(&m, &t, WOOL), Some(wool3.as_str())); + let ticks: Vec = (0..23).map(|_| m.random_textures.due(&t, 1)).collect(); + let due: Vec = (0..23).filter(|&i| ticks[i]).collect(); + assert_eq!(due, [1, 21]); m.extra.color = 0; - choose(&mut m, &t, None, 0); - assert_eq!( - texture(&m, &t, SHEEP), - Some(variant("entity/sheep/sheep2").as_str()) + choose(&mut m, &t, &data, None, 0); + let sheep2 = variant("entity/sheep/sheep2"); + assert_eq!(texture(&m, &t, SHEEP), Some(sheep2.as_str())); + // Another scan (a pack reload): nothing until chosen again, at once, from the same + // first sighting. + let reloaded = pack( + "colour-again", + &[ + ("sheep/sheep.properties", "textures.1=2"), + ("sheep/sheep2.png", ""), + ], ); - // A pack reload: nothing until chosen again, at once. - m.random_textures.forget_picks(); - assert_eq!(texture(&m, &t, SHEEP), None); - assert!(m.random_textures.due(5, m.id)); + assert_eq!(texture(&m, &reloaded, SHEEP), None); + assert!(m.random_textures.due(&reloaded, m.id)); + choose(&mut m, &reloaded, &data, None, 0); + assert_eq!(texture(&m, &reloaded, SHEEP), Some(sheep2.as_str())); + } + + #[test] + fn no_rule_matching_is_the_texture_itself() { + // ETF: `sheep1.png` replaces variant 1, but a mob no rule matches (or a rule's 0) + // wears vanilla's texture, and so does the wool that follows it. + let t = pack( + "zero", + &[ + ( + "sheep/sheep.properties", + "textures.1=2\nbiomes.1=desert\ntextures.2=0\nbiomes.2=forest", + ), + ("sheep/sheep1.png", ""), + ("sheep/sheep2.png", ""), + ("sheep/sheep_wool1.png", ""), + ], + ); + let data = data(); + for biome in ["minecraft:plains", "minecraft:forest"] { + let mut m = mob(Species::Sheep); + choose(&mut m, &t, &data, Some(seen(biome)), 0); + assert_eq!(texture(&m, &t, SHEEP), None, "{biome}"); + assert_eq!(texture(&m, &t, WOOL), None, "{biome}"); + } } #[test] @@ -288,21 +392,17 @@ mod tests { ("sheep/sheep2.png", ""), ], ); + let data = data(); let mut m = mob(Species::Sheep); - choose(&mut m, &t, Some(seen("minecraft:desert")), 0); - assert_eq!( - texture(&m, &t, SHEEP), - Some(variant("entity/sheep/sheep2").as_str()) - ); + choose(&mut m, &t, &data, Some(seen("minecraft:desert")), 0); + let sheep2 = variant("entity/sheep/sheep2"); + assert_eq!(texture(&m, &t, SHEEP), Some(sheep2.as_str())); // Walking into plains doesn't change it: biomes are read when first seen. - choose(&mut m, &t, Some(seen("minecraft:plains")), 0); - assert_eq!( - texture(&m, &t, SHEEP), - Some(variant("entity/sheep/sheep2").as_str()) - ); + choose(&mut m, &t, &data, Some(seen("minecraft:plains")), 0); + assert_eq!(texture(&m, &t, SHEEP), Some(sheep2.as_str())); // Without a first sighting nothing is chosen. let mut unseen = mob(Species::Sheep); - choose(&mut unseen, &t, None, 0); + choose(&mut unseen, &t, &data, None, 0); assert_eq!(texture(&unseen, &t, SHEEP), None); } @@ -317,26 +417,168 @@ mod tests { ("sheep/sheep_wool2.png", ""), ], ); + let data = data(); let mut m = mob(Species::Sheep); - choose(&mut m, &t, Some(seen("minecraft:plains")), 4); + choose(&mut m, &t, &data, Some(seen("minecraft:plains")), 4); // 4 % 3 + 1 = 2. - assert_eq!( - texture(&m, &t, SHEEP), - Some(variant("entity/sheep/sheep2").as_str()) - ); - assert_eq!( - texture(&m, &t, WOOL), - Some(variant("entity/sheep/sheep_wool2").as_str()) - ); - choose(&mut m, &t, None, 5); + let wool2 = variant("entity/sheep/sheep_wool2"); + assert_eq!(texture(&m, &t, WOOL), Some(wool2.as_str())); + choose(&mut m, &t, &data, None, 5); // 5 % 3 + 1 = 3: there is no sheep_wool3, so the wool is vanilla's. - assert_eq!( - texture(&m, &t, SHEEP), - Some(variant("entity/sheep/sheep3").as_str()) - ); + let sheep3 = variant("entity/sheep/sheep3"); + assert_eq!(texture(&m, &t, SHEEP), Some(sheep3.as_str())); assert_eq!(texture(&m, &t, WOOL), None); } + #[test] + fn a_layer_with_its_own_rules_and_a_warden_glow() { + // The warden's pulsating spots, OptiFine's own example: a layer `render_visible` adds + // with rules of its own (and a name ending in a digit). + let t = pack( + "warden", + &[ + ("warden/warden_pulsating_spots_2.properties", "textures.1=2"), + ("warden/warden_pulsating_spots_2.2.png", ""), + ], + ); + let mut m = mob(Species::Warden); + choose(&mut m, &t, &data(), Some(seen("minecraft:deep_dark")), 0); + let spots = variant("entity/warden/warden_pulsating_spots_2.2"); + assert_eq!( + texture(&m, &t, "entity/warden/warden_pulsating_spots_2"), + Some(spots.as_str()) + ); + } + + #[test] + fn a_texture_drawn_without_a_pick_is_chosen_next_tick() { + let t = pack( + "missed", + &[ + ("sheep/sheep.properties", "textures.1=2"), + ("sheep/sheep2.png", ""), + ("cow/cow.properties", "textures.1=2"), + ("cow/cow2.png", ""), + ], + ); + let mut m = mob(Species::Sheep); + choose(&mut m, &t, &data(), Some(seen("minecraft:plains")), 0); + assert!(m.random_textures.due(&t, 1), "checked, then 19 quiet ticks"); + assert!(!m.random_textures.due(&t, 1)); + // The sheep never drew a cow texture: drawing one asks for a choice at once. + assert_eq!(texture(&m, &t, "entity/cow/cow"), None); + assert!(m.random_textures.due(&t, 1)); + assert!(!m.random_textures.due(&t, 1)); + // Still missing (never listed): no second choice until the regular check. + assert_eq!(texture(&m, &t, "entity/cow/cow"), None); + assert!(!m.random_textures.due(&t, 1)); + // 15 more quiet ticks, the regular check, then a miss is answered at once again. + for _ in 0..15 { + assert!(!m.random_textures.due(&t, 1)); + } + assert!(m.random_textures.due(&t, 1)); + assert_eq!(texture(&m, &t, "entity/cow/cow"), None); + assert!(m.random_textures.due(&t, 1)); + } + + #[test] + fn a_layer_without_rules_follows_the_bodys_pick_or_miss() { + // The wool's variants are in ETF's folder, which holds no rules, so it follows the body + // (whose rules are in OptiFine's): `sheep_wool1.png` doesn't stand in for a miss. + let t = pack( + "follow", + &[ + ("sheep/sheep.properties", "textures.1=2\nbiomes.1=desert"), + ("sheep/sheep2.png", ""), + ( + "assets/minecraft/etf/random/entity/sheep/sheep_wool1.png", + "", + ), + ( + "assets/minecraft/etf/random/entity/sheep/sheep_wool2.png", + "", + ), + ], + ); + let mut plains = mob(Species::Sheep); + choose(&mut plains, &t, &data(), Some(seen("minecraft:plains")), 0); + assert_eq!(texture(&plains, &t, WOOL), None); + let mut desert = mob(Species::Sheep); + choose(&mut desert, &t, &data(), Some(seen("minecraft:desert")), 0); + assert_eq!( + texture(&desert, &t, WOOL), + Some("minecraft:etf/random/entity/sheep/sheep_wool2") + ); + } + + #[test] + fn villager_layers_choose_for_themselves() { + let t = pack( + "villager", + &[ + ("villager/villager2.png", ""), + ("villager/type/plains2.png", ""), + ("villager/type/plains3.png", ""), + ], + ); + let plains = "entity/villager/type/plains"; + let mut m = mob(Species::Villager); + // Seed 2: the body 1 + 2 % 2 = 1 (itself), the plains layer its own 1 + 2 % 3 = 3. + choose(&mut m, &t, &data(), Some(seen("minecraft:plains")), 2); + assert_eq!(texture(&m, &t, "entity/villager/villager"), None); + let plains3 = variant("entity/villager/type/plains3"); + assert_eq!(texture(&m, &t, plains), Some(plains3.as_str())); + // Seed 3: the body's 2 would give plains2; the layer's own 1 + 3 % 3 = 1 is itself. + choose(&mut m, &t, &data(), None, 3); + let villager2 = variant("entity/villager/villager2"); + assert_eq!( + texture(&m, &t, "entity/villager/villager"), + Some(villager2.as_str()) + ); + assert_eq!(texture(&m, &t, plains), None); + } + + #[test] + fn the_facts_a_mob_gives() { + let mut slime = mob(Species::Slime); + slime.extra.size = 4; + assert_eq!(size(&slime), Some(3), "big: the docs' 3"); + slime.extra.size = 1; + assert_eq!(size(&slime), Some(0)); + let mut phantom = mob(Species::Phantom); + phantom.extra.size = 5; + assert_eq!(size(&phantom), Some(5)); + assert_eq!(size(&mob(Species::Sheep)), None); + let mut shulker = mob(Species::Shulker); + shulker.extra.color = 16; + assert_eq!(color(&shulker), None, "16: no colour"); + shulker.extra.color = 3; + assert_eq!(color(&shulker), Some("light_blue")); + // A librarian of level 3 matches `professions.1=librarian:3`. + let t = pack( + "librarian", + &[ + ( + "villager/villager.properties", + "textures.1=2\nprofessions.1=librarian:3", + ), + ("villager/villager2.png", ""), + ], + ); + let mut m = mob(Species::Villager); + m.extra.variant2 = PROFESSIONS.iter().position(|&p| p == "librarian").unwrap() as u8; + m.extra.variant3 = 3; + choose(&mut m, &t, &data(), Some(seen("minecraft:plains")), 0); + let villager2 = variant("entity/villager/villager2"); + assert_eq!( + texture(&m, &t, "entity/villager/villager"), + Some(villager2.as_str()) + ); + m.extra.variant3 = 2; + choose(&mut m, &t, &data(), None, 0); + assert_eq!(texture(&m, &t, "entity/villager/villager"), None); + } + #[test] fn seeds_from_uuid_ints() { let m = mob(Species::Sheep); diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 17d0efb754..f27bf87a4b 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1646,6 +1646,77 @@ pub fn render_posed( let base = base_transform_at(m, alpha, origin); render_visible(m, alpha, base, atlas, light, data, &[], true, out); } +/// The custom model's key of a body model: `main` is the wide player model, `kind.model`. +fn cem_key(model_key: &str) -> &str { + if model_key == "main" { + "player#main" + } else { + model_key + } +} +/// Every texture [`render_visible`] may look up for `m` now (keep the two in step: a texture +/// drawn but not listed here never gets a random pick): the body's and each layer's, a +/// custom model's own texture beside them (`cem_texture` falls back to the vanilla one when +/// the atlas lacks it, and only the layer a `.jem` was made for takes it, `paints_with_jem`), +/// and the warden's glowing layers (whether they glow this frame or not). Random textures +/// choose a variant for each. +pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec { + fn push(names: &mut Vec, custom: Option<&super::cem::CemLayer>, vanilla: String) { + if let Some(texture) = custom.and_then(|c| c.texture.clone()) { + names.push(texture); + } + names.push(vanilla); + } + let cem = data + .cem + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (model_key, texture) = body(m); + let cem_key = cem_key(&model_key); + let mut names = Vec::new(); + push(&mut names, cem.layer(cem_key), texture); + let mut all_layers = layers(m, m.age as f32); + if m.species() == Species::Warden { + for (model, texture, _) in super::warden::emissive_layers(m, 0.0) { + all_layers.push(Layer { + model: model.into(), + texture: texture.into(), + color: 0, + full_bright: true, + scroll: 0.0, + }); + } + } + let body_keys = [model_key.as_str(), cem_key]; + for (i, layer) in all_layers.iter().enumerate() { + let custom = cem + .layer(&layer.model) + .filter(|_| paints_with_jem(&all_layers, i, body_keys)); + push(&mut names, custom, layer.texture.clone()); + } + names +} +/// The texture a model drawn as `custom` takes: the `.jem`'s when the atlas has it, else +/// `vanilla` (a texture the atlas lacks would hide the model). +fn cem_texture<'a>( + custom: Option<&'a super::cem::CemLayer>, + vanilla: &'a str, + atlas: Atlas<'_>, +) -> &'a str { + custom + .and_then(|c| c.texture.as_deref()) + .filter(|t| atlas(t).is_some()) + .unwrap_or(vanilla) +} +/// Whether `layers[i]` is the layer its model's `.jem` was made for, so the `.jem`'s texture +/// paints it: the first drawn with that model (`sheep#wool`, a wolf's armour). Later layers +/// on the same model are overlays (the armour's cracks), like those on the body's model +/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures +/// over the new geometry. +fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool { + let key = layers[i].model.as_str(); + !body.contains(&key) && layers[..i].iter().all(|l| l.model != key) +} #[allow(clippy::too_many_arguments)] fn render_visible( m: &Mob, @@ -1667,26 +1738,18 @@ fn render_visible( .cem .read() .unwrap_or_else(std::sync::PoisonError::into_inner); - // `main` is the wide player model, `kind.model`. - let cem_key = if model_key == "main" { - "player#main" - } else { - model_key.as_str() - }; - let body_animation = cem.layer(cem_key).and_then(|c| c.animation.as_ref()); - let (model, texture) = match cem.layer(cem_key) { - Some(custom) => ( - custom.model.clone(), - custom.texture.clone().unwrap_or(texture), - ), - None => ( - m.kind - .layer(&model_key) - .cloned() - .unwrap_or_else(|| m.kind.model.clone()), - texture, - ), + let cem_key = cem_key(&model_key); + let custom_body = cem.layer(cem_key); + let body_animation = custom_body.and_then(|c| c.animation.as_ref()); + let model = match custom_body { + Some(custom) => custom.model.clone(), + None => m + .kind + .layer(&model_key) + .cloned() + .unwrap_or_else(|| m.kind.model.clone()), }; + let texture = cem_texture(custom_body, &texture, atlas); // OptiFine random textures: the sprite of the variant the mob wears for a texture, when // the atlas has it. The texture keeps its name for everything else. let random = data @@ -1707,7 +1770,7 @@ fn render_visible( let draw_body = |out: &mut Vec| { // A player or mannequin whose skin has no atlas sprite (an evicted or // never stored download) draws with its default skin, not invisibly. - let uv = random_uv(&texture).or_else(|| atlas(&texture)).or_else(|| { + let uv = random_uv(texture).or_else(|| atlas(texture)).or_else(|| { if avatar(m) { atlas(&m.kind.texture) } else { @@ -1757,21 +1820,24 @@ fn render_visible( // One Poses refilled for every layer (armour draws up to seven): it keeps // its maps' capacity. let mut layer_poses = Poses::default(); - for mut layer in all_layers { + let body_keys = [model_key.as_str(), cem_key]; + for (i, layer) in all_layers.iter().enumerate() { let custom = cem.layer(&layer.model); + // `layer.texture` stays the vanilla name (the villager hats below look for its + // `/type/` folder). + let texture = cem_texture( + custom.filter(|_| paints_with_jem(&all_layers, i, body_keys)), + &layer.texture, + atlas, + ); let part = match custom { - Some(custom) => { - if let Some(texture) = &custom.texture { - layer.texture.clone_from(texture); - } - custom.model.clone() - } + Some(custom) => custom.model.clone(), None => match m.kind.layer(&layer.model) { Some(part) => part.clone(), None => continue, }, }; - let Some(uv) = random_uv(&layer.texture).or_else(|| atlas(&layer.texture)) else { + let Some(uv) = random_uv(texture).or_else(|| atlas(texture)) else { continue; }; layer_poses.reset(&part); @@ -1815,7 +1881,7 @@ fn render_visible( overlay, out, ); - if translucent_layer(m, &layer) { + if translucent_layer(m, layer) { mark_oit(&mut out[start..]); } // The eyes render type's glowing eyes also shine in HDR output, at @@ -1927,7 +1993,8 @@ pub fn part_matrix( .iter() .find_map(|c| part_matrix(c, m, poses, name, x_offset)) } -fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> { +/// The first part named `name`, depth first. +pub(super) fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> { if part.name == name { return Some(part); } @@ -2950,3 +3017,97 @@ mod scroll_tests { ); } } + +#[cfg(test)] +mod random_texture_tests { + use super::*; + use crate::mobs::{Polygon, Species}; + use horizon_optifine::random_textures::RandomTextures; + use std::sync::{Arc, RwLock}; + + /// A silverfish (drawn with its type's texture) whose model is one quad. + fn silverfish() -> Mob { + let mut kind = crate::mobs::sensor_scan_tests::kind(); + let k = Arc::make_mut(&mut kind); + k.species = Species::Silverfish; + k.texture = "entity/silverfish".into(); + k.model = Arc::new(Part { + name: "root".into(), + pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + polygons: vec![Polygon { + normal: [0.0, 0.0, -1.0], + vertices: [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 0.0], + [1.0, 1.0, 0.0, 1.0, 1.0], + [0.0, 1.0, 0.0, 0.0, 1.0], + ], + }], + children: Vec::new(), + }); + Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0) + } + + /// A pack whose silverfish has a second variant, every silverfish wearing it. + fn data() -> MobData { + let root = + std::env::temp_dir().join(format!("horizon-render-random-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("assets/minecraft/optifine/random/entity"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("silverfish.properties"), "textures.1=2").unwrap(); + std::fs::write(dir.join("silverfish2.png"), "").unwrap(); + let random = RandomTextures::scan(&root); + let _ = std::fs::remove_dir_all(&root); + MobData { + states: Vec::new(), + placements: Default::default(), + heightmaps: Default::default(), + animations: Default::default(), + villager_hats: Default::default(), + cem: Default::default(), + random_textures: RwLock::new(random), + biome_tags: Default::default(), + entity_tags: Default::default(), + pois: Default::default(), + species_placements: Default::default(), + } + } + + fn uvs(m: &Mob, data: &MobData, atlas: Atlas) -> Vec<[f32; 2]> { + let mut out = Vec::new(); + render_with(m, 0.0, glam::DVec3::ZERO, atlas, 0, data, &[], &mut out); + out.iter().map(|v| v.uv).collect() + } + + #[test] + fn a_mob_draws_its_variant_and_the_texture_when_the_atlas_lacks_it() { + let data = data(); + let mut m = silverfish(); + { + let textures = data.random_textures.read().unwrap(); + let seen = crate::mobs::random_textures::Seen { + biome: None, + y: 64, + blocks: Vec::new(), + moon_phase: 0, + day_time: 0, + weather: horizon_optifine::random::Weather::Clear, + }; + crate::mobs::random_textures::choose(&mut m, &textures, &data, Some(seen), 0); + } + let both = |name: &str| match name { + "entity/silverfish" => Some([0.0, 0.0, 0.5, 0.5]), + "minecraft:optifine/random/entity/silverfish2" => Some([0.5, 0.5, 1.0, 1.0]), + _ => None, + }; + let drawn = uvs(&m, &data, &both); + assert_eq!(drawn.len(), 4); + assert!(drawn.iter().all(|uv| uv[0] >= 0.5 && uv[1] >= 0.5)); + let vanilla_only = + |name: &str| (name == "entity/silverfish").then_some([0.0, 0.0, 0.5, 0.5]); + let drawn = uvs(&m, &data, &vanilla_only); + assert_eq!(drawn.len(), 4, "still drawn"); + assert!(drawn.iter().all(|uv| uv[0] <= 0.5 && uv[1] <= 0.5)); + } +} diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 590836042e..53795f4c27 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -403,6 +403,16 @@ pub(crate) fn with_built_test_world( others: &[Seen], build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry), f: impl FnOnce(&MobWorld<'_>, &super::MobTypes), +) -> bool { + with_built_test_world_rules(others, true, build, f) +} +/// `with_built_test_world` with the `mob_griefing` game rule the world reports. +#[cfg(test)] +pub(crate) fn with_built_test_world_rules( + others: &[Seen], + mob_griefing: bool, + build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry), + f: impl FnOnce(&MobWorld<'_>, &super::MobTypes), ) -> bool { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../.local/horizon-client") @@ -454,7 +464,7 @@ pub(crate) fn with_built_test_world( block_tags: &items.block_tags, landing: &motion.landing, biomes: &biomes, - mob_griefing: true, + mob_griefing, universal_anger: false, mob_drops: true, max_entity_cramming: 24, diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 24980ee182..86aa558c5e 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -225,6 +225,10 @@ struct Rename { /// template state moves to the first of them, which is the one every /// declared property is at its default for. fanout: u32, + /// The lookup from a template state and a declared combination to the state's row, as an + /// offset from `first`: `rows[template offset * fanout + combination]`, laid out as vanilla + /// does (`state_order`). Empty: the template's states times the combinations, in that order. + rows: Vec, } impl Rename { fn new(from: &str, to: &str) -> Self { @@ -234,6 +238,7 @@ impl Rename { states: 0..0, first: 0, fanout: 1, + rows: Vec::new(), } } /// The id itself, `Block{id}`, and `block..` / `item..` keys. @@ -252,13 +257,100 @@ impl Rename { } None } + /// The row of a template state's `combination`, as an offset from `first`. + fn row(&self, at: u32, combination: u32) -> u32 { + let index = at * self.fanout + combination; + self.rows.get(index as usize).copied().unwrap_or(index) + } + /// Where a template state moves: its combination with every declared property at its default. fn id(&self, id: u32) -> Option { self.states .contains(&id) - .then(|| self.first + (id - self.states.start) * self.fanout) + .then(|| self.first + self.row(id - self.states.start, 0)) } } +/// A template's own state properties, sorted by name, each with how many values it takes across +/// the template's `states`: how `StateTable::new` reads them back, the first name slowest. +fn template_properties<'a>( + states: &Range, + properties: impl Fn(u32) -> Option<&'a J>, +) -> Vec<(String, u32)> { + let Some(J::Obj(own)) = states.clone().next().and_then(&properties) else { + return Vec::new(); + }; + let mut names: Vec<&str> = own.iter().map(|(k, _)| k.as_str()).collect(); + names.sort_unstable(); + let mut out = Vec::new(); + for name in names { + let mut seen: Vec<&J> = Vec::new(); + for id in states.clone() { + if let Some(v) = properties(id).and_then(|p| p.get(name)) + && !seen.contains(&v) + { + seen.push(v); + } + } + out.push((name.to_owned(), (seen.len() as u32).max(1))); + } + out +} + +/// A block's states: in vanilla's order when the template's own properties multiply out to its +/// states (every vanilla block's do), else the template's states times the combinations. +fn block_order<'a>( + states: &Range, + properties: impl Fn(u32) -> Option<&'a J>, + declared: &[(String, u32)], + combinations: u32, +) -> Vec<(u32, u32)> { + let template = template_properties(states, properties); + if template.iter().map(|p| p.1).product::() == states.len() as u32 { + return state_order(&template, declared); + } + (0..states.len() as u32) + .flat_map(|state| (0..combinations).map(move |combo| (state, combo))) + .collect() +} + +/// The states of a block in vanilla's order (`StateDefinition`): every property, the template's +/// and the declared ones, sorted by name, the first name varying slowest. Each entry is a +/// template state's offset and the index of a declared combination (`combinations`), whose own +/// order is by declared name, the first slowest. With every declared name after the template's +/// this is the template's states times the combinations, as before. +fn state_order(template: &[(String, u32)], declared: &[(String, u32)]) -> Vec<(u32, u32)> { + // (name, values, its own side's stride, from the template) + let mut all: Vec<(&str, u32, u32, bool)> = Vec::new(); + for (side, from_template) in [(template, true), (declared, false)] { + let mut stride = 1; + let mut strides = Vec::new(); + for (_, len) in side.iter().rev() { + strides.push(stride); + stride *= len; + } + for ((name, len), stride) in side.iter().zip(strides.into_iter().rev()) { + all.push((name, *len, stride, from_template)); + } + } + all.sort_by(|a, b| a.0.cmp(b.0)); + let total: u32 = all.iter().map(|p| p.1).product(); + (0..total) + .map(|position| { + let (mut state, mut combo, mut rest) = (0, 0, total); + for &(_, len, stride, from_template) in &all { + rest /= len; + let value = (position / rest) % len; + if from_template { + state += value * stride; + } else { + combo += value * stride; + } + } + (state, combo) + }) + .collect() +} + /// The combinations of a block's declared state properties, in a fixed /// order: by name (a `BTreeMap`'s), each one's declared default first, and /// the first name varying slowest. So the first combination is every @@ -450,14 +542,17 @@ struct NewBlock<'a> { /// default first (one empty combination when the block declares none): /// the block's states are its template's states times these. combos: Vec>, + /// Each state's template state offset and combination index, in registry order + /// (`state_order`). + order: Vec<(u32, u32)>, } impl NewBlock<'_> { /// Template states and declared combinations in registry order. fn state_combinations(&self) -> impl Iterator { - self.rename - .states - .clone() - .flat_map(|id| self.combos.iter().map(move |combo| (id, combo.as_slice()))) + let start = self.rename.states.start; + self.order + .iter() + .map(move |&(state, combo)| (start + state, self.combos[combo as usize].as_slice())) } } /// An item being added (declared, or a block's). @@ -513,6 +608,73 @@ fn component_types(map: &J) -> BTreeSet { } } +/// Blocks and items native mods declare in code (`native_blocks`, +/// `native_items`), joined to the content mods: each namespace's go into +/// the content mod of that id when one is on (a native mod's own `.hmod`, +/// with its models and textures), else into a content mod with no files of +/// its own. Checked as files are ([`horizon_mods::Content::declare`], +/// [`accept_mod`]); a namespace whose declarations are refused is left out +/// whole, and its error returned. +pub fn accept_declared( + mods: &mut Vec, + blocks: &[horizon_mods::content::Block], + items: &[horizon_mods::content::Item], + known: &BTreeSet, +) -> Vec { + let namespace = |id: &str| id.split_once(':').map_or("", |(ns, _)| ns).to_owned(); + let mut by_namespace: std::collections::BTreeMap, Vec<_>)> = Default::default(); + for block in blocks { + by_namespace + .entry(namespace(&block.id)) + .or_default() + .0 + .push(block.clone()); + } + for item in items { + by_namespace + .entry(namespace(&item.id)) + .or_default() + .1 + .push(item.clone()); + } + let mut errors = Vec::new(); + for (ns, (blocks, items)) in by_namespace { + let result = match mods.iter().position(|m| m.id == ns) { + Some(i) => { + let mut content = horizon_mods::Content::declared(&ns); + content.blocks.clone_from(&mods[i].content.blocks); + content.items.clone_from(&mods[i].content.items); + content + .declare(&blocks, &items) + .and_then(|()| { + let mut added = horizon_mods::Content::declared(&ns); + added.blocks.clone_from(&blocks); + added.items.clone_from(&items); + validate_components(&added, known).map_err(|e| e.to_string()) + }) + .map(|()| { + mods[i].content.blocks.extend(blocks); + mods[i].content.items.extend(items); + }) + } + None => { + let mut content = horizon_mods::Content::declared(&ns); + content.declare(&blocks, &items).and_then(|()| { + let candidate = ContentMod { + id: ns.clone(), + content, + }; + accept_mod(mods, candidate, known).map_err(|e| e.to_string()) + }) + } + }; + if let Err(e) = result { + errors.push(format!("native blocks and items of {ns}: {e}")); + } + } + errors +} + /// Check and accept one mod without disturbing previously accepted mods. pub fn accept_mod( mods: &mut Vec, @@ -764,25 +926,6 @@ impl<'a> Plan<'a> { .into()); } } - // A block's states are its template's times the declared combinations, the - // template's properties varying slowest, but the game reads a state table the - // way vanilla's `StateDefinition` does: sorted by property name, the first name - // slowest. The two agree only when every declared name sorts after the template's, - // and a table that disagrees stops the game at load, so such a block is refused. - let last = match properties(default) { - Some(J::Obj(own)) => own.iter().map(|(k, _)| k.as_str()).max(), - _ => None, - }; - if let Some(last) = last - && let Some(early) = decl.state_properties.keys().find(|k| k.as_str() < last) - { - return Err(format!( - "{}: state property {early} sorts before {last}, a property of {}: the \ - game orders states by property name, so name it after {last}", - decl.id, decl.template - ) - .into()); - } let combos = combinations(&decl.state_properties, &decl.default_state) .map_err(|e| format!("{}: {e}", decl.id))?; // A `default_state` key the block itself declares is its own, @@ -836,9 +979,25 @@ impl<'a> Plan<'a> { } default = matches[0]; } + // The game reads a state table as vanilla's `StateDefinition` does: every + // property, the template's and the declared ones, sorted by name, the first + // name slowest. A declared name that sorts before the template's has to + // interleave with them, so the states are laid out in that order. + let declared: Vec<(String, u32)> = decl + .state_properties + .iter() + .map(|(name, property)| (name.clone(), property.values().len() as u32)) + .collect(); + let order = block_order(&states, properties, &declared, combos.len() as u32); + // The lookup from (template state, combination) to the state's row. + let mut rows = vec![0; order.len()]; + for (position, &(state, combo)) in order.iter().enumerate() { + rows[state as usize * combos.len() + combo as usize] = position as u32; + } let mut rename = Rename::new(&decl.template, &decl.id); rename.first = next; rename.fanout = combos.len() as u32; + rename.rows = rows; let count = u32::try_from(states.len() as u64 * combos.len() as u64).unwrap_or(u32::MAX); next = next.saturating_add(count); @@ -856,6 +1015,7 @@ impl<'a> Plan<'a> { shapes, default, combos, + order, }); } } @@ -1338,15 +1498,20 @@ impl<'a> Plan<'a> { }; for block in &self.blocks { if let Some(shapes) = &block.shapes { - let fanout = block.combos.len(); - for (i, shape) in shapes.iter().enumerate() { - let id = entries.len() as i32; - entries.push(encode_shape(shape)); - // One shape per template state, shared by every combination - // of it: the geometry is the template's, the properties are - // the block's. - for c in 0..fanout { - indices.push((block.rename.first as usize + i * fanout + c, id)); + // One shape per template state, shared by every combination + // of it: the geometry is the template's, the properties are + // the block's. + let ids: Vec = shapes + .iter() + .map(|shape| { + entries.push(encode_shape(shape)); + entries.len() as i32 - 1 + }) + .collect(); + for (at, &shape) in ids.iter().enumerate() { + for combination in 0..block.rename.fanout { + let row = block.rename.row(at as u32, combination); + indices.push((block.rename.first as usize + row as usize, shape)); } } } @@ -1454,10 +1619,11 @@ impl<'a> Plan<'a> { for b in &self.blocks { // Parallel to `states`: every combination of a template // state keeps that state's shape. - let mut copy = Vec::new(); - for id in b.rename.states.clone() { - for _ in &b.combos { - copy.push(a[id as usize]); + let mut copy = vec![0; b.order.len()]; + for (at, id) in b.rename.states.clone().enumerate() { + for combination in 0..b.rename.fanout { + copy[b.rename.row(at as u32, combination) as usize] = + a[id as usize]; } } a.extend(copy); @@ -2262,7 +2428,10 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result /// and mod files reuse it. With ATM10's carried mods a build takes about /// 25 s and peaks above 3 GB, more than everything the game keeps. pub fn resource_root(base: &Path, mods: &[ContentMod], cache: &Path) -> Result { - if mods.iter().all(|m| m.content.is_empty()) { + if mods + .iter() + .all(|m| m.content.is_empty() && m.content.blocks.is_empty() && m.content.items.is_empty()) + { return Ok(base.to_owned()); } let out = cache.join("mod-content"); @@ -2317,8 +2486,12 @@ fn stamp(base: &Path, mods: &[ContentMod]) -> Result { } for m in mods { parts.push(m.id.clone()); + // What it declares, so blocks and items from code count too. + parts.push(format!("{:?} {:?}", m.content.blocks, m.content.items)); let source = m.content.source(); - if source.is_dir() { + if m.content.files.is_empty() { + // Declared in code only: nothing to read. + } else if source.is_dir() { for file in &m.content.files { parts.push(meta(&source.join(file))?); } diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 8b441e0d98..42fe968aae 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -2608,6 +2608,98 @@ fn content_block_state_properties_fan_out_its_states() { fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok(); } +/// A declared property whose name sorts before the template's goes in vanilla's order +/// (`StateDefinition`): all the names sorted, the first slowest, so `alpha` varies slower than the +/// template's `axis`. A state table that read the rows in the other order stopped the game at +/// load ("registry state order differs from vanilla's state definition"). +#[test] +fn content_block_state_properties_follow_vanilla_name_order() { + let (base, mods) = block_base( + "block-name-order", + &[r#"{"template":"minecraft:test","item":false, + "state_properties":{"alpha":{"type":"boolean"}}}"#], + ); + let plan = Plan::new(&base, &mods).unwrap(); + let facts = fs::read_to_string(base.join("facts.json")).unwrap(); + let mut doc: J = serde_json::from_str(&facts).unwrap(); + plan.json("facts.json", &mut doc).unwrap(); + let Some(J::Arr(rows)) = doc.get("states") else { + panic!("no states") + }; + assert_eq!(rows.len(), 2 + 4); + let properties = |i: usize| -> (String, String) { + let J::Arr(row) = &rows[i] else { + panic!("row {i} is not a state") + }; + let Some(J::Obj(state)) = row.get(4) else { + panic!("row {i} has no state") + }; + let Some((_, J::Obj(p))) = state.iter().find(|(k, _)| k == "properties") else { + panic!("row {i} has no properties") + }; + let value = |name: &str| match p.iter().find(|(k, _)| k == name) { + Some((_, J::Str(s))) => s.clone(), + _ => panic!("row {i} has no {name}"), + }; + (value("alpha"), value("axis")) + }; + let own = |a: &str, b: &str| (a.to_owned(), b.to_owned()); + // `alpha` slowest, then `axis`: the first name varies slowest, as in vanilla. + assert_eq!( + (2..6).map(properties).collect::>(), + [ + own("false", "x"), + own("false", "y"), + own("true", "x"), + own("true", "y") + ] + ); + // The default is the template's default state with every declared property at its default. + assert_eq!( + doc.get("defaults").unwrap().get("testmod:test"), + Some(&J::Num(2.into())) + ); + fs::remove_dir_all(&base).unwrap(); + fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok(); +} + +/// `state_order` over the template's and the declared properties together. +#[test] +fn block_states_are_laid_out_in_vanilla_order() { + let p = |name: &str, len: u32| (name.to_owned(), len); + // Every declared name after the template's: the template's states times the combinations, + // as before, so ids of existing blocks do not move. + let order = state_order(&[p("axis", 2)], &[p("lit", 2)]); + assert_eq!(order, [(0, 0), (0, 1), (1, 0), (1, 1)]); + // No declared property, or no template property: the identity, whichever side it is on. + assert_eq!(state_order(&[p("axis", 3)], &[]), [(0, 0), (1, 0), (2, 0)]); + assert_eq!(state_order(&[], &[p("lit", 2)]), [(0, 0), (0, 1)]); + assert_eq!(state_order(&[], &[]), [(0, 0)]); + // `bites` (4 values) before `facing` (4 values): the declared one slowest. + let order = state_order(&[p("facing", 4)], &[p("bites", 4)]); + assert_eq!(order.len(), 16); + assert!( + order + .iter() + .enumerate() + .all(|(j, &(s, c))| (s, c) == (j as u32 % 4, j as u32 / 4)) + ); + // Interleaved: `b` between the template's `a` and `c`. a (2), b (2), c (3), a slowest. + let order = state_order(&[p("a", 2), p("c", 3)], &[p("b", 2)]); + assert_eq!(order.len(), 12); + // Template state = a * 3 + c, combo = b. + assert_eq!(order[0], (0, 0)); + assert_eq!(order[1], (1, 0)); + assert_eq!(order[3], (0, 1)); + assert_eq!(order[6], (3, 0)); + assert_eq!(order[11], (5, 1)); + // Every pair appears once. + let mut seen = order.clone(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), 12); +} + /// The declared default comes first in the run, so its id is the block's /// default with no arithmetic of its own. #[test] @@ -2677,24 +2769,6 @@ fn content_block_state_properties_are_validated() { fs::remove_dir_all(&base).unwrap(); fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok(); - // A declared name that sorts before the template's `axis` would make the state table - // disagree with vanilla's order and stop the game at load: refused, naming the block. - let (base, mods) = block_base( - "block-name-order", - &[r#"{"template":"minecraft:test","state_properties":{"alpha":{"type":"boolean"}}}"#], - ); - let error = match Plan::new(&base, &mods) { - Err(e) => e.to_string(), - Ok(_) => panic!("a property sorting before the template's was accepted"), - }; - assert!( - error.contains("testmod:") && error.contains("alpha sorts before axis, a property of"), - "{error}" - ); - assert!(!error.contains(" "), "one space between words: {error}"); - fs::remove_dir_all(&base).unwrap(); - fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok(); - // A default is one of the values the block declared. let (base, mods) = block_base( "block-bad-default", @@ -3369,3 +3443,101 @@ fn biome_modifiers_take_one_biome_or_a_list() { ); let _ = fs::remove_dir_all(&scratch); } + +/// Blocks and items a native mod declares in code join the content plan: +/// inside the content mod of their namespace, or as one of their own, with +/// ids after vanilla's as files' are. +#[test] +#[ignore = "full content root: CI and pr_ready run it"] +fn blocks_and_items_declared_in_code_get_ids() { + use horizon_mods::content::{Block, Item}; + let Some(base) = prepared() else { + return; + }; + let scratch = std::env::temp_dir().join(format!("horizon-mod-declared-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let known = known_components(&base).unwrap(); + let block = |id: &str| Block { + id: id.into(), + template: "minecraft:oak_log".into(), + item: true, + ..Block::default() + }; + let wrench = Item { + id: "gears:wrench".into(), + template: "minecraft:stick".into(), + max_stack_size: Some(1), + ..Item::default() + }; + let mut mods = vec![ruby_mod(&scratch.join("ruby"))]; + let errors = accept_declared( + &mut mods, + &[block("ruby:ruby_log"), block("gears:shaft")], + &[wrench], + &known, + ); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(mods.len(), 2); + assert!( + mods[0] + .content + .blocks + .iter() + .any(|b| b.id == "ruby:ruby_log") + ); + assert_eq!(mods[1].id, "gears"); + // A namespace another mod owns, and a clash with a file's block, are refused whole. + let refused = accept_declared( + &mut mods, + &[block("ruby:ruby_block"), block("ruby:ruby_post")], + &[], + &known, + ); + assert_eq!(refused.len(), 1, "{refused:?}"); + assert!( + !mods[0] + .content + .blocks + .iter() + .any(|b| b.id == "ruby:ruby_post") + ); + + let root = resource_root(&base, &mods, &scratch.join("cache")).unwrap(); + let vanilla = Assets::load(&base).unwrap(); + let assets = Assets::load(&root).unwrap(); + let n = vanilla.registry.len() as u32; + let log_states = (0..n) + .filter(|&s| vanilla.registry.state(BlockStateId(s)).unwrap().name == "minecraft:oak_log") + .count() as u32; + // Sorted by mod, then block: gears:shaft, then ruby's ruby_block, ruby_door, ruby_glass, ruby_log. + let shaft = (n..assets.registry.len() as u32) + .find(|&s| assets.registry.state(BlockStateId(s)).unwrap().name == "gears:shaft") + .unwrap(); + assert_eq!(shaft, n); + assert!(assets.registry.default_state("ruby:ruby_log").is_some()); + assert_eq!( + assets.registry.len() as u32, + n + log_states + 1 + 64 + 1 + log_states + ); + let ids = horizon_protocol::registry::Builtin::load(&root.join("registries.json")).unwrap(); + for item in ["gears:shaft", "gears:wrench", "ruby:ruby_log"] { + assert!( + ids.get("minecraft:item").unwrap().id(item).is_some(), + "{item}" + ); + } + let facts = BlockFacts::load(&root, assets.registry).unwrap(); + let items = Items::load(&root, assets.registry, &facts, &PackOverlay::NONE).unwrap(); + assert_eq!(items.items["gears:wrench"].max_stack, 1); + assert!(items.items["gears:shaft"].block.is_some()); + // The same declarations reuse the root; others build a new one. + assert_eq!( + resource_root(&base, &mods, &scratch.join("cache")).unwrap(), + root + ); + let stamp = fs::read(root.join(STAMP)).unwrap(); + mods[1].content.items[0].max_stack_size = Some(2); + resource_root(&base, &mods, &scratch.join("cache")).unwrap(); + assert_ne!(fs::read(root.join(STAMP)).unwrap(), stamp); + let _ = fs::remove_dir_all(&scratch); +} diff --git a/crates/horizon-client/src/motion/environment.rs b/crates/horizon-client/src/motion/environment.rs index 56dad3323e..ecae2fabcf 100644 --- a/crates/horizon-client/src/motion/environment.rs +++ b/crates/horizon-client/src/motion/environment.rs @@ -1,5 +1,6 @@ //! Player liquid travel and environmental damage over the shared fluid state. use super::*; +use crate::breathing::{Breath, apply, breathe}; use horizon_core::random::RandomSource; use horizon_world::{ fluid::FluidKind, @@ -57,12 +58,12 @@ impl MotionWorld<'_, '_, '_> { .state(self.block(eye)) .is_some_and(|state| state.name == "minecraft:bubble_column"); let submerged = self.player.is_eye_in_fluid(WATER) && !bubble_column; - if submerged + let drowns = submerged && !self.player.status.water_breathing && !self.player.status.breath_of_the_nautilus && !self.player.invulnerable - && !self.player.spectator - { + && !self.player.spectator; + let vanilla = if drowns { // decreaseAirSupply's getAttribute: the instance exists from now // on, even unmodified. let key = self.player.attributes.key("minecraft:oxygen_bonus"); @@ -74,22 +75,37 @@ impl MotionWorld<'_, '_, '_> { .player .attribute("minecraft:oxygen_bonus")? .max(f64::from(self.player.respiration)); - if oxygen <= 0.0 || self.scene.random.next_double() < 1.0 / (oxygen + 1.0) { - self.player.air -= 1; + let sparing = oxygen > 0.0 && self.scene.random.next_double() >= 1.0 / (oxygen + 1.0); + Breath { + can_breathe: false, + consume: i32::from(!sparing), + refill: 0, } - if self.player.air <= -20 { - self.player.air = 0; - self.player.events.push(MotionEvent::Drown); - self.player.events.push(MotionEvent::Health( - crate::health::HealthEvent::DrownDamage(2.0), - )); - self.environment_hurt("minecraft:drown", 2.0)?; + } else { + // The nautilus' breath keeps a submerged player's air as it is. + let held = submerged + && self.player.status.breath_of_the_nautilus + && !self.player.status.water_breathing; + Breath { + can_breathe: true, + consume: 1, + refill: if held { 0 } else { 4 }, } - } else if !submerged - || !self.player.status.breath_of_the_nautilus - || self.player.status.water_breathing - { - self.player.air = (self.player.air + 4).min(300); + }; + // LivingBreatheEvent, on the server's copy of the player only. + let breath = if self.player.client_side { + vanilla + } else { + breathe(self.player.entity_id as u32, "minecraft:player", vanilla) + }; + let (air, drowned) = apply(self.player.air, 300, breath); + self.player.air = air; + if drowned { + self.player.events.push(MotionEvent::Drown); + self.player.events.push(MotionEvent::Health( + crate::health::HealthEvent::DrownDamage(2.0), + )); + self.environment_hurt("minecraft:drown", 2.0)?; } if self.player.is_in_lava() { self.player.ignite_for_ticks(300)?; diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 94a9f9be77..1e9ea89ed4 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -656,7 +656,7 @@ impl Projectile { } } /// `ShulkerBullet.tick`. - fn tick_bullet(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) { + fn tick_bullet(&mut self, w: &impl ProjectileWorld, impact: bool) -> (bool, Hit) { let target = self.target_box(w); let ProjectileKind::ShulkerBullet { delta, .. } = &mut self.kind else { return (false, Hit::None); @@ -700,11 +700,11 @@ impl Projectile { self.left_owner = owner_box.is_none_or(|b| !b.inflate(Vec3::new(1.0, 1.0, 1.0)).contains(from)); } - if let Some((who, at, _)) = best { + if impact && let Some((who, at, _)) = best { self.position = at; return (false, Hit::Entity { who, at }); } - if let Some((at, pos, face)) = block { + if impact && let Some((at, pos, face)) = block { self.position = at; return (false, Hit::Block { pos, at, face }); } @@ -768,8 +768,17 @@ impl Projectile { /// One tick: returns whether to keep the projectile and what it hit. pub fn tick(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) { + self.tick_with(w, true) + } + /// One tick that meets nothing: what `tick` would have done had its hit been + /// canceled (`ProjectileImpactEvent`). The projectile goes the whole way of its + /// motion and flies on, with no hit to act on. + pub fn tick_through(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) { + self.tick_with(w, false) + } + fn tick_with(&mut self, w: &impl ProjectileWorld, impact: bool) -> (bool, Hit) { let from = self.position; - let result = self.tick_inner(w); + let result = self.tick_inner(w, impact); w.block_contacts( self.id as i32, self.kind.id(), @@ -779,11 +788,11 @@ impl Projectile { ); result } - fn tick_inner(&mut self, w: &impl ProjectileWorld) -> (bool, Hit) { + fn tick_inner(&mut self, w: &impl ProjectileWorld, impact: bool) -> (bool, Hit) { self.previous = self.position; self.age += 1; if matches!(self.kind, ProjectileKind::ShulkerBullet { .. }) { - return self.tick_bullet(w); + return self.tick_bullet(w, impact); } // ThrowableProjectile and AbstractHurtingProjectile.applyInertia read // `isInWater` before their move and `Projectile.tick`'s base tick; @@ -852,11 +861,11 @@ impl Projectile { self.left_owner = owner_box .is_none_or(|b| !b.inflate(Vec3::new(1.0, 1.0, 1.0)).contains(self.position)); } - if let Some((who, at, _)) = best { + if impact && let Some((who, at, _)) = best { self.position = at; return (false, Hit::Entity { who, at }); } - if let Some((at, pos, face)) = block { + if impact && let Some((at, pos, face)) = block { self.position = at; return (false, Hit::Block { pos, at, face }); } @@ -990,6 +999,80 @@ mod tests { assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9); assert!((p.position.x - 0.99).abs() < 1e-9); } + /// A wall of blocks across x = 1. + struct Wall(Vec<(Who, Aabb)>); + impl ProjectileWorld for Wall { + fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { + (from.x < 1.0 && to.x >= 1.0) + .then(|| (Vec3::new(1.0, from.y, from.z), [1, 100, 0], [-1, 0, 0])) + } + fn in_water(&self, _: Aabb) -> bool { + false + } + fn targets(&self) -> &[(Who, Aabb)] { + &self.0 + } + } + fn thrown(kind: ProjectileKind) -> Projectile { + let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), Some(Who::Player)); + (p.left_owner, p.velocity) = (true, Vec3::new(1.5, 0.0, 0.0)); + p + } + #[test] + fn a_tick_through_a_wall_flies_the_whole_way_and_hits_nothing() { + for kind in [ + ProjectileKind::Snowball, + ProjectileKind::SmallFireball, + ProjectileKind::LlamaSpit, + ] { + let wall = Wall(Vec::new()); + let mut hit = thrown(kind.clone()); + let (keep, hits) = hit.tick(&wall); + assert!( + !keep + && matches!( + hits, + Hit::Block { + pos: [1, 100, 0], + .. + } + ) + ); + assert_eq!(hit.position.x, 1.0); + // The same tick, canceled: on, as in open air. + let mut through = thrown(kind.clone()); + let mut open = thrown(kind); + let (kept, none) = through.tick_through(&wall); + assert_eq!(none, Hit::None); + let _ = open.tick(&Empty(Vec::new())); + assert_eq!( + (through.position, through.velocity), + (open.position, open.velocity) + ); + assert!(kept); + } + } + #[test] + fn a_tick_through_an_entity_does_not_hit_it() { + let pig = Aabb::new(Vec3::new(1.0, 99.5, -0.5), Vec3::new(2.0, 100.5, 0.5)); + let world = Empty(vec![(Who::Mob(7), pig)]); + let mut hit = thrown(ProjectileKind::EnderPearl); + assert!(matches!( + hit.tick(&world), + ( + false, + Hit::Entity { + who: Who::Mob(7), + .. + } + ) + )); + let mut through = thrown(ProjectileKind::EnderPearl); + let (keep, none) = through.tick_through(&world); + assert!(keep && none == Hit::None); + // Gravity and drag of the tick, then the whole of its motion. + assert!((through.position.x - 1.5 * 0.99).abs() < 1e-9); + } #[test] fn a_snowball_with_no_gravity_stays_where_it_was_summoned() { // Entity.getGravity is 0 with NoGravity: at rest, it never moves. diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 13019d9f93..ea83fbc572 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -885,12 +885,15 @@ fn decode_mod_block_entity(id: String, tag: &Tag) -> Option Option<((i32, i32, i32), crate::containers::BlockEntity)> { use crate::containers::BlockEntity; diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index f38e69d267..8750ecb64d 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -1280,7 +1280,7 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() { /// with no `BlockEntityData` for one whose mod gives no tag. #[test] fn a_synced_mod_block_entity_reaches_the_chunks_players_once_a_tick() { - use horizon_client::containers::{BlockEntity, ModBlockEntity}; + use horizon_client::containers::{BlockEntity, ModBlockEntity, ModBlockEntityFields}; use horizon_world::nbt::{Compound, JavaString, Tag}; let Some(resources) = resources() else { return; @@ -1309,10 +1309,10 @@ fn a_synced_mod_block_entity_reaches_the_chunks_players_once_a_tick() { s.world .set(pos.0, pos.1, pos.2, BlockStateId(MACHINE)) .unwrap(); - let mut machine = ModBlockEntity { + let mut machine = ModBlockEntity::new(ModBlockEntityFields { id: "test:machine".into(), - ..ModBlockEntity::default() - }; + ..Default::default() + }); if let Some(on) = on { machine.nbt.insert("on".into(), Tag::Byte(on)); } @@ -1445,3 +1445,46 @@ fn a_respawned_guest_is_immune_until_its_client_loads_the_world() { "the packet marks it loaded" ); } + +/// `tickPlayer`'s vehicle check (BoatFly): the driver of a boat its last accepted move left hovering +/// over air is kicked once the boat has floated longer than `getMaximumFlyingTicks` (160 ticks at a +/// boat's gravity), and a move that doesn't float starts the count again. +#[test] +fn a_boat_floating_too_long_kicks_its_driver() { + let Some(resources) = resources() else { + return; + }; + let mut s = server(&resources); + let port = s.hub.lan.port; + let _bot = run(&mut s, move || connect(port, "Boater", true)); + let deadline = Instant::now() + Duration::from_secs(5); + while s.hub.guests.is_empty() { + s.tick(&[]); + assert!(Instant::now() < deadline, "guest did not join"); + thread::yield_now(); + } + let g = &mut s.hub.guests[0]; + (g.vehicle, g.vehicle_floating, g.vehicle_gravity) = (Some(7), true, 0.04); + for _ in 0..100 { + s.tick(&[]); + } + assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 100); + // The boat touched water: the next floating move counts from zero. + s.hub.guests[0].vehicle_floating = false; + s.tick(&[]); + assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 0); + s.hub.guests[0].vehicle_floating = true; + for _ in 0..160 { + s.tick(&[]); + } + assert!( + s.hub.guests[0].left_reason.is_empty(), + "160 ticks are a boat's allowance: {}", + s.hub.guests[0].left_reason + ); + s.tick(&[]); + assert!( + !s.hub.guests[0].left_reason.is_empty(), + "the 161st floating tick kicks the driver" + ); +} diff --git a/crates/horizon-mods/src/content.rs b/crates/horizon-mods/src/content.rs index 09849de75c..7d3b024ad8 100644 --- a/crates/horizon-mods/src/content.rs +++ b/crates/horizon-mods/src/content.rs @@ -181,8 +181,10 @@ fn yes() -> bool { } /// A block the mod adds: a vanilla template's behaviour, states and shapes -/// under a new id, with `properties` applied. -#[derive(Clone, Debug, PartialEq)] +/// under a new id, with `properties` applied. A native mod may build one in +/// code (`native_blocks`): `..Default::default()` leaves the rest as a file +/// leaves it out, except `item`, which a file sets unless told not to. +#[derive(Clone, Debug, Default, PartialEq)] pub struct Block { /// `:`. pub id: String, @@ -248,8 +250,9 @@ struct ItemFile { } /// An item the mod adds: a vanilla template item's components under a new -/// id, with these changed. -#[derive(Clone, Debug, PartialEq)] +/// id, with these changed. A native mod may build one in code +/// (`native_items`). +#[derive(Clone, Debug, Default, PartialEq)] pub struct Item { pub id: String, /// The vanilla item it copies, `minecraft:`. @@ -334,6 +337,8 @@ pub const MAX_MENU_SLOTS: u16 = 256; enum Source { Dir(PathBuf), Zip(PathBuf), + /// No files: blocks and items a native mod declares in code. + Code, } /// A mod's content: its files and declarations. @@ -445,10 +450,95 @@ impl Content { Ok(content) } + /// Content with no files, for the blocks and items native mods declare + /// in code in `namespace` ([`Content::declare`]). + pub fn declared(namespace: &str) -> Content { + Content { + source: Source::Code, + files: Vec::new(), + blocks: Vec::new(), + items: Vec::new(), + effects: Vec::new(), + creative_tabs: Vec::new(), + entity_types: Vec::new(), + menu_types: Vec::new(), + namespaces: vec![namespace.to_owned()], + } + } + + /// Adds blocks and items a native mod declares in code (`native_blocks`, + /// `native_items`), checked as declaration files are: ids in this mod's + /// own namespace (its id, as `data//horizon/` is) and declared once + /// with what it already has, a vanilla template, a crafting remainder + /// that is vanilla or this mod's. All or nothing: on an error nothing is + /// added. + pub fn declare(&mut self, blocks: &[Block], items: &[Item]) -> Result<(), String> { + let own = self + .namespaces + .first() + .map(String::as_str) + .unwrap_or_default(); + let mut ids: BTreeSet<&str> = self + .blocks + .iter() + .map(|b| b.id.as_str()) + .chain(self.items.iter().map(|i| i.id.as_str())) + .collect(); + let mut item_ids: BTreeSet<&str> = self + .blocks + .iter() + .filter(|b| b.item) + .map(|b| b.id.as_str()) + .chain(self.items.iter().map(|i| i.id.as_str())) + .collect(); + let in_own = + |id: &str| namespaced_id(id) && id.split_once(':').is_some_and(|(ns, _)| ns == own); + for block in blocks { + let id = block.id.as_str(); + if !in_own(id) { + return Err(format!("block {id:?} is not an id in {own}:")); + } + check_block(block).map_err(|e| format!("block {id}: {e}"))?; + if !ids.insert(id) { + return Err(format!("block {id} is declared twice")); + } + if block.item { + item_ids.insert(id); + } + } + for item in items { + let id = item.id.as_str(); + if !in_own(id) { + return Err(format!("item {id:?} is not an id in {own}:")); + } + check_item(item).map_err(|e| format!("item {id}: {e}"))?; + if !item_ids.insert(id) { + return Err(format!( + "item {id} is declared twice, or is also a block's item" + )); + } + } + for item in items { + if let Some(rest) = &item.crafting_remainder + && (rest == "minecraft:air" + || !vanilla_id(rest) && !item_ids.contains(rest.as_str())) + { + return Err(format!( + "item {}: crafting_remainder {rest:?} is not a vanilla item (minecraft:...) or one of this mod's", + item.id + )); + } + } + self.blocks.extend_from_slice(blocks); + self.items.extend_from_slice(items); + Ok(()) + } + /// Where the files are read from: the mod's folder or its `.hmod`. pub fn source(&self) -> &Path { match &self.source { Source::Dir(path) | Source::Zip(path) => path, + Source::Code => Path::new(""), } } @@ -468,6 +558,7 @@ impl Content { Ok(match &self.source { Source::Dir(root) => FileReader::Dir(root), Source::Zip(path) => FileReader::Zip(Box::new(open_zip(path)?)), + Source::Code => FileReader::Dir(Path::new("")), }) } @@ -543,11 +634,16 @@ impl Content { for (block, name) in blocks { let file: BlockFile = serde_json::from_slice(&self.file(&name)?).map_err(|e| format!("{name}: {e}"))?; - check_block(&file).map_err(|e| format!("{name}: {e}"))?; - if file.item { - ids.insert(block.clone()); + if file + .connects_to + .as_ref() + .is_some_and(|t| !t.starts_with('#')) + { + return Err(format!( + "{name}: connects_to is a block tag, #namespace:path" + )); } - self.blocks.push(Block { + let block = Block { id: block, template: file.template, properties: file.properties, @@ -560,37 +656,33 @@ impl Content { .connects_to .map(|t| t.trim_start_matches('#').to_owned()), thickness: file.thickness, - }); + }; + check_block(&block).map_err(|e| format!("{name}: {e}"))?; + if block.item { + ids.insert(block.id.clone()); + } + self.blocks.push(block); } let mut remainders = Vec::new(); for (item, name) in items { let file: ItemFile = serde_json::from_slice(&self.file(&name)?).map_err(|e| format!("{name}: {e}"))?; - if !vanilla_id(&file.template) { - return Err(format!( - "{name}: template {:?} is not a vanilla item (minecraft:...)", - file.template - )); - } - if file.max_stack_size.is_some_and(|n| !(1..=99).contains(&n)) { - return Err(format!("{name}: max_stack_size is 1 to 99")); - } - check_components(&file.components, &["max_stack_size", "rarity"]) - .map_err(|e| format!("{name}: {e}"))?; - if !ids.insert(item.clone()) { - return Err(format!("{name}: {item} is also a block's item")); - } - if let Some(rest) = &file.crafting_remainder { - remainders.push((name, rest.clone())); - } - self.items.push(Item { + let item = Item { id: item, template: file.template, max_stack_size: file.max_stack_size, rarity: file.rarity, components: file.components, crafting_remainder: file.crafting_remainder, - }); + }; + check_item(&item).map_err(|e| format!("{name}: {e}"))?; + if !ids.insert(item.id.clone()) { + return Err(format!("{name}: {} is also a block's item", item.id)); + } + if let Some(rest) = &item.crafting_remainder { + remainders.push((name, rest.clone())); + } + self.items.push(item); } for (effect, name) in effects { let file: MobEffect = @@ -712,7 +804,23 @@ fn check_components( Ok(()) } -fn check_block(file: &BlockFile) -> Result<(), String> { +/// An item's fields, as a file or code gives them. +fn check_item(item: &Item) -> Result<(), String> { + if !vanilla_id(&item.template) { + return Err(format!( + "template {:?} is not a vanilla item (minecraft:...)", + item.template + )); + } + if item.max_stack_size.is_some_and(|n| !(1..=99).contains(&n)) { + return Err("max_stack_size is 1 to 99".into()); + } + check_components(&item.components, &["max_stack_size", "rarity"]) +} + +/// A block's fields, as a file or code gives them (`connects_to` without +/// its `#`). +fn check_block(file: &Block) -> Result<(), String> { if !vanilla_id(&file.template) { return Err(format!( "template {:?} is not a vanilla block (minecraft:...)", @@ -817,7 +925,7 @@ fn check_block(file: &BlockFile) -> Result<(), String> { if file.template != "minecraft:chorus_plant" { return Err("connects_to needs template minecraft:chorus_plant (a PipeBlock)".into()); } - let id = tag.strip_prefix('#').unwrap_or_default(); + let id = tag.as_str(); let valid = |s: &str| { !s.is_empty() && s.chars() @@ -906,6 +1014,83 @@ capabilities = ["registries"] content } + #[test] + fn blocks_and_items_declared_in_code_pass_the_files_checks() { + let block = |id: &str| Block { + id: id.into(), + template: "minecraft:iron_block".into(), + item: true, + ..Block::default() + }; + let item = |id: &str| Item { + id: id.into(), + template: "minecraft:diamond".into(), + ..Item::default() + }; + let mut content = Content::declared("ruby"); + assert!(content.is_empty()); + let wrench = Item { + crafting_remainder: Some("ruby:gear".into()), + ..item("ruby:wrench") + }; + content + .declare(&[block("ruby:gear"), block("ruby:casing")], &[wrench]) + .unwrap(); + assert_eq!(content.blocks.len(), 2); + assert_eq!(content.items[0].id, "ruby:wrench"); + // Each refusal adds nothing. + let refused = |blocks: &[Block], items: &[Item]| { + let mut c = Content::declared("ruby"); + c.declare(&[block("ruby:gear")], &[]).unwrap(); + let e = c.declare(blocks, items).unwrap_err(); + assert_eq!((c.blocks.len(), c.items.len()), (1, 0), "{e}"); + e + }; + assert!(refused(&[block("emerald:gear")], &[]).contains("not an id in ruby:")); + assert!(refused(&[block("minecraft:gear")], &[]).contains("not an id in ruby:")); + assert!(refused(&[], &[item("ruby")]).contains("not an id in ruby:")); + assert!(refused(&[block("ruby:gear")], &[]).contains("declared twice")); + assert!(refused(&[], &[item("ruby:gear")]).contains("block's item")); + let modded = Block { + template: "ruby:other".into(), + ..block("ruby:casing") + }; + assert!(refused(&[modded], &[]).contains("not a vanilla block")); + let dim = Block { + properties: BlockProperties { + light: Some(16), + ..BlockProperties::default() + }, + ..block("ruby:lamp") + }; + assert!(refused(&[dim], &[]).contains("light is 0 to 15")); + let stack = Item { + max_stack_size: Some(100), + ..item("ruby:ingot") + }; + assert!(refused(&[], &[stack]).contains("max_stack_size")); + let rest = Item { + crafting_remainder: Some("emerald:dust".into()), + ..item("ruby:dust") + }; + assert!(refused(&[block("ruby:pipe")], &[rest]).contains("crafting_remainder")); + // A cable's tag comes bare from code. + let cable = Block { + template: "minecraft:chorus_plant".into(), + connects_to: Some("ruby:cables".into()), + ..block("ruby:cable") + }; + content.declare(&[cable], &[]).unwrap(); + } + + #[test] + fn a_connecting_block_file_names_its_tag_with_a_hash() { + let cable = br#"{"template": "minecraft:chorus_plant", "connects_to": "ruby:cables"}"#; + let files: &[(&str, &[u8])] = &[("data/ruby/horizon/block/cable.json", cable)]; + let e = hmod("bare-tag", MANIFEST_TEXT, files).unwrap_err(); + assert!(e.contains("#namespace:path"), "{e}"); + } + #[test] fn content_only_mod_round_trips() { let files: &[(&str, &[u8])] = &[ diff --git a/crates/horizon-optifine/src/random.rs b/crates/horizon-optifine/src/random.rs index d30b09428b..fb356960e2 100644 --- a/crates/horizon-optifine/src/random.rs +++ b/crates/horizon-optifine/src/random.rs @@ -139,13 +139,19 @@ impl Rules { Self { rules } } - /// The variant this entity wears: the first matching rule's pick, or 1 (the default). ETF - /// also lets a rule list 0 for the default; the caller treats 0 as 1. + /// The first matching rule's pick, or 1 when none matches. [`Rules::matching`] tells a miss + /// (the texture itself, as is a rule's 0) from a pick of 1, which `1.png` replaces. pub fn select(&self, facts: &Facts, seed: i32) -> u32 { + self.matching(facts, seed).unwrap_or(1) + } + + /// The first matching rule's pick, `None` when no rule matches: the texture itself, even + /// where a `1.png` replaces variant 1 (ETF). + pub fn matching(&self, facts: &Facts, seed: i32) -> Option { self.rules .iter() .find(|r| r.matches(facts)) - .map_or(1, |r| r.pick(seed)) + .map(|r| r.pick(seed)) } /// Whether any rule reads a fact that changes while the entity lives (name, profession, @@ -495,6 +501,10 @@ mod tests { ..Default::default() }; assert_eq!(r.select(&baby, 1), 3); + // `matching` tells a miss from a rule that picks 1. + assert_eq!(r.matching(&Facts::default(), 1), None); + assert_eq!(r.matching(&baby, 1), Some(3)); + assert_eq!(rules("textures.1=1").matching(&baby, 0), Some(1)); } #[test] diff --git a/crates/horizon-optifine/src/random_textures.rs b/crates/horizon-optifine/src/random_textures.rs index 4953eaa999..9c2f222332 100644 --- a/crates/horizon-optifine/src/random_textures.rs +++ b/crates/horizon-optifine/src/random_textures.rs @@ -41,6 +41,8 @@ use crate::random::{MAX_INDEX, Rules}; pub struct RandomTextures { entries: HashMap, rules: Vec, + /// Different for every scan: choices made from another scan's rules are stale. + generation: u64, } /// One texture's variants. @@ -82,6 +84,20 @@ struct Source { impl RandomTextures { pub fn scan(root: &Path) -> Self { + static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let generation = GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Self { + generation, + ..Self::scan_files(root) + } + } + + /// This scan's number, never 0 (the empty set's). + pub fn generation(&self) -> u64 { + self.generation + } + + fn scan_files(root: &Path) -> Self { let mut found: HashMap = HashMap::new(); let Ok(namespaces) = fs::read_dir(root.join("assets")) else { return Self::default(); diff --git a/docs/architecture.md b/docs/architecture.md index 1e43fd261f..21ca3a42ad 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`, `crates/horizon-assets/` | 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`, `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/graphics.md b/docs/graphics.md index 19c7ba1030..879e9c5bee 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -35,7 +35,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi **Translucent batching** (only in the file for now: `translucentBatching` in `horizon-graphics.txt`, off by default; edit it with the game closed, it applies at the next start) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same; it costs a little video memory, as solid and translucent quads no longer share free space. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again. -**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 by default, for 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, others show it washed out. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. +**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 by default, for 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. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. 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 (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta. diff --git a/docs/mods.md b/docs/mods.md index e552136399..addd612814 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -95,8 +95,12 @@ for a vanilla block that ticks, and its sections count it. A block copies its template's states. It can also declare state properties of its own, which its template does not have — how a pipe says which sides it connects to, or a machine whether it is running. Its states then become its -template's states times the combinations of the ones it declares, in a fixed -order with every declared default first, so ids do not move between loads: +template's states times the combinations of the ones it declares, laid out as +vanilla lays out a block's states (`StateDefinition`): every property, the +template's and the declared ones, sorted by name with the first name varying +slowest, and each declared property's default value first. The order is fixed, +so ids do not move between loads, and a declared name may sort before one of +the template's (`bites` on a carved pumpkin, whose `facing` comes after it): ```json { "template": "minecraft:stone", diff --git a/docs/native-mods.md b/docs/native-mods.md index b70766561d..bfccf81b8a 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -962,6 +962,12 @@ For mods that run where the world is simulated: a dedicated server, a LAN host, `number`, `boolean`, `player`, `item`, `block_pos`. The usages of one command merge into one tree, which every player receives after vanilla's (filtered by their level, as vanilla's is), so their chat box completes and colours it. A name vanilla has, or a usage that doesn't parse, is left out with a warning in the log. +- **`native_sign_changed(player, name, dimension, pos, front, lines, cancel)`**: a player finished a sign they were + editing, heard once every check has passed (editing that sign, in reach, not waxed, the chat filter) and right before + the text goes on it. `lines` holds the four lines as written, formatting codes still in; rewrite them and that is + what the sign gets (the chat filter does not look at it again), or set `cancel` and it keeps its old text. The + player's state is swapped in, so `native_tell` reaches them. Not told: the host's own editing on a LAN or + single-player world, and changes that are no text edit (dye, glow ink, wax, `/data`, `/setblock`). - **`native_player_command(player, name, command, canceled, replies)`**: any command a player typed (without the slash), vanilla's too and commands the player may not run, heard before anything looks at it, and each line of a quiet command batch. Set `canceled` and nothing runs; push lines to `replies` and the player gets them as system chat @@ -1126,6 +1132,13 @@ items are left out (NeoForge's `RenderLivingEvent.Pre`, cancelled); its name tag - A mod's own creature is an ordinary mob (`native_spawn_mob`, API 37) drawn this way: a quiet villager, an allay or a bat gives it movement, sounds and saving, and the mod gives it a model and animations of its own. Tell yours apart by the ids the server half sends with mod messages (API 40), or by its custom name. +- Players have their own hook, `native_player_submit(player, who, draw, drawn)` (`RenderPlayerEvent.Pre`): once a frame for + each other player in view and for this window's player in third person, with the same frame and `who` (the profile name, + and `local`). `drawn` leaves out the vanilla model, armour, held items, cape and other layers. +- `native_mob_layer(mob, who, draw)` (`EntityRenderersEvent.AddLayers`, `RenderLivingEvent.Post`) adds to every mob and player + after its vanilla model and layers, in the same frame (not for one a mod drew itself); `who` is set for a player. The + geometry hangs from the entity's frame, not from a model bone: place a hat from `head_yaw`, `pitch` and the entity type's + eye height. ### Advancement triggers diff --git a/docs/resource-packs.md b/docs/resource-packs.md index f02f98ad31..1df4467dea 100644 --- a/docs/resource-packs.md +++ b/docs/resource-packs.md @@ -38,7 +38,7 @@ 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` | -| Entity models | (code in vanilla) | n/a | entity models aren't resource files in 26.3 (`test_entity_model_parity` checks the code ones) | +| 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) | | 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` | | Texture animation: `interpolate` | `*.png.mcmeta` `animation` | **no** | frames step instead of blending (vanilla's own prismarine, magma, stems, lit furnaces and campfires, command blocks, sculk too): fix in progress | diff --git a/tests/test_world_input_capture.py b/tests/test_world_input_capture.py index 9355f8a02c..a449eb8a3d 100644 --- a/tests/test_world_input_capture.py +++ b/tests/test_world_input_capture.py @@ -264,18 +264,21 @@ fn main() { // Grabbing and releasing put the pointer at the window's centre // (MouseHandler.grabMouse / releaseMouse), in the surface's pixels when it // has one; releasing an already released pointer leaves it where it is. + // Windows also warps before the Locked grab, which clips the pointer + // where it is (#6076). + let grab_warps = if cfg!(windows) { 2 } else { 1 }; game.renderer = Some(Renderer { config: Config { width: 1000, height: 600 } }); game.capture(true); assert!(game.captured); let window = game.window.as_ref().unwrap(); - assert_eq!((window.warps.get(), window.at.get()), (1, (500.0, 300.0))); + assert_eq!((window.warps.get(), window.at.get()), (grab_warps, (500.0, 300.0))); game.capture(false); let window = game.window.as_ref().unwrap(); - assert_eq!(window.warps.get(), 3); + assert_eq!(window.warps.get(), grab_warps + 2); assert_eq!(game.cursor, (250.0, 150.0)); game.cursor = (1.0, 1.0); game.capture(false); - assert_eq!(game.window.as_ref().unwrap().warps.get(), 3); + assert_eq!(game.window.as_ref().unwrap().warps.get(), grab_warps + 2); assert_eq!(game.cursor, (1.0, 1.0)); game.renderer = None; game.window.as_mut().unwrap().fail = true; diff --git a/tools/native-hook-fragments/block_entity_energy.json b/tools/native-hook-fragments/block_entity_energy.json new file mode 100644 index 0000000000..0fcb1f3fc8 --- /dev/null +++ b/tools/native-hook-fragments/block_entity_energy.json @@ -0,0 +1,8 @@ +{ + "hooks": [ + {"name": "block_entity_energy", "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_energy", "neoforge": "Capabilities.Energy.BLOCK", "side": "server"} + ], + "rust_summary": "an energy buffer on a mod block's block entity (`native_block_entity_energy`), and the energy helpers in `machines/native_energy.rs`: `native_energy`, `native_energy_insert`, `native_energy_extract`, `native_move_energy`.", + "doc_summary": "an energy buffer on a mod block's block entity (`Capabilities.Energy.BLOCK`), and the helpers that read and change it. `native_block_entity_energy` declares the buffer as `SimpleEnergyHandler` is built: its capacity, the most one insert and one extract moves, and the faces a fill may come through and a drain may leave by. The amount lives in the entity's saved compound as the int `energy`, so it survives restarts and the block's own tick reads it there. `native_energy(dimension, pos, face)` reads it through a side; `native_energy_insert(pos, face, amount, simulate)` and `native_energy_extract(dimension, pos, face, amount, simulate)` answer the amount moved (`simulate` changes nothing); `native_move_energy(dimension, from, from_face, to, to_face, max)` is `EnergyHandlerUtil.move` between two blocks and answers the amount moved. Bundles need \"hooks\": @API:block_entity_energy@.", + "comments": ["crates/horizon-client/src/game/machines/native_energy.rs"] +} diff --git a/tools/native-hook-fragments/data_map_types.json b/tools/native-hook-fragments/data_map_types.json new file mode 100644 index 0000000000..367087673e --- /dev/null +++ b/tools/native-hook-fragments/data_map_types.json @@ -0,0 +1,8 @@ +{ + "hooks": [ + {"name": "data_map_types", "target": "horizon_client::game::native_hooks::Game<'_>::native_data_map_types", "neoforge": "RegisterDataMapTypesEvent", "side": "server"} + ], + "rust_summary": "data map types a native mod registers (`native_data_map_types`), merged from the data packs' and mods' files each time the server's tables are built, read with `native_data_map` and `native_data_map_entries` (`native_data_maps.rs`).", + "doc_summary": "data maps (`RegisterDataMapTypesEvent`). `native_data_map_types(types)` pushes the types a mod registers: an `id` (`mod_id:name`, not in `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` so far; asked once per load of the server's data (a world's first open and every `/reload`). The values come from `data//data_maps//.json` in the world's data packs and from files a mod pushes through `native_data_pack_files` under the key `data_maps/:/`: `{\"replace\": false, \"values\": {\"minecraft:coal\": ..., \"#minecraft:logs\": ...}, \"remove\": [...]}`, an entry's value being the data itself or `{\"value\": ..., \"replace\": false}`; `replace` clears what earlier files set, a tag gives every entry in it the value, `remove` applies after the file's own values, and a newer value overrides an older one. `native_data_map(registry, type, entry)` reads an entry's value (the JSON the file held) and `native_data_map_entries(registry, type)` lists them. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet. Bundles need \"hooks\": @API:data_map_types@.", + "comments": ["crates/horizon-client/src/game/native_data_maps.rs"] +} diff --git a/tools/native-hook-fragments/entity_mount.json b/tools/native-hook-fragments/entity_mount.json new file mode 100644 index 0000000000..d56a3b8b9f --- /dev/null +++ b/tools/native-hook-fragments/entity_mount.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "entity_mount", + "target": "horizon_client::game::native_hooks::Game<'_>::native_entity_mount", + "neoforge": "EntityMountEvent", + "side": "server", + "cancellable": true + } + ], + "rust_summary": "an entity getting on or off a vehicle, which a mod can cancel (`native_entity_mount`).", + "doc_summary": "an entity getting on or off a vehicle, which a mod can cancel, through `native_entity_mount` (NeoForge's `EntityMountEvent`): the rider's id and type (`minecraft:player`, `minecraft:zombie`), the vehicle's id and type (`minecraft:oak_boat`, `minecraft:horse`), whether it is mounting, and a `canceled` flag. Set `canceled` and the rider does not board, or keeps riding; a `/ride` that is canceled fails. It is told for a player boarding a boat, minecart, cushion or mount (pig, strider, horse and its kin, llama, nautilus, happy ghast) and getting off by their own input, `/ride`, a stab, a buck, a teleport or spectator mode, and for the mobs Horizon seats on a vehicle (jockeys, raiders on a ravager, `/ride`) and lets off one with `/ride` or a stab. It is not told for a rider whose vehicle is gone or dies, nor for riders loaded with a save. On the single player server thread and on a dedicated server. Bundles that use it need \"hooks\": @API:entity_mount@." +} diff --git a/tools/native-hook-fragments/entity_tick.json b/tools/native-hook-fragments/entity_tick.json new file mode 100644 index 0000000000..85f1f24e91 --- /dev/null +++ b/tools/native-hook-fragments/entity_tick.json @@ -0,0 +1,26 @@ +{ + "hooks": [ + { + "name": "entity_tick_pre", + "target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_pre", + "neoforge": "EntityTickEvent.Pre", + "side": "server", + "cancellable": true + }, + { + "name": "entity_tick_post", + "target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_post", + "neoforge": "EntityTickEvent.Post", + "side": "server", + "cancellable": false + }, + { + "name": "entity_tick_listened", + "target": "horizon_client::game::native_hooks::Game<'_>::native_entity_tick_listened", + "side": "server", + "cancellable": false + } + ], + "rust_summary": "entities ticking, which a mod can stop (`native_entity_tick_pre`, `native_entity_tick_post`, told while a mod sets `native_entity_tick_listened`).", + "doc_summary": "entities ticking, through `native_entity_tick_pre` (NeoForge's `EntityTickEvent.Pre`) and `native_entity_tick_post` (`EntityTickEvent.Post`), with the entity's id and type (`minecraft:zombie`, `minecraft:item`) and the level. Set `canceled` in the Pre and the entity doesn't tick this tick (and its Post isn't told). Told for the mobs, item entities, minecarts and boats that tick in an entity-ticking chunk, on the single player server thread and on a dedicated server; not for a player's own boat or minecart, projectiles, orbs, primed TNT, falling blocks or armor stands yet (check `kind`: later APIs add kinds). Mobs and items hear every Pre before the family's first tick and every Post after its last; minecarts and boats hear theirs around each one's tick. A mod that hooks them also hooks `native_entity_tick_listened(listened)` and sets `*listened = true`: otherwise no entity's tick is told. It runs each tick for every ticking entity, so keep it cheap. Bundles that use them need \"hooks\": @API:entity_tick@." +} diff --git a/tools/native-hook-fragments/gui_layers.json b/tools/native-hook-fragments/gui_layers.json new file mode 100644 index 0000000000..69ac994094 --- /dev/null +++ b/tools/native-hook-fragments/gui_layers.json @@ -0,0 +1,20 @@ +{ + "hooks": [ + { + "name": "gui_layer_pre", + "target": "horizon_client::game::native_hooks::Game<'_>::native_gui_layer_pre", + "neoforge": "RenderGuiLayerEvent.Pre", + "side": "client", + "cancellable": true + }, + { + "name": "gui_layer_post", + "target": "horizon_client::game::native_hooks::Game<'_>::native_gui_layer_post", + "neoforge": "RenderGuiLayerEvent.Post", + "side": "client", + "cancellable": false + } + ], + "rust_summary": "HUD layers around vanilla's own (`native_gui_layer_pre`, `native_gui_layer_post`).", + "doc_summary": "`native_gui_layer_pre(layer, batch, canceled)` and `native_gui_layer_post(layer, batch)` fire around each vanilla HUD layer Horizon draws, in its paint order, so a mod places its own drawing below a layer (in `pre`) or above it (in `post`), NeoForge's `RegisterGuiLayersEvent` registerBelow/registerAbove, and hides a layer by setting `canceled` in `pre` (`RenderGuiLayerEvent.Pre`). Layer ids: `minecraft:crosshair`, `hotbar`, `effects`, `boss_overlay`, `vehicle_health` (riding a mount), `contextual_info_bar` (the jump bar, locator bar or experience bar with the level number), `player_health` (health, armor, food and air rows, one pass), `selected_item_name`, `sleep_overlay`, `overlay_message` (action bar), `title`, `scoreboard_sidebar`, `subtitle_overlay`, `chat` (the unfocused lines) and `tab_list`. F1 hides the layers it hides in vanilla, and then nothing fires for them. The camera overlays (drawn by the screen-effects pass) and the debug screen are not layers. A hidden layer still runs its game logic (the health rows' blink, the bubble pop sound) and `post` still fires. Bundles need \"hooks\": @API:gui_layers@." +} diff --git a/tools/native-hook-fragments/interaction_key.json b/tools/native-hook-fragments/interaction_key.json new file mode 100644 index 0000000000..11f94bd66f --- /dev/null +++ b/tools/native-hook-fragments/interaction_key.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "interaction_key", + "target": "horizon_client::game::native_hooks::Game<'_>::native_interaction_key", + "neoforge": "InputEvent.InteractionKeyMappingTriggered", + "side": "client", + "cancellable": true + } + ], + "rust_summary": "a cancellable attack, use and pick-block key hook (`native_interaction_key`).", + "doc_summary": "`native_interaction_key(button, key_mapping, hand, swing_hand, canceled)` fires while playing when an interaction key is about to act on the crosshair: button 0 the attack key (`key.attack`; when an attack is allowed, and each tick a held key keeps digging a block), 1 the use key (`key.use`; once for each hand tried, main first, before the hand's target and item), 2 pick block (`key.pickItem`; only with a block or entity aimed at). Set `canceled` to skip the action; a cancelled use ends the click, so the other hand gets no turn. A cancelled attack or use still swings `hand` unless the mod clears `swing_hand` (true on entry). A single player's window and a server's player (any window that plays on a server); the deprecated in-frame path is not covered. Bundles need \"hooks\": @API:interaction_key@." +} diff --git a/tools/native-hook-fragments/item_fluid_tank.json b/tools/native-hook-fragments/item_fluid_tank.json new file mode 100644 index 0000000000..e023fa0edf --- /dev/null +++ b/tools/native-hook-fragments/item_fluid_tank.json @@ -0,0 +1,8 @@ +{ + "hooks": [ + {"name": "item_fluid_tank", "target": "horizon_client::game::native_hooks::Game<'_>::native_item_fluid_tank", "neoforge": "Capabilities.FluidHandler.ITEM"} + ], + "rust_summary": "a mod item as a fluid container (`native_item_fluid_tank`), and the item fluid helpers in `native_item_fluids.rs`: `native_item_fluid`, `native_item_fluid_fill`, `native_item_fluid_drain`.", + "doc_summary": "a fluid handler on items (`Capabilities.FluidHandler.ITEM`). `native_item_fluid_tank(item, capacity)` makes a mod item a container with a capacity in millibuckets; what it holds lives in the stack's `minecraft:custom_data` under `horizon_fluid` (`Fluid`, `Amount`), so it is saved, sent to Horizon clients and stacks only with equal contents. `native_item_fluid(stack)` reads one item of a stack (vanilla's buckets are built in: empty, water and lava, a whole bucket or nothing); `native_item_fluid_fill(stack, fluid, amount)` and `native_item_fluid_drain(stack, max)` answer how much one item takes or gives and the single item it becomes, as `IFluidHandler.fill`/`drain` through an `ItemAccess`; the stack given is not changed, so the caller shrinks it by one and hands out the result. They work on any side. Bundles need \"hooks\": @API:item_fluid_tank@.", + "comments": ["crates/horizon-client/src/game/native_item_fluids.rs"] +} diff --git a/tools/native-hook-fragments/living_breathe.json b/tools/native-hook-fragments/living_breathe.json new file mode 100644 index 0000000000..87c5997f4a --- /dev/null +++ b/tools/native-hook-fragments/living_breathe.json @@ -0,0 +1,16 @@ +{ + "hooks": [ + { + "name": "living_breathe", + "target": "horizon_client::breathing::native_living_breathe", + "neoforge": "LivingBreatheEvent", + "side": "server", + "cancellable": false + } + ], + "rust_summary": "a player's or mob's breathing each tick (`native_living_breathe`, a free function in `horizon_client::breathing`).", + "doc_summary": "a player's or mob's breathing, on the single player server thread and on a dedicated server: `native_living_breathe(entity, kind, can_breathe, consume, refill)` every tick for a player and for a mob with the generic air logic (NeoForge's `LivingBreatheEvent`, vanilla's `LivingEntity.baseTick`), with Horizon's answers as the defaults: set `can_breathe` and how much air `consume` takes or `refill` gives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function in `horizon_client::breathing`, so it answers and cannot call helpers. Axolotls and `WaterAnimal`s keep their own air logic. Bundles that use it need \"hooks\": @API:living_breathe@.", + "comments": [ + "crates/horizon-client/src/breathing.rs" + ] +} diff --git a/tools/native-hook-fragments/living_equipment_change.json b/tools/native-hook-fragments/living_equipment_change.json new file mode 100644 index 0000000000..f789bb91ce --- /dev/null +++ b/tools/native-hook-fragments/living_equipment_change.json @@ -0,0 +1,19 @@ +{ + "hooks": [ + { + "name": "living_equipment_change", + "target": "horizon_client::game::native_hooks::Game<'_>::native_living_equipment_change", + "neoforge": "LivingEquipmentChangeEvent", + "side": "server", + "cancellable": false + }, + { + "name": "living_equipment_listened", + "target": "horizon_client::game::native_hooks::Game<'_>::native_living_equipment_listened", + "side": "server", + "cancellable": false + } + ], + "rust_summary": "a mob's or an armor stand's equipment slot taking another stack (`native_living_equipment_change`), and whether a mod listens to the mobs' (`native_living_equipment_listened`).", + "doc_summary": "a mob's or an armor stand's equipment changing, on the single player server thread and on a dedicated server: `native_living_equipment_change(entity, kind, slot, from, to)` once for each slot (`mainhand`, `offhand`, `feet`, `legs`, `chest`, `head`, `body`, `saddle`) that holds another stack than the mods last heard (NeoForge's `LivingEquipmentChangeEvent`, vanilla's `LivingEntity.collectEquipmentChanges`). A mob new to the level counts from empty on its first tick. A mod that hooks it also hooks `native_living_equipment_listened(listened)` and sets `*listened = true`: otherwise the game does not read every mob's equipment each tick, and only armor stands are told. Players keep `native_on_equip`. It only hears. Bundles that use it need \"hooks\": @API:living_equipment_change@." +} diff --git a/tools/native-hook-fragments/map_decorations.json b/tools/native-hook-fragments/map_decorations.json new file mode 100644 index 0000000000..254782aa0e --- /dev/null +++ b/tools/native-hook-fragments/map_decorations.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "map_decorations", + "target": "horizon_client::game::native_hooks::Game<'_>::native_map_decorations", + "neoforge": "MapDecorationType", + "side": "client", + "cancellable": false + } + ], + "rust_summary": "a mod's own icons on filled maps (`native_map_decorations`).", + "doc_summary": "`native_map_decorations(map, decorations)` is asked each time a map is drawn (held, in an item frame, or on the cartography table's screen) with `NativeMapInfo` (id, centre block, scale, dimension, locked, whether it is in a frame); push `NativeMapDecoration { sprite, x, y, rot, shown_on_frame }` (an atlas sprite, a position in half map pixels from -128 to 127, a rotation 0 to 15, and whether it shows in frames and on the screen like vanilla's markers) and it is drawn after the vanilla decorations with the same pose; a sprite the atlas lacks is left out. Nothing is saved in the map and vanilla's decorations are not passed in. This is the client half of NeoForge's `MapDecorationType`; types registered on the server and saved in map data need code-side registration. Bundles need \"hooks\": @API:map_decorations@." +} diff --git a/tools/native-hook-fragments/native_blocks_items.json b/tools/native-hook-fragments/native_blocks_items.json new file mode 100644 index 0000000000..ae3029a584 --- /dev/null +++ b/tools/native-hook-fragments/native_blocks_items.json @@ -0,0 +1,16 @@ +{ + "hooks": [ + { + "name": "blocks", + "target": "horizon_client::game::native_hooks::Game<'_>::native_blocks", + "neoforge": "RegisterEvent" + }, + { + "name": "items", + "target": "horizon_client::game::native_hooks::Game<'_>::native_items", + "neoforge": "RegisterEvent" + } + ], + "rust_summary": "blocks and items declared in code (`native_blocks`, `native_items`).", + "doc_summary": "blocks and items a native mod declares in code, as NeoForge's `DeferredRegister.Blocks` and `.Items` drained by `RegisterEvent`, instead of one content file each: `native_blocks(blocks)` and `native_items(items)`, asked once at start on every side, take `horizon_mods::content::Block` and `Item`, the declarations a content mod's `data//horizon/block/` and `item/` files hold (a vanilla template and what differs; `item: true` gives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. Bundles that use them need `\"hooks\": @API:native_blocks_items@`." +} diff --git a/tools/native-hook-fragments/native_sign_changed.json b/tools/native-hook-fragments/native_sign_changed.json new file mode 100644 index 0000000000..c0d418b372 --- /dev/null +++ b/tools/native-hook-fragments/native_sign_changed.json @@ -0,0 +1,13 @@ +{ + "hooks": [ + { + "name": "sign_changed", + "target": "horizon_client::game::native_hooks::Game<'_>::native_sign_changed", + "neoforge": "none (NeoForge has no sign-editing event)", + "cancellable": true, + "side": "server" + } + ], + "rust_summary": "a player finishing a sign, rewritable or cancellable (`native_sign_changed`).", + "doc_summary": "a player finishing a sign: `native_sign_changed(player, name, dimension, pos, front, lines, cancel)` is heard when a player sends the text they wrote on a sign they were editing, right before it goes on the sign, once the checks have passed, on a server, with the player's state swapped in (`native_tell` reaches them). A mod may rewrite `lines` (four lines of plain text) or set `cancel` so the sign keeps its old text; a waxed sign is not told. Bundles that use it need `\"hooks\": @API:native_sign_changed@." +} diff --git a/tools/native-hook-fragments/player_container_events.json b/tools/native-hook-fragments/player_container_events.json new file mode 100644 index 0000000000..92e3c11423 --- /dev/null +++ b/tools/native-hook-fragments/player_container_events.json @@ -0,0 +1,20 @@ +{ + "hooks": [ + { + "name": "player_container_open", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_container_open", + "neoforge": "PlayerContainerEvent.Open", + "side": "server", + "cancellable": false + }, + { + "name": "player_container_close", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_container_close", + "neoforge": "PlayerContainerEvent.Close", + "side": "server", + "cancellable": false + } + ], + "rust_summary": "a player opening and closing a menu (`native_player_container_open`, `native_player_container_close`).", + "doc_summary": "a player opening and closing any menu on a server, on the single player server thread and on a dedicated server: `native_player_container_open(player, name, dimension, menu, pos)` and `native_player_container_close(...)` (NeoForge's `PlayerContainerEvent.Open` and `.Close`), with the menu type's name and, for a block's menu, its position. They run with the player's state swapped in. Unlike the viewer count of a mod container (`native_mod_container_viewers`) they name the player and cover every menu: chests, furnaces, crafting tables, trades, a mod's own. A menu is heard once a tick, so one opened and closed inside a tick is not; each open has one close, also when the player leaves. Bundles that use them need \"hooks\": @API:player_container_events@." +} diff --git a/tools/native-hook-fragments/player_render.json b/tools/native-hook-fragments/player_render.json new file mode 100644 index 0000000000..a01dfa9d6e --- /dev/null +++ b/tools/native-hook-fragments/player_render.json @@ -0,0 +1,26 @@ +{ + "hooks": [ + { + "name": "player_submit", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_submit", + "neoforge": "RenderPlayerEvent.Pre", + "side": "client", + "cancellable": true + }, + { + "name": "mob_layer", + "target": "horizon_client::game::native_hooks::Game<'_>::native_mob_layer", + "neoforge": "EntityRenderersEvent.AddLayers", + "side": "client", + "cancellable": false + }, + { + "name": "player_render_listened", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_render_listened", + "side": "client", + "cancellable": false + } + ], + "rust_summary": "a player drawn with a mod's own model, and layers added to mobs and players (`native_player_submit`, `native_mob_layer`, told while a mod sets `native_player_render_listened`).", + "doc_summary": "`native_player_submit(player, who, draw, drawn)` fires once a frame for each other player in view and for this window's player in third person (not while invisible), with the `native_mob_submit` frame (`NativeMobRender`, a `BlockEntityDraw` at the feet turned with the body) and `who` (profile name, whether it is the local player); set `drawn` to leave out the vanilla model, armour, held items, cape and other layers (`RenderPlayerEvent.Pre`). `native_mob_layer(mob, who, draw)` fires once a frame after the vanilla model and layers of every mob and player in view that a mod did not draw itself, and what the mod adds in that frame is drawn with it (`EntityRenderersEvent.AddLayers`, `RenderLivingEvent.Post`); `who` is set for a player. Geometry attaches to the entity's frame, not to the model's bones: head yaw, pitch and the walk cycle are in `NativeMobRender`. A mod that hooks either also hooks `native_player_render_listened(listened)` and sets `*listened = true`: otherwise neither is asked. Bundles need \"hooks\": @API:player_render@." +} diff --git a/tools/native-hook-fragments/player_tracking.json b/tools/native-hook-fragments/player_tracking.json new file mode 100644 index 0000000000..9efa0ff731 --- /dev/null +++ b/tools/native-hook-fragments/player_tracking.json @@ -0,0 +1,20 @@ +{ + "hooks": [ + { + "name": "player_start_tracking", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_start_tracking", + "neoforge": "PlayerEvent.StartTracking", + "side": "server", + "cancellable": false + }, + { + "name": "player_stop_tracking", + "target": "horizon_client::game::native_hooks::Game<'_>::native_player_stop_tracking", + "neoforge": "PlayerEvent.StopTracking", + "side": "server", + "cancellable": false + } + ], + "rust_summary": "a player starting and stopping tracking an entity (`native_player_start_tracking`, `native_player_stop_tracking`).", + "doc_summary": "a player's client starting and stopping tracking an entity, on the single player server thread and on a dedicated server: `native_player_start_tracking(player, name, entity, uuid, kind)` when the entity is sent to the player (NeoForge's `PlayerEvent.StartTracking`, vanilla's `ServerEntity.addPairing`) and `native_player_stop_tracking(...)` when it stops (`PlayerEvent.StopTracking`, `removePairing`): out of range, gone from the level, or the player left, changed level or respawned. They name the entity by ID, UUID and type and run with no player's state swapped in, so a mod sends what the player needs with `native_net_send_to_player`. Each start has one stop. Bundles that use them need \"hooks\": @API:player_tracking@." +} diff --git a/tools/native-hook-fragments/projectile_impact.json b/tools/native-hook-fragments/projectile_impact.json new file mode 100644 index 0000000000..73e2c36dde --- /dev/null +++ b/tools/native-hook-fragments/projectile_impact.json @@ -0,0 +1,18 @@ +{ + "hooks": [ + { + "name": "hears_projectile_impacts", + "target": "horizon_client::game::native_hooks::Game<'_>::native_hears_projectile_impacts", + "side": "server" + }, + { + "name": "projectile_impact", + "target": "horizon_client::game::native_hooks::Game<'_>::native_projectile_impact", + "neoforge": "ProjectileImpactEvent", + "side": "server", + "cancellable": true + } + ], + "rust_summary": "a projectile's impact a mod can cancel (`native_projectile_impact`, heard once a mod sets `native_hears_projectile_impacts`).", + "doc_summary": "a projectile's impact, which a mod can cancel, through `native_projectile_impact` (NeoForge's `ProjectileImpactEvent`): it fires when a projectile has found what it meets, before the hit acts, with the projectile's id, type (`minecraft:arrow`) and owner, the level, and what it hit: an entity (its id and type) or a block (its position and the face's normal) and the point. Set `canceled` and the impact does not happen: no damage, knockback, sound or block reaction, an arrow does not stick, a pearl does not teleport, a potion or a fireball does not burst. The projectile flies on and is not discarded, so a mod that cancels decides what becomes of it. It covers arrows, tridents, thrown items, fireballs, wither skulls, wind charges, llama spit and shulker bullets, on the single player server thread and on a dedicated server. It does not fire for a breeze's deflection, a hit on a wind charge, fireworks or fishing bobbers. A mod hooks `native_hears_projectile_impacts(hears)` too and sets `hears`: without it the server keeps no copy of its flying projectiles and the impact hook is not called. Bundles that use it need \"hooks\": @API:projectile_impact@." +} diff --git a/tools/native-hook-fragments/standalone_models.json b/tools/native-hook-fragments/standalone_models.json new file mode 100644 index 0000000000..73d5500b22 --- /dev/null +++ b/tools/native-hook-fragments/standalone_models.json @@ -0,0 +1,15 @@ +{ + "hooks": [ + { + "name": "standalone_models", + "target": "horizon_client::game::native_hooks::Game<'_>::native_standalone_models", + "neoforge": "ModelEvent.RegisterStandalone", + "side": "client" + } + ], + "rust_summary": "models no blockstate names, baked for drawing (`native_standalone_models`,\n`native_draw_model`).", + "doc_summary": "models no blockstate names, as NeoForge's `ModelEvent.RegisterStandalone`: `native_standalone_models(models)`, asked once at start, takes the ids of models a block entity or a mob draws as parts (`mymod:block/shaft_half`, from `assets/mymod/models/block/shaft_half.json`), and every resource load, a pack reload included, bakes them beside the block models. `native_draw_model(draw, model, tint)` draws one under `draw`'s pose in `native_block_entity_submit` or `native_mob_submit`, as `native_draw_block` draws a block's model, with `tint` (0xRRGGBB) on its tinted faces. A model that doesn't read is logged and left out, and drawing it gives false; no block has to be registered just to bake a part's model. Bundles that use them need `\"hooks\": @API:standalone_models@`.", + "comments": [ + "crates/horizon-client/src/game/native_block_entity_draw.rs" + ] +}