Merge remote-tracking branch 'origin/main' into hdr-items

# Conflicts:
#	docs/graphics.md
This commit is contained in:
Senjin committed 2026-11-01 21:50:29 +01:00
commit e03324cd4e
291 files changed
+14339 -1596

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+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"
+16 -9
View File
@@ -40,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -85,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -103,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -129,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -152,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| 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` |
@@ -161,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+57
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1374,6 +1375,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
+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();
+2 -4
View File
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
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"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
@@ -0,0 +1,436 @@
//! How long a big resource pack takes to load and reload, on generated files only (no game
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
//! is printed as a Markdown table.
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
use horizon_client_render::{
hud::HudAtlas,
model_library::ParsedModels,
packs::{self, Pack},
sprites::Sprites,
};
use std::{
fs,
path::{Path, PathBuf},
time::Instant,
};
/// A small deterministic generator: the same files on every run and every branch.
struct Noise(u64);
impl Noise {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
}
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
/// about as well as real textures do.
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<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;
@@ -419,6 +419,31 @@ pub enum CameraFluid {
PowderSnow,
}
/// `Camera.NearPlane`: the near plane's centre and its half extents toward
/// the camera's left and up, relative to the camera.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NearPlane {
pub forward: [f64; 3],
pub left: [f64; 3],
pub up: [f64; 3],
}
impl NearPlane {
/// `forward`, `getTopLeft`, `getTopRight`, `getBottomLeft`, `getBottomRight`:
/// the points `Camera.getFluidInCamera` looks for lava and powder snow at.
pub fn points(&self) -> [[f64; 3]; 5] {
let corner = |up: f64, left: f64| -> [f64; 3] {
std::array::from_fn(|i| self.forward[i] + self.up[i] * up + self.left[i] * left)
};
[
self.forward,
corner(1.0, 1.0),
corner(1.0, -1.0),
corner(-1.0, 1.0),
corner(-1.0, -1.0),
]
}
}
/// `Camera`: the eye-height and field-of-view easing and the view placement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
@@ -481,6 +506,19 @@ impl Camera {
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
}
/// `Camera.getNearPlane(fov)` for a `width` x `height` window, at the
/// projection's `zNear` of 0.05.
pub fn near_plane(&self, fov: f32, width: f32, height: f32) -> NearPlane {
let z_near = f64::from(0.05f32);
let aspect = f64::from(width) / f64::from(height);
let plane_height = horizon_core::jmath::tan(f64::from(fov * DEG_TO_RAD) / 2.0) * z_near;
let scale = |v: [f32; 3], by: f64| v.map(|c| f64::from(c) * by);
NearPlane {
forward: scale(self.forwards, z_near),
left: scale(self.left, plane_height * aspect),
up: scale(self.up, plane_height),
}
}
/// `Camera.move(forwards, up, right)`.
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
let offset = self.rotation.transform([right, up, -forwards]);
@@ -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;
+1 -6
View File
@@ -227,12 +227,7 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
t.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
})
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
+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()));
+11 -2
View File
@@ -61,7 +61,8 @@ struct Out {
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour), or the
// dissolve mask's atlas UV for overlay 38.
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
@@ -255,7 +256,15 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
}
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
let texel = textureSample(atlas, atlas_sampler, uv);
var tint = select(in.back_color, in.color, front);
if in.overlay == 38u {
// RenderPipelines.ENTITY_CUTOUT_DISSOLVE (entity.fsh's DISSOLVE): gone where the vertex
// alpha is below the mask's (its atlas UV in glint_uv), the rest drawn opaque.
if tint.a < textureSampleLevel(atlas, atlas_sampler, in.glint_uv, 0.0).a { discard; }
tint.a = 1.0;
}
var color = texel * tint;
if in.overlay == 9u || in.overlay == 10u {
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
// for gateways) layers of end_portal.png projected from the screen.
+42 -18
View File
@@ -5,6 +5,7 @@
use crate::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
camera::NearPlane,
environment::Sky,
world::World,
};
@@ -135,9 +136,24 @@ fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option
};
Some((s.fluid, height))
}
/// `Camera.getFluidInCamera` (near-plane points reduced to the camera and
/// the point just in front of it).
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f64; 3]) -> FogType {
/// `Camera.getFluidInCamera`: water at the camera, then lava or powder snow
/// at the near plane's centre and corners.
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], near: &NearPlane) -> FogType {
camera_fog_type(
camera,
near,
|b| camera_fluid_height(world, facts, b),
|b| &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow",
)
}
/// `fog_type` over a block lookup: the fluid and its camera height, and
/// whether the block is powder snow.
fn camera_fog_type(
camera: [f64; 3],
near: &NearPlane,
fluid: impl Fn([i32; 3]) -> Option<(Fluid, f64)>,
powder_snow: impl Fn([i32; 3]) -> bool,
) -> FogType {
let block = |p: [f64; 3]| {
[
p[0].floor() as i32,
@@ -146,28 +162,19 @@ pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f
]
};
let b = block(camera);
if let Some((Fluid::Water, h)) = camera_fluid_height(world, facts, b)
if let Some((Fluid::Water, h)) = fluid(b)
&& camera[1] < f64::from(b[1]) + h
{
return FogType::Water;
}
for p in [
[
camera[0] + forward[0] * 0.05,
camera[1] + forward[1] * 0.05,
camera[2] + forward[2] * 0.05,
],
camera,
] {
for point in near.points() {
let p: [f64; 3] = std::array::from_fn(|i| camera[i] + point[i]);
let b = block(p);
match camera_fluid_height(world, facts, b) {
match fluid(b) {
Some((Fluid::Lava, h)) if p[1] <= f64::from(b[1]) + h => return FogType::Lava,
Some((Fluid::Lava, _)) => {}
_ => {
if &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow" {
return FogType::PowderSnow;
}
}
_ if powder_snow(b) => return FogType::PowderSnow,
_ => {}
}
}
FogType::Atmospheric
@@ -448,6 +455,23 @@ mod tests {
}
}
#[test]
fn lava_fog_starts_when_a_near_plane_corner_dips_under_the_surface() {
// Flowing lava (amount 4) on y 64: the camera sees its surface at 64 + 4/9.
let surface = 64.0 + 4.0 / 9.0;
let fluid = |b: [i32; 3]| (b[1] == 64).then_some((Fluid::Lava, 4.0 / 9.0));
let fog = |y: f64, pitch: f32| {
let mut camera = crate::camera::Camera::default();
camera.set_rotation(0.0, pitch);
let near = camera.near_plane(70.0, 1600.0, 900.0);
camera_fog_type([0.5, y, 0.5], &near, fluid, |_| false)
};
// Looking level, the near plane reaches tan(35 degrees) * 0.05 = 0.035 below the eye.
assert_eq!(fog(surface + 0.02, 0.0), FogType::Lava);
assert_eq!(fog(surface + 0.05, 0.0), FogType::Atmospheric);
// The eye itself is not tested: looking up, the whole plane is above it.
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
}
#[test]
fn boss_world_fog_closes_in() {
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
let sky = Sky {
+25 -16
View File
@@ -40,7 +40,7 @@ mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{
DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit,
};
@@ -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;
@@ -4100,14 +4102,18 @@ impl Renderer {
.iter()
.take_while(|&&p| p == (page, index_page))
.count();
// Translucent draws, and only they, have sorted indices.
let vertices = match &self.translucent_arena {
Some(arena) if index_page.is_some() => arena.buffer(page),
_ => self.arena.buffer(page),
// Translucent draws, and only they, have sorted indices; split, their large
// pages are bound up to their last range in use (`Arena::binding`).
let (vertices, indices) = match (&self.translucent_arena, index_page) {
(Some(arena), Some(p)) => (arena.binding(page), self.sorted_indices.binding(p)),
(_, p) => (
self.arena.buffer(page).slice(..),
p.map_or(&self.indices, |p| self.sorted_indices.buffer(p))
.slice(..),
),
};
pass.set_vertex_buffer(0, vertices.slice(..));
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, vertices);
pass.set_index_buffer(indices, wgpu::IndexFormat::Uint32);
if self.multi_draw {
let offset = (i - base as usize) as u64 * 20;
pass.multi_draw_indexed_indirect(indirect, offset, run as u32);
@@ -7825,6 +7831,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.
@@ -8322,19 +8331,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])?;
@@ -252,6 +252,18 @@ impl Arena {
pub fn page_vertices(&self) -> u64 {
self.page_limit
}
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
/// submitted, most of a page just made or grown.
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
let p = self.pages[page as usize].as_ref().expect("live arena page");
let end = match p.free.0.last_key_value() {
Some((&start, &len)) if start + len == p.capacity => start,
_ => p.capacity,
};
// A page a draw names holds a range; wgpu refuses an empty slice.
p.buffer.slice(..end.max(GRAIN) * self.stride)
}
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
@@ -784,6 +796,32 @@ mod tests {
}
}
/// Draws bind a large page up to the end of its last range in use, a grain at
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
#[test]
fn large_pages_are_bound_up_to_their_last_range_in_use() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::translucent(&device, 24, GRAIN);
let ranges: Vec<_> = (0..3)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
// The third grain grew the page to four.
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
// A free range before the last in use stays bound, none after it; an empty
// page binds a grain (wgpu refuses an empty slice).
arena.release(0, ranges[1].1, GRAIN);
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
arena.release(0, ranges[2].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
arena.release(0, ranges[0].1, GRAIN);
assert_eq!(arena.binding(0).size(), GRAIN * 24);
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -22,6 +22,10 @@ const MATERIALS: usize = 10;
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// `RenderPipelines.ENTITY_CUTOUT_DISSOLVE` (the dying dragon): a pixel is dropped where the
/// vertex alpha is below the mask's, whose atlas UV rides in `Vertex::glint_uv`; the rest is
/// drawn opaque. No overlay.
pub const DISSOLVE_OVERLAY: u32 = 38;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -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,
@@ -2740,6 +2743,13 @@ impl PackPost {
let rp = &mut open.as_mut().expect("open").0;
rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
// sun's fallback) binds them, as the other passes do.
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
}
@@ -2896,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()
@@ -3613,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 {
@@ -3851,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()
@@ -51,6 +51,12 @@ pub struct PackInputs {
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
pub status: PlayerStatus,
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
}
}
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
/// `isSpectator`, `is_burning`...).
#[derive(Clone, Copy, Debug, Default)]
pub struct PlayerStatus {
/// Survival or adventure: the vitals are -1 in creative and spectator.
pub survival: bool,
pub health: f32,
pub max_health: f32,
pub food: f32,
/// Air supply ticks, of 300.
pub air: f32,
/// Armor points (the `armor` attribute).
pub armor: f32,
pub spectator: bool,
pub riding: bool,
pub feet_in_water: bool,
pub swimming: bool,
pub burning: bool,
pub hurt: bool,
pub invisible: bool,
pub sneaking: bool,
pub sprinting: bool,
}
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
const MAX_HUNGER: f32 = 20.0;
const MAX_AIR: f32 = 300.0;
const MAX_ARMOR: f32 = 50.0;
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -198,6 +233,26 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"maxPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
"maxPlayerArmor",
"isSpectator",
"isRiding",
"feetInWater",
"inSwimmingAnimation",
"is_burning",
"is_hurt",
"is_invisible",
"is_sneaking",
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -220,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,
}
@@ -235,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(),
}
}
@@ -379,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.
@@ -424,6 +493,8 @@ impl FrameUniforms {
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"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]),
@@ -483,7 +554,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
};
self.values.clear();
@@ -529,6 +600,46 @@ impl FrameUniforms {
}
}
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
let status = inputs.status;
let vital = |v: f32| {
Value::f(&[if status.survival {
v.clamp(0.0, 1.0)
} else {
-1.0
}])
};
let flag = |b: bool| Value::i(&[i32::from(b)]);
Some(match name {
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
"maxPlayerHealth" => Value::f(&[if status.survival {
status.max_health
} else {
-1.0
}]),
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
"currentPlayerAir" => vital(status.air / MAX_AIR),
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
"isSpectator" => flag(status.spectator),
"isRiding" => flag(status.riding),
"feetInWater" => flag(status.feet_in_water),
"inSwimmingAnimation" => flag(status.swimming),
"is_burning" => flag(status.burning),
"is_hurt" => flag(status.hurt),
// Spectators count as invisible.
"is_invisible" => flag(status.invisible || status.spectator),
"is_sneaking" => flag(status.sneaking),
"is_sprinting" => flag(status.sprinting),
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
_ => return None,
})
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
@@ -547,7 +658,8 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -576,6 +688,77 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::F(f)) => f[0],
other => panic!("{name}: {other:?}"),
};
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::I(i)) => i[0],
other => panic!("{name}: {other:?}"),
};
let mut inputs = PackInputs {
status: PlayerStatus {
survival: true,
health: 15.0,
max_health: 20.0,
food: 10.0,
air: 150.0,
armor: 20.0,
sneaking: true,
..Default::default()
},
..Default::default()
};
// The vitals are fractions of their maximums.
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("is_sneaking", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 0);
// Creative and spectator: -1 for the vitals and the maximums of health and air;
// spectators are invisible.
inputs.status.survival = false;
inputs.status.spectator = true;
for name in [
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
] {
assert_eq!(float(name, &inputs), -1.0, "{name}");
}
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("isSpectator", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 1);
// Each is provided, not left at zero for the pack.
for name in [
"currentPlayerHealth",
"isSpectator",
"is_sprinting",
"bedrockLevel",
] {
assert!(index(name).is_some(), "{name} is not provided");
}
assert!(
NAMES
.iter()
.filter(|n| status_value(n, &inputs).is_some())
.count()
== 18
);
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
+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;
+197 -16
View File
@@ -26,6 +26,11 @@ const MAX_SAVED: usize = 1024;
/// (`Lod::observing`).
pub const OBSERVE_AHEAD: usize = 8;
const SAMPLE_JOBS: usize = 4;
/// Sample jobs queued per sampling thread once the near terrain is in.
/// Results are taken and jobs started once a frame: with four in flight, a
/// tile taking milliseconds left the threads idle for most of a slow frame
/// (2-3 fps on a CPU renderer, with far terrain a few tiles a frame).
const QUEUED_PER_SAMPLER: usize = 32;
/// Threads that sample far tiles beside the mesher. Sampling is the cold-load
/// bottleneck (each task evaluates a shared density lattice for 256 columns),
/// so medium machines use half their cores, not a quarter.
@@ -898,8 +903,9 @@ struct Shared {
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
}
/// One background worker and at most two outstanding sample jobs. Only the
/// current view's quadtree and a bounded set of visited summaries are retained.
/// A background worker, sampling and meshing threads, and a bounded queue of
/// sample jobs (`sample_jobs`). Only the current view's quadtree and a
/// bounded set of visited summaries are retained.
pub struct Lod {
shared: Arc<Shared>,
thread: Option<thread::JoinHandle<()>>,
@@ -1193,12 +1199,17 @@ impl Lod {
if worker.stop.load(Ordering::Relaxed) {
break;
}
if sampling.is_empty() {
// Summaries and detail requests first: they
// would otherwise wait behind a whole queue of
// samples where no sampling threads take them.
let other =
jobs.iter().position(|job| !matches!(job, Job::Sample(_)));
if let Some(index) = other {
jobs.remove(index)
} else if sampling.is_empty() {
jobs.pop_front()
} else {
jobs.iter()
.position(|job| !matches!(job, Job::Sample(_)))
.and_then(|index| jobs.remove(index))
None
}
};
let Some(job) = job else { continue };
@@ -1260,7 +1271,7 @@ impl Lod {
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
small: cores <= 4,
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
sample_jobs: sampling_threads(cores) * QUEUED_PER_SAMPLER,
near_loading: false,
held_sample_at: std::time::Instant::now(),
retry: FxHashMap::default(),
@@ -1326,14 +1337,31 @@ impl Lod {
}
(desired, sampled)
}
/// Drawn columns that take their chunk's generated detail (trees,
/// structures; `request_detail`), and how many of them have it.
/// `refined_samples` counts the detail of every drawn tile instead.
pub fn detail_progress(&self) -> (usize, usize) {
self.active
.iter()
.filter(|key| self.wants_detail(key))
.fold((0, 0), |(wanted, refined), key| {
let mask = self.detail_masks.get(key).map_or(0, detail_count);
(wanted + CELLS * CELLS, refined + mask)
})
}
pub fn refined_samples(&self) -> usize {
self.active
.iter()
.filter_map(|key| self.detail_masks.get(key))
.flat_map(|mask| mask.iter())
.map(|bits| bits.count_ones() as usize)
.map(detail_count)
.sum()
}
/// An active tile whose columns take generated chunks' detail.
fn wants_detail(&self, key: &TileKey) -> bool {
key.level <= DETAIL_LEVEL
&& !self.plan.split.contains(key)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
}
pub fn removed(&mut self) -> Vec<TileKey> {
std::mem::take(&mut self.removed)
}
@@ -1733,9 +1761,43 @@ impl Lod {
// neighbours. Coarser tiles do not read visited columns (stride > 16),
// so re-meshing every tile touching the chunk was pure waste.
}
/// Whether the near terrain is still loading: on small machines far
/// terrain then holds back (`HELD_SAMPLE_STEP`).
/// Whether the near terrain is still loading: far terrain then queues
/// `SAMPLE_JOBS` at most, and on small machines holds back
/// (`HELD_SAMPLE_STEP`).
pub fn set_near_loading(&mut self, loading: bool) {
if loading && !self.near_loading {
// The queue filled while nothing loaded nearby; the near chunks
// must not wait behind it. Larger machines keep its first
// `SAMPLE_JOBS`, small ones none.
let mut keep = if self.small { 0 } else { SAMPLE_JOBS };
let dropped = drop_queued_samples(&self.shared, &mut self.pending, |_| {
let kept = keep > 0;
keep = keep.saturating_sub(1);
kept
});
// Back in line where `wanted_tiles` put them: the near terrain can
// start and stop loading every few frames, and rebuilding the whole
// queue each time cost more than the samples it held back.
let camera = self.plan.camera;
self.wanted.extend(dropped.into_iter().map(|key| {
let class = if self.plan.split.contains(&key) {
BACKDROP
} else if key.reach(camera) < self.plan.first {
SEAM
} else {
REST
};
std::cmp::Reverse(Wanted {
class,
distance: i64::from(key.distance(camera)),
x: key.x,
z: key.z,
level: key.level,
})
}));
// A sample may still be running: the held pace starts from now.
self.held_sample_at = std::time::Instant::now();
}
self.near_loading = loading;
let held = self.held();
if self.shared.mesh_held.swap(held, Ordering::Release) && !held {
@@ -1764,10 +1826,7 @@ impl Lod {
.active
.iter()
.filter(|key| {
key.level <= DETAIL_LEVEL
&& !self.plan.split.contains(key)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
self.wants_detail(key) && self.detail_masks.get(key) != Some(&[u64::MAX; 4])
})
.copied()
.collect();
@@ -1876,6 +1935,11 @@ impl Lod {
self.reduced_detail,
);
self.ready.retain(|key, _| self.plan.nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
self.detail_masks
.retain(|key, _| self.plan.nodes.contains(key));
// Complete resident columns need no duplicate noise work.
@@ -2245,7 +2309,8 @@ impl Lod {
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
)
} else {
// The extra sampling threads wait until the near terrain is in.
// `SAMPLE_JOBS` while the near terrain loads, the full queue
// (`QUEUED_PER_SAMPLER` a thread) once it is in.
let slots = if self.near_loading {
SAMPLE_JOBS
} else {
@@ -2467,6 +2532,28 @@ impl Drop for Lod {
}
}
}
/// The columns of a tile's `detail_masks` entry that have their detail.
fn detail_count(mask: &[u64; 4]) -> usize {
mask.iter().map(|bits| bits.count_ones() as usize).sum()
}
/// Removes the queued sample jobs `keep` rejects (in queue order, best
/// first) and frees their slots; the tiles removed.
fn drop_queued_samples(
shared: &Shared,
pending: &mut FxHashSet<TileKey>,
mut keep: impl FnMut(&TileKey) -> bool,
) -> Vec<TileKey> {
let mut dropped = Vec::new();
shared.jobs.lock().unwrap().retain(|job| match job {
Job::Sample(key) if !keep(key) => {
pending.remove(key);
dropped.push(*key);
false
}
_ => true,
});
dropped
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual detail-request
/// budget and waits for the replacement mesh before changing cutout.
@@ -3595,6 +3682,100 @@ mod tests {
assert_eq!(lod.shared.mesh_jobs.lock().unwrap().len(), 1);
}
#[test]
fn queued_samples_follow_the_view_and_make_way_for_the_near_terrain() {
let mut lod = Lod::scheduler_for_test();
lod.small = true;
lod.sample_jobs = 16;
let queued = |lod: &Lod| -> Vec<TileKey> {
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter_map(|job| match job {
Job::Sample(key) => Some(*key),
_ => None,
})
.collect()
};
let observing = |lod: &Lod| {
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter().any(|job| matches!(job, Job::Observe((5, 5))))
};
let pending = |lod: &Lod| -> Vec<TileKey> {
let mut keys: Vec<_> = lod.pending.iter().copied().collect();
keys.sort();
keys
};
let sorted = |mut keys: Vec<TileKey>| {
keys.sort();
keys
};
let none = || Some(FxHashSet::default());
lod.update([8., 64., 8.], 2, 64, none());
let before = queued(&lod);
assert_eq!(before.len(), 16, "a full queue once the near terrain is in");
lod.shared
.jobs
.lock()
.unwrap()
.push_back(Job::Observe((5, 5)));
// A chunk's move: the tiles still planned keep their place in line.
lod.update([24., 64., 8.], 2, 64, none());
let moved = queued(&lod);
let kept: Vec<_> = before
.iter()
.copied()
.filter(|key| lod.plan.nodes.contains(key))
.collect();
assert!(!kept.is_empty());
assert_eq!(moved[..kept.len()], kept[..]);
assert_eq!(moved.len(), 16);
assert!(observing(&lod), "only sample jobs are dropped");
// A teleport: none of the old view's tiles is sampled any more.
lod.update([100_000., 64., 100_000.], 2, 64, none());
let after = queued(&lod);
assert_eq!(after.len(), 16);
assert!(after.iter().all(|key| !moved.contains(key)));
assert!(after.iter().all(|key| lod.plan.nodes.contains(key)));
assert_eq!(
pending(&lod),
sorted(after.clone()),
"dropped jobs free their slots"
);
// New chunks load nearby: the queue gives way at once, not after it
// drained, and the dropped tiles come back, best first, once they are in.
lod.set_near_loading(true);
assert!(queued(&lod).is_empty());
assert!(lod.pending.is_empty());
assert!(observing(&lod));
lod.set_near_loading(false);
lod.update([100_000., 64., 100_000.], 2, 64, none());
assert_eq!(queued(&lod), after);
// Larger machines keep the first `SAMPLE_JOBS` of the queue.
lod.small = false;
lod.set_near_loading(true);
assert_eq!(queued(&lod), after[..SAMPLE_JOBS]);
assert_eq!(pending(&lod), sorted(after[..SAMPLE_JOBS].to_vec()));
}
#[test]
fn detail_progress_counts_the_columns_that_take_generated_detail() {
let mut lod = Lod::scheduler_for_test();
let tile = |x, level| TileKey { x, z: 0, level };
let (fine, covered, coarse, split) = (tile(0, 0), tile(1, 0), tile(1, 3), tile(1, 2));
lod.active = [fine, covered, coarse, split].into_iter().collect();
lod.covered.insert((1, 0));
lod.plan.split.insert(split);
lod.detail_masks.insert(fine, [u64::MAX, 1, 0, 0]);
lod.detail_masks.insert(coarse, [u64::MAX; 4]);
// Only the fine tile: the covered one is the near chunk's, the split
// one stands in for finer tiles and the coarse one is past the detail.
assert_eq!(lod.detail_progress(), (256, 65));
}
/// Drives the scheduler by hand: every sample the new incremental path
/// produces must select exactly the tiles `Plan::select` selects, after
/// every single sample, and across camera moves and distance changes.
+353 -50
View File
@@ -7,6 +7,7 @@ mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
@@ -18,6 +19,7 @@ use crate::{
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -1017,6 +1019,32 @@ impl<'a> Tints<'a> {
layers: Default::default(),
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
&self.biomes.get(id).name
}
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
/// while it holds a layer.
fn biome_id(
zoom: &BiomeZoom,
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz, base_y]: [i32; 3],
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
plains
}
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
let r = self.radius;
@@ -1032,27 +1060,21 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none).
let q = self
.zoom
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
self.plains
};
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1242,7 +1264,8 @@ impl Mesher<'_> {
origin,
face,
ao,
state,
(id, state),
world,
interior_leaf,
);
}
@@ -1281,7 +1304,8 @@ impl Mesher<'_> {
origin: [f32; 3],
face: &BakedFace,
ao: bool,
state: &Info,
(id, state): (u32, &Info),
world: BlockPos,
interior_leaf: bool,
) {
let q = &face.quad;
@@ -1309,50 +1333,329 @@ impl Mesher<'_> {
([lighting::gray(directional); 4], [coords; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
// 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 {
let tints = &*tints;
connected.pick(
id,
face.layer as u16,
CtmFace::ALL[q.facing as usize],
[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 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],
)
})
};
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;
let own = TileLayer::Layer {
layer: face.layer as u16,
translucent: face.translucent,
opaque: face.opaque,
};
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
let tile = |t: TileLayer| match t {
TileLayer::Layer {
layer,
translucent,
opaque,
} => TileLayer::Layer {
layer,
translucent: translucent || q.material.force_translucent,
opaque,
},
TileLayer::Skip | TileLayer::Default => own,
};
match pick {
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
Pick::Quarters(tiles) => {
let cuts = cuts(&corners);
for s in cuts.windows(2) {
for t in cuts.windows(2) {
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
.map(|(s, t)| bilinear(&corners, s, t));
let quarter = quarter_of(&piece);
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
}
}
}
}
let translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
// 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,
translucent,
opaque,
} = tile
else {
return;
};
if self.tables.is_animated(layer) {
out.note_sprite_layer(layer);
}
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 {
layer | OPAQUE_LAYER
} else {
layer
};
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
};
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
};
for i in 0..4 {
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];
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
layer,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
for vertex in &mut v {
vertex.layer = layer;
}
target.extend(v);
}
}
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
/// of `.0`.
struct Cuts([f32; 4], usize);
impl std::ops::Deref for Cuts {
type Target = [f32];
fn deref(&self) -> &[f32] {
&self.0[..self.1]
}
}
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
/// slab's side).
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
let (a, b) = (c[0].1, c[to].1);
// The UV the edge runs along.
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
let at = (0.5 - a[k]) / (b[k] - a[k]);
(at > 0.0 && at < 1.0).then_some(at)
}
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
/// split face shades exactly like the whole one; the others lie on one side of it.
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
let mut out = Cuts([0.0; 4], 1);
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
if s > t {
std::mem::swap(&mut s, &mut t);
}
for at in [s, t].into_iter().flatten().chain([1.0]) {
if out.0[out.1 - 1] < at {
out.0[out.1] = at;
out.1 += 1;
}
}
out
}
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
/// quad lies in, by its UV centre (v grows downward).
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
}
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
/// like an unsplit one.
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
let w = if s >= t {
[1.0 - s, s - t, t, 0.0]
} else {
[1.0 - t, 0.0, s, t - s]
};
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
};
// Packed bytes (colour, light) blend one by one.
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
(0..count)
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
.sum::<u32>()
};
let color = bytes(|v| v.color, 4);
let light = bytes(|v| u32::from(v.light), 2) as u16;
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
(TerrainVertex::new(pos, uv, 0, color, light), uv)
}
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
}
#[cfg(test)]
mod quarter_tests {
use super::*;
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
}
#[test]
fn quarters_blend_the_corners_by_triangle() {
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
// middle, where a bilinear blend would put a quarter and the other diagonal none.
let c = [
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
];
let (centre, uv) = bilinear(&c, 0.5, 0.5);
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
assert_eq!(uv, [0.5, 0.5]);
assert_eq!(centre.color, 0xFF00_0000 | 100);
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
}
#[test]
fn compact_faces_cut_where_the_sprite_does() {
// A full face: cut in the middle both ways.
let full = [
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
];
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
// directions are cut there and at the middle of u.
let mut path = full;
path[0].1 = [0.0, 1.0 / 16.0];
path[1].1 = [1.0, 1.0 / 16.0];
assert_eq!(
*cuts(&path),
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
);
// A slab's lower side (v 0.5..1) crosses the middle only along u.
let mut slab = full;
slab[0].1 = [0.0, 0.5];
slab[1].1 = [1.0, 0.5];
assert_eq!(crossing(&slab, 3), None);
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
let piece = |s0: f32, t0: f32| {
[
(s0, t0),
(s0 + 0.5, t0),
(s0 + 0.5, t0 + 0.5),
(s0, t0 + 0.5),
]
.map(|(s, t)| bilinear(&full, s, t))
};
assert_eq!(
[
piece(0.0, 0.0),
piece(0.5, 0.0),
piece(0.0, 0.5),
piece(0.5, 0.5)
]
.map(|p| quarter_of(&p)),
[0, 1, 2, 3]
);
}
}
+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()));
+18 -3
View File
@@ -1288,13 +1288,28 @@ impl Streaming {
}
/// Whether any chunk is queued or on its way from generation to a mesh.
pub fn busy(&self) -> bool {
self.chunks_in_flight() || !self.meshing.is_empty() || self.pending() != 0
}
/// Whether the near terrain is still coming in: chunks generating,
/// decorating, finishing or being lit (or their neighbours' light views),
/// or sections meshing for the first time. Each of those is counted from
/// when its job is queued, so queued jobs need no separate count.
/// Remeshing sections already drawn (block, light and fluid updates) is
/// not loading: it kept small machines' far terrain held back
/// (`Lod::set_near_loading`) for as long as anything nearby changed.
pub fn terrain_loading(&self) -> bool {
self.chunks_in_flight()
|| self
.meshing
.keys()
.any(|section| !self.compile_started.contains_key(section))
}
fn chunks_in_flight(&self) -> bool {
!(self.generating.is_empty()
&& self.decorating == 0
&& self.viewing.is_empty()
&& self.finishing.is_empty()
&& self.lighting.is_empty()
&& self.meshing.is_empty()
&& self.pending() == 0)
&& self.lighting.is_empty())
}
/// How much work each stage holds: queued jobs, then what is in flight or
/// waiting at each step from generation to meshing.
+16 -8
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -15,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" }
@@ -36,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"
@@ -104,20 +109,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -12,6 +12,7 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
@@ -2,6 +2,7 @@
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
@@ -1,5 +1,6 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
extern crate horizon_client_lib as horizon_client;
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -5,6 +5,7 @@
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
extern crate horizon_client_lib as horizon_client;
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
@@ -2,6 +2,7 @@
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
@@ -4,6 +4,7 @@
//!
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
//! their dependencies. How a probe runs is in dispatch.rs.
extern crate horizon_client_lib as horizon_client;
#[macro_use]
mod dispatch;
@@ -1,5 +1,6 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
@@ -9,6 +9,7 @@
//! and the process RSS (what the allocator holds on to). On macOS it ends
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
//! counts freed pages the allocator marked reusable.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
@@ -2,6 +2,7 @@
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
biome::Biomes,
chunk::{Chunk, block_strategy},
@@ -2,6 +2,7 @@
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -2,6 +2,7 @@
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
extern crate horizon_client_lib as horizon_client;
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
+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],
@@ -1,6 +1,7 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
extern crate horizon_client_lib as horizon_client;
mod commands;
mod launch;
mod mcp;
+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);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
#![forbid(unsafe_code)]
extern crate self as horizon_client;
pub mod advancements;
pub mod ambience;
pub mod animate_tick;
pub mod arrows;
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, 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_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;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
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;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod ram_limit;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+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!(
+107 -3
View File
@@ -796,7 +796,24 @@ pub fn mob_to_nbt(
put_memories(map, m);
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:wandering_trader" => {
put_offers(map, m);
// WanderingTrader.addAdditionalSaveData: the visit's countdown and
// the place it is walking to, a BlockPos codec (an int array).
map.insert("DespawnDelay".into(), Tag::Int(m.extra.ints[5]));
if let Some(p) = m.extra.point {
// The trader keeps its target as a position; the tag is a
// BlockPos, so only the block it is in is saved.
map.insert(
"wander_target".into(),
Tag::IntArray(vec![
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
]),
);
}
}
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
@@ -2089,7 +2106,14 @@ pub fn ominous_item_spawner_to_nbt(
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "item", item_to_nbt(item));
// `ItemStack.CODEC` can't encode `ItemStack.EMPTY`, so an empty item
// writes no key.
if let Tag::Compound(map) = &mut tag {
map.remove(&"item".into());
}
if &*item.item != "minecraft:air" {
put(&mut tag, "item", item_to_nbt(item));
}
put(
&mut tag,
"spawn_item_after_ticks",
@@ -2101,7 +2125,11 @@ pub fn ominous_item_spawner_to_nbt(
/// and `spawn_item_after_ticks` (0 when absent). None without an item:
/// such a spawner would spawn nothing.
pub fn ominous_item_spawner_from_nbt(tag: &Tag) -> Option<(crate::items::ItemStack, Vec3, i64)> {
let item = tag.get("item").and_then(item_from_nbt)?;
// `readAdditionalSaveData`: `ItemStack.EMPTY` when it has none.
let item = tag
.get("item")
.and_then(item_from_nbt)
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:air", 1));
let after = number(tag.get("spawn_item_after_ticks")).unwrap_or(0);
Some((item, position(tag)?, after))
}
@@ -2986,6 +3014,16 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
if m.species() == crate::mobs::Species::Villager {
crate::mobs::villager::read_nbt(m, tag);
}
if m.species() == crate::mobs::Species::WanderingTrader {
// WanderingTrader.readAdditionalSaveData: both default to idle.
m.extra.ints[5] = number(tag.get("DespawnDelay")).unwrap_or(0) as i32;
m.extra.point = match tag.get("wander_target") {
Some(Tag::IntArray(a)) if a.len() == 3 => {
Some([f64::from(a[0]), f64::from(a[1]), f64::from(a[2])])
}
_ => None,
};
}
let offers_tag = tag.get("Offers");
let xp = number(tag.get("Xp")).unwrap_or(0) as i32;
if offers_tag.is_none() && xp == 0 && tag.get("LastRestock").is_none() {
@@ -3037,6 +3075,17 @@ mod tests {
);
assert_eq!((pos, after), (at, 97));
assert!(ominous_item_spawner_from_nbt(&raw).is_none());
// No item is `ItemStack.EMPTY`, which writes no key back.
let empty = ominous_item_spawner_to_nbt(
&crate::items::ItemStack::new("minecraft:air", 1),
at,
0,
[1, 2, 3, 4],
Some(&tag),
);
assert!(empty.get("item").is_none());
let (back, pos, after) = ominous_item_spawner_from_nbt(&empty).unwrap();
assert_eq!((back.item.as_ref(), pos, after), ("minecraft:air", at, 0));
}
#[test]
@@ -3461,6 +3510,8 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
Some("minecraft:pig/variant"),
Some("minecraft:pig/sound_variant"),
),
// ZombieNautilus.applyImplicitComponents: the variant only.
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -3936,6 +3987,49 @@ mod sheep_nbt_tests {
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
/// `WanderingTrader.addAdditionalSaveData`: the visit's countdown and the
/// place it is walking to (a `BlockPos` codec, so an int array), which the
/// despawn and stroll goals read back after a reload.
#[test]
fn a_wandering_traders_countdown_and_target_survive_a_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:wandering_trader"].clone(),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
m.extra.ints[5] = 48000;
m.extra.point = Some([12.0, 65.0, -30.0]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
panic!("wander_target missing");
};
assert_eq!(target, &vec![12, 65, -30]);
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.ints[5], 48000);
assert_eq!(back.extra.point, Some([12.0, 65.0, -30.0]));
// A save with neither field: idle, and nothing to walk to.
let bare = Tag::compound([
("id", string("minecraft:wandering_trader")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
let back = mob_from_nbt(&bare, &types, 3, None).unwrap();
assert_eq!((back.extra.ints[5], back.extra.point), (0, None));
}
#[test]
fn mobs_keep_their_variant_puff_state_carried_block_and_conversion_times() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -4186,6 +4280,16 @@ mod item_component_tests {
let mut sheep = spawn("minecraft:sheep", 2);
apply_item_components(&mut sheep, &egg);
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
// ZombieNautilus.applyImplicitComponents: the egg carries the coral
// variant over the temperate one the biome picked.
let mut egg = crate::items::ItemStack::new("minecraft:zombie_nautilus_spawn_egg", 1);
egg.data_mut().set_other(
"minecraft:zombie_nautilus/variant",
Some(string("minecraft:warm")),
);
let mut nautilus = spawn("minecraft:zombie_nautilus", 4);
apply_item_components(&mut nautilus, &egg);
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
+108
View File
@@ -0,0 +1,108 @@
//! Startup shared by the gameplay library and its executable.
use crate::{crash, game, logging, ram_limit};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
.clone()
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
// --home before the log opens in it.
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
let folder = game_args
.clone()
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
.nth(1)
.and_then(|dir| std::path::absolute(dir).ok())
.unwrap_or_else(|| ".".into());
(folder, "server", "Server thread")
} else {
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
// its log and crash reports in its own; crash telemetry stays with the home's consent.
if let Some(dir) = game_args
.clone()
.skip_while(|a| *a != "--player-dir")
.nth(1)
{
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
(logging::game_home(), "client", "Render thread")
};
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
thread,
);
// The log's first line names the build, so a bug report that carries the log says which one it was.
log::info!("Horizon {}", crash::version());
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
raise_open_file_limit();
if let Err(error) = game::run() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
logging::flush();
// The report's path is the last line on stderr (crash::SAVED_LINE).
if !help && !usage {
crash::report_error(&home, side, &*error);
}
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(if usage {
game::exit_code::USAGE
} else {
game::exit_code::ERROR
});
}
logging::flush();
}
/// The JVM raises its open-file limit to the hard limit as it starts
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
/// sockets and region files as the host allows, not the soft default (often
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
/// JVM does there.
fn raise_open_file_limit() {
#[cfg(unix)]
{
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let limit = getrlimit(Resource::Nofile);
let hard = if cfg!(target_os = "macos") {
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
} else {
limit.maximum
};
let raised = match (limit.current, hard) {
(Some(soft), Some(hard)) => hard > soft,
(Some(_), None) => true,
(None, _) => false,
};
if raised {
let to = Rlimit {
current: hard,
maximum: limit.maximum,
};
match setrlimit(Resource::Nofile, to) {
Ok(()) => log::info!(
"Open file limit raised from {:?} to {hard:?}",
limit.current
),
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
}
}
}
}
+184 -79
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 {
@@ -2415,6 +2423,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,
@@ -3758,6 +3770,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();
@@ -3772,6 +3807,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];
@@ -3784,7 +3821,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
@@ -8129,42 +8167,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 {
@@ -8179,47 +8225,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
@@ -8264,9 +8319,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
@@ -8284,21 +8341,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
@@ -8308,8 +8374,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;
@@ -11350,6 +11424,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();
@@ -11777,6 +11852,8 @@ impl Game<'_> {
self.streaming.sky_light = dim != Dim::Nether;
self.streaming.nether_shading = dim == Dim::Nether;
self.load_world_state();
// The level the world opens in, before any of its chunks.
self.native_level_opened(dim);
Ok(())
}
/// Streaming and region storage for one dimension of a world.
@@ -15288,7 +15365,7 @@ impl Game<'_> {
&self.world,
self.res.facts(),
[camera.x, camera.y, camera.z],
self.forward(),
&self.near_plane(),
),
horizon_client::fog::FogType::Lava | horizon_client::fog::FogType::PowderSnow
),
@@ -16119,7 +16196,7 @@ impl Game<'_> {
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance =
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
lod.set_near_loading(self.streaming.busy());
lod.set_near_loading(self.streaming.terrain_loading());
lod.set_reduced_detail(far_reduced);
// The covered columns change only with the complete ones and
// the render area: rebuilt then, not every frame.
@@ -16350,6 +16427,23 @@ impl Game<'_> {
{
title.drop_file(path.clone());
}
// A character that comes with no key (Windows' Alt codes, `Alt` + digits on the numeric
// keypad: a `WM_CHAR` after the Alt is released) reaches winit as a key event with no
// physical key. GLFW's char callback types it in the focused text field (`charTyped`),
// and every screen's key handler here would drop it for want of a key code.
if let WindowEvent::KeyboardInput {
event: key_event, ..
} = &event
&& key_event.state == ElementState::Pressed
&& matches!(key_event.physical_key, PhysicalKey::Unidentified(_))
&& let Some(text) = key_event.text.as_deref()
{
let text: String = text.chars().filter(|c| !c.is_control()).collect();
if !text.is_empty() && self.text_field_focused() && self.types_text() {
let _ = self.type_chars_into_screen(&text);
}
return;
}
// Fullscreen is a window shortcut, before screens consume keyboard input.
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Pressed
@@ -17975,6 +18069,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(),
@@ -18591,11 +18686,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
@@ -20215,6 +20319,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.
+20 -1
View File
@@ -67,6 +67,8 @@ pub(super) enum Target {
Tnt(u32),
/// A falling block, by its entity id (`minecraft:falling_block`).
Falling(u32),
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
Spawner(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -141,7 +143,8 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Falling(id)) = t
| Target::Falling(id)
| Target::Spawner(id)) = t
else {
return None;
};
@@ -525,6 +528,11 @@ impl Game<'_> {
.iter()
.find(|f| f.id == id)
.map(|f| [f.position.x, f.position.y, f.position.z]),
Target::Spawner(id) => self
.ominous_spawners
.iter()
.find(|s| s.id == id)
.map(|s| s.position),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -573,6 +581,7 @@ impl Game<'_> {
Target::Dragon(_) => "minecraft:ender_dragon".into(),
Target::Tnt(_) => "minecraft:tnt".into(),
Target::Falling(_) => "minecraft:falling_block".into(),
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -864,6 +873,7 @@ impl Game<'_> {
}
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
candidates.extend(self.ominous_spawners.iter().map(|s| Target::Spawner(s.id)));
candidates
}
/// The loaded levels, in the order the server holds them: the
@@ -897,6 +907,7 @@ impl Game<'_> {
Target::Dragon(id),
Target::Tnt(id),
Target::Falling(id),
Target::Spawner(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
@@ -920,6 +931,7 @@ impl Game<'_> {
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
@@ -1542,6 +1554,11 @@ impl Game<'_> {
f.previous = v;
}
}
Target::Spawner(id) => {
if let Some(s) = self.ominous_spawners.iter_mut().find(|s| s.id == id) {
s.position = [v.x, v.y, v.z];
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -2041,6 +2058,7 @@ impl Game<'_> {
Target::Dragon(id) => self.kill_dragon(id),
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
Target::Falling(id) => self.falling.retain(|f| f.id != id),
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
let before = self.orb_ids();
@@ -3714,6 +3732,7 @@ mod look_at_tests {
Target::Dragon(7),
Target::Tnt(7),
Target::Falling(7),
Target::Spawner(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
}
@@ -207,6 +207,17 @@ impl Game<'_> {
);
crate::game::persist::with_raw(tag, raw)
}
Target::Spawner(id) => {
let s = self.ominous_spawners.iter().find(|s| s.id == id)?;
let [x, y, z] = s.position;
entity_nbt::ominous_item_spawner_to_nbt(
&s.item,
horizon_core::geometry::Vec3::new(x, y, z),
s.spawn_item_after,
s.uuid,
s.raw.as_deref(),
)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -811,6 +822,8 @@ impl Game<'_> {
Some(Target::Tnt(id))
} else if self.falling.iter().any(|f| f.id == id) {
Some(Target::Falling(id))
} else if self.ominous_spawners.iter().any(|s| s.id == id) {
Some(Target::Spawner(id))
} else {
None
}
@@ -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)
@@ -1265,6 +1265,12 @@ impl Game<'_> {
let (target, sampled) = self.lod.as_ref().map_or((0, 0), |lod| lod.progress());
status["lod"]["target_tiles"] = target.into();
status["lod"]["sampled_target_tiles"] = sampled.into();
let (wanted, refined) = self
.lod
.as_ref()
.map_or((0, 0), |lod| lod.detail_progress());
status["lod"]["detail_target_columns"] = wanted.into();
status["lod"]["refined_target_columns"] = refined.into();
status["pending_uploads"] = self.pending_uploads.len().into();
status["destroying"] = json!(destroying);
status["physical_keys"] = json!(physical_keys);
+122 -47
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)
@@ -858,7 +864,10 @@ impl Game<'_> {
self.tick_village_siege();
}
self.tick_raids();
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities())
let ticked =
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities());
self.dismount_underwater_mob_riders();
ticked
}
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
@@ -885,6 +894,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 +1020,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 +1105,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];
@@ -2308,19 +2337,17 @@ impl Game<'_> {
flags,
} => {
if effect {
// EatBlockGoal passes the block's default state constant
// (Blocks.GRASS_BLOCK.defaultBlockState()), not the live state
// (which could be snowy).
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
let s = self.res.facts().get(old).sounds.break_sound.clone();
self.audio.play(
&s,
Some([
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
]),
1.0,
0.8,
);
let state = self
.pack
.assets
.registry
.default_state(&self.res.facts().get(old).name)
.unwrap_or(old);
self.level_event_at(2001, pos, state.0 as i32);
}
if let Some(state) = self.pack.assets.registry.default_state(name) {
self.level_set_with_flags(pos, state, flags)?;
@@ -2372,7 +2399,7 @@ impl Game<'_> {
}
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
self.level_event_at(2001, pos, old.0 as i32);
}
if drop {
let mut drops = world_items!(self).block_drops_in(
@@ -3411,24 +3438,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 +3487,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 +3506,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 +3572,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 +3611,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 +3635,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 +3921,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> {
@@ -5908,7 +5965,9 @@ impl Game<'_> {
}
return Ok(true);
}
let p = if fluid {
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
@@ -6060,6 +6119,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 {
@@ -6141,10 +6204,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 {
@@ -6223,6 +6296,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() {
+48 -7
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`.
@@ -2739,15 +2739,19 @@ impl Game<'_> {
} else {
0.0
};
// The dissolve is approximated by fading out.
let color = if death > 0.0 {
let alpha = ((1.0 - death / 200.0) * 255.0).clamp(0.0, 255.0) as u32;
alpha << 24 | 0x00FF_FFFF
// Dying: DYING_RENDER_TYPE, `entityCutoutDissolve` of the skin with dragon_exploding as
// the mask, white at `ARGB.white(1 - deathTime / 200)` and no overlay.
let mask = sprite("entity/enderdragon/dragon_exploding").filter(|_| death > 0.0);
let (color, overlay) = if mask.is_some() {
(dissolve_color(death), horizon_client::gfx::DISSOLVE_OVERLAY)
} else {
0xFFFF_FFFF
(0xFFFF_FFFF, u32::from(d.hurt_time > 0))
};
let overlay = u32::from(d.hurt_time > 0);
let first = out.len();
horizon_client::mobs::render::draw(model, base, &poses, skin, color, light, overlay, out);
if let Some(mask) = mask {
dissolve_mask(&mut out[first..], skin, mask);
}
// RenderTypes.eyes: the glowing eyes, added on top.
let mut eye_quads = Vec::new();
horizon_client::mobs::render::draw(
@@ -3860,6 +3864,24 @@ impl Game<'_> {
}
}
/// The dying model's colour, `ARGB.white(1 - deathTime / 200)`: `Mth.floor` of the alpha
/// shifted as a Java int, so past 200 it wraps as vanilla's does.
fn dissolve_color(death: f32) -> u32 {
let alpha = ((1.0 - death / 200.0) * 255.0).floor() as i32;
(alpha << 24) as u32 | 0x00FF_FFFF
}
/// `DissolveMaskSampler` at the vertex's `UV0`: the same place in the mask's sprite as in the
/// skin's, as the dissolve overlay reads it from `glint_uv` (entity.wgsl).
fn dissolve_mask(quads: &mut [Vertex], skin: [f32; 4], mask: [f32; 4]) {
for v in quads {
let u = (v.uv[0] - skin[0]) / (skin[2] - skin[0]);
let w = (v.uv[1] - skin[1]) / (skin[3] - skin[1]);
v.glint_uv = [
mask[0] + (mask[2] - mask[0]) * u,
mask[1] + (mask[3] - mask[1]) * w,
];
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out: &mut Vec<Vertex>) {
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
@@ -3986,6 +4008,25 @@ mod tests {
std::fs::write(path, serde_json::to_vec(&cases).unwrap()).unwrap();
}
#[test]
fn the_dying_model_reads_the_mask_where_it_reads_the_skin() {
// The skin in the atlas's top-left quarter, the mask in its bottom-right one.
let mut quad = [Vertex {
pos: [0.0; 3],
uv: [0.25, 0.125],
layer: 0,
color: 0,
light: 0,
normal: [0; 4],
glint_uv: [0.0; 2],
}];
dissolve_mask(&mut quad, [0.0, 0.0, 0.5, 0.5], [0.5, 0.5, 1.0, 1.0]);
assert_eq!(quad[0].glint_uv, [0.75, 0.625]);
// ARGB.white(1 - deathTime / 200): floor(127.5), and past 200 the int shift wraps.
assert_eq!(dissolve_color(100.0), 0x7FFF_FFFF);
assert_eq!(dissolve_color(200.5), 0xFFFF_FFFF);
}
#[test]
fn death_rays_sample_inside_the_white_sprite() {
// A four-texel white sprite surrounded by transparent atlas pixels.
@@ -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);
+38 -3
View File
@@ -177,6 +177,15 @@ impl EndFlash {
fn intensity(&self, partial: f32) -> f32 {
self.old_intensity + (self.intensity - self.old_intensity) * partial
}
/// SkyRenderer.renderEndFlash's pose: the sprite at height 100 turned to the flash.
fn pose(&self) -> glam::Mat4 {
flash_pose(self.x_angle, self.y_angle)
}
}
fn flash_pose(x_angle: f32, y_angle: f32) -> glam::Mat4 {
glam::Mat4::from_rotation_y((180.0 - y_angle).to_radians())
* glam::Mat4::from_rotation_x((-90.0 - x_angle).to_radians())
}
impl EndState {
pub(super) fn locator(&self) -> Option<Arc<VanillaTerrain>> {
@@ -316,9 +325,7 @@ impl Game<'_> {
.get("environment/celestial/end_flash")
.copied()
{
let flash = &self.end.flash;
let pose = glam::Mat4::from_rotation_y((180.0 - flash.y_angle).to_radians())
* glam::Mat4::from_rotation_x((-90.0 - flash.x_angle).to_radians());
let pose = self.end.flash.pose();
let level = (intensity.clamp(0.0, 1.0) * 255.0) as u32;
let color = level << 24 | level << 16 | level << 8 | level;
let corners = [
@@ -344,6 +351,21 @@ impl Game<'_> {
out
}
/// Iris's `endFlashIntensity` and the flash's direction (camera-relative, length 100: the
/// sprite's centre), for shader packs; no flash outside the End.
pub(super) fn end_flash_for_pack(&self, partial: f32) -> (f32, glam::Vec3) {
if self.dim != Dim::End {
return (0.0, glam::Vec3::ZERO);
}
let flash = &self.end.flash;
(
flash.intensity(partial).clamp(0.0, 1.0),
flash
.pose()
.transform_point3(glam::Vec3::new(0.0, 100.0, 0.0)),
)
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
@@ -1859,4 +1881,17 @@ mod tests {
let centre = corners.iter().copied().sum::<glam::Vec3>() / 4.0;
assert!((centre - at).abs_diff_eq(up_dir * 0.125, 1e-5), "{centre}");
}
/// `endFlashPosition`'s direction: the flash sprite's centre, 100 blocks away.
#[test]
fn the_end_flash_is_where_its_sprite_is() {
let at =
|x: f32, y: f32| flash_pose(x, y).transform_point3(glam::Vec3::new(0.0, 100.0, 0.0));
// Straight up whatever the turn, and level toward the north at y_angle 180.
assert!(at(-90.0, 37.0).abs_diff_eq(glam::Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(at(0.0, 180.0).abs_diff_eq(glam::Vec3::new(0.0, 0.0, -100.0), 1e-3));
for (x, y) in [(-60.0, -180.0), (10.0, 95.0), (-25.0, 3.0)] {
assert!((at(x, y).length() - 100.0).abs() < 1e-3);
}
}
}
+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.
+1 -1
View File
@@ -107,7 +107,7 @@ impl Game<'_> {
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
match fog_type(&self.world, self.res.facts(), camera, self.forward()) {
match fog_type(&self.world, self.res.facts(), camera, &self.near_plane()) {
FogType::Water => CameraFluid::Water,
FogType::Lava => CameraFluid::Lava,
FogType::PowderSnow => CameraFluid::PowderSnow,
+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
@@ -207,7 +207,7 @@ impl Game<'_> {
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
pub(super) fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
+6 -1
View File
@@ -869,7 +869,12 @@ impl Game<'_> {
self.lan = Some(host);
let lines = self.as_console(|g| {
g.chat_capture = Some(Vec::new());
g.run_command(&command);
// A command a native mod registered goes to the mods
// first, as the owner's own chat does (the console is
// level 4, so every registered usage is allowed).
if !g.perform_native_command(conn, 4, command.trim()) {
g.run_command(&command);
}
g.chat_capture.take().unwrap_or_default()
});
host = self.lan.take().expect("a command puts the hub back");
@@ -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
+27 -93
View File
@@ -243,51 +243,19 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
}
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
/// comes after AbstractPiglin's zombification flag (17).
pub(super) fn baby_index(kind: &str) -> Option<u8> {
match kind {
"minecraft:piglin" => Some(17),
"minecraft:cow"
| "minecraft:mooshroom"
| "minecraft:pig"
| "minecraft:sheep"
| "minecraft:chicken"
| "minecraft:horse"
| "minecraft:donkey"
| "minecraft:mule"
| "minecraft:skeleton_horse"
| "minecraft:zombie_horse"
| "minecraft:llama"
| "minecraft:trader_llama"
| "minecraft:cat"
| "minecraft:wolf"
| "minecraft:fox"
| "minecraft:rabbit"
| "minecraft:panda"
| "minecraft:polar_bear"
| "minecraft:bee"
| "minecraft:frog"
| "minecraft:sniffer"
| "minecraft:armadillo"
| "minecraft:villager"
| "minecraft:zombie"
| "minecraft:husk"
| "minecraft:drowned"
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:hoglin"
| "minecraft:zoglin"
| "minecraft:goat"
| "minecraft:ocelot"
| "minecraft:turtle"
| "minecraft:strider"
| "minecraft:camel"
| "minecraft:axolotl"
| "minecraft:dolphin"
| "minecraft:squid"
| "minecraft:glow_squid"
| "minecraft:sulfur_cube" => Some(16),
/// Zombie.DATA_BABY_ID (Zoglin's too) are the first entry after Mob's (16);
/// Piglin's comes after AbstractPiglin's zombification flag (17). Every
/// AgeableMob has one, the happy ghast and the nautilus included (#6133).
pub(super) fn baby_index(species: Species) -> Option<u8> {
use Species as S;
match species {
S::Piglin => Some(17),
s if horizon_client::mobs::finalize::ageable(s)
|| horizon_client::mobs::doors::is_zombie(s)
|| s == S::Zoglin =>
{
Some(16)
}
_ => None,
}
}
@@ -507,8 +475,8 @@ fn mob_meta(
meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((15, serializer::BYTE, Value::Byte(0)));
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
if let Some(index) = baby_index(kind) {
// AgeableMob.DATA_BABY_ID (and Zombie's, Zoglin's, Piglin's).
if let Some(index) = baby_index(m.species()) {
if m.is_baby() {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
@@ -1113,54 +1081,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 +2259,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
+39 -1
View File
@@ -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(
@@ -344,7 +380,9 @@ impl Game<'_> {
e.meta.push((
8,
serializer::ITEM_STACK,
Value::Item(hub.stack(&Some(o.item.clone()))),
Value::Item(
hub.stack(&Some(o.item.clone()).filter(|i| &*i.item != "minecraft:air")),
),
));
out.push(e);
}
@@ -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 -3
View File
@@ -1,6 +1,6 @@
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api`.
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
pub const VERSION: &str = "0.1.0";
@@ -155,7 +155,7 @@ pub fn markdown() -> String {
let w = &mut out;
let _ = writeln!(
w,
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->"
);
let _ = writeln!(w, "# Launch API reference\n");
let _ = writeln!(
@@ -265,7 +265,7 @@ mod tests {
.replace("\r\n", "\n");
assert!(
have == want,
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api"
);
}
}
+55 -1
View File
@@ -80,6 +80,9 @@ pub(super) struct Parked {
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
/// them: what its players' moves are checked against before this tick describes them again.
lan_states: horizon_client::lan::entity::States,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -118,6 +121,9 @@ impl Default for Pass {
#[derive(Default)]
pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
/// The levels whose open the mods heard (`native_level_load`), each to
/// be told closed once.
heard_open: Vec<Dim>,
}
impl Levels {
@@ -213,6 +219,7 @@ impl Game<'_> {
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_states: Default::default(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
@@ -291,7 +298,11 @@ impl Game<'_> {
/// The level that was in place is kept with the others; the shared
/// clock and weather go with the swap. `host` is the hub when the caller
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
pub(super) fn enter_level(
&mut self,
mut host: Option<&mut LanHost>,
target: Dim,
) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.execution_role.is_client() {
return Ok(());
@@ -310,12 +321,23 @@ impl Game<'_> {
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
// The tracked entities go with their level: a level's moves, read before its tick
// describes its entities again, would otherwise meet the last level's (the anticheat
// missed the boat under a player while anyone was in another dimension).
let states = match host.as_deref_mut() {
Some(h) => Some(&mut h.states),
None => self.lan.as_mut().map(|h| &mut h.states),
};
if let Some(states) = states {
std::mem::swap(states, &mut p.lan_states);
}
p.idle = 0;
let left = std::mem::replace(&mut self.dim, target);
self.levels.parked.insert(left, p);
if opened {
// The new streaming's radius and meshing options.
self.apply_settings();
self.native_level_opened(target);
}
}
let host = match host {
@@ -617,10 +639,42 @@ impl Game<'_> {
self.save_level(true)?;
self.enter_level(None, back)?;
self.levels.parked.remove(&dim);
self.native_level_closed(dim);
}
Ok(())
}
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
pub(super) fn native_level_opened(&mut self, dim: Dim) {
if self.remote.is_some()
|| self.options.execution_role.is_client()
|| self.levels.heard_open.contains(&dim)
{
return;
}
self.levels.heard_open.push(dim);
self.native_level_load(dim.id());
}
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
/// each open they heard.
fn native_level_closed(&mut self, dim: Dim) {
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
return;
};
self.levels.heard_open.remove(i);
self.native_level_unload(dim.id());
}
/// Every level still open closes with the server, however it ends
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
/// order of its levels: the Overworld, the Nether, the End.
pub(super) fn native_levels_closing(&mut self) {
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
self.native_level_closed(dim);
}
}
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
/// in place is the caller's to save.
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
+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
+61 -1
View File
@@ -631,7 +631,15 @@ impl Game<'_> {
horizon_assets::item_model::SpecialKind::Head {
kind: kind.to_owned(),
texture: None,
animation: 0.0,
animation: worn_head_animation(
mob,
// The local player's synthetic mob carries no `mount`.
mob.extra
.mount
.or(if local_player { self.riding } else { None }),
&self.mobs,
alpha as f32,
),
}
},
};
@@ -686,11 +694,63 @@ impl Game<'_> {
}
}
/// `LivingEntityRenderer.extractRenderState`'s `wornHeadAnimationPos`: the
/// walk position of the living mob `mount` carries the wearer on (a boat or
/// minecart is no mob, so it is not found), else the wearer's own.
fn worn_head_animation(wearer: &Mob, mount: Option<u32>, mobs: &[Mob], alpha: f32) -> f32 {
mount
.and_then(|id| mobs.iter().find(|m| m.id == id))
.map_or_else(
|| horizon_client::mobs::anim::state(wearer, alpha).pos,
|v| horizon_client::mobs::anim::walk_position(v, alpha),
)
}
#[cfg(test)]
mod tests {
use super::*;
use glam::{Mat4, Vec3};
/// A mob of the prepared resources' type `kind` (None without them).
fn mob(id: u32, kind: &str) -> Option<horizon_client::mobs::Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
Some(horizon_client::mobs::Mob::new(
id,
t,
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
0.0,
))
}
#[test]
fn a_worn_head_follows_the_living_vehicle() {
let (Some(mut wearer), Some(mut horse)) =
(mob(1, "minecraft:zombie"), mob(2, "minecraft:horse"))
else {
return;
};
wearer.limb_position = 1.0;
wearer.limb_speed = 0.5;
horse.limb_position = 4.0;
horse.limb_speed = 1.0;
let mobs = [horse];
// Walking on its own: its own position (1 - 0.5 * 0.5).
assert_eq!(worn_head_animation(&wearer, None, &mobs, 0.5), 0.75);
// A mount that is no mob (boat, minecart) or unknown: still its own.
assert_eq!(worn_head_animation(&wearer, Some(99), &mobs, 0.5), 0.75);
// Riding the horse (a passenger's own position is 0): the horse's.
wearer.extra.passenger = true;
assert_eq!(worn_head_animation(&wearer, None, &mobs, 0.5), 0.0);
assert_eq!(worn_head_animation(&wearer, Some(2), &mobs, 0.5), 3.5);
// Dead wearer, living vehicle: the vehicle's.
wearer.health = 0.0;
assert_eq!(worn_head_animation(&wearer, Some(2), &mobs, 0.5), 3.5);
}
#[test]
fn carried_block_vertices_match_vanilla_block_coordinates() {
// Captured from 26.3 CarriedBlockLayer.submit with an identity PoseStack:
+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))
+119 -25
View File
@@ -54,6 +54,27 @@ impl MountState {
self.jump_scale = 0.0;
}
}
/// `EntityTypeTags.DISMOUNTS_UNDERWATER` (`Entity.dismountsUnderwater`): the mounts that throw their
/// riders off when the rider's eyes are in water.
pub(super) fn dismounts_underwater(species: Species) -> bool {
matches!(
species,
Species::Camel
| Species::Chicken
| Species::Donkey
| Species::HappyGhast
| Species::Horse
| Species::Llama
| Species::Mule
| Species::Pig
| Species::Ravager
| Species::Spider
| Species::Strider
| Species::TraderLlama
| Species::ZombieHorse
)
}
impl Game<'_> {
fn mount_index(&self) -> Option<usize> {
let id = self.riding?;
@@ -86,7 +107,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 +268,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;
@@ -364,6 +393,19 @@ impl Game<'_> {
}
jump
}
/// Whether the rider's eyes are in water (`Entity.updateFluidOnEyes` then `isEyeInFluid(WATER)`),
/// and not in a bubble column, as `LivingEntity.baseTick` asks before it lets a rider stay.
fn rider_eyes_in_water(&self) -> bool {
let Some([x, y, z]) = self.eye_position() else {
return false;
};
let (bx, by, bz) = (x.floor() as i32, y.floor() as i32, z.floor() as i32);
if &*self.res.facts().get(self.world.block(bx, by, bz)).name == "minecraft:bubble_column" {
return false;
}
self.fluid_height_of(bx, by, bz, Some(Fluid::Water))
.is_some_and(|(height, _)| f64::from(by) + f64::from(height) > y)
}
/// The rider's input for this tick; returns false when not on a mount.
pub(super) fn tick_mount_riding(&mut self, keys: Keys) -> bool {
let Some(i) = self.mount_index() else {
@@ -373,6 +415,12 @@ impl Game<'_> {
self.dismount_mount();
return true;
}
// `LivingEntity.baseTick`: a rider whose eyes are in water gets off a mount of the
// `dismounts_underwater` tag (a server's player too: `leave_guest` tells their client).
if dismounts_underwater(self.mobs[i].species()) && self.rider_eyes_in_water() {
self.dismount_mount();
return true;
}
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
@@ -422,6 +470,23 @@ impl Game<'_> {
});
true
}
/// `LivingEntity.baseTick` for the mobs that ride a mount of the `dismounts_underwater` tag (a baby
/// zombie on a chicken): with their eyes in water they get off it.
pub(super) fn dismount_underwater_mob_riders(&mut self) {
let swimmers: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.alive() && m.fluids.eye_in_water)
.filter_map(|m| {
let mount = m.extra.mount?;
let under = self.mobs.iter().find(|v| v.id == mount)?;
dismounts_underwater(under.species()).then_some(m.id)
})
.collect();
for rider in swimmers {
self.dismount_mob(rider);
}
}
/// `positionRider`: the player sits on the mount after it moves.
pub(super) fn position_rider(&mut self) {
let Some(i) = self.mount_index() else {
@@ -480,9 +545,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 +709,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,
@@ -847,6 +915,32 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn the_mounts_that_throw_a_swimmer_off_are_the_dismounts_underwater_tag() {
// data/minecraft/tags/entity_type/dismounts_underwater.json of 26.3.
for species in [
Species::Camel,
Species::Chicken,
Species::Donkey,
Species::HappyGhast,
Species::Horse,
Species::Llama,
Species::Mule,
Species::Pig,
Species::Ravager,
Species::Spider,
Species::Strider,
Species::TraderLlama,
Species::ZombieHorse,
] {
assert!(dismounts_underwater(species), "{species:?}");
}
// Not in the tag: a skeleton horse swims with its rider, a camel husk is not the camel.
for species in [Species::SkeletonHorse, Species::CamelHusk, Species::Cow] {
assert!(!dismounts_underwater(species), "{species:?}");
}
}
#[test]
fn steering_accepts_the_correct_item_in_either_hand() {
let carrot = "minecraft:carrot_on_a_stick";
@@ -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)));
}
}
@@ -115,7 +115,8 @@ fn target_id(t: Target) -> Option<u32> {
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Falling(id) => Some(id),
| Target::Falling(id)
| Target::Spawner(id) => Some(id),
Target::Player | Target::Guest(_) => None,
}
}
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff. Show more