Merge current main through server and LAN joins

Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
martipopsandOpenAI Codex committed 2026-11-01 15:36:26 -05:00
commit 78ca40d8a5
126 files changed
+10484 -995

No files matched your search

+61
View File
@@ -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"
+1 -1
View File
@@ -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` |
+95 -2
View File
@@ -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<BakedItemModel> {
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<V
}
Ok(quads)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CuboidModel, TextureSlot};
use std::collections::BTreeMap;
/// The stick: 2x2 with one transparent pixel. `item/cit`: one opaque pixel, so a bake
/// shows whose mask it extruded.
struct Sprites;
impl SpriteLookup for Sprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
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]);
}
}
+236
View File
@@ -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<String, CuboidModel>) -> Result<Self> {
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<ResolvedCuboidModel> {
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<String, TextureSlot>,
) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, Some(child))
}
fn resolve_chain(
&self,
name: &str,
child: Option<&BTreeMap<String, TextureSlot>>,
) -> Result<ResolvedCuboidModel> {
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::<String, Material>::new();
let mut unresolved_textures = BTreeSet::<String>::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();
@@ -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<u8> {
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<u8> {
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<u8>,
central: Vec<u8>,
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<String> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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>) -> 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<String> = 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<String> = (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);
}
@@ -208,10 +208,26 @@ pub fn list_textures(root: &Path) -> Vec<String> {
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.
@@ -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<u32, Arc<ModFluidTanks>>,
/// The energy buffers of mod blocks' block entities, by block state id;
/// built with `states`, like the containers.
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
/// 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<ModFluidTanks>> {
self.mod_fluids.get(&state)
}
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
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<ModEnergyBuffer>> {
self.mod_energy.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -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<crate::test_instance_block::TestInstanceBlock>),
}
/// 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<Option<ItemStack>>,
pub data: Vec<i32>,
}
/// 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<ModBlockEntityFields> 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<usize>,
}
/// 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(&copy.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)),
])
);
}
}
@@ -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";
/// `<namespace>:<path>`, 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<String> {
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 `.<ext>`: `assets/<ns>/<folder>/<p>` is `<ns>:<p>`, any
/// other `assets/<rest>` is `horizon_cit:<rest>`.
fn id(resource: &str, folder: &str, ext: &str) -> Option<String> {
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<String> {
id(resource, "textures", "png")
}
/// The model id of a `.json` resource path.
pub fn model_id(resource: &str) -> Option<String> {
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<String> {
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.<name>` 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<String> {
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")
);
}
}
+285 -17
View File
@@ -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<u32>)>,
}
struct LoadedRule {
rule: Rule,
tiles: Vec<TileLayer>,
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<u32>,
tiles: Vec<u16>,
tint: Option<(i32, Option<u32>)>,
}
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<u32>,
) -> 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<String> = 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::<crate::Result<Vec<_>>>();
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<u32>> = 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<u16>> = 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 `<skip>`.
/// One rule on a face: `None` when it doesn't apply, picks `<skip>` 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<Pick> {
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<u16>, 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=<skip>",
),
(
"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 <skip>
// 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!(
@@ -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;
+13 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// 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
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -584,7 +584,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
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()));
+13 -8
View File
@@ -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])?;
@@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
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", &copyright),
(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);
}
}
@@ -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<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
}
";
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct CenterDepth {
slots: Vec<Slot>,
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<f32>,
}
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<f32> {
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::<f32>(&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));
}
}
@@ -512,6 +512,8 @@ pub struct PackPost {
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
center_depth: Option<super::pack_center_depth::CenterDepth>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -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()
@@ -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<f32>,
center_depth: Option<f32>,
/// 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]),
+422 -3
View File
@@ -137,6 +137,22 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
}
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<PathBuf> {
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<TextureFlags>, bool)> {
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let ids: Vec<String> = 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<String> = 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<TextureFlags>, 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<PackedImage>,
animated: &mut Vec<String>,
extras: &HashSet<String>,
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<PathBuf> = named
.keys()
let mut extra: Vec<PathBuf> = 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<PathBuf> = ["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<String> = HudAtlas::build(&root)
.unwrap()
.sprites
.into_keys()
.collect();
fs::remove_dir_all(root.join("assets/minecraft/optifine")).unwrap();
let without: HashSet<String> = 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<PackedImage> {
(0..count)
+1
View File
@@ -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;
+89 -25
View File
@@ -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<u32>| {
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 {
+45 -1
View File
@@ -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<Vec<String>> = 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<String>) -> 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::<std::result::Result<_, &str>>()?;
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"]);
}
}
@@ -52,10 +52,55 @@ pub struct StateModels {
states: Vec<Option<(usize, SelectedModels)>>,
/// 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<String, u32>,
}
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<BakedModel> {
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<BakedModel> {
let particle = model
.material("particle")?
.map(|material| sprites.insert(root, &material.sprite))
.transpose()?;
let faces: Vec<BakedFace> = 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::<Result<_>>()?;
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<BakedFace> =
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::<Result<_>>()?;
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<String> = [
"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(())
}
}
+102 -20
View File
@@ -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<String>,
hidden: &BTreeSet<String>,
files: &BTreeMap<String, (usize, Provided)>,
files: &'a BTreeMap<String, (usize, Provided)>,
merged: &BTreeMap<String, Vec<u8>>,
filters: &[&Filter],
zips: &[Option<Zip>],
dependencies: &mut cache::Dependencies,
extract: &mut Vec<Extract<'a>>,
) -> 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<String> = (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<String, Vec<u8>> = 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<String, Vec<u8>>) {
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()));
+228 -14
View File
@@ -99,6 +99,9 @@ pub struct Sprites {
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
/// resource path.
ready: std::collections::HashMap<String, crate::sprite_cache::Decoded>,
}
/// 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<Item = &'a str>) {
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::<std::collections::BTreeSet<_>>()
.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<u32> {
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<crate::sprite_cache::Decoded> {
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<crate::sprite_cache::Decoded> {
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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<String> = (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<u32> = 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()));
+2 -1
View File
@@ -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"
+35 -4
View File
@@ -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],
+130
View File
@@ -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);
}
}
+12 -11
View File
@@ -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;
+6
View File
@@ -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!(
+163 -77
View File
@@ -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() {
+14 -8
View File
@@ -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(),
))
})
},
);
@@ -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.
+39 -1
View File
@@ -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.
+22 -1
View File
@@ -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]
+3
View File
@@ -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<u32>,
/// `WitherSkull.getBlockExplosionResistance`: a dangerous skull's blast
/// caps every destroyable block's resistance at this value.
pub(super) blast_resistance_cap: Option<f32>,
/// 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.
@@ -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)
+104 -32
View File
@@ -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<Projectile>,
/// 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<super::wind::Burst>,
/// 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<usize> {
pub(super) fn wind_charge_hit(id: u32) -> Option<usize> {
(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<Seen> = 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<Impact> = 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<u32> = 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 {
+68 -5
View File
@@ -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));
@@ -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() {
+1 -1
View File
@@ -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`.
@@ -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);
+64 -2
View File
@@ -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<Key, ()> = 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<f32>, 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.
+3
View File
@@ -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
@@ -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
+12 -46
View File
@@ -1113,54 +1113,11 @@ fn mob_equipment(
hub: &mut Hub,
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
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);
}
@@ -93,6 +93,8 @@ pub(super) struct GuestPlay {
pub(super) mount_window: Option<super::lan_mount_menu::GuestMount>,
/// The lectern the guest reads (game/lan_stations.rs).
pub(super) lectern: Option<super::lan_stations::GuestLectern>,
/// The menu the mods last heard this guest open (`tick_player_menus`).
pub(super) menu_heard: Option<super::native_menus::HeardMenu>,
/// 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<u32> = host
.hub
@@ -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(
@@ -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<Menu>,
/// The data slots the client was last sent.
sent_data: Vec<i32>,
@@ -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<usize>)> {
pub(super) fn menu_of(kind: StationKind) -> Option<(&'static str, Kind, std::ops::Range<usize>)> {
Some(match kind {
StationKind::Loom => ("minecraft:loom", Kind::Loom, 0..3),
StationKind::Stonecutter => ("minecraft:stonecutter", Kind::Stonecutter, 0..1),
+27 -2
View File
@@ -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.
+3
View File
@@ -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;
@@ -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::{
@@ -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<ModEnergyBuffer>, 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<NativeEnergy> {
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);
}
}
+113 -3
View File
@@ -38,6 +38,42 @@ pub(super) struct Decoration {
pub name: Option<String>,
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_<id>`).
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/<name>`, 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<NativeMapDecoration> {
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).
+37 -2
View File
@@ -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<Item = &'m horizon_client::models::catalog::BakedModel>,
m: glam::Mat4,
light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32,
diffuse: bool,
tint: impl Fn(i32) -> Option<u32>,
out: &mut Vec<Vertex>,
) {
for model in models {
for face in &model.faces {
let Some(s) = self
.pack
+40
View File
@@ -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<horizon_mods::content::Block>,
Vec<horizon_mods::content::Item>,
) {
static DECLARED: std::sync::OnceLock<(
Vec<horizon_mods::content::Block>,
Vec<horizon_mods::content::Item>,
)> = 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 {
+13 -10
View File
@@ -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))
+36 -25
View File
@@ -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,
@@ -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());
}
}
@@ -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/<map namespace>/data_maps/<registry path>/<map path>.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/<ns>:<registry path>/<map path>`),
//! 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/<mod_id>/data_maps/<registry path>/<name>.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<String, Value>>,
}
/// `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<Value> {
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<String, Value>,
file: &Value,
tags: &HashMap<String, HashSet<String>>,
) -> 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<String> {
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<Vec<u8>>)],
item_tags: &HashMap<String, HashSet<String>>,
block_tags: &HashMap<String, HashSet<String>>,
) -> Self {
let mut maps: HashMap<(String, String), BTreeMap<String, Value>> = 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::<Value>(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<String, Value>> {
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<String, HashSet<String>> {
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<Vec<u8>>)> {
list.iter()
.map(|(key, versions)| {
(
(*key).to_owned(),
versions
.iter()
.map(|v| v.to_string().into_bytes())
.collect(),
)
})
.collect()
}
fn load(files: &[(String, Vec<Vec<u8>>)]) -> 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)));
}
}
+558 -7
View File
@@ -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<u8>)>) {
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/<mod_id>/data_maps/<registry path>/<name>.json`, and from files
/// you push through `native_data_pack_files` under the key
/// `data_maps/<mod_id>:<registry path>/<name>`: `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<NativeDataMapType>) {
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<String>) {
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/<id>/horizon/block/<path>.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<horizon_mods::content::Block>) {
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/<id>/horizon/item/<path>.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<horizon_mods::content::Item>) {
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<String>) {
let _ = models;
}
/// Options > Mods lists the native mods: push yours.
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
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<ModEnergyBuffer>,
) {
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<i32>) {
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<NativeMapDecoration>,
) {
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<String>,
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<u32>,
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<R>(
&mut self,
layer: &str,
batch: &mut HudBatch,
draw: impl FnOnce(&mut Self, &mut HudBatch) -> R,
) -> Option<R> {
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<u32, Arc<ModEnergyBuffer>> {
let registry = self.pack.assets.registry;
let mut by_block: FxHashMap<String, Option<Arc<ModEnergyBuffer>>> = 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<bool> {
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 {
@@ -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<String>,
/// 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<i32> = 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<String>, 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<i32>) -> Option<NativeItemFluid> {
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<i32>,
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<i32>,
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<i32> {
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<NativeItemFluid> {
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);
}
}
+141 -1
View File
@@ -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<u32> = 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.
@@ -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<Vertex>,
) -> 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<Vertex>,
) -> 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<NativePlayerRender<'_>>,
partial: f32,
camera: DVec3,
light: u32,
out: &mut Vec<Vertex>,
) {
// 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<Vertex>,
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
}
}
@@ -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)]
@@ -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<u32>, 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)
}
}
@@ -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<usize>,
}
/// 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<bool> {
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<Projectile>,
listed: &[bool],
impacts: Vec<Impact>,
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)
}
}
@@ -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<MobType>),
}
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<u32>,
}
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));
}
}
@@ -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);
+2 -4
View File
@@ -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();
@@ -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.
+27 -11
View File
@@ -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);
@@ -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);
}
}
@@ -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;
+56 -14
View File
@@ -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(),
+48 -1
View File
@@ -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;
};
+12 -4
View File
@@ -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
+7 -2
View File
@@ -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();
}
+21 -1
View File
@@ -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;
+9
View File
@@ -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;
+3 -3
View File
@@ -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 =
+113 -11
View File
@@ -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<Tracked>,
/// The registry id of its type (None: a type the clients don't know,
/// never paired).
kind: Option<i32>,
@@ -265,6 +268,10 @@ impl ServerEntity {
fn new(e: &EntityState, kind: Option<i32>, 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<str>,
}
/// 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<Tracked>,
}
/// 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<i32>,
seen: crate::fast_hash::FxHashMap<i32, Arc<Tracked>>,
events: Vec<TrackEvent>,
}
impl Tracker {
pub fn is_tracking(&self, id: i32) -> bool {
self.seen.contains(&id)
self.seen.contains_key(&id)
}
pub fn ids(&self) -> Vec<i32> {
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<TrackEvent> {
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
+12
View File
@@ -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,
+63 -9
View File
@@ -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<super::entity::TrackEvent>,
}
/// Moves a guest's tracking events into `out`, one batch.
fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
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<super::menu::NativeMenuEvent>,
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
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;
+9 -6
View File
@@ -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);
+60
View File
@@ -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<Box<[Option<crate::items::ItemStack>; 8]>>,
}
impl Extra {
pub fn set_main_hand(&mut self, item: Option<Arc<str>>) {
@@ -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<crate::items::ItemStack>; 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<ItemStack>; 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
+127 -6
View File
@@ -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<MobEvent>) {
} 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<MobEvent>) {
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::*;
+10 -2
View File
@@ -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.
+732 -88
View File
@@ -1,18 +1,30 @@
//! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/<name>.jem` (or
//! `cem/<name>/<name>.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<String>,
}
/// 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<String, Arc<MobType>>) -> Self {
let vanilla: HashMap<&str, &Arc<Part>> = 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<Part>>) -> 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<String, Vec<String>>,
vanilla: &HashMap<&str, &Arc<Part>>,
) -> 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<String> = files.keys().cloned().collect();
let mut frontier: Vec<String> = 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<String, Vec<(usize, &str)>> = 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<EntityModel>> = 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<EntityModel> {
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<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
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<String> {
@@ -105,26 +181,65 @@ fn read_small(path: &Path) -> Option<String> {
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<String, String> {
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
/// `<name>.jem` before `<name>/<name>.jem`.
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = 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 {
// `<model>_baby[_<layer>]`: 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<String> {
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<String> {
let mut out: Vec<String> = 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<String> {
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<Part>> = 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<bool> {
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::<usize>()
}
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<MobType> {
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<CemModels>,
sprites: &[&str],
custom: bool,
) -> Vec<usize> {
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<String, Arc<MobType>> = 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:#?}");
}
}
File diff suppressed because it is too large. Load diff
+126 -10
View File
@@ -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<Vec<MobEvent>> {
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::*;
+59
View File
@@ -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;
}
}
+326 -84
View File
@@ -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<Seen>,
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<u32>,
/// 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<Seen>, 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<Seen>,
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<Seen>, seed:
return;
};
choice.seed = seed;
choice.generation = textures.generation();
choice.picks.clear();
choice.primary = None;
choice.stale = false;
let states: Vec<Vec<(&str, &str)>> = seen
.blocks
.iter()
@@ -114,10 +167,7 @@ pub fn choose(m: &mut Mob, textures: &RandomTextures, seen: Option<Seen>, 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<Seen>, 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<Seen>, 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<bool> = (0..23).map(|_| m.random_textures.due(&t, 1)).collect();
let due: Vec<usize> = (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);
+191 -30
View File
@@ -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<String> {
fn push(names: &mut Vec<String>, 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<Vertex>| {
// 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));
}
}
+11 -1
View File
@@ -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,
+212 -39
View File
@@ -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<u32>,
}
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.<ns>.<path>` / `item.<ns>.<path>` 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<u32> {
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<u32>,
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<u32>,
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::<u32>() == 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<Vec<(String, String)>>,
/// 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<Item = (u32, &[(String, String)])> {
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<String> {
}
}
/// 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<ContentMod>,
blocks: &[horizon_mods::content::Block],
items: &[horizon_mods::content::Item],
known: &BTreeSet<String>,
) -> Vec<String> {
let namespace = |id: &str| id.split_once(':').map_or("", |(ns, _)| ns).to_owned();
let mut by_namespace: std::collections::BTreeMap<String, (Vec<_>, 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<ContentMod>,
@@ -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<i32> = 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<Vec<String>
/// 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<PathBuf> {
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<String> {
}
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))?);
}
+190 -18
View File
@@ -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::<Vec<_>>(),
[
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);
}
+33 -17
View File
@@ -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)?;
+91 -8
View File
@@ -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.
+9 -6
View File
@@ -885,12 +885,15 @@ fn decode_mod_block_entity(id: String, tag: &Tag) -> Option<crate::containers::M
} else {
items.clear();
}
Some(crate::containers::ModBlockEntity {
id,
nbt,
items,
data: Vec::new(),
})
Some(
crate::containers::ModBlockEntityFields {
id,
nbt,
items,
data: Vec::new(),
}
.into(),
)
}
pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::containers::BlockEntity)> {
use crate::containers::BlockEntity;
+47 -4
View File
@@ -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"
);
}
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more