mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into hdr-items
# Conflicts: # docs/graphics.md
This commit is contained in:
commit
e03324cd4e
291 files changed
+14339
-1596
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(©.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
Reference in new issue
Block a user