Merge remote-tracking branch 'origin/main' into fix-6260

# Conflicts:
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game/native_hooks.rs
This commit is contained in:
Ludovic Marie committed 2026-11-01 20:57:30 +01:00
commit 978591ea13
221 files changed
+6944 -874

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
+16 -9
View File
@@ -40,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -85,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
@@ -103,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
@@ -129,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -152,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
@@ -161,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
Generated
+57
View File
@@ -1314,6 +1314,7 @@ dependencies = [
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-lib",
"horizon-client-render",
"horizon-command",
"horizon-core",
@@ -1374,6 +1375,62 @@ dependencies = [
"winit",
]
[[package]]
name = "horizon-client-lib"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-chat-filter",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
"horizon-protocol",
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
+1
View File
@@ -8,6 +8,7 @@ members = [
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-lib",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
+2 -4
View File
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; ARM boards name the machine once.
key_value(&info, "model name", ':')
.or_else(|| key_value(&info, "Model", ':'))
.map(str::to_owned)
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
+221
View File
@@ -0,0 +1,221 @@
[package]
name = "horizon-client-lib"
autobins = false
autoexamples = false
autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Reusable client systems, control tool and parity probes"
[lib]
path = "../horizon-client/src/client_lib.rs"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
horizon-chat-filter = { path = "../horizon-chat-filter" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[lints]
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
[[example]]
name = "json_load_bench"
path = "../horizon-client/examples/json_load_bench.rs"
[[example]]
name = "mod_port_probe"
path = "../horizon-client/examples/mod_port_probe.rs"
[[example]]
name = "model_library_bench"
path = "../horizon-client/examples/model_library_bench.rs"
[[example]]
name = "model_states_bench"
path = "../horizon-client/examples/model_states_bench.rs"
[[example]]
name = "old_world_fixture"
path = "../horizon-client/examples/old_world_fixture.rs"
[[example]]
name = "portal_scan_bench"
path = "../horizon-client/examples/portal_scan_bench.rs"
[[example]]
name = "random_tick_eligibility_bench"
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
[[example]]
name = "resource_memory"
path = "../horizon-client/examples/resource_memory.rs"
[[example]]
name = "save_batch_bench"
path = "../horizon-client/examples/save_batch_bench.rs"
[[example]]
name = "shared_json_startup_profile"
path = "../horizon-client/examples/shared_json_startup_profile.rs"
[[example]]
name = "sprite_cache_parity"
path = "../horizon-client/examples/sprite_cache_parity.rs"
[[example]]
name = "client_probe"
path = "../horizon-client/examples/probe/main.rs"
[[test]]
name = "custom_component_api"
path = "../horizon-client/tests/custom_component_api.rs"
[[test]]
name = "custom_component_unregistered"
path = "../horizon-client/tests/custom_component_unregistered.rs"
[[test]]
name = "custom_component_wire"
path = "../horizon-client/tests/custom_component_wire.rs"
[[test]]
name = "forced_chunks"
path = "../horizon-client/tests/forced_chunks.rs"
[[test]]
name = "generation_saves"
path = "../horizon-client/tests/generation_saves.rs"
[[test]]
name = "lan_bot"
path = "../horizon-client/tests/lan_bot.rs"
[[test]]
name = "streaming_meshes"
path = "../horizon-client/tests/streaming_meshes.rs"
[[test]]
name = "tall_column"
path = "../horizon-client/tests/tall_column.rs"
[[test]]
name = "wide_states"
path = "../horizon-client/tests/wide_states.rs"
@@ -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;
@@ -221,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
@@ -4,8 +4,8 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity,
ModBlockEntityFields, ModContainer, ModFluidTanks, ModRevision, PotentSulfur, Shelf, Skull,
SkullSkin,
ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur,
Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
+1 -6
View File
@@ -227,12 +227,7 @@ fn operating_system() -> String {
pub fn cpu() -> String {
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
let name = if cfg!(target_os = "linux") {
read("/proc/cpuinfo").and_then(|t| {
t.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split_once(':'))
.map(|(_, v)| v.trim().to_owned())
})
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
+13 -3
View File
@@ -575,7 +575,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -584,7 +584,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" => kind,
| "villager_trade" | "dialog" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -623,6 +623,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"data_maps",
"dialog",
"function",
"item_modifier",
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
mod tests {
use super::*;
#[test]
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
assert_eq!(
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
Some("data_maps/mymod:item/fuel")
);
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
}
#[test]
fn enabled_packs_override_worldgen_in_order() {
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
+24 -15
View File
@@ -111,6 +111,7 @@ mod end_sky;
mod flat;
mod frame_gen;
mod hdr;
mod hdr_icc;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
@@ -126,6 +127,7 @@ mod occlusion;
mod oit;
mod oit_features;
mod pack_atlas;
mod pack_center_depth;
pub mod pack_draw;
mod pack_entities;
mod pack_hand;
@@ -4079,14 +4081,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);
@@ -7789,6 +7795,9 @@ impl Renderer {
if let Some(timer) = &mut self.own_timing {
timer.submitted(&self.queue);
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.submitted();
}
let failed = checks.and_then(GpuChecks::pop);
if let Some((request, buffer)) = readback {
// Out of memory on the way: the PNG would be black; taken again at half the size.
@@ -8246,19 +8255,19 @@ impl Renderer {
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the peak and paper white as its content light levels (cLLI).
/// third edition), with the peak and paper white as its content light levels (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
let Some(hdr) = &self.hdr else {
return Ok(());
};
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
self.config.width,
self.config.height,
);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Sixteen);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
@@ -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 {
@@ -0,0 +1,159 @@
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
//! An ICC profile has no values above white, so those programs show a correct SDR picture
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
const WHITE: f64 = 203.0;
/// PQ's values from 0 to 1, in the curve's steps.
const STEPS: usize = 4096;
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
fn pq_nits(signal: f64) -> f64 {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = signal.powf(1.0 / m2);
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
}
fn s15_fixed16(v: f64) -> [u8; 4] {
((v * 65536.0).round() as i32).to_be_bytes()
}
fn xyz(v: [f64; 3]) -> Vec<u8> {
let mut tag = b"XYZ \0\0\0\0".to_vec();
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
tag
}
fn curve() -> Vec<u8> {
let mut tag = b"curv\0\0\0\0".to_vec();
tag.extend((STEPS as u32).to_be_bytes());
for i in 0..STEPS {
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
}
tag
}
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
fn description(text: &str) -> Vec<u8> {
let mut tag = b"desc\0\0\0\0".to_vec();
tag.extend((text.len() as u32 + 1).to_be_bytes());
tag.extend(text.as_bytes());
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
tag
}
/// An ICC v2 matrix-shaper display profile.
pub(super) fn profile() -> Vec<u8> {
let trc = curve();
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
let tags: [(&[u8; 4], &[u8]); 9] = [
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
(b"cprt", &copyright),
(b"wtpt", &xyz(D50)),
(b"rXYZ", &xyz(RED)),
(b"gXYZ", &xyz(GREEN)),
(b"bXYZ", &xyz(BLUE)),
(b"rTRC", &trc),
(b"gTRC", &trc),
(b"bTRC", &trc),
];
let data_start = 128 + 4 + 12 * tags.len();
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
let mut data = Vec::new();
let mut trc_at = None;
for (name, tag) in tags {
// The three channels share one curve.
let is_trc = tag == &trc[..];
let at = match trc_at.filter(|_| is_trc) {
Some(at) => at,
None => {
let at = data_start + data.len();
data.extend(tag);
data.resize(data.len().next_multiple_of(4), 0);
at
}
};
if is_trc {
trc_at = Some(at);
}
table.extend(name);
table.extend((at as u32).to_be_bytes());
table.extend((tag.len() as u32).to_be_bytes());
}
let mut out = vec![0; 128];
let size = data_start + data.len();
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
out[12..16].copy_from_slice(b"mntr");
out[16..20].copy_from_slice(b"RGB ");
out[20..24].copy_from_slice(b"XYZ ");
out[36..40].copy_from_slice(b"acsp");
// Relative colorimetric, and the connection space's illuminant.
out[64..68].copy_from_slice(&1u32.to_be_bytes());
for (i, &c) in D50.iter().enumerate() {
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
}
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
let at = (0..u32_at(128))
.map(|i| 132 + 12 * i)
.find(|&e| &profile[e..e + 4] == name)
.unwrap();
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
}
#[test]
fn the_profile_is_whole() {
let p = profile();
assert_eq!(
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
p.len()
);
assert_eq!(&p[36..40], b"acsp");
assert_eq!(p.len() % 4, 0);
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
// One curve, shared: the profile stays small.
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
assert!(p.len() < 10_000, "{}", p.len());
}
#[test]
fn the_primaries_add_up_to_white() {
for i in 0..3 {
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
}
}
#[test]
fn the_curve_reaches_white_at_reference_white_and_stays() {
let p = profile();
let trc = tag(&p, b"rTRC");
let value = |signal: f64| {
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
u16::from_be_bytes([trc[i], trc[i + 1]])
};
assert_eq!(value(0.0), 0);
// 203 nits in PQ is a signal of 0.5807.
assert!(value(0.57) < 65535);
assert_eq!(value(0.59), 65535);
assert_eq!(value(1.0), 65535);
}
}
@@ -0,0 +1,296 @@
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
//! target, copied to one of a few small buffers and read back frames later without waiting for
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
const SLOTS: usize = 3;
const FREE: u8 = 0;
/// Copied this frame: mapped once the frame is submitted.
const COPIED: u8 = 1;
const PENDING: u8 = 2;
const MAPPED: u8 = 3;
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
const SHADER: &str = "
@group(0) @binding(0) var depth: texture_depth_2d;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<f32> {
let centre = vec2<i32>(textureDimensions(depth) / 2u);
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
}
";
struct Slot {
buffer: wgpu::Buffer,
state: Arc<AtomicU8>,
}
pub struct CenterDepth {
slots: Vec<Slot>,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
/// The 1x1 target the centre texel is loaded into.
texel: wgpu::Texture,
texel_view: wgpu::TextureView,
/// The bind group of the depth texture it reads, made again when that texture changes.
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
/// The depth last read back: GL window depth, 0..1.
latest: Option<f32>,
}
impl CenterDepth {
pub fn new(device: &wgpu::Device) -> Self {
let slots = (0..SLOTS)
.map(|_| Slot {
buffer: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack centre depth"),
size: u64::from(ROW),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
state: Arc::new(AtomicU8::new(FREE)),
})
.collect();
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack centre depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader pack centre depth"),
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack centre depth"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let format = wgpu::TextureFormat::R32Float;
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Shader pack centre depth"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let texel = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack centre depth"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let texel_view = texel.create_view(&Default::default());
Self {
slots,
pipeline,
layout,
texel,
texel_view,
group: None,
latest: None,
}
}
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
pub fn copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::Texture,
) {
let Some(slot) = self
.slots
.iter()
.find(|s| s.state.load(Ordering::Acquire) == FREE)
else {
return;
};
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
let view = depth.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack centre depth"),
layout: &self.layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
}],
});
self.group = Some((depth.clone(), group));
}
let Some((_, group)) = &self.group else {
return;
};
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack centre depth"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.texel_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
self.texel.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &slot.buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(ROW),
rows_per_image: Some(1),
},
},
self.texel.size(),
);
slot.state.store(COPIED, Ordering::Release);
}
/// After the frame's submission: start reading back what it copied.
pub fn submitted(&mut self) {
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != COPIED {
continue;
}
slot.state.store(PENDING, Ordering::Release);
let state = slot.state.clone();
slot.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let next = if result.is_ok() { MAPPED } else { FREE };
state.store(next, Ordering::Release);
});
}
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
for slot in &self.slots {
if slot.state.load(Ordering::Acquire) != MAPPED {
continue;
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.ok()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
if let Some(depth) = depth.filter(|d| d.is_finite()) {
self.latest = Some(depth.clamp(0.0, 1.0));
}
}
self.latest
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
/// later, without the frame waiting for it.
#[test]
fn the_centre_depth_comes_back() {
let Some((device, queue)) = test_device() else {
return;
};
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 9,
height: 7,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
let mut center = CenterDepth::new(&device);
assert_eq!(center.collect(&device), None);
let mut encoder = device.create_command_encoder(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.375),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
// Its slot is free again: every slot can be used once more.
for _ in 0..SLOTS {
let mut encoder = device.create_command_encoder(&Default::default());
center.copy(&device, &mut encoder, &depth);
queue.submit([encoder.finish()]);
center.submitted();
}
let _ = device.poll(wgpu::PollType::wait_indefinitely());
assert_eq!(center.collect(&device), Some(0.375));
}
}
@@ -512,6 +512,8 @@ pub struct PackPost {
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<super::pack_timing::PassTiming>,
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
center_depth: Option<super::pack_center_depth::CenterDepth>,
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
custom: horizon_shaders::Custom,
atlas: Option<wgpu::TextureView>,
@@ -2242,6 +2244,7 @@ impl PackPost {
block_ids,
page_groups: HashMap::new(),
timing: None,
center_depth: None,
custom,
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
lightmap: world.lightmap,
@@ -2740,6 +2743,13 @@ impl PackPost {
let rp = &mut open.as_mut().expect("open").0;
rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
// sun's fallback) binds them, as the other passes do.
if pass.buffers
&& let Some(g) = &self.buffers.group
{
rp.set_bind_group(4, g, &[]);
}
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
}
@@ -2896,6 +2906,13 @@ impl PackPost {
}
}
/// After the frame's submission: the centre depth it copied starts its way back.
pub fn submitted(&mut self) {
if let Some(center) = &mut self.center_depth {
center.submitted();
}
}
/// Whether the pack draws entities or block entities itself.
pub fn has_entities(&self) -> bool {
self.entities.is_some()
@@ -3613,6 +3630,10 @@ impl PackPost {
if let Some(t) = &mut self.timing {
t.begin(device, queue);
}
self.uniforms.center_depth_raw = self
.center_depth
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
.collect(device);
self.uniforms.update(frame, inputs, size);
// Until the camera's biome is known, `smooth` waits for the world's values.
if !inputs.biome_known {
@@ -3851,6 +3872,10 @@ impl PackPost {
self.draw_translucent(device, queue, encoder, size, terrain);
}
let targets = self.targets.as_ref().expect("resized");
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
if let Some(center) = &mut self.center_depth {
center.copy(device, encoder, &targets.depth_texture);
}
let view = |m: u16, i: usize, alt: bool| {
targets.color[i]
.as_ref()
@@ -51,6 +51,12 @@ pub struct PackInputs {
pub cloud_height: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
pub status: PlayerStatus,
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
pub end_flash: f32,
pub end_flash_direction: Vec3,
}
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
}
}
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
/// `isSpectator`, `is_burning`...).
#[derive(Clone, Copy, Debug, Default)]
pub struct PlayerStatus {
/// Survival or adventure: the vitals are -1 in creative and spectator.
pub survival: bool,
pub health: f32,
pub max_health: f32,
pub food: f32,
/// Air supply ticks, of 300.
pub air: f32,
/// Armor points (the `armor` attribute).
pub armor: f32,
pub spectator: bool,
pub riding: bool,
pub feet_in_water: bool,
pub swimming: bool,
pub burning: bool,
pub hurt: bool,
pub invisible: bool,
pub sneaking: bool,
pub sprinting: bool,
}
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
const MAX_HUNGER: f32 = 20.0;
const MAX_AIR: f32 = 300.0;
const MAX_ARMOR: f32 = 50.0;
/// One uniform's value: up to 16 floats or 3 integers.
#[derive(Clone, Copy, Debug)]
pub enum Value {
@@ -198,6 +233,26 @@ pub const NAMES: &[&str] = &[
"playerLookVector",
"playerBodyVector",
"firstPersonCamera",
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"maxPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
"maxPlayerArmor",
"isSpectator",
"isRiding",
"feetInWater",
"inSwimmingAnimation",
"is_burning",
"is_hurt",
"is_invisible",
"is_sneaking",
"is_sprinting",
"endFlashIntensity",
"endFlashPosition",
"centerDepthSmooth",
"isRightHanded",
"is_on_ground",
"bedrockLevel",
@@ -220,6 +275,9 @@ pub struct FrameUniforms {
previous: Option<(glam::DVec3, Mat4, Mat4)>,
wetness: f32,
eye_smooth: [f32; 2],
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
pub center_depth_raw: Option<f32>,
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
}
@@ -235,6 +293,8 @@ impl Default for FrameUniforms {
previous: None,
wetness: 0.0,
eye_smooth: [0.0; 2],
center_depth_raw: None,
center_depth: None,
shadow: Shadow::default(),
}
}
@@ -379,6 +439,15 @@ impl FrameUniforms {
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
}
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
// read back is taken as it is.
if let Some(raw) = self.center_depth_raw {
self.center_depth = Some(match self.center_depth {
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
None => raw,
});
}
let center_depth = self.center_depth.unwrap_or(1.0);
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
// of one with the nearer end of the other made fog begin at the camera on clear days.
@@ -424,6 +493,8 @@ impl FrameUniforms {
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
"sunPosition" => v3(sun),
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
"centerDepthSmooth" => Value::f(&[center_depth]),
"moonPosition" => v3(moon),
"shadowLightPosition" => v3(if day { sun } else { moon }),
"sunAngle" => Value::f(&[sun_angle]),
@@ -483,7 +554,7 @@ impl FrameUniforms {
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
// renderStage is NONE outside gbuffers draws.
_ => Value::i(&[0]),
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
};
self.values.clear();
@@ -529,6 +600,46 @@ impl FrameUniforms {
}
}
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
let status = inputs.status;
let vital = |v: f32| {
Value::f(&[if status.survival {
v.clamp(0.0, 1.0)
} else {
-1.0
}])
};
let flag = |b: bool| Value::i(&[i32::from(b)]);
Some(match name {
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
"maxPlayerHealth" => Value::f(&[if status.survival {
status.max_health
} else {
-1.0
}]),
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
"currentPlayerAir" => vital(status.air / MAX_AIR),
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
"isSpectator" => flag(status.spectator),
"isRiding" => flag(status.riding),
"feetInWater" => flag(status.feet_in_water),
"inSwimmingAnimation" => flag(status.swimming),
"is_burning" => flag(status.burning),
"is_hurt" => flag(status.hurt),
// Spectators count as invisible.
"is_invisible" => flag(status.invisible || status.spectator),
"is_sneaking" => flag(status.sneaking),
"is_sprinting" => flag(status.sprinting),
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
_ => return None,
})
}
/// How many components the uniform `name` has (16 for a matrix).
fn components(name: &str) -> usize {
match name {
@@ -547,7 +658,8 @@ fn components(name: &str) -> usize {
| "moonPosition"
| "shadowLightPosition"
| "fogColor"
| "skyColor" => 3,
| "skyColor"
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
@@ -576,6 +688,77 @@ mod tests {
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
}
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
#[test]
fn status_uniforms_follow_iris() {
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::F(f)) => f[0],
other => panic!("{name}: {other:?}"),
};
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
Some(Value::I(i)) => i[0],
other => panic!("{name}: {other:?}"),
};
let mut inputs = PackInputs {
status: PlayerStatus {
survival: true,
health: 15.0,
max_health: 20.0,
food: 10.0,
air: 150.0,
armor: 20.0,
sneaking: true,
..Default::default()
},
..Default::default()
};
// The vitals are fractions of their maximums.
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("is_sneaking", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 0);
// Creative and spectator: -1 for the vitals and the maximums of health and air;
// spectators are invisible.
inputs.status.survival = false;
inputs.status.spectator = true;
for name in [
"currentPlayerHealth",
"maxPlayerHealth",
"currentPlayerHunger",
"currentPlayerAir",
"maxPlayerAir",
"currentPlayerArmor",
] {
assert_eq!(float(name, &inputs), -1.0, "{name}");
}
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
assert_eq!(int("isSpectator", &inputs), 1);
assert_eq!(int("is_invisible", &inputs), 1);
// Each is provided, not left at zero for the pack.
for name in [
"currentPlayerHealth",
"isSpectator",
"is_sprinting",
"bedrockLevel",
] {
assert!(index(name).is_some(), "{name} is not provided");
}
assert!(
NAMES
.iter()
.filter(|n| status_value(n, &inputs).is_some())
.count()
== 18
);
}
#[test]
fn cloud_height_needs_a_cloud_color() {
assert_eq!(cloud_height(0.8, 192.33), 192.33);
+285 -46
View File
@@ -7,6 +7,7 @@ mod fluid;
mod leaf_culling;
mod lighting;
mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
@@ -18,6 +19,7 @@ use crate::{
use horizon_core::{
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
};
use horizon_optifine::ctm::connect::Face as CtmFace;
use horizon_world::{
block_render::FaceVisibility,
fluid::{FluidKind, FluidState},
@@ -1017,6 +1019,32 @@ impl<'a> Tints<'a> {
layers: Default::default(),
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
/// picks it.
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
&self.biomes.get(id).name
}
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
/// while it holds a layer.
fn biome_id(
zoom: &BiomeZoom,
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz, base_y]: [i32; 3],
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
plains
}
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
let r = self.radius;
@@ -1032,27 +1060,21 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none).
let q = self
.zoom
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
})
} else {
self.plains
};
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1242,7 +1264,8 @@ impl Mesher<'_> {
origin,
face,
ao,
state,
(id, state),
world,
interior_leaf,
);
}
@@ -1281,7 +1304,8 @@ impl Mesher<'_> {
origin: [f32; 3],
face: &BakedFace,
ao: bool,
state: &Info,
(id, state): (u32, &Info),
world: BlockPos,
interior_leaf: bool,
) {
let q = &face.quad;
@@ -1309,50 +1333,265 @@ 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);
}
let translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
let target = if translucent {
&mut out.translucent
// OptiFine connected textures: another sprite, or one per quarter.
let connected = &self.render.connected;
let pick = if connected.is_empty() {
Pick::Keep
} else {
&mut out.solid
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])),
)
};
// 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 mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
for (i, corner) in corners.iter_mut().enumerate() {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
*corner = (
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],
);
}
// 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,
emitting(pack_rgb(rgb), tier),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
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));
}
}
}
}
}
/// 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,
mut v: [TerrainVertex; 4],
) {
let TileLayer::Layer {
layer,
translucent,
opaque,
} = tile
else {
return;
};
if self.tables.is_animated(layer) {
out.note_sprite_layer(layer);
}
let translucent = translucent
|| self
.translucent_layers
.get(usize::from(layer))
.copied()
.unwrap_or(false);
// 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
};
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]
);
}
}
+16 -8
View File
@@ -6,8 +6,12 @@ edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Horizon native development client"
autobins = false
autoexamples = false
autotests = false
[dependencies]
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
@@ -15,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# OptiFine-format pack files: custom entity models, random entity textures.
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
@@ -36,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
horizon-optifine = { path = "../horizon-optifine" }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -104,20 +109,23 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[lints]
workspace = true
@@ -12,6 +12,7 @@
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
//! work on the machine changes it less. The checksum of the meshes shows that an
//! A/B pair of builds made the same ones.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
@@ -1,6 +1,7 @@
//! Profile the two mandatory passes over gameplay-facts.json independently.
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
extern crate horizon_client_lib as horizon_client;
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,5 +1,6 @@
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
extern crate horizon_client_lib as horizon_client;
use serde_json::Value;
use std::{
fs::{self, File},
@@ -7,6 +7,7 @@
//!
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
Result,
assets::Assets,
@@ -2,6 +2,7 @@
//! for live requested heap bytes; leave it unset for latency measurements.
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, item_render::ItemRenderer};
use std::{
alloc::{GlobalAlloc, Layout, System},
@@ -1,5 +1,6 @@
//! Compare the old two-parse loading path with catalog construction from shared facts.
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
extern crate horizon_client_lib as horizon_client;
use horizon_client::{Result, models::StateModels, sprites::Sprites};
use horizon_core::position::BlockPos;
use horizon_world::{
@@ -5,6 +5,7 @@
//! generated around them blend with them (`Blender.of`).
//!
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
extern crate horizon_client_lib as horizon_client;
use horizon_core::position::ChunkPos;
use horizon_world::{
nbt::{Limits, Tag},
@@ -2,6 +2,7 @@
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
@@ -4,6 +4,7 @@
//!
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
//! their dependencies. How a probe runs is in dispatch.rs.
extern crate horizon_client_lib as horizon_client;
#[macro_use]
mod dispatch;
@@ -1,5 +1,6 @@
//! Selection cost only (not block simulation). Run with prepared client resources.
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
@@ -9,6 +9,7 @@
//! and the process RSS (what the allocator holds on to). On macOS it ends
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
//! counts freed pages the allocator marked reusable.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
@@ -2,6 +2,7 @@
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
biome::Biomes,
chunk::{Chunk, block_strategy},
@@ -2,6 +2,7 @@
//! Run each mode in a separate process to capture CPU usage and peak RSS.
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
//! actual client startup performs other initialization between these phases.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{assets::Assets, blocks::BlockFacts};
use horizon_world::level::Behaviours;
use std::{path::Path, time::Instant};
@@ -2,6 +2,7 @@
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
extern crate horizon_client_lib as horizon_client;
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
+35 -4
View File
@@ -505,6 +505,24 @@ fn signum(v: f64) -> f64 {
/// entity stops there and is not kept: the game hands it to
/// `finish_entity_hit` once it has hurt the entity.
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
tick_with(p, scene, targets, true)
}
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
pub fn tick_through(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
) -> (bool, Hit) {
tick_with(p, scene, targets, false)
}
fn tick_with(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
impact: bool,
) -> (bool, Hit) {
let Some(mut st) = p.arrow.take() else {
return (false, Hit::None);
};
@@ -517,7 +535,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
st: &mut st,
scene,
}
.tick(targets);
.tick(targets, impact);
p.stuck = st.in_ground;
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
p.arrow = Some(st);
@@ -600,7 +618,7 @@ impl Ticker<'_, '_, '_, '_> {
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
@@ -711,7 +729,7 @@ impl Ticker<'_, '_, '_, '_> {
original.z + movement.z,
);
if physics {
hit = self.step_move_and_hit(original, end, targets);
hit = self.step_move_and_hit(original, end, targets, impact);
if matches!(hit, Hit::Entity { .. }) {
return hit;
}
@@ -795,7 +813,13 @@ impl Ticker<'_, '_, '_, '_> {
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
/// block. A piercing arrow that hits an entity here goes on from it next
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
fn step_move_and_hit(
&mut self,
from: Vec3,
to: Vec3,
targets: &[(Who, Aabb)],
impact: bool,
) -> Hit {
let block = self.scene.clip(from, to);
let end = block.map_or(to, |b| b.0);
// findHitEntities: ProjectileUtil.computeMargin and the search box.
@@ -828,6 +852,10 @@ impl Ticker<'_, '_, '_, '_> {
self.p.position = next;
self.apply_effects_from_blocks(from, next);
if let Some((who, at, _)) = best {
// A canceled impact ends the search, the arrow where it would have hit.
if !impact {
return Hit::None;
}
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
// arrow is discarded before it hurts the next one.
if self.st.pierce > 0 {
@@ -849,6 +877,9 @@ impl Ticker<'_, '_, '_, '_> {
if self.scene.is_air(state) {
return Hit::None;
}
if !impact {
return Hit::None;
}
self.on_hit_block(pos, state);
Hit::Block {
pos: [pos.x, pos.y, pos.z],
@@ -1,6 +1,7 @@
//! horizon-ctl: play and script a running Horizon client through its control
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
#![forbid(unsafe_code)]
extern crate horizon_client_lib as horizon_client;
mod commands;
mod launch;
mod mcp;
+163
View File
@@ -0,0 +1,163 @@
//! 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, 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 cauldron;
pub mod chunk_io;
pub mod clock;
pub mod command_storage;
pub mod confirm_screen;
pub mod container_screen;
pub mod content_identity;
pub mod control;
pub mod crafting;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod debug_chart;
pub mod debug_entries;
pub mod debug_keys;
pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
pub mod entity_culling;
pub mod entity_nbt;
pub mod extras_hud;
pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
pub mod item_activation;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod lan;
pub mod landing;
pub mod locator_bar;
pub mod lod_net;
pub mod menu;
pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod ram_limit;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
pub mod save;
pub mod scoreboard_cmd;
pub mod screen_layout;
pub mod screen_narrator;
pub mod social_screen;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
pub mod title;
pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::flowers;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+6
View File
@@ -2322,6 +2322,12 @@ mod armor_stand_motion_tests {
let start = Vec3::new(20.5, 20.0, 20.5);
let mut stand = ArmorStand::new(1, start, 0.0);
stand.on_ground = false;
// A summoned stand has no motion yet: the first tick only builds it
// (vanilla moves by the velocity, then applies gravity), so it falls
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.position, start);
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.previous, start);
assert!(
+83 -1
View File
@@ -796,7 +796,24 @@ pub fn mob_to_nbt(
put_memories(map, m);
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:wandering_trader" => {
put_offers(map, m);
// WanderingTrader.addAdditionalSaveData: the visit's countdown and
// the place it is walking to, a BlockPos codec (an int array).
map.insert("DespawnDelay".into(), Tag::Int(m.extra.ints[5]));
if let Some(p) = m.extra.point {
// The trader keeps its target as a position; the tag is a
// BlockPos, so only the block it is in is saved.
map.insert(
"wander_target".into(),
Tag::IntArray(vec![
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
]),
);
}
}
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
@@ -2986,6 +3003,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() {
@@ -3461,6 +3488,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 +3965,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 +4258,16 @@ mod item_component_tests {
let mut sheep = spawn("minecraft:sheep", 2);
apply_item_components(&mut sheep, &egg);
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
// ZombieNautilus.applyImplicitComponents: the egg carries the coral
// variant over the temperate one the biome picked.
let mut egg = crate::items::ItemStack::new("minecraft:zombie_nautilus_spawn_egg", 1);
egg.data_mut().set_other(
"minecraft:zombie_nautilus/variant",
Some(string("minecraft:warm")),
);
let mut nautilus = spawn("minecraft:zombie_nautilus", 4);
apply_item_components(&mut nautilus, &egg);
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
+108
View File
@@ -0,0 +1,108 @@
//! Startup shared by the gameplay library and its executable.
use crate::{crash, game, logging, ram_limit};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
let help = game_args
.clone()
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
// --home before the log opens in it.
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
let folder = game_args
.clone()
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
.nth(1)
.and_then(|dir| std::path::absolute(dir).ok())
.unwrap_or_else(|| ".".into());
(folder, "server", "Server thread")
} else {
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
// its log and crash reports in its own; crash telemetry stays with the home's consent.
if let Some(dir) = game_args
.clone()
.skip_while(|a| *a != "--player-dir")
.nth(1)
{
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
}
(logging::game_home(), "client", "Render thread")
};
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
thread,
);
// The log's first line names the build, so a bug report that carries the log says which one it was.
log::info!("Horizon {}", crash::version());
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
raise_open_file_limit();
if let Err(error) = game::run() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
logging::flush();
// The report's path is the last line on stderr (crash::SAVED_LINE).
if !help && !usage {
crash::report_error(&home, side, &*error);
}
// Started from Explorer, the console window closes with the game:
// keep the message on screen until it is read.
#[cfg(windows)]
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
eprintln!("Press Enter to close.");
let _ = std::io::stdin().read_line(&mut String::new());
}
std::process::exit(if usage {
game::exit_code::USAGE
} else {
game::exit_code::ERROR
});
}
logging::flush();
}
/// The JVM raises its open-file limit to the hard limit as it starts
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
/// sockets and region files as the host allows, not the soft default (often
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
/// JVM does there.
fn raise_open_file_limit() {
#[cfg(unix)]
{
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
let limit = getrlimit(Resource::Nofile);
let hard = if cfg!(target_os = "macos") {
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
} else {
limit.maximum
};
let raised = match (limit.current, hard) {
(Some(soft), Some(hard)) => hard > soft,
(Some(_), None) => true,
(None, _) => false,
};
if raised {
let to = Rlimit {
current: hard,
maximum: limit.maximum,
};
match setrlimit(Resource::Nofile, to) {
Ok(()) => log::info!(
"Open file limit raised from {:?} to {hard:?}",
limit.current
),
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
}
}
}
}
+134 -76
View File
@@ -196,6 +196,7 @@ 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;
@@ -206,9 +207,11 @@ 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;
@@ -1790,6 +1793,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 +2421,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,
@@ -8129,42 +8139,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 +8197,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 +8291,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 +8313,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 +8346,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;
@@ -11777,6 +11823,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.
@@ -17975,6 +18023,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 +18640,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
@@ -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.
+14
View File
@@ -1046,7 +1046,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 +1246,9 @@ impl Game<'_> {
{
return false;
}
if !self.native_may_board(self.boats[i].id) {
return true;
}
self.riding = Some(self.boats[i].id);
self.yaw = self.boats[i].yaw;
// ServerPlayer.startRiding.
+22 -1
View File
@@ -162,6 +162,22 @@ impl Game<'_> {
u32::MAX,
);
}
// A native mod's own icons (`native_map_decorations`).
for d in self.mod_map_decorations(id, true) {
let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else {
continue;
};
batch.quad(
pose_corners(
super::maps::decoration_pose_at(d.x, d.y, d.rot, 0.0),
x,
y,
scale,
),
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
u32::MAX,
);
}
}
}
@@ -169,9 +185,14 @@ impl Game<'_> {
/// `scale` GUI pixels per map pixel: (-1, 1), (1, 1), (1, -1), (-1, -1)
/// under `MapRenderer.render`'s pose.
fn decoration_corners(d: &super::maps::Decoration, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
pose_corners(super::maps::decoration_pose(d, 0.0), x, y, scale)
}
/// The corners of a decoration drawn under `pose` (`decoration_pose`), like
/// [`decoration_corners`].
fn pose_corners(pose: glam::Mat4, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
let m = glam::Mat4::from_translation(glam::Vec3::new(x, y, 0.0))
* glam::Mat4::from_scale(glam::Vec3::new(scale, scale, 1.0))
* super::maps::decoration_pose(d, 0.0);
* pose;
[(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)].map(|(cx, cy)| {
let p = m.transform_point3(glam::Vec3::new(cx, cy, 0.0));
[p.x, p.y]
@@ -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)
+61 -29
View File
@@ -2,7 +2,7 @@
//! 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_projectiles::Impact, view_vector};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result,
@@ -147,7 +147,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)
@@ -3411,24 +3411,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 +3460,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 +3479,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 +3545,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 +3584,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 +3608,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 +3894,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 +5938,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 {
[
+68 -5
View File
@@ -43,6 +43,29 @@ fn projectile_slot(
})
}
/// `CrossbowItem.onUseTick`'s loading sounds for a charge now at `charge`
/// (of `getChargeDuration`) that has played `played` so far: the start and
/// middle sounds each play once the charge reaches their point, and both
/// count as unheard again while the charge is under the first one. A tick
/// that jumped the charge past a point still plays that point's sound, as
/// vanilla's two flags do. Returns the flags for the next tick and the
/// sounds to play now.
fn charge_sounds(
charge: f32,
played: [bool; 2],
sounds: [&'static str; 3],
) -> ([bool; 2], Option<&'static str>, Option<&'static str>) {
let mut played = if charge < 0.2 { [false; 2] } else { played };
let mut play = [None; 2];
for (i, point) in [0.2, 0.5].into_iter().enumerate() {
if charge >= point && !played[i] && !sounds[i].is_empty() {
played[i] = true;
play[i] = Some(sounds[i]);
}
}
(played, play[0], play[1])
}
impl Game<'_> {
pub(super) fn charge_ticks(stack: &ItemStack) -> u32 {
// crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level.
@@ -100,17 +123,19 @@ impl Game<'_> {
let held = self.ticks_held(stack, ticks);
self.using = Some((slot, ticks + 1));
let charge = Self::charge_ticks(stack).max(1) as f32;
let (before, now) = (held as f32 / charge, (held + 1) as f32 / charge);
let now = (held + 1) as f32 / charge;
let sounds = Self::charging_sounds(stack);
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if before < 0.2 && now >= 0.2 && !sounds[0].is_empty() {
self.audio.play(sounds[0], at, 0.5, 1.0);
let (played, start, middle) = charge_sounds(now, self.crossbow_sounds, sounds);
self.crossbow_sounds = played;
if let Some(sound) = start {
self.audio.play(sound, at, 0.5, 1.0);
}
if before < 0.5 && now >= 0.5 && !sounds[1].is_empty() {
self.audio.play(sounds[1], at, 0.5, 1.0);
if let Some(sound) = middle {
self.audio.play(sound, at, 0.5, 1.0);
}
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
if now >= 1.0 && !charged {
@@ -254,6 +279,44 @@ impl Game<'_> {
mod tests {
use super::*;
/// A charge that crosses a point between two ticks plays that point's
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
/// cutting the ticks left) still plays every point it passed, as
/// `CrossbowItem.onUseTick`'s two flags do.
#[test]
fn a_jumped_charge_still_plays_the_loading_sounds_it_passed() {
let sounds = [
"minecraft:item.crossbow.loading_start",
"minecraft:item.crossbow.loading_middle",
"minecraft:item.crossbow.loading_end",
];
// Each step: the charge and the sounds it plays, from the flags the
// step before left.
let mut played = [false; 2];
let mut step = |charge: f32| {
let (next, start, middle) = charge_sounds(charge, played, sounds);
played = next;
[start, middle]
};
assert_eq!(step(0.1), [None, None]);
assert_eq!(step(0.25), [Some(sounds[0]), None]);
assert_eq!(step(0.25), [None, None]);
assert_eq!(step(0.55), [None, Some(sounds[1])]);
assert_eq!(step(1.0), [None, None]);
// A charge below the first point is a new charge: both sound again.
assert_eq!(step(0.1), [None, None]);
assert_eq!(step(0.6), [Some(sounds[0]), Some(sounds[1])]);
// One tick from the start past every point, as a mod's tick makes it.
assert_eq!(step(0.1), [None, None]);
assert_eq!(step(0.9), [Some(sounds[0]), Some(sounds[1])]);
// Quick Charge 1 and up have no middle sound.
let quick = [sounds[0], "", sounds[2]];
assert_eq!(
charge_sounds(0.9, [false; 2], quick),
([true, false], Some(sounds[0]), None)
);
}
#[test]
fn held_projectiles_come_before_the_inventory() {
let stack = |item: &str| Some(ItemStack::new(item, 1));
@@ -338,6 +338,9 @@ impl Game<'_> {
}
// `Entity.startRiding`: a passenger first gets off what it rides
// (`stopRiding`), so a seated player moves to this cushion.
if !self.native_may_board(id) {
return true;
}
self.stop_riding();
self.riding = Some(id);
let p = self.cushions[i].position;
@@ -454,6 +457,9 @@ impl Game<'_> {
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
/// rather than one whose cushion was killed.
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
if self.riding == Some(c.id) && !self.native_may_leave(c.id) {
return;
}
let at = c.dismount();
self.riding = None;
if let Some(p) = self.player.as_mut() {
+1 -1
View File
@@ -27,7 +27,7 @@ use horizon_world::nbt::Tag;
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
const CRYSTAL_IDS: u32 = SPECIAL + 16;
pub(super) const CRYSTAL_IDS: u32 = SPECIAL + 16;
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
const MAX_HEALTH: f32 = 200.0;
/// `Mob.getAmbientSoundInterval`.
+38 -3
View File
@@ -177,6 +177,15 @@ impl EndFlash {
fn intensity(&self, partial: f32) -> f32 {
self.old_intensity + (self.intensity - self.old_intensity) * partial
}
/// SkyRenderer.renderEndFlash's pose: the sprite at height 100 turned to the flash.
fn pose(&self) -> glam::Mat4 {
flash_pose(self.x_angle, self.y_angle)
}
}
fn flash_pose(x_angle: f32, y_angle: f32) -> glam::Mat4 {
glam::Mat4::from_rotation_y((180.0 - y_angle).to_radians())
* glam::Mat4::from_rotation_x((-90.0 - x_angle).to_radians())
}
impl EndState {
pub(super) fn locator(&self) -> Option<Arc<VanillaTerrain>> {
@@ -316,9 +325,7 @@ impl Game<'_> {
.get("environment/celestial/end_flash")
.copied()
{
let flash = &self.end.flash;
let pose = glam::Mat4::from_rotation_y((180.0 - flash.y_angle).to_radians())
* glam::Mat4::from_rotation_x((-90.0 - flash.x_angle).to_radians());
let pose = self.end.flash.pose();
let level = (intensity.clamp(0.0, 1.0) * 255.0) as u32;
let color = level << 24 | level << 16 | level << 8 | level;
let corners = [
@@ -344,6 +351,21 @@ impl Game<'_> {
out
}
/// Iris's `endFlashIntensity` and the flash's direction (camera-relative, length 100: the
/// sprite's centre), for shader packs; no flash outside the End.
pub(super) fn end_flash_for_pack(&self, partial: f32) -> (f32, glam::Vec3) {
if self.dim != Dim::End {
return (0.0, glam::Vec3::ZERO);
}
let flash = &self.end.flash;
(
flash.intensity(partial).clamp(0.0, 1.0),
flash
.pose()
.transform_point3(glam::Vec3::new(0.0, 100.0, 0.0)),
)
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
@@ -1859,4 +1881,17 @@ mod tests {
let centre = corners.iter().copied().sum::<glam::Vec3>() / 4.0;
assert!((centre - at).abs_diff_eq(up_dir * 0.125, 1e-5), "{centre}");
}
/// `endFlashPosition`'s direction: the flash sprite's centre, 100 blocks away.
#[test]
fn the_end_flash_is_where_its_sprite_is() {
let at =
|x: f32, y: f32| flash_pose(x, y).transform_point3(glam::Vec3::new(0.0, 100.0, 0.0));
// Straight up whatever the turn, and level toward the north at y_angle 180.
assert!(at(-90.0, 37.0).abs_diff_eq(glam::Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(at(0.0, 180.0).abs_diff_eq(glam::Vec3::new(0.0, 0.0, -100.0), 1e-3));
for (x, y) in [(-60.0, -180.0), (10.0, 95.0), (-25.0, 3.0)] {
assert!((at(x, y).length() - 100.0).abs() < 1e-3);
}
}
}
@@ -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
@@ -593,9 +602,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 {
@@ -1168,6 +1183,20 @@ fn add_or_append_stack(
mod tests {
use super::*;
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
/// off, and is thrown and hurt as usual by one that destroys blocks or by
/// any blast with the rule back on.
#[test]
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
assert!(!items_ignore_explosion(
false,
BlockInteraction::DestroyWithDecay
));
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
}
#[test]
fn blasts_reach_entities_within_their_diameter() {
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
+3
View File
@@ -90,6 +90,9 @@ impl Game<'_> {
if harnessed && !sneaking {
// doPlayerRide.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
let was_empty = self.mobs[index].rider.is_none();
// addPassenger: the rider is not standing on top, so no still
@@ -207,7 +207,7 @@ impl Game<'_> {
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
pub(super) fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
+6 -1
View File
@@ -869,7 +869,12 @@ impl Game<'_> {
self.lan = Some(host);
let lines = self.as_console(|g| {
g.chat_capture = Some(Vec::new());
g.run_command(&command);
// A command a native mod registered goes to the mods
// first, as the owner's own chat does (the console is
// level 4, so every registered usage is allowed).
if !g.perform_native_command(conn, 4, command.trim()) {
g.run_command(&command);
}
g.chat_capture.take().unwrap_or_default()
});
host = self.lan.take().expect("a command puts the hub back");
@@ -32,6 +32,9 @@ impl Game<'_> {
self.using = Some((self.held_slot(), 0));
self.use_duration = duration;
self.use_ticks_set = ticks_set;
// A new charge hears the crossbow's loading sounds again
// (`game/crossbow.rs`).
self.crossbow_sounds = [false; 2];
true
}
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
+9
View File
@@ -2334,6 +2334,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);
}
@@ -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(
+27 -2
View File
@@ -11,8 +11,9 @@
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::lan::hub::no_blocks_in;
use horizon_client::mobs::equine;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_core::geometry::{Aabb, Axis, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
text::translatable,
@@ -367,7 +368,31 @@ impl Game<'_> {
back(host, old, yaw);
return;
}
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
// clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto
// something on its last move (`verticalCollisionBelow` before this one), and nothing but air is
// around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or
// no-gravity mount, and a server that allows flight, never float.
let (gravity, no_gravity) = match mount {
Some(i) => (0.08, self.mobs[i].no_gravity),
None => (0.04, false),
};
let around = moved_box
.inflate(Vec3::new(0.0625, 0.0625, 0.0625))
.expand_towards(Vec3::new(0.0, -0.55, 0.0));
let corner = |max: bool| {
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
};
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
let allow_flight = host.hub.config.allow_flight;
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
let rested = std::mem::replace(
&mut g.vehicle_rests,
wanted.y < 0.0 && resolved.y != wanted.y,
);
g.vehicle_floating =
wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks;
g.vehicle_gravity = gravity;
if let Some(i) = mount {
// absSnapTo: where and how its rider's client turned it; the
// rider sits on it from its own ride tick.
+3 -3
View File
@@ -1,6 +1,6 @@
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api`.
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
pub const VERSION: &str = "0.1.0";
@@ -155,7 +155,7 @@ pub fn markdown() -> String {
let w = &mut out;
let _ = writeln!(
w,
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->"
);
let _ = writeln!(w, "# Launch API reference\n");
let _ = writeln!(
@@ -265,7 +265,7 @@ mod tests {
.replace("\r\n", "\n");
assert!(
have == want,
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api"
);
}
}
+55 -1
View File
@@ -80,6 +80,9 @@ pub(super) struct Parked {
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
/// them: what its players' moves are checked against before this tick describes them again.
lan_states: horizon_client::lan::entity::States,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -118,6 +121,9 @@ impl Default for Pass {
#[derive(Default)]
pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
/// The levels whose open the mods heard (`native_level_load`), each to
/// be told closed once.
heard_open: Vec<Dim>,
}
impl Levels {
@@ -213,6 +219,7 @@ impl Game<'_> {
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_states: Default::default(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
@@ -291,7 +298,11 @@ impl Game<'_> {
/// The level that was in place is kept with the others; the shared
/// clock and weather go with the swap. `host` is the hub when the caller
/// holds it (`self.lan` is taken during the network tick).
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
pub(super) fn enter_level(
&mut self,
mut host: Option<&mut LanHost>,
target: Dim,
) -> Result<()> {
// The in-frame game keeps its one level (`enter_dimension`).
if self.options.execution_role.is_client() {
return Ok(());
@@ -310,12 +321,23 @@ impl Game<'_> {
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
// The tracked entities go with their level: a level's moves, read before its tick
// describes its entities again, would otherwise meet the last level's (the anticheat
// missed the boat under a player while anyone was in another dimension).
let states = match host.as_deref_mut() {
Some(h) => Some(&mut h.states),
None => self.lan.as_mut().map(|h| &mut h.states),
};
if let Some(states) = states {
std::mem::swap(states, &mut p.lan_states);
}
p.idle = 0;
let left = std::mem::replace(&mut self.dim, target);
self.levels.parked.insert(left, p);
if opened {
// The new streaming's radius and meshing options.
self.apply_settings();
self.native_level_opened(target);
}
}
let host = match host {
@@ -617,10 +639,42 @@ impl Game<'_> {
self.save_level(true)?;
self.enter_level(None, back)?;
self.levels.parked.remove(&dim);
self.native_level_closed(dim);
}
Ok(())
}
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
pub(super) fn native_level_opened(&mut self, dim: Dim) {
if self.remote.is_some()
|| self.options.execution_role.is_client()
|| self.levels.heard_open.contains(&dim)
{
return;
}
self.levels.heard_open.push(dim);
self.native_level_load(dim.id());
}
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
/// each open they heard.
fn native_level_closed(&mut self, dim: Dim) {
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
return;
};
self.levels.heard_open.remove(i);
self.native_level_unload(dim.id());
}
/// Every level still open closes with the server, however it ends
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
/// order of its levels: the Overworld, the Nether, the End.
pub(super) fn native_levels_closing(&mut self) {
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
self.native_level_closed(dim);
}
}
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
/// in place is the caller's to save.
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
+3
View File
@@ -130,6 +130,9 @@ impl Game<'_> {
}
// doPlayerRide; mounting drops a lead.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
let m = &mut self.mobs[index];
m.extra.flag = false;
@@ -5,6 +5,7 @@
use super::{Game, storage::is_shulker_box};
mod crafter;
pub(in crate::game) mod native_energy;
pub(in crate::game) mod native_fluids;
pub(in crate::game) mod native_items;
use horizon_client::{
@@ -0,0 +1,275 @@
//! Energy in a block's buffer for native server mods (docs/native-mods.md):
//! the buffer a mod block declared through `native_block_entity_energy`, read
//! through a side, filled, drained and moved to another block. Vanilla has no
//! energy; the contract is NeoForge's `EnergyHandler` (`SimpleEnergyHandler`
//! and `EnergyHandlerUtil.move`). Like the fluid helpers, they act where the
//! world is simulated, never on a client's copy, and only on the level in
//! place, which the readers name by their `dimension`
//! (`minecraft:overworld`), as `native_set_block` does.
use super::Game;
use horizon_client::containers::ModEnergyBuffer;
use horizon_core::geometry::Direction;
use horizon_world::nbt::{Compound, JavaString, Tag};
use std::sync::Arc;
/// A block's energy as a side reaches it (`native_energy`).
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeEnergy {
pub(crate) amount: i32,
pub(crate) capacity: i32,
/// The most one insert moves.
pub(crate) max_insert: i32,
/// The most one extract moves.
pub(crate) max_extract: i32,
/// Whether a fill may come through this side (and moves more than 0).
pub(crate) can_insert: bool,
/// Whether a drain may leave by this side (and moves more than 0).
pub(crate) can_extract: bool,
}
/// The amount in `nbt`: the int `energy`, as `SimpleEnergyHandler` saves it;
/// anything else, or a negative, reads as empty.
fn read_energy(nbt: &Compound) -> i32 {
match nbt.get(&JavaString::from("energy")) {
Some(Tag::Int(energy)) => (*energy).max(0),
_ => 0,
}
}
/// `SimpleEnergyHandler.insert`: the room left (none when the amount is
/// over the capacity), the call's limit and what was offered, whichever is
/// least.
fn insert_amount(buffer: &ModEnergyBuffer, energy: i32, offered: i32) -> i32 {
(buffer.capacity.saturating_sub(energy))
.min(offered)
.min(buffer.max_insert)
.max(0)
}
/// `SimpleEnergyHandler.extract`: what is stored, the call's limit and what
/// was asked, whichever is least.
fn extract_amount(buffer: &ModEnergyBuffer, energy: i32, asked: i32) -> i32 {
energy.min(asked).min(buffer.max_extract).max(0)
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The buffer of the mod block at `pos` in the level in place,
/// `dimension`, and the energy it holds (its block entity's, and not the
/// placeholder standing in while it ticks).
fn native_energy_at(
&self,
dimension: &str,
pos: [i32; 3],
) -> Option<(Arc<ModEnergyBuffer>, i32)> {
if !self.native_simulates() || dimension != self.dim.id() {
return None;
}
let key = (pos[0], pos[1], pos[2]);
let state = self.world.block(pos[0], pos[1], pos[2]);
let buffer = self.world.block_entities.mod_energy(state.0)?;
// The block entity is out of the world while it ticks.
match self.world.block_entities.get(&key) {
Some(horizon_client::containers::BlockEntity::Mod(m)) if !m.id.is_empty() => {
Some((Arc::clone(buffer), read_energy(&m.nbt)))
}
_ => None,
}
}
/// Writes `energy` into the block entity at `pos`, saved with its chunk.
fn native_energy_store(&mut self, pos: [i32; 3], energy: i32) {
let key = (pos[0], pos[1], pos[2]);
if let Some(horizon_client::containers::BlockEntity::Mod(m)) =
self.world.block_entities.get_mut(&key)
{
m.nbt
.insert(JavaString::from("energy"), Tag::Int(energy.max(0)));
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
}
/// What the buffer at `pos` takes (`insert`) or gives through `face` of
/// up to `amount`, written unless `simulate`. Nothing where the side
/// doesn't reach the buffer for that.
fn native_energy_transfer(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
amount: i32,
insert: bool,
simulate: bool,
) -> i32 {
if amount <= 0 {
return 0;
}
let Some((buffer, energy)) = self.native_energy_at(dimension, pos) else {
return 0;
};
let moved = if insert {
if !buffer.insert_faces[face as usize] {
return 0;
}
insert_amount(&buffer, energy, amount)
} else {
if !buffer.extract_faces[face as usize] {
return 0;
}
extract_amount(&buffer, energy, amount)
};
if moved > 0 && !simulate {
let after = if insert {
energy.saturating_add(moved)
} else {
energy - moved
};
self.native_energy_store(pos, after);
}
moved
}
/// The energy buffer of the block at `pos` in the level in place,
/// `dimension`, as its side `face` reaches it: what it holds, its
/// capacity and its limits per call. None where no buffer is: another
/// dimension, a chunk that isn't loaded, a block without one, a side
/// that reaches neither a fill nor a drain, or the mod block entity
/// ticking now (it's out of the world). `Capabilities.Energy.BLOCK`.
pub(in crate::game) fn native_energy(
&self,
dimension: &str,
pos: [i32; 3],
face: Direction,
) -> Option<NativeEnergy> {
let (buffer, amount) = self.native_energy_at(dimension, pos)?;
let (fill, drain) = (
buffer.insert_faces[face as usize],
buffer.extract_faces[face as usize],
);
(fill || drain).then(|| NativeEnergy {
amount,
capacity: buffer.capacity,
max_insert: buffer.max_insert,
max_extract: buffer.max_extract,
can_insert: fill && buffer.max_insert > 0,
can_extract: drain && buffer.max_extract > 0,
})
}
/// `EnergyHandler.insert`: up to `amount` into the buffer of the block at
/// `pos` through its side `face`, in the level in place: no more than
/// its room and its limit per call. With `simulate` nothing changes and
/// the answer says what would. The amount that went in.
pub(in crate::game) fn native_energy_insert(
&mut self,
pos: [i32; 3],
face: Direction,
amount: i32,
simulate: bool,
) -> i32 {
let dimension = self.dim.id();
self.native_energy_transfer(dimension, pos, face, amount, true, simulate)
}
/// `EnergyHandler.extract`: up to `amount` out of the buffer of the block
/// at `pos` in the level in place, `dimension`, through its side `face`:
/// no more than it holds and its limit per call. With `simulate` nothing
/// changes and the answer says what would. The amount that came out.
pub(in crate::game) fn native_energy_extract(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
amount: i32,
simulate: bool,
) -> i32 {
self.native_energy_transfer(dimension, pos, face, amount, false, simulate)
}
/// `EnergyHandlerUtil.move`: up to `max` from the block at `from`,
/// through its side `from_face`, into the one at `to`, through
/// `to_face`, in the level in place, `dimension`. It asks what the
/// source can give, offers that to the target, and takes from the source
/// exactly what the target accepted, so none is lost. The amount moved;
/// 0 where either has no buffer for it, and within one block.
pub(in crate::game) fn native_move_energy(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
max: i32,
) -> i32 {
if from == to || max <= 0 {
return 0;
}
let offered = self.native_energy_transfer(dimension, from, from_face, max, false, true);
let accepted = self.native_energy_transfer(dimension, to, to_face, offered, true, true);
if accepted == 0 {
return 0;
}
let taken = self.native_energy_transfer(dimension, from, from_face, accepted, false, false);
let put = self.native_energy_transfer(dimension, to, to_face, taken, true, false);
if put < taken {
// Both were just simulated and nothing ran between; a buffer
// that answers differently is a bug, and its energy goes back.
log::warn!("Horizon: an energy move lost {} energy", taken - put);
let _ =
self.native_energy_transfer(dimension, from, from_face, taken - put, true, false);
}
put
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buffer(capacity: i32, max_insert: i32, max_extract: i32) -> ModEnergyBuffer {
ModEnergyBuffer {
capacity,
max_insert,
max_extract,
insert_faces: [true; 6],
extract_faces: [true; 6],
}
}
#[test]
fn an_insert_stops_at_the_room_the_limit_and_the_offer() {
let b = buffer(1000, 100, 50);
assert_eq!(insert_amount(&b, 0, 40), 40);
assert_eq!(insert_amount(&b, 0, 500), 100);
assert_eq!(insert_amount(&b, 950, 500), 50);
assert_eq!(insert_amount(&b, 1000, 500), 0);
// Over the capacity after a mod update shrank it: nothing goes in.
assert_eq!(insert_amount(&b, 1500, 500), 0);
assert_eq!(insert_amount(&b, 0, i32::MAX), 100);
assert_eq!(
insert_amount(&buffer(i32::MAX, i32::MAX, 0), 5, i32::MAX),
i32::MAX - 5
);
}
#[test]
fn an_extract_stops_at_what_is_stored_the_limit_and_the_ask() {
let b = buffer(1000, 100, 50);
assert_eq!(extract_amount(&b, 800, 30), 30);
assert_eq!(extract_amount(&b, 800, 500), 50);
assert_eq!(extract_amount(&b, 20, 500), 20);
assert_eq!(extract_amount(&b, 0, 500), 0);
assert_eq!(extract_amount(&buffer(1000, 100, 0), 800, 500), 0);
}
#[test]
fn the_amount_reads_as_the_saved_int_and_anything_else_as_empty() {
let mut nbt = Compound::new();
assert_eq!(read_energy(&nbt), 0);
nbt.insert(JavaString::from("energy"), Tag::Int(750));
assert_eq!(read_energy(&nbt), 750);
nbt.insert(JavaString::from("energy"), Tag::Int(-5));
assert_eq!(read_energy(&nbt), 0);
nbt.insert(
JavaString::from("energy"),
Tag::String(JavaString::from("lots")),
);
assert_eq!(read_energy(&nbt), 0);
}
}
+113 -3
View File
@@ -38,6 +38,42 @@ pub(super) struct Decoration {
pub name: Option<String>,
pub key: Key,
}
/// A map as a native mod sees it when `native_map_decorations` is asked.
// Native mods read it; nothing in Horizon does.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeMapInfo<'a> {
/// The map's id (`map_<id>`).
pub id: i32,
/// The map's centre block `[x, z]`.
pub center: [i32; 2],
/// `MapItemSavedData.scale`: a map pixel is `1 << scale` blocks.
pub scale: u8,
/// The dimension the map shows (`minecraft:overworld`).
pub dimension: &'a str,
pub locked: bool,
/// Whether it is drawn in an item frame, not held or on the cartography screen.
pub in_frame: bool,
}
/// An icon a native mod puts on a map: the same place and pose as vanilla's
/// decorations (`MapDecoration`).
#[derive(Clone, Debug, PartialEq)]
pub struct NativeMapDecoration {
/// An atlas sprite: vanilla's `map/decorations/<name>`, or a content
/// mod's texture. An unknown sprite is left out.
pub sprite: String,
/// The position on the map in half map pixels, -128 (left, top) to 127.
/// For the world position `(wx, wz)`: `(wx - center_x) / (1 << scale) * 2`,
/// and the same for z, floored and clamped.
pub x: i8,
pub y: i8,
/// 0 to 15, in 22.5 degree steps clockwise (only the low four bits count).
pub rot: u8,
/// Whether it shows in an item frame and on the cartography screen
/// (`MapDecorationType.showOnItemFrame`); a held map shows all.
pub shown_on_frame: bool,
}
/// `MapBanner`: a banner a map marks, as `banners` saves it.
#[derive(Clone, Debug, PartialEq)]
pub(super) struct MapBanner {
@@ -675,11 +711,15 @@ const DECORATION_TYPES: [(&str, &str); 40] = [
/// `MapRenderer.render`'s pose for decoration `d` in map pixels (0..128,
/// y down) at depth `z`: its quad is (-1, -1) to (1, 1) under it.
pub(super) fn decoration_pose(d: &Decoration, z: f32) -> glam::Mat4 {
decoration_pose_at(d.x, d.y, d.rot, z)
}
/// [`decoration_pose`] for a decoration at `x`, `y` (half map pixels) turned `rot`.
pub(super) fn decoration_pose_at(x: i8, y: i8, rot: u8, z: f32) -> glam::Mat4 {
glam::Mat4::from_translation(glam::Vec3::new(
f32::from(d.x) / 2.0 + 64.0,
f32::from(d.y) / 2.0 + 64.0,
f32::from(x) / 2.0 + 64.0,
f32::from(y) / 2.0 + 64.0,
z,
)) * glam::Mat4::from_rotation_z((f32::from(d.rot) * 360.0 / 16.0).to_radians())
)) * glam::Mat4::from_rotation_z((f32::from(rot) * 360.0 / 16.0).to_radians())
* glam::Mat4::from_scale(glam::Vec3::splat(4.0))
* glam::Mat4::from_translation(glam::Vec3::new(-0.125, 0.125, 0.0))
}
@@ -1736,6 +1776,54 @@ impl Game<'_> {
);
count += 1;
}
// A native mod's own icons, over the vanilla ones.
for d in self.mod_map_decorations(id, show_only_frame) {
let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else {
continue;
};
let dm = decoration_pose_at(d.x, d.y, d.rot, -0.02 - count as f32 * 0.001);
let p = |x: f32, y: f32| dm.transform_point3(glam::Vec3::new(x, y, 0.0)).to_array();
let corners = [p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)];
let uvs = [[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]];
for (c, uv) in corners.into_iter().zip(uvs) {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
count += 1;
}
}
/// The icons `native_map_decorations` adds to map `id`, as it is drawn
/// (`frame`: in an item frame or on a screen, which show only what
/// `shown_on_frame` allows).
pub(super) fn mod_map_decorations(&self, id: i32, frame: bool) -> Vec<NativeMapDecoration> {
let Some(map) = self.maps.data.get(&id) else {
return Vec::new();
};
let info = NativeMapInfo {
id,
center: map.center,
scale: map.scale,
dimension: &map.dimension,
locked: map.locked,
in_frame: frame,
};
let mut decorations = Vec::new();
self.native_map_decorations(&info, &mut decorations);
decorations
.into_iter()
.filter(|d| !frame || d.shown_on_frame)
.map(|d| NativeMapDecoration {
rot: d.rot & 15,
..d
})
.collect()
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
/// arms under it: `first_person::map_arm_poses`).
@@ -1803,6 +1891,28 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
fn a_mod_icon_sits_where_a_vanilla_decoration_would() {
// Half pixels from the map's centre: x 20 is 10 map pixels right of it.
let vanilla = Decoration {
sprite: "map/decorations/frame",
x: 20,
y: -30,
rot: 4,
name: None,
key: Key::Item,
};
assert_eq!(
decoration_pose(&vanilla, -0.02),
decoration_pose_at(20, -30, 4, -0.02)
);
let at = decoration_pose_at(20, -30, 0, 0.0).transform_point3(glam::Vec3::ZERO);
assert!(
(at.x - 74.0).abs() < 0.6 && (at.y - 49.0).abs() < 0.6,
"{at:?}"
);
}
#[test]
fn every_target_decoration_type_has_its_sprite() {
// MapDecorationTypes: (name, asset).
+8 -1
View File
@@ -1444,6 +1444,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 +1607,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),
+61 -1
View File
@@ -631,7 +631,15 @@ impl Game<'_> {
horizon_assets::item_model::SpecialKind::Head {
kind: kind.to_owned(),
texture: None,
animation: 0.0,
animation: worn_head_animation(
mob,
// The local player's synthetic mob carries no `mount`.
mob.extra
.mount
.or(if local_player { self.riding } else { None }),
&self.mobs,
alpha as f32,
),
}
},
};
@@ -685,11 +693,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:
+36 -25
View File
@@ -86,7 +86,11 @@ impl Game<'_> {
if !m.extra.saddled || food || self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft) {
return false;
}
self.riding = Some(m.id);
let id = m.id;
if !self.native_may_board(id) {
return true;
}
self.riding = Some(id);
self.trigger_player("minecraft:started_riding");
true
}
@@ -243,6 +247,10 @@ impl Game<'_> {
return true;
}
// doPlayerRide
let id = self.mobs[index].id;
if !self.native_may_board(id) {
return true;
}
let m = &mut self.mobs[index];
m.extra.flag = false;
m.extra.counter = 0;
@@ -480,9 +488,13 @@ impl Game<'_> {
}
/// `removePassenger` + `getDismountLocationForPassenger`: beside the mount.
pub(super) fn dismount_mount(&mut self) {
let Some(id) = self.riding.take() else {
let Some(id) = self.riding else {
return;
};
if !self.native_may_leave(id) {
return;
}
self.riding = None;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
@@ -640,38 +652,37 @@ impl Game<'_> {
pub(super) fn mount_jumping(&self) -> bool {
self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0
}
/// `Hud.extractVehicleHealth`, and the jump bar when it is the
/// contextual bar (`jump_bar`). Returns the mount's heart count
/// `Hud.extractVehicleHealth` (a native mod's `minecraft:vehicle_health`
/// layer, only while riding a mount). Returns the mount's heart count
/// (`getVehicleMaxHearts`).
pub(super) fn draw_mount_hud(
&self,
batch: &mut HudBatch,
w: i32,
h: i32,
jump_bar: bool,
) -> i32 {
pub(super) fn draw_mount_hud(&mut self, batch: &mut HudBatch, w: i32, h: i32) -> i32 {
let Some(i) = self.mount_index() else {
return 0;
};
let m = &self.mobs[i];
let atlas = &self.gui.atlas;
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
horizon_client::hud_layout::paint(batch, atlas, &draws);
if jump_bar {
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
// the nautilus' dash have; a horse always shows the progress.
let cooldown = i32::from(
self.mount.jump_ticks < 0
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
);
let draws =
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
horizon_client::hud_layout::paint(batch, atlas, &draws);
}
self.gui_layer("minecraft:vehicle_health", batch, |g, batch| {
horizon_client::hud_layout::paint(batch, &g.gui.atlas, &draws);
});
hearts
}
/// The jump bar when it is the contextual bar.
pub(super) fn draw_jump_bar(&self, batch: &mut HudBatch, w: i32, h: i32) {
let Some(i) = self.mount_index() else {
return;
};
let m = &self.mobs[i];
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
// the nautilus' dash have; a horse always shows the progress.
let cooldown = i32::from(
self.mount.jump_ticks < 0
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
);
let draws = horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
horizon_client::hud_layout::paint(batch, &self.gui.atlas, &draws);
}
}
/// The mount a rider gets off: its kind, where it stands, its box,
@@ -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)));
}
}
+341 -3
View File
@@ -272,10 +272,12 @@
//! that would break such a bundle is not made.
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::native_data_maps::NativeDataMapType;
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
use super::{
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
maps::{NativeMapDecoration, NativeMapInfo},
native_block_entity_draw::BlockEntityDraw,
native_mob_draw::NativeMobRender,
native_panel_recipes::RecipePage,
@@ -287,7 +289,8 @@ use horizon_client::{
block_entities::Pos,
chat::ChatSource,
containers::{
BlockEntity, MOD_MENUS, ModBlockEntity, ModBlockEntityFields, ModContainer, ModFluidTanks,
BlockEntity, MOD_MENUS, ModBlockEntity, ModBlockEntityFields, ModContainer,
ModEnergyBuffer, ModFluidTanks,
},
crafting::{CustomRecipe, Ingredient, recipe_result},
extras::Corner,
@@ -1148,6 +1151,24 @@ impl Game<'_> {
/// Atlas, models, renderer and client caches already refer to the new resources.
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
pub(super) fn native_client_resources_reloaded(&mut self) {}
/// The client made a new level for `dimension` (NeoForge's
/// `LevelEvent.Load` from the `ClientLevel` constructor): on joining a
/// server, single player's own included, and on changing dimension.
/// Its chunks come after. When the dimension changes, the old level's
/// `native_client_level_unload` follows this, as `Minecraft.setLevel`
/// tells it after the new level is made. Client-side, on the window; the
/// server's levels have `native_level_load`. API @API:client_level_events@.
pub(super) fn native_client_level_load(&mut self, dimension: &str) {
let _ = dimension;
}
/// The client dropped its level for `dimension` (`LevelEvent.Unload`
/// from `Minecraft.setLevel` and `disconnect`): replaced by another
/// dimension's, or on leaving the server. Once for each
/// `native_client_level_load`. Not told when the game itself quits.
/// Client-side. API @API:client_level_events@.
pub(super) fn native_client_level_unload(&mut self, dimension: &str) {
let _ = dimension;
}
/// Once a tick, in every `Game`: the window's (in single player, on LAN
/// and on servers), and the server's own (`native_window` is false
/// there: the single player server thread, a dedicated server).
@@ -1248,6 +1269,22 @@ impl Game<'_> {
pub(super) fn native_data_pack_files(&self, files: &mut Vec<(String, Vec<u8>)>) {
let _ = files;
}
/// The server is assembling its data tables, as for
/// `native_data_pack_files` (a world's first open and every `/reload`):
/// push the data map types your mod registers (NeoForge's
/// `RegisterDataMapTypesEvent`). A type is an `id` (`mod_id:name`, not in
/// `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block`
/// so far. Its values come from the world's data packs'
/// `data/<mod_id>/data_maps/<registry path>/<name>.json`, and from files
/// you push through `native_data_pack_files` under the key
/// `data_maps/<mod_id>:<registry path>/<name>`: `replace`, `values`
/// (entry ids and `#tags` to JSON values) and `remove`, a newer value
/// overriding an older. Read them with `native_data_map` and
/// `native_data_map_entries`, in `native_data_maps.rs`. Server-side, not
/// synced to clients. API @API:data_map_types@.
pub(super) fn native_data_map_types(&self, types: &mut Vec<NativeDataMapType>) {
let _ = types;
}
/// The server is about to open its worlds: nothing of the world is
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
@@ -1296,6 +1333,37 @@ impl Game<'_> {
pub(super) fn native_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
let _ = (batch, corners);
}
/// Before one of vanilla's HUD layers is drawn (`RenderGuiLayerEvent.Pre`):
/// draw here to put something below the layer, or set `canceled` to hide
/// it. `layer` is the layer's id, in the order Horizon draws them:
/// `minecraft:crosshair`, `hotbar`, `effects`, `boss_overlay`,
/// `vehicle_health` (only riding a mount), `contextual_info_bar` (the
/// mount's jump bar, the locator bar or the experience bar, with the level
/// number), `player_health` (the health, armor, food and air rows, drawn in
/// one pass), `selected_item_name`, `sleep_overlay`, `overlay_message`
/// (the action bar), `title`, `scoreboard_sidebar`, `subtitle_overlay`,
/// `chat` and `tab_list`. A layer F1 hides fires nothing, and the chat
/// screen draws the focused chat itself, so `chat` is only the unfocused
/// lines. The camera overlays and the debug screen are not layers here:
/// they are not drawn in this pass. Draw with `native_fill`, `native_text`
/// and the sprite helpers; `batch.width` and `batch.height` are the GUI
/// size. Together with `native_gui_layer_post` this is NeoForge's
/// `RegisterGuiLayersEvent`: a mod keeps its own list of what it draws
/// above or below which layer. API @API:gui_layers@.
pub(super) fn native_gui_layer_pre(
&mut self,
layer: &str,
batch: &mut HudBatch,
canceled: &mut bool,
) {
let _ = (layer, batch, canceled);
}
/// After one of vanilla's HUD layers (or after the layer a mod hid), with
/// the ids of `native_gui_layer_pre`: draw here to put something above
/// the layer (`RenderGuiLayerEvent.Post`). API @API:gui_layers@.
pub(super) fn native_gui_layer_post(&mut self, layer: &str, batch: &mut HudBatch) {
let _ = (layer, batch);
}
/// A player right-clicks the entity `entity` (`Entity.getId`) of type
/// `kind` (`minecraft:cow`) with `self.hand`, holding `stack` (none in an
/// empty hand): where `Player.interactOn` starts, before the entity's own
@@ -1396,6 +1464,27 @@ impl Game<'_> {
) {
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
}
/// An interaction key acts on what the crosshair points at
/// (`InputEvent.InteractionKeyMappingTriggered`), while playing: `button` 0 is
/// the attack key (`key_mapping` `key.attack`), 1 the use key (`key.use`, once
/// for each hand it tries, the main hand first), 2 pick block (`key.pickItem`,
/// only with something aimed at). The attack fires when an attack is allowed
/// and again each tick a held attack key keeps digging a block. Set `canceled`
/// to skip the action: a cancelled use also skips the other hand. A cancelled
/// attack or use still swings `hand` unless the mod clears `swing_hand` (it
/// starts true). A server's player and a single player's window only; a mod
/// that claims an item's use (`mod_use_start`) sees the event first. API
/// @API:interaction_key@.
pub(super) fn native_interaction_key(
&mut self,
button: i32,
key_mapping: &str,
hand: horizon_client::first_person::Hand,
swing_hand: &mut bool,
canceled: &mut bool,
) {
let _ = (button, key_mapping, hand, swing_hand, canceled);
}
/// A command typed or clicked in chat, without its slash, before the
/// server and Horizon's commands see it: set `ran` when the mod ran it.
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
@@ -1622,6 +1711,22 @@ impl Game<'_> {
) {
let _ = (block, fluids);
}
/// Block `block`'s block entity has an energy buffer
/// (`Capabilities.Energy.BLOCK`): set `energy` when the block is yours,
/// as `SimpleEnergyHandler` is built: its capacity, the most one insert
/// and one extract moves, and the faces a fill may come through and a
/// drain may leave by. Asked once for each mod block when the ticking
/// table is built; a capacity under 1 leaves the buffer out, and the
/// limits are clamped to 0..=capacity. The amount is kept in the entity's
/// saved compound, as the int `energy`, and the energy helpers below read
/// and write it. API @API:block_entity_energy@.
pub(super) fn native_block_entity_energy(
&self,
block: &str,
energy: &mut Option<ModEnergyBuffer>,
) {
let _ = (block, energy);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -1720,6 +1825,26 @@ impl Game<'_> {
) {
let _ = (mob, draw, drawn);
}
/// Icons a mod puts on map `map` each time it is drawn: held, in an
/// item frame or on the cartography table's screen (a map's pixels are
/// its own; nothing is saved in the map). Push `NativeMapDecoration`s
/// (an atlas sprite, a position in half map pixels, a rotation, and
/// whether it shows in a frame and on the screen like vanilla's markers
/// do); they are drawn after the vanilla ones with the same pose, and a
/// sprite the atlas lacks is left out. `map.center`, `map.scale` and
/// `map.dimension` say which blocks the map shows, and
/// `NativeMapDecoration::x` has the formula. Vanilla's decorations are
/// not passed in. Asked every frame a map is drawn, so keep it cheap.
/// The client half of `MapDecorationType` and `addDecoration`: a type
/// registered on the server and saved in the map's data is not here.
/// API @API:map_decorations@.
pub(super) fn native_map_decorations(
&self,
map: &NativeMapInfo<'_>,
decorations: &mut Vec<NativeMapDecoration>,
) {
let _ = (map, decorations);
}
/// A menu opens on this client for block `block`, a mod's: set `screen`
/// when the block is yours, to lay its screen out (`native_screen_draw`
/// draws it). The server asks it too: a slot it hides is not in the menu
@@ -2175,7 +2300,9 @@ impl Game<'_> {
/// without its slash, for a command a mod registered and the player's
/// level allows: set `ran` when it is yours. The player's state is
/// swapped in for the call, so `native_player` and the other helpers
/// read that player; `native_feedback` answers them. API 8.
/// read that player; `native_feedback` answers them. A single player's
/// owner console (`horizon-ctl command`) reaches it too, as the owner at
/// level 4. API 8.
pub(super) fn native_perform_command(
&mut self,
player: &NativeServerPlayer,
@@ -2585,6 +2712,28 @@ impl Game<'_> {
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
let _ = (channel, data);
}
/// `dimension`'s level opened on the server (NeoForge's `LevelEvent.Load`,
/// posted in `MinecraftServer.createLevels`): before any of its chunks
/// (`native_chunk_load`), so a mod sets up what it keeps for the level
/// here. The level the world opens in comes before `WorldOpenReady`; the
/// others open when someone first goes there (a player, a portal, an
/// `execute in` command), with the level in place. Once for each open:
/// `native_level_unload` follows. Server-side. API @API:level_events@.
pub(super) fn native_level_load(&mut self, dimension: &str) {
let _ = dimension;
}
/// `dimension`'s level closed (NeoForge's `LevelEvent.Unload`, posted after
/// `level.close()` in `MinecraftServer.stopServer`), after its last save:
/// drop what the mod kept for it. A level other than the Overworld closes
/// once nobody has been in it for 300 ticks, after its chunks' unloads;
/// every level still open closes when the server stops, before
/// `WorldCloseFinal`, and when its open or its loop fails, before
/// `WorldOpenFailed` or `WorldCloseFailed`. Once for each
/// `native_level_load`. Don't read its blocks. Server-side. API
/// @API:level_events@.
pub(super) fn native_level_unload(&mut self, dimension: &str) {
let _ = dimension;
}
/// Chunk `chunk` (`[x, z]`, in chunks) of `dimension` came into the world
/// (NeoForge's `ChunkEvent.Load`): `new` when it was just generated, false
/// when read from the save. A flat world's chunk that leaves before it's
@@ -2651,6 +2800,46 @@ impl Game<'_> {
) {
let _ = (dimension, pos, viewers, previous);
}
/// A client started tracking an entity (NeoForge's
/// `PlayerEvent.StartTracking`, posted at the end of
/// `ServerEntity.addPairing`): `player` (`name`) is now sent `entity`
/// (its ID, `uuid` its UUID, `kind` its type, `minecraft:zombie`) and its
/// changes. It comes into range, its client's chunks reach it, or it is
/// put in the level; another player is an entity too, and a player never
/// tracks their own. A mod sends the player what they need to show it
/// (`native_net_send_to_player`). The entity is as it was last tick: it
/// may be gone by now. Heard after the tick's players and entities have
/// moved, in order, with no player's state swapped in. Server-side.
/// API @API:player_tracking@.
pub(super) fn native_player_start_tracking(
&mut self,
player: u128,
name: &str,
entity: u32,
uuid: u128,
kind: &str,
) {
let _ = (player, name, entity, uuid, kind);
}
/// A client stopped tracking an entity (NeoForge's
/// `PlayerEvent.StopTracking`, posted at the end of
/// `ServerEntity.removePairing`): it went out of range, left the level or
/// died, or `player` (`name`) left, changed level or respawned, which
/// stops everything they tracked. `entity`, `uuid` and `kind` are as
/// `native_player_start_tracking` told. Once for each start, a leaving
/// player's after `native_player_logged_out`. The entity is not
/// necessarily still there, and a player who left is gone: no player's
/// state is swapped in. Server-side. API @API:player_tracking@.
pub(super) fn native_player_stop_tracking(
&mut self,
player: u128,
name: &str,
entity: u32,
uuid: u128,
kind: &str,
) {
let _ = (player, name, entity, uuid, kind);
}
/// `player` died: where `ServerPlayer.die` starts, with the death
/// message's key when the game knows it (`death.attack.lava`; none for a
/// message it builds from the combat). It only hears. Server-side, for
@@ -2708,6 +2897,40 @@ impl Game<'_> {
) {
let _ = (dimension, entity, kind, pos, loaded_from_disk, canceled);
}
/// An entity mounts or dismounts a vehicle (NeoForge's `EntityMountEvent`, posted from
/// `Entity.startRiding` and `removeVehicle`): `entity` of type `kind` (`minecraft:player`,
/// `minecraft:zombie`) in the server level `dimension`, getting on (`mounting`) or off the
/// `vehicle` of type `vehicle_kind` (`minecraft:oak_boat`, `minecraft:minecart`,
/// `minecraft:horse`, `minecraft:cushion`). Set `canceled` and it does not: a player stays
/// where they were, a mob does not board, a `/ride` fails, and a rider whose dismount is
/// canceled keeps riding. Told for a player boarding a boat, minecart, cushion or a mount (pig,
/// strider, horse and its kin, llama, nautilus, happy ghast) and getting off one by their
/// own input, `/ride`, a stab, a buck, a teleport or spectator mode; and for the mobs
/// Horizon seats on a vehicle (jockeys, raiders on a ravager, `/ride`) and lets off one
/// (`/ride`, a stab). It is not told for a rider whose vehicle was removed from the
/// level (a boat or minecart destroyed, a broken cushion), nor for riders loaded with a
/// save. The acting player is the one whose action runs, a guest on a server. Server-side.
/// API @API:entity_mount@.
pub(super) fn native_entity_mount(
&mut self,
dimension: &str,
entity: u32,
kind: &str,
vehicle: u32,
vehicle_kind: &str,
mounting: bool,
canceled: &mut bool,
) {
let _ = (
dimension,
entity,
kind,
vehicle,
vehicle_kind,
mounting,
canceled,
);
}
/// Whether the experience orb `into` takes the orb `from` in (vanilla's
/// `ExperienceOrb.canMerge`): the same value, and ids a multiple of 40 apart. Asked
/// for each pair whose boxes meet, when an orb spawns (`tryMergeToExisting`: `from` is
@@ -2947,6 +3170,61 @@ impl Game<'_> {
canceled,
);
}
/// Whether a mod hears projectiles' impacts (`native_projectile_impact`):
/// set `hears`. Asked once a server tick before the projectiles fly; only
/// then does the tick keep the copy of each flying projectile that a
/// canceled impact flies on from, so a game without such a mod pays
/// nothing. Server-side. API @API:projectile_impact@.
pub(super) fn native_hears_projectile_impacts(&self, hears: &mut bool) {
let _ = hears;
}
/// A projectile hit something (NeoForge's `ProjectileImpactEvent`, posted
/// where vanilla's projectile has found what it meets, before the hit
/// acts): `projectile` (its entity id) of type `kind` (`minecraft:arrow`),
/// shot by `owner` (a mob's or a player's entity id, 0 for none) in the level
/// `dimension`. It hit the entity `entity` of type `entity_kind` (a player is
/// `minecraft:player`; a dragon's part is `minecraft:ender_dragon`), or, with
/// `entity` None, the block at `block` on its `face` (the face's normal,
/// `[0, 1, 0]` for the top), at the point `at`. Set `canceled` and the impact
/// does not happen: no damage, no knockback, no sound, no block reaction, the
/// arrow does not stick, a pearl does not teleport, a potion or a fireball
/// does not burst. The projectile flies on and is not discarded, so a mod
/// that cancels also decides what becomes of it: it hits again the next tick
/// unless the mod moves, turns or removes it. An arrow stays where it would
/// have hit, and the other projectiles go on through the whole of their
/// motion. Covers what `tick_projectiles` flies: arrows, tridents, snowballs,
/// eggs, pearls, potions, fireballs, wither skulls, wind charges, llama spit and
/// shulker bullets, from players, mobs and dispensers. It does not fire for
/// a breeze's deflection, for a hit on a wind charge, for fireworks or for
/// fishing bobbers. A dragon part or End crystal is told by the id of the
/// dragon or crystal. Heard only when a mod also sets
/// `native_hears_projectile_impacts`. Server-side. API @API:projectile_impact@.
pub(super) fn native_projectile_impact(
&mut self,
dimension: &str,
projectile: u32,
kind: &str,
owner: u32,
entity: Option<u32>,
entity_kind: &str,
block: [i32; 3],
face: [i32; 3],
at: [f64; 3],
canceled: &mut bool,
) {
let _ = (
dimension,
projectile,
kind,
owner,
entity,
entity_kind,
block,
face,
at,
canceled,
);
}
/// `Block.stepOn` for a mod block, on its authoritative server level.
/// No self: the simulation owns the entity during this callback.
@@ -3741,6 +4019,21 @@ impl Game<'_> {
self.audio.play_after(sound, at, volume, pitch, delay);
true
}
/// Draws one vanilla HUD layer between the mods' hooks: `native_gui_layer_pre`
/// (which may hide it), the layer, then `native_gui_layer_post`. `None` when
/// a mod hid the layer.
pub(super) fn gui_layer<R>(
&mut self,
layer: &str,
batch: &mut HudBatch,
draw: impl FnOnce(&mut Self, &mut HudBatch) -> R,
) -> Option<R> {
let mut canceled = false;
self.native_gui_layer_pre(layer, batch, &mut canceled);
let drawn = (!canceled).then(|| draw(self, batch));
self.native_gui_layer_post(layer, batch);
drawn
}
/// Whether a box in `corner` shows: F3's debug screen takes both top
/// corners (its left and right columns), and a bottom corner while one
/// of its charts is drawn there.
@@ -4509,6 +4802,34 @@ impl Game<'_> {
}
out
}
/// The energy buffers of the mod blocks' block entities, by block state
/// (`native_block_entity_energy`), checked as the containers are.
pub(super) fn native_energy_tables(&self) -> FxHashMap<u32, Arc<ModEnergyBuffer>> {
let registry = self.pack.assets.registry;
let mut by_block: FxHashMap<String, Option<Arc<ModEnergyBuffer>>> = FxHashMap::default();
let mut out = FxHashMap::default();
for i in 0..registry.len() as u32 {
let Some(state) = registry.state(horizon_world::registry::BlockStateId(i)) else {
continue;
};
if state.name.starts_with("minecraft:") {
continue;
}
let buffer = by_block.entry(state.name.clone()).or_insert_with(|| {
let mut energy = None;
self.native_block_entity_energy(&state.name, &mut energy);
energy.filter(|e| e.capacity >= 1).map(|mut e| {
e.max_insert = e.max_insert.clamp(0, e.capacity);
e.max_extract = e.max_extract.clamp(0, e.capacity);
Arc::new(e)
})
});
if let Some(b) = buffer {
out.insert(i, Arc::clone(b));
}
}
out
}
/// Whether a native mod's block `name` has a ticking block entity.
pub(super) fn native_ticks(&self, name: &str) -> bool {
let mut ticks = false;
@@ -4956,6 +5277,23 @@ impl Game<'_> {
self.native_key(name, pressed);
}
}
/// `ClientHooks.onClickInput`: tells `native_interaction_key` that `button`
/// (0 attack, 1 use, 2 pick) is about to act with `hand`. `None` when the
/// action goes on; when a mod cancelled it, `Some` of whether the hand swings.
pub(super) fn native_interaction(
&mut self,
button: i32,
hand: horizon_client::first_person::Hand,
) -> Option<bool> {
let key_mapping = match button {
0 => "key.attack",
1 => "key.use",
_ => "key.pickItem",
};
let (mut swing_hand, mut canceled) = (true, false);
self.native_interaction_key(button, key_mapping, hand, &mut swing_hand, &mut canceled);
canceled.then_some(swing_hand)
}
/// A mouse button while playing, for `native_key`.
pub(super) fn native_mouse_event(&mut self, button: MouseButton, pressed: bool) {
let name = keybinds::mouse_name(button);
@@ -5457,7 +5795,7 @@ mod tests {
#[test]
fn loot_table_load_stays_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
// From its doc comment's last line, so an attribute above it counts.
let hook = source.find("pub fn native_loot_table_load(").unwrap();
let start = source[..hook].rfind("///").unwrap();
@@ -239,6 +239,8 @@ impl Game<'_> {
/// `error_text` is the engine's own error text, unchanged.
pub(super) fn native_lifecycle_world_failed(&mut self, error_text: &str) {
// The levels that opened before the failure close first.
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_open_failed() {
self.native_world_lifecycle(&context, WorldPhase::WorldOpenFailed, error_text);
}
@@ -246,6 +248,7 @@ impl Game<'_> {
/// Report a server-loop or shutdown failure once and retire its epoch.
pub(super) fn native_lifecycle_close_failed(&mut self, error_text: &str) {
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_close_failed() {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFailed, error_text);
}
@@ -256,6 +259,8 @@ impl Game<'_> {
if self.remote.is_some() {
return;
}
// Each level closes after the final save (`LevelEvent.Unload`).
self.native_levels_closing();
if let Some(context) = self.native_lifecycle.world_close_final() {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, "");
}
@@ -0,0 +1,82 @@
//! Mounting for native mods (`native_entity_mount`: NeoForge's `EntityMountEvent`). Each place
//! the acting player or a mob gets on or off a vehicle asks `native_may_board` or
//! `native_may_leave` first, and does nothing when a mod says no.
use super::{Game, native_hooks::mob_kind};
impl Game<'_> {
/// Whether this game is a server whose mounts the mods hear: the server thread or a
/// dedicated server, never a client nor the deprecated in-frame path.
fn native_hears_mounts(&self) -> bool {
self.serving.is_some() || self.options.dedicated.is_some()
}
/// The entity id of the acting player: a guest's while their action runs, else the host's.
fn native_acting_entity(&self) -> u32 {
let guests = self.lan_guest_entities();
match self.lan_acting {
Some(conn) => guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1),
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
}
}
/// The type of the vehicle with entity id `id`.
fn native_vehicle_kind(&self, id: u32) -> String {
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
b.entity_type()
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
c.kind.id().into()
} else if self.cushions.iter().any(|c| c.id == id) {
"minecraft:cushion".into()
} else if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
mob_kind(m)
} else {
String::new()
}
}
/// `native_entity_mount` for `rider` (the acting player when `None`) and `vehicle`: whether
/// the mounting or dismounting goes ahead.
fn native_mount_allowed(&mut self, rider: Option<u32>, vehicle: u32, mounting: bool) -> bool {
if !self.native_hears_mounts() {
return true;
}
let (entity, kind) = match rider {
Some(id) => (
id,
self.mobs
.iter()
.find(|m| m.id == id)
.map_or_else(String::new, mob_kind),
),
None => (self.native_acting_entity(), "minecraft:player".into()),
};
let vehicle_kind = self.native_vehicle_kind(vehicle);
let mut canceled = false;
self.native_entity_mount(
self.dim.id(),
entity,
&kind,
vehicle,
&vehicle_kind,
mounting,
&mut canceled,
);
!canceled
}
/// The acting player is about to board `vehicle`: false when a mod canceled it.
pub(super) fn native_may_board(&mut self, vehicle: u32) -> bool {
self.native_mount_allowed(None, vehicle, true)
}
/// The acting player is about to get off `vehicle`: false when a mod canceled it.
pub(super) fn native_may_leave(&mut self, vehicle: u32) -> bool {
self.native_mount_allowed(None, vehicle, false)
}
/// The mob `rider` is about to board (`mounting`) or leave `vehicle`: false when a mod
/// canceled it.
pub(super) fn native_mob_may_ride(&mut self, rider: u32, vehicle: u32, mounting: bool) -> bool {
self.native_mount_allowed(Some(rider), vehicle, mounting)
}
}
@@ -0,0 +1,255 @@
//! Projectile impacts for native mods (`native_projectile_impact`, NeoForge's
//! `ProjectileImpactEvent`). `tick_projectiles` flies every projectile first and
//! collects the impacts; once its worlds are let go, the mods hear each one, and a
//! projectile whose impact a mod canceled flies the tick again from where it was,
//! through whatever it hit.
use super::{
Game,
creatures::{ProjWorld, wind_charge_hit},
dragon::{CRYSTAL_IDS, SPECIAL},
lan_pvp::meets_players,
native_hooks::mob_kind,
};
use horizon_client::{
arrows::ArrowEvent,
mobs::Who,
projectiles::{Hit, Projectile},
};
use horizon_core::geometry::Vec3;
use std::ops::Range;
/// A projectile's impact in one tick of `tick_projectiles`.
pub(super) struct Impact {
/// Where the projectile was in the tick's list.
pub index: usize,
/// The projectile before the tick.
pub before: Projectile,
pub hit: Hit,
/// Where its hit is in the tick's `hits`.
pub hit_slot: usize,
/// Where its arrow events are in the tick's `arrow_events`.
pub events: Range<usize>,
}
/// The type the mods know a projectile by: a spectral arrow is its own.
fn projectile_type(p: &Projectile) -> &'static str {
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
if spectral {
"minecraft:spectral_arrow"
} else {
p.kind.id()
}
}
impl Game<'_> {
/// Whether this game is a server that flies projectiles for the mods (the
/// server thread or a dedicated server, never the deprecated in-frame path
/// nor a client) and a mod asked to hear their impacts
/// (`native_hears_projectile_impacts`). Without one, the tick keeps no copy
/// of its projectiles: it costs nothing.
pub(super) fn native_hears_impacts(&self) -> bool {
if self.serving.is_none() && self.options.dedicated.is_none() {
return false;
}
let mut hears = false;
self.native_hears_projectile_impacts(&mut hears);
hears
}
/// The entity id of the one who shot `p`: a mob's, a player's, else 0.
fn native_impact_owner(&self, p: &Projectile) -> u32 {
match p.owner {
Some(Who::Mob(id)) => id,
Some(Who::Player) => match p.guest {
Some(conn) => self
.lan_guest_entities()
.into_iter()
.find(|&(c, _)| c == conn)
.map_or(0, |(_, id)| id),
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
},
None => 0,
}
}
/// The entity id and type a projectile's hit on the reserved or ordinary
/// `id` stands for: a dragon part is the dragon, an End crystal its own id.
fn native_impact_entity(&self, id: u32) -> (u32, String) {
if id >= SPECIAL {
return if id < CRYSTAL_IDS {
let dragon = self.dragon.as_ref().map_or(0, |d| d.id);
(dragon, "minecraft:ender_dragon".into())
} else {
let crystal = self.crystals.get((id - CRYSTAL_IDS) as usize);
(crystal.map_or(0, |c| c.id), "minecraft:end_crystal".into())
};
}
if let Some(mob) = self.mobs.iter().find(|m| m.id == id) {
return (id, mob_kind(mob));
}
if let Some(cart) = self.carts.iter().find(|c| c.id == id) {
return (id, cart.kind.id().into());
}
if self.lan_guest_entities().iter().any(|&(_, e)| e == id) {
return (id, "minecraft:player".into());
}
(id, String::new())
}
/// `native_projectile_impact` for each of `impacts`: whether the mods
/// canceled it. A hit on a wind charge is not told.
fn native_projectile_impacts(&mut self, impacts: &[Impact]) -> Vec<bool> {
let dimension = self.dim.id();
let mut canceled = Vec::with_capacity(impacts.len());
for impact in impacts {
let p = &impact.before;
let (entity, entity_kind, block, face, at) = match impact.hit {
Hit::Block { pos, at, face } => (None, String::new(), pos, face, at),
Hit::Entity {
who: Who::Mob(id),
at,
} => {
if wind_charge_hit(id).is_some() {
canceled.push(false);
continue;
}
let (entity, kind) = self.native_impact_entity(id);
(Some(entity), kind, [0; 3], [0; 3], at)
}
Hit::Entity {
who: Who::Player,
at,
} => {
let Some(host) = self.lan.as_ref().map(|h| h.host_entity as u32) else {
canceled.push(false);
continue;
};
(Some(host), "minecraft:player".into(), [0; 3], [0; 3], at)
}
Hit::None => {
canceled.push(false);
continue;
}
};
let owner = self.native_impact_owner(p);
let mut stop = false;
self.native_projectile_impact(
dimension,
p.id,
projectile_type(p),
owner,
entity,
&entity_kind,
block,
face,
[at.x, at.y, at.z],
&mut stop,
);
canceled.push(stop);
}
canceled
}
/// The end of a tick of `tick_projectiles` that met `impacts`, with
/// `projectiles` in place (those it keeps, as `listed` says of the tick's
/// list): the mods hear each, and a projectile one canceled flies the tick
/// again from before it, through what it hit. Its hit and its arrow events are
/// taken out of `hits` and `arrow_events`, and it goes back among the
/// projectiles. One a mod removed in its hook stays removed.
pub(super) fn native_settle_impacts(
&mut self,
projectiles: &mut Vec<Projectile>,
listed: &[bool],
impacts: Vec<Impact>,
hits: &mut Vec<(Projectile, Hit)>,
arrow_events: &mut Vec<(Vec3, ArrowEvent)>,
) {
// The mods see the projectiles where they will be, as `projectile_hit` does.
self.creatures.projectiles = std::mem::take(projectiles);
let canceled = self.native_projectile_impacts(&impacts);
*projectiles = std::mem::take(&mut self.creatures.projectiles);
if !canceled.contains(&true) {
return;
}
let mut drop_hits = vec![false; hits.len()];
let mut drop_events = vec![false; arrow_events.len()];
let mut flown = Vec::new();
for (impact, _) in impacts.into_iter().zip(canceled).filter(|(_, c)| *c) {
drop_hits[impact.hit_slot] = true;
drop_events[impact.events.clone()].fill(true);
let present = projectiles.iter().position(|q| q.id == impact.before.id);
if present.is_none() && listed[impact.index] {
continue;
}
let (p, keep, events) = self.native_projectile_through(impact.before);
flown.extend(events);
match (present, keep) {
(Some(i), true) => projectiles[i] = p,
(Some(i), false) => {
projectiles.remove(i);
}
(None, true) => projectiles.push(p),
(None, false) => {}
}
}
let mut n = 0;
hits.retain(|_| {
n += 1;
!drop_hits[n - 1]
});
let mut n = 0;
arrow_events.retain(|_| {
n += 1;
!drop_events[n - 1]
});
arrow_events.extend(flown);
}
/// `p`, as it was before its tick, flown that tick through what it hit
/// (`Projectile::tick_through`, `arrows::tick_through`): the projectile after
/// it, whether it stays, and the arrow events it made.
fn native_projectile_through(
&self,
mut p: Projectile,
) -> (Projectile, bool, Vec<(Vec3, ArrowEvent)>) {
let mob_shot = !matches!(p.owner, Some(Who::Player));
let pvp = !mob_shot && meets_players(&p.kind);
// A guest's shot flies from that guest, as in `tick_projectiles`.
let shooter = p
.guest
.filter(|_| pvp)
.and_then(|c| self.lan_guest_entities().into_iter().find(|&(g, _)| g == c))
.map(|(_, id)| Who::Mob(id));
if shooter.is_some() {
p.owner = shooter;
}
let mut targets = self.projectile_targets();
if p.arrow.is_some() {
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
}
if mob_shot || pvp {
targets.extend(self.lan_projectile_targets());
}
let rain = self.arrow_rain();
let scene = self.arrow_scene(&rain);
let (keep, _) = if p.arrow.is_some() {
horizon_client::arrows::tick_through(&mut p, &scene, &targets)
} else {
let w = self.mob_world_base(&[]);
p.tick_through(&ProjWorld { w: &w, targets })
};
if shooter.is_some() {
p.owner = Some(Who::Player);
}
let at = p.position;
let events = p
.arrow
.as_mut()
.map(|a| a.events.drain(..).map(|e| (at, e)).collect())
.unwrap_or_default();
(p, keep, events)
}
}
@@ -127,6 +127,9 @@ impl Game<'_> {
if tame && !food {
// doPlayerRide.
let id = self.mobs[index].id;
if !self.native_may_board(id) {
return true;
}
self.break_leash(id, true, false);
self.yaw = self.mobs[index].yaw;
self.riding = Some(id);
@@ -27,7 +27,15 @@ fn zombie_kind(s: Species) -> bool {
impl Game<'_> {
/// `Entity.startRiding` for two mobs.
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
self.try_mount_mob(rider, vehicle);
}
/// `Entity.startRiding` for two mobs: false when a mod canceled it.
pub(super) fn try_mount_mob(&mut self, rider: u32, vehicle: u32) -> bool {
if !self.native_mob_may_ride(rider, vehicle, true) {
return false;
}
riders::link(&mut self.mobs, rider, vehicle);
true
}
/// Riders sit on their vehicles (`Entity.positionRider`), mobs or boats,
/// and let go when the vehicle dies or is gone.
@@ -709,7 +709,9 @@ impl Game<'_> {
if let Some(search) = self.recipe_ui.search.as_mut() {
search.x = xo + 25.0;
search.y = yo + 13.0;
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, 17.0, 14.0);
// RecipeBookComponent's magnifierIconPlacement: from the book's
// left edge plus eight to the search box's own left edge.
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, search.x - xo, 14.0);
if magnifier || inside(cursor, search.x, search.y, search.width, search.height) {
self.recipe_ui.search_drag = true;
search.focused = true;
+146 -27
View File
@@ -471,6 +471,9 @@ pub(super) struct Remote {
/// the first teleport, which takes them then.
abilities: Option<play::PlayerAbilities>,
loaded_sent: bool,
/// The dimension of the client level the mods heard made
/// (`native_client_level_load`), to be told dropped once.
heard_level: Option<String>,
/// `ClientPacketListener.levelLoadTracker`: the loading screen after a login
/// or respawn, until the player's section is shown.
pub(super) level_load: horizon_client::level_loading::LevelLoadTracker,
@@ -967,6 +970,7 @@ impl Game<'_> {
carried: None,
abilities: None,
loaded_sent: false,
heard_level: None,
level_load: horizon_client::level_loading::LevelLoadTracker::new(0),
sent_info: info,
predictions: HashMap::new(),
@@ -1073,6 +1077,10 @@ impl Game<'_> {
self.clear_narrator();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
// Minecraft.disconnect: the client level goes.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -2069,6 +2077,15 @@ impl Game<'_> {
if let Some(renderer) = self.renderer.as_mut() {
renderer.clear_sections(self.world.layout());
}
// The new ClientLevel is told before setLevel drops the old one.
let old = self
.remote
.as_mut()
.and_then(|r| r.heard_level.replace(spawn.dimension.clone()));
self.native_client_level_load(&spawn.dimension);
if let Some(old) = old {
self.native_client_level_unload(&old);
}
}
let has_sky = self
.remote
@@ -2175,19 +2192,7 @@ impl Game<'_> {
if let Some((_, mut loaded)) =
horizon_client::save::decode_block_entity(&Tag::Compound(full))
{
// The turn of a vault's item and a trial spawner's mob is the client's
// own, not in the tag: an update keeps it (VaultClientData).
match (self.world.block_entities.get(&key), &mut loaded) {
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
_ => {}
}
keep_client_state(self.world.block_entities.get(&key), &mut loaded);
let command_block = matches!(loaded, BlockEntity::CommandBlock(_));
self.world.block_entities.insert(key, loaded);
self.update_spawner_display(key);
@@ -6894,6 +6899,14 @@ impl Game<'_> {
if self.hands_busy() {
return;
}
// InputEvent.InteractionKeyMappingTriggered for the main hand: a cancel
// ends the whole click, the off hand gets no turn.
if let Some(swing) = self.native_interaction(1, Hand::Main) {
if swing {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
return;
}
// An item a mod claims: its use is the mod's, and the server never hears of it.
if self.mod_use_start() {
return;
@@ -7854,7 +7867,13 @@ impl Game<'_> {
self.heights.item_used(Hand::Main);
return;
}
// The off hand's turn: the entity again, or the block.
// The off hand's turn: the event again, then the entity or the block.
if let Some(swing) = self.native_interaction(1, Hand::Off) {
if swing {
self.swing_hand(Hand::Off, SwingAnimation::DEFAULT);
}
return;
}
let off_target = if self.entity_targeted() {
self.remote_use_entity(Hand::Off)
} else {
@@ -8135,16 +8154,25 @@ impl Game<'_> {
self.send(S::SpectatorAction(play::SpectatorAction { target }));
return true;
}
let spear = self
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item));
if spear.is_some() && self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
return false;
}
// InputEvent.InteractionKeyMappingTriggered, after the checks that stop
// an attack: a cancel ends it there, swinging unless the mod said not to.
if let Some(swing) = self.native_interaction(0, Hand::Main) {
if swing {
let animation = spear.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation());
self.swing_hand(Hand::Main, animation);
}
return false;
}
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
// stabs, this client swings, plays the sound and starts its charge
// again.
if let Some(kinetic) = self
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
{
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
return false;
}
if let Some(kinetic) = spear {
// ServerboundPlayerActionPacket.Action.STAB.
self.send(S::PlayerAction(play::PlayerAction {
action: 8,
@@ -8282,9 +8310,22 @@ impl Game<'_> {
&& let Some(hit) = self.pick()
{
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if self.remote_break_restricted() {
let air = self.res.facts().is_air(state);
// InputEvent.InteractionKeyMappingTriggered, before the dig goes on:
// a cancel digs nothing and swings unless the mod said not to.
let canceled = if air {
None
} else {
self.native_interaction(0, Hand::Main)
};
if let Some(swing) = canceled {
if swing {
self.swing_arm();
self.send(S::Punch(Empty));
}
} else if self.remote_break_restricted() {
// continueDestroyBlock returned false: no swing.
} else if !self.res.facts().is_air(state) {
} else if !air {
self.remote_mining(false);
self.swing_arm();
self.send(S::Punch(Empty));
@@ -9567,7 +9608,9 @@ fn use_ends_click(info: &horizon_client::items::ItemInfo, fall_flying: bool) ->
/// Whether `Item.use` usually acts on this item (eats, drinks, equips, raises,
/// throws, shoots, fills or places), which ends `Minecraft.startUseItem` at
/// its hand. Tools and materials pass, and the off hand gets its turn. The
/// server decides; this is the client's guess of whether to go on.
/// server decides; this is the client's guess of whether to go on. A carrot or warped fungus
/// on a stick is not here: `FoodOnAStickItem.use` passes on a client, and only the server's
/// answer to a boost that can run swings the hand.
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
id.ends_with("_spear")
@@ -9603,8 +9646,6 @@ fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
| "goat_horn"
| "bundle"
| "brush"
| "carrot_on_a_stick"
| "warped_fungus_on_a_stick"
)
}
@@ -9614,6 +9655,31 @@ fn client_view_distance(render_distance: i32) -> i8 {
render_distance.clamp(2, i32::from(i8::MAX)) as i8
}
/// `ClientPacketListener.handleBlockEntityData` loads the tag into the existing
/// block entity, so the state the client builds itself stays: copied from `old`
/// into `new`, the block entity decoded from the tag.
fn keep_client_state(old: Option<&BlockEntity>, new: &mut BlockEntity) {
match (old, new) {
// The turn of a vault's item and a trial spawner's mob (VaultClientData).
(Some(BlockEntity::Vault(old)), BlockEntity::Vault(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
(Some(BlockEntity::TrialSpawner(old)), BlockEntity::TrialSpawner(new)) => {
new.spin = old.spin;
new.o_spin = old.o_spin;
}
// A beacon's beam (`BeaconBlockEntity.loadAdditional` reads only the
// powers): a fresh one draws nothing until the next tick walks it again.
(Some(BlockEntity::Beacon(old)), BlockEntity::Beacon(new)) => {
new.sections.clone_from(&old.sections);
new.checking.clone_from(&old.checking);
new.last_check_y = old.last_check_y;
}
_ => {}
}
}
/// `LocalPlayer.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS)`: the server's
/// flags (1 using, 2 the off hand) start a use the client is not running
/// (`startUsingItem`, which needs an item in that hand), and stop one it is
@@ -9684,12 +9750,18 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
// for (`handlePickItemFromEntity`), else the block.
if let Some(entity) = self.0.pick_entity_hit() {
if let Some(id) = entity {
if self.0.native_interaction(2, Hand::Main).is_some() {
return;
}
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
id,
include_data: self.0.has_control_down(),
}));
}
} else if let Some(hit) = self.0.pick() {
if self.0.native_interaction(2, Hand::Main).is_some() {
return;
}
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
pos: hit.pos,
include_data: self.0.has_control_down(),
@@ -9847,7 +9919,46 @@ fn client_millis() -> u64 {
#[cfg(test)]
mod tests {
use super::synced_local_use;
use super::{keep_client_state, synced_local_use};
use horizon_client::containers::{Beacon, BlockEntity};
use horizon_world::nbt::Tag;
#[test]
fn a_beacon_update_tag_keeps_the_beam_the_client_walked() {
let old = BlockEntity::Beacon(Box::new(Beacon {
levels: 1,
primary: Some("minecraft:speed".into()),
sections: vec![(0xF9FFFE, 2)],
checking: vec![(0xF9FFFE, 1)],
last_check_y: 70,
..Default::default()
}));
// BeaconBlockEntity.getUpdateTag: saveCustomOnly.
let tag = Tag::compound([
("id", Tag::String("minecraft:beacon".into())),
("x", Tag::Int(0)),
("y", Tag::Int(64)),
("z", Tag::Int(0)),
("primary_effect", Tag::String("minecraft:haste".into())),
(
"secondary_effect",
Tag::String("minecraft:regeneration".into()),
),
("Levels", Tag::Int(4)),
]);
let (_, mut new) = horizon_client::save::decode_block_entity(&tag).unwrap();
keep_client_state(Some(&old), &mut new);
let BlockEntity::Beacon(b) = new else {
panic!("not a beacon");
};
assert_eq!(b.sections, vec![(0xF9FFFE, 2)]);
assert_eq!(b.checking, vec![(0xF9FFFE, 1)]);
assert_eq!(b.last_check_y, 70);
// The powers and the levels are the tag's.
assert_eq!(b.primary.as_deref(), Some("minecraft:haste"));
assert_eq!(b.secondary.as_deref(), Some("minecraft:regeneration"));
assert_eq!(b.levels, 4);
}
#[test]
fn the_server_stopping_the_use_lowers_the_shield() {
@@ -10518,6 +10629,14 @@ mod tests {
for gliding in [false, true] {
assert!(super::use_ends_click(shield, gliding));
assert!(!super::use_ends_click(stick, gliding));
// FoodOnAStickItem.use passes on a client: no swing while the boost cools down.
for rod in [
"minecraft:carrot_on_a_stick",
"minecraft:warped_fungus_on_a_stick",
] {
let rod = items.get(rod).unwrap();
assert!(!super::use_ends_click(rod, gliding), "{}", rod.id);
}
}
}
}
+32 -5
View File
@@ -1713,6 +1713,7 @@ impl Game<'_> {
Option<i64>,
Option<u32>,
u32,
bool,
)> = self
.mobs
.iter()
@@ -1727,10 +1728,11 @@ impl Game<'_> {
m.last_damage.map(|(_, t)| t),
m.extra.mount,
m.age,
m.species() == Species::Bat && horizon_client::mobs::bat::resting(m),
)
})
.collect();
for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot {
for (id, at, on_ground, in_water, species, damaged, vehicle, age, resting) in snapshot {
if on_ground {
self.step_on_redstone_ore(at, false, false);
}
@@ -1749,10 +1751,10 @@ impl Game<'_> {
false,
warden,
);
// Entity.processFlappingMovement with Bee.isFlapping: a bee off
// the ground over air flaps on every other tick (the mob's own
// `MobEvent::GameEvent` is not read here).
if species == Species::Bee && !on_ground && age.is_multiple_of(2) {
// Entity.processFlappingMovement: what flaps this move flaps
// over air only (the mob's own `MobEvent::GameEvent` is not
// read here).
if flaps(species, resting, on_ground, age) {
let below = self.world.block(
at[0].floor() as i32,
(at[1] - 1.0e-5).floor() as i32,
@@ -1954,6 +1956,17 @@ pub(super) fn darkness_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32
(factor, factor)
}
/// `Entity.processFlappingMovement`'s per-tick gate, before its air-below
/// test: `Bee.isFlapping` (flying, every other tick) and `Bat.isFlapping`
/// (awake, every tenth tick).
fn flaps(species: Species, resting: bool, on_ground: bool, age: u32) -> bool {
match species {
Species::Bee => !on_ground && age.is_multiple_of(2),
Species::Bat => !resting && age.is_multiple_of(10),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1973,6 +1986,20 @@ mod tests {
assert_eq!(index.iter().last(), Some(&(41, 4, 0)));
}
/// A bat flaps every tenth tick while it is not resting, whether it is on
/// the ground or not; a resting bat never flaps and nothing else does.
#[test]
fn a_bat_flaps_every_tenth_tick_unless_it_is_resting() {
assert!(flaps(Species::Bat, false, false, 0));
assert!(flaps(Species::Bat, false, true, 10));
assert!(!flaps(Species::Bat, false, false, 11));
assert!(!flaps(Species::Bat, true, false, 20), "resting");
assert!(flaps(Species::Bee, false, false, 4));
assert!(!flaps(Species::Bee, false, true, 4), "a bee on the ground");
assert!(!flaps(Species::Bat, false, false, 5));
assert!(!flaps(Species::Cow, false, false, 0));
}
#[test]
fn centres_and_blocks_agree() {
assert_eq!(center((1, 2, 3)), [1.5, 2.5, 3.5]);
+37 -1
View File
@@ -5,7 +5,7 @@
//! dimension changes (spec 1.2).
use horizon_client::camera::CameraFluid;
use horizon_client::gfx::pack_uniforms::PackInputs;
use horizon_client::gfx::pack_uniforms::{PackInputs, PlayerStatus};
use horizon_client::shader_options::ShaderOptions;
use horizon_client::text::Text;
use horizon_shaders::{BlockIds, Macros, Pack, Properties, dimension_folder, load_post};
@@ -294,6 +294,8 @@ impl Game<'_> {
.get(&(at.x >> 4, at.z >> 4))
.map(|c| c.biome(at.x, at.y, at.z));
let (held_items, held_light) = self.held_for_pack();
let status = self.pack_player_status();
let (end_flash, end_flash_direction) = self.end_flash_for_pack(partial);
let info = biome.map(|b| world_biomes!(self).get(b));
PackInputs {
day_time: self.world.day_time,
@@ -332,6 +334,40 @@ impl Game<'_> {
sky.cloud_height,
),
mod_params: self.native_shader_params_now(partial),
status,
end_flash,
end_flash_direction,
}
}
/// The local player's state for Iris's status uniforms, read as the HUD reads it.
fn pack_player_status(&mut self) -> PlayerStatus {
let spectator = self.spectator();
let survival = !self.creative && !spectator;
let invisible = self.own_invisible();
let riding = self.riding.is_some();
let Some(p) = &mut self.player else {
return PlayerStatus::default();
};
PlayerStatus {
survival,
health: p.health.health(),
max_health: p.attribute("minecraft:max_health").unwrap_or(20.0) as f32,
food: p.health.food.food as f32,
air: p.air as f32,
armor: p
.attribute("minecraft:armor")
.or_else(|_| p.attribute("armor"))
.unwrap_or(0.0) as f32,
spectator,
riding,
feet_in_water: p.fluids.was_touching_water,
swimming: p.pose == horizon_client::pose::Pose::Swimming,
burning: p.fire_ticks > 0 && !spectator,
hurt: p.health.hurt_animation_time > 0,
invisible,
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
sprinting: p.sprinting,
}
}
+11
View File
@@ -276,6 +276,17 @@ impl Game<'_> {
0.2,
(actual * 0.5) as u32,
);
// The players a server serves are sent them too (the window of the server thread).
self.lan_send_particles(super::lan_particles::QueuedParticle {
dimension: self.dim.id(),
options: indicator,
state: None,
at: [feet[0], feet[1] + height * 0.5, feet[2]],
spread: [0.1, 0.0, 0.1],
speed: 0.2,
count: (actual * 0.5) as u32 as i32,
randomization: 0,
});
}
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
+12 -4
View File
@@ -11,16 +11,24 @@ use horizon_client::{
};
impl Game<'_> {
/// `Hud.extractOverlayMessage` and `extractTitle`.
pub(super) fn draw_titles(&self, batch: &mut HudBatch, bar_height: i32) {
/// `Hud.extractOverlayMessage`: the action bar.
pub(super) fn draw_overlay_message(&self, batch: &mut HudBatch, bar_height: i32) {
let (w, h) = (batch.width as i32, batch.height as i32);
let partial = self.clock.fraction() as f32;
let background = self.settings.text_background(0.0);
let font = &self.gui.atlas.font;
let mut draws = self
let draws = self
.titles
.overlay_message(w, h, bar_height, partial, font, background);
draws.extend(self.titles.title(w, h, partial, font, background));
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// `Hud.extractTitle`.
pub(super) fn draw_title(&self, batch: &mut HudBatch) {
let (w, h) = (batch.width as i32, batch.height as i32);
let partial = self.clock.fraction() as f32;
let background = self.settings.text_background(0.0);
let font = &self.gui.atlas.font;
let draws = self.titles.title(w, h, partial, font, background);
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// `Hud.extractSavingIndicator`, under the screens: the option, then the
+7 -2
View File
@@ -624,7 +624,7 @@ mod tests {
}
world.set(20, 4, 20, water).unwrap();
world.set(21, 4, 20, flowing).unwrap();
let at = Vec3::new(20.5, 6.0, 20.5);
let at = Vec3::new(20.5, 4.0, 20.5);
let mut tnt = PrimedTnt {
id: 0,
position: at,
@@ -702,10 +702,15 @@ mod tests {
.registry
.default_state("minecraft:soul_sand")
.unwrap();
let column = assets
// Soul sand makes an up-pushing column (`drag=false`); the block's
// default state is the down-dragging one.
let drag_down = assets
.registry
.default_state("minecraft:bubble_column")
.unwrap();
let mut up = assets.registry.state(drag_down).unwrap().clone();
up.properties.insert("drag".into(), "false".into());
let column = assets.registry.resolve(&up).unwrap();
for y in 3..=8 {
world.set(20, y, 20, column).unwrap();
}
+21 -1
View File
@@ -632,7 +632,7 @@ impl Game<'_> {
};
if m.session.offers.len() > 7 {
let max = m.session.offers.len() - 7;
m.scroll = (m.scroll as i32 - d.signum() as i32).clamp(0, max as i32) as usize;
m.scroll = scrolled(m.scroll, max, d);
}
}
/// `MerchantScreen` rendering.
@@ -930,10 +930,30 @@ fn drag_scroll(y: f32, offers: usize) -> usize {
((offs * max as f32 + 0.5) as i32).clamp(0, max) as usize
}
/// `MerchantScreen.mouseScrolled`: `Mth.clamp((int)(scrollOff - scrollY), 0, max)`,
/// so a fractional wheel step that truncates to zero does not move the list.
fn scrolled(scroll: usize, max: usize, d: f32) -> usize {
((scroll as f64 - f64::from(d)) as i32).clamp(0, max as i32) as usize
}
#[cfg(test)]
mod tests {
use super::*;
/// `MerchantScreen.mouseScrolled`: the offset is `(int)(scrollOff - scrollY)`
/// clamped, so the list moves by as many rows as the event's delta and a
/// fractional delta still moves one row (the truncation is after the
/// subtraction). Horizon moved exactly one row per event.
#[test]
fn the_offers_scroll_by_the_wheel_delta() {
assert_eq!(scrolled(3, 5, 1.0), 2, "one notch down");
assert_eq!(scrolled(3, 5, -1.0), 4, "one notch up");
assert_eq!(scrolled(3, 5, 0.25), 2, "a fraction still moves a row");
assert_eq!(scrolled(3, 5, 2.5), 0, "a two-and-a-half notch event");
assert_eq!(scrolled(0, 5, 1.0), 0, "clamped at the top");
assert_eq!(scrolled(5, 5, -1.0), 5, "clamped at the bottom");
}
#[test]
fn scroller_takes_presses_only_with_more_than_seven_offers() {
let x = SHIFT + 97.0;
+113 -11
View File
@@ -16,6 +16,7 @@ use horizon_protocol::{
SetEntityMotion, SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
},
};
use std::sync::Arc;
/// The attribute instances of `now` that differ from what `sent` held (an
/// instance is never removed: one that disappears is left as it was sent).
@@ -203,6 +204,8 @@ impl Riding {
/// What a level's clients were last told about one entity (ServerEntity's
/// fields), shared by every client that tracks it.
struct ServerEntity {
/// What the mods hear of it when a client starts or stops tracking it.
tracked: Arc<Tracked>,
/// The registry id of its type (None: a type the clients don't know,
/// never paired).
kind: Option<i32>,
@@ -265,6 +268,10 @@ impl ServerEntity {
fn new(e: &EntityState, kind: Option<i32>, riding: &Riding) -> Self {
let (range, interval, track_deltas) = tracking::settings(&e.kind);
Self {
tracked: Arc::new(Tracked {
uuid: e.uuid,
kind: Arc::from(e.kind.as_str()),
}),
kind,
range: i32::from(range),
interval,
@@ -583,27 +590,61 @@ impl ServerEntity {
}
}
/// An entity a client tracks, as the mods hear of it.
#[derive(Debug)]
pub struct Tracked {
pub uuid: u128,
/// Its type's registry id (`minecraft:zombie`).
pub kind: Arc<str>,
}
/// A client started or stopped tracking an entity (`ServerEntity.addPairing`
/// and `removePairing`).
#[derive(Debug)]
pub struct TrackEvent {
pub start: bool,
pub entity: i32,
pub tracked: Arc<Tracked>,
}
/// The entities one client tracks (`TrackedEntity.seenBy`, from the
/// client's side).
/// client's side), and what started and stopped since the game last took
/// the events.
#[derive(Default)]
pub struct Tracker {
seen: crate::fast_hash::FxHashSet<i32>,
seen: crate::fast_hash::FxHashMap<i32, Arc<Tracked>>,
events: Vec<TrackEvent>,
}
impl Tracker {
pub fn is_tracking(&self, id: i32) -> bool {
self.seen.contains(&id)
self.seen.contains_key(&id)
}
pub fn ids(&self) -> Vec<i32> {
self.seen.iter().copied().collect()
self.seen.keys().copied().collect()
}
/// Stop tracking everything without a packet: the caller removes them
/// from the client.
pub fn clear(&mut self) {
self.seen.clear();
let events = &mut self.events;
events.extend(self.seen.drain().map(|(entity, tracked)| TrackEvent {
start: false,
entity,
tracked,
}));
}
/// Stop tracking `id` without a packet: the caller removes it from the
/// client, and the next tick pairs it again if it is still in range.
pub fn forget(&mut self, id: i32) -> bool {
self.seen.remove(&id)
let Some(tracked) = self.seen.remove(&id) else {
return false;
};
self.events.push(TrackEvent {
start: false,
entity: id,
tracked,
});
true
}
pub fn len(&self) -> usize {
self.seen.len()
@@ -611,6 +652,10 @@ impl Tracker {
pub fn is_empty(&self) -> bool {
self.seen.is_empty()
}
/// The starts and stops since the last call, in order.
pub fn take_events(&mut self) -> Vec<TrackEvent> {
std::mem::take(&mut self.events)
}
}
/// Who a level's entities are tracked by this tick: a client's position,
@@ -829,7 +874,7 @@ impl Entities {
flags.extend(self.probes.iter().zip(pairs.iter_mut()).map(|(p, pairs)| {
if p.id == v.entity_id || !sees(v, p, chunks, held) {
0
} else if v.tracker.seen.contains(&p.id) {
} else if v.tracker.seen.contains_key(&p.id) {
kept += 1;
SEES | TRACKS
} else {
@@ -844,7 +889,7 @@ impl Entities {
let is_moved = &self.is_moved;
flags.extend(self.probes.iter().zip(pairs.iter_mut()).zip(is_moved).map(
|((p, pairs), &moved)| {
let tracks = v.tracker.seen.contains(&p.id);
let tracks = v.tracker.seen.contains_key(&p.id);
if p.id == v.entity_id || (moved && !sees(v, p, chunks, held)) {
0
} else if tracks {
@@ -861,7 +906,7 @@ impl Entities {
} else {
// One tracking little of it, from what it tracks.
flags.resize(self.probes.len(), 0);
for id in &v.tracker.seen {
for id in v.tracker.seen.keys() {
if let Some(s) = self.states.get(id).filter(|s| s.seen == now) {
flags[s.index] = SEES | TRACKS;
kept += 1;
@@ -885,13 +930,19 @@ impl Entities {
let mut removed = Vec::new();
if kept != v.tracker.seen.len() {
let states = &self.states;
v.tracker.seen.retain(|id| match states.get(id) {
let Tracker { seen, events } = &mut *v.tracker;
seen.retain(|id, tracked| match states.get(id) {
Some(s) if s.seen == now && flags[s.index] & TRACKS != 0 => true,
other => {
if let (Some(gone), Some(s)) = (v.gone.as_deref_mut(), other) {
gone.push(s.base);
}
removed.push(*id);
events.push(TrackEvent {
start: false,
entity: *id,
tracked: Arc::clone(tracked),
});
false
}
});
@@ -938,7 +989,17 @@ impl Entities {
packets.iter().map(play::Clientbound::encode).collect()
});
v.out.extend(bodies.iter().cloned());
v.tracker.seen.insert(e.id);
if v.tracker
.seen
.insert(e.id, Arc::clone(&s.tracked))
.is_none()
{
v.tracker.events.push(TrackEvent {
start: true,
entity: e.id,
tracked: Arc::clone(&s.tracked),
});
}
}
}
}
@@ -1594,6 +1655,47 @@ mod tests {
assert!(c.trackers[0].is_tracking(2));
}
#[test]
fn pairing_and_unpairing_are_kept_for_the_game() {
let mut level = Entities::default();
let mut c = Clients::new();
let zombie = state(0.0);
c.tick(&mut level, &[&zombie]);
c.tick(&mut level, &[&zombie]);
// Both clients were paired with it, once.
for t in &mut c.trackers {
let events = t.take_events();
assert_eq!(events.len(), 1, "{events:?}");
assert!(events[0].start && events[0].entity == 7);
assert_eq!(&*events[0].tracked.kind, "minecraft:zombie");
assert_eq!(events[0].tracked.uuid, 1);
assert!(t.take_events().is_empty());
}
// It leaves the level: both clients lose it.
c.tick(&mut level, &[]);
for t in &mut c.trackers {
let events = t.take_events();
assert!(
matches!(&events[..], [e] if !e.start && e.entity == 7),
"{events:?}"
);
}
// Back, then forgotten and cleared without a tick.
c.tick(&mut level, &[&zombie]);
c.tick(&mut level, &[&zombie]);
c.trackers[0].take_events();
c.trackers[1].take_events();
assert!(c.trackers[0].forget(7));
c.trackers[1].clear();
for t in &mut c.trackers {
let events = t.take_events();
assert!(
matches!(&events[..], [e] if !e.start && e.entity == 7),
"{events:?}"
);
}
}
#[test]
fn a_forgotten_entity_is_paired_again_at_the_next_tick() {
// A respawned player: the hub removes it from the other clients and
+12
View File
@@ -159,6 +159,14 @@ pub struct Guest {
/// (`clientIsFloating`), and the ticks it has (`aboveGroundTickCount`).
pub client_is_floating: bool,
pub above_ground_ticks: i32,
/// The same for the vehicle the player drives (`clientVehicleIsFloating`,
/// `aboveGroundVehicleTickCount`), with that vehicle's gravity
/// (`getMaximumFlyingTicks(lastVehicle)`) and whether its last accepted
/// move came down onto something (`verticalCollisionBelow`).
pub vehicle_floating: bool,
pub above_ground_vehicle_ticks: i32,
pub vehicle_gravity: f64,
pub vehicle_rests: bool,
/// A position move arrived since the client's last ClientTickEnd
/// (receivedPositionThisTick): a second one is a protocol error.
pub position_this_tick: bool,
@@ -371,6 +379,10 @@ impl Guest {
known_moves: 0,
client_is_floating: false,
above_ground_ticks: 0,
vehicle_floating: false,
above_ground_vehicle_ticks: 0,
vehicle_gravity: 0.0,
vehicle_rests: false,
position_this_tick: false,
anticheat: Default::default(),
replay: None,
+63 -9
View File
@@ -198,9 +198,32 @@ pub enum HostEvent {
},
}
/// What one player started and stopped tracking, in order, for the game to
/// tell the mods.
pub struct TrackingBatch {
pub player: u128,
pub name: String,
pub events: Vec<super::entity::TrackEvent>,
}
/// Moves a guest's tracking events into `out`, one batch.
fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
let events = g.tracker.take_events();
if !events.is_empty() {
out.push(TrackingBatch {
player: g.uuid,
name: g.name.clone(),
events,
});
}
}
pub struct Hub {
/// Clicks on and closings of native mods' menus, for the game.
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
pub lan: Lan,
pub config: HubConfig,
pub encoder: ChunkEncoder,
@@ -539,6 +562,7 @@ impl Hub {
entity_gone: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
native_tracking: Vec::new(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
}
@@ -1551,6 +1575,14 @@ impl Hub {
}
}
/// The starts and stops of tracking the guests had since the last call
/// are kept in `native_tracking`.
pub fn collect_tracking(&mut self) {
for g in &mut self.guests {
take_tracking(g, &mut self.native_tracking);
}
}
fn remove_guest(&mut self, i: usize, reason: &str) -> Guest {
let mut g = self.guests.remove(i);
if let Some(m) = g
@@ -1573,6 +1605,9 @@ impl Hub {
if !g.placed {
return g;
}
// `PlayerList.remove`: the entities it tracked stop (`removePairing`).
g.tracker.clear();
take_tracking(&mut g, &mut self.native_tracking);
self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove {
uuids: vec![g.uuid],
}));
@@ -3177,8 +3212,7 @@ impl Hub {
/// move after joining, before the spawn chunks are generated) is not
/// known to be air, so it never makes the player float.
fn no_blocks_around(&self, i: usize, at: [f64; 3], world: &World) -> bool {
let g = &self.guests[i];
let d = g.dimensions();
let d = self.guests[i].dimensions();
let half = f64::from(d.width) / 2.0 + 0.0625;
let lo = [at[0] - half, at[1] - 0.0625 - 0.55, at[2] - half];
let hi = [
@@ -3186,13 +3220,7 @@ impl Hub {
at[1] + f64::from(d.height) + 0.0625,
at[2] + half,
];
let cell = |v: f64| v.floor() as i32;
(cell(lo[0])..=cell(hi[0])).all(|x| {
(cell(lo[2])..=cell(hi[2])).all(|z| {
world.loaded(x, z)
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
})
})
no_blocks_in(world, lo, hi)
}
/// `ServerGamePacketListenerImpl.tickPlayer`'s flying check: a player the
@@ -3212,6 +3240,20 @@ impl Hub {
g.client_is_floating = false;
g.above_ground_ticks = 0;
}
// The vehicle the player drives, by its own gravity: a boat may hover 160 ticks, a horse 80.
let g = &mut self.guests[i];
if g.vehicle_floating && g.vehicle.is_some() {
g.above_ground_vehicle_ticks += 1;
if g.above_ground_vehicle_ticks > maximum_flying_ticks(g.vehicle_gravity) {
let line = format!("{} was kicked for floating a vehicle too long!", g.name);
let conn = g.conn;
events.push(HostEvent::Warn(line));
self.kick(conn, translatable("multiplayer.disconnect.flying", &[]));
}
} else {
g.vehicle_floating = false;
g.above_ground_vehicle_ticks = 0;
}
}
/// The collision part of handlePlayerPositionChange: whether the move
@@ -3703,6 +3745,18 @@ const PLAYER_GRAVITY: f64 = 0.08;
/// `ServerGamePacketListenerImpl.getMaximumFlyingTicks`: 80 ticks, more where
/// gravity is weaker, none at all (never kicked) without gravity.
/// `ServerGamePacketListenerImpl.noBlocksAround` over a box: every block from `lo` to `hi` is air, in
/// loaded chunks (a chunk not loaded yet is not known to be air: nothing floats there).
pub fn no_blocks_in(world: &World, lo: [f64; 3], hi: [f64; 3]) -> bool {
let cell = |v: f64| v.floor() as i32;
(cell(lo[0])..=cell(hi[0])).all(|x| {
(cell(lo[2])..=cell(hi[2])).all(|z| {
world.loaded(x, z)
&& (cell(lo[1])..=cell(hi[1])).all(|y| world.is_air(world.block(x, y, z)))
})
})
}
fn maximum_flying_ticks(gravity: f64) -> i32 {
if gravity < f64::from(1.0E-5_f32) {
return i32::MAX;
+6 -2
View File
@@ -89,7 +89,9 @@ pub fn save_owner(guest: &Guest, level: &mut Level, world: &Path) {
// never another player's file the world happened to hold.
level.player_uuid.get_or_insert(uuid_ints(guest.uuid));
store(guest, level, None);
let _ = level.write_player(world);
if let Err(e) = level.write_player(world) {
log::warn!("Horizon: the owner's playerdata is not saved: {e}");
}
save(guest, world);
}
@@ -181,7 +183,9 @@ pub fn save(guest: &Guest, world: &Path) {
if let Some(dir) = file.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = write_dat(&file, level.player_tag());
if let Err(e) = write_dat(&file, level.player_tag()) {
log::warn!("Horizon: {} is not saved: {e}", file.display());
}
}
/// Put a guest's state in `level`'s player fields; `vehicle` is the
+5 -161
View File
@@ -1,162 +1,6 @@
//! Horizon native client: resources, world streaming, rendering and the
//! bindings between the validated simulation pieces and the game loop.
//! Gameplay and startup, above the reusable client library.
#![forbid(unsafe_code)]
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, 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 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;
extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
+1 -105
View File
@@ -1,8 +1,5 @@
//! Horizon: native Rust client for Minecraft Java Edition 26.3 gameplay.
#![forbid(unsafe_code)]
mod game;
use horizon_client::{crash, logging, ram_limit};
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
@@ -13,107 +10,6 @@ static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
fn main() {
horizon_threads::configure_allocator();
std::hint::black_box(&MOD_API_LEVEL);
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),
}
}
}
horizon_client::entry::main();
}
+32 -1
View File
@@ -193,7 +193,7 @@ pub fn family(s: Species) -> Family {
| S::Parched
| S::WitherSkeleton
| S::ZombifiedPiglin => Family::Undead,
S::Creeper | S::Spider | S::CaveSpider | S::Silverfish => Family::Hostile,
S::Creeper | S::Spider | S::CaveSpider | S::Silverfish | S::Endermite => Family::Hostile,
S::Squid
| S::GlowSquid
| S::Cod
@@ -3122,6 +3122,37 @@ mod forgiveness_tests {
}
}
#[cfg(test)]
mod endermite_goal_tests {
use super::*;
use crate::mobs::world::with_test_world;
/// `Endermite.registerGoals` is the silverfish's family list without the
/// stone tricks: a powder-snow climb, a stroll at 1.0 and a
/// `HurtByTargetGoal` that alerts the others. The generic monster block
/// an unlisted species gets has none of those (stroll 0.8, no alert).
#[test]
fn an_endermite_takes_the_hostile_goal_list() {
let ran = with_test_world(&[], |_, types| {
let m = Mob::new(
1,
std::sync::Arc::new(types.types["minecraft:endermite"].clone()),
Vec3::ZERO,
0.0,
);
assert_eq!(family(m.species()), Family::Hostile);
assert!(
m.ai.goals.has("ClimbOnTopOfPowderSnow"),
"ClimbOnTopOfPowderSnowGoal"
);
assert!(m.ai.goals.has("Stroll"), "WaterAvoidingRandomStrollGoal");
assert!(m.ai.targets.has("HurtByTarget"));
assert!(m.ai.targets.has("NearestTarget"));
});
let _ = ran;
}
}
#[cfg(test)]
mod fox_screech_tests {
use super::*;
+18 -2
View File
@@ -186,6 +186,10 @@ pub struct State {
pub baby: bool,
pub partial: f32,
}
/// `WalkAnimationState.position(partial)`: babies' legs move 3 times as far.
pub fn walk_position(m: &Mob, alpha: f32) -> f32 {
(m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 }
}
pub fn state(m: &Mob, alpha: f32) -> State {
let body = m.previous_body_yaw + wrap(m.body_yaw - m.previous_body_yaw) * alpha;
let head = m.previous_head_yaw + wrap(m.head_yaw - m.previous_head_yaw) * alpha;
@@ -198,8 +202,7 @@ pub fn state(m: &Mob, alpha: f32) -> State {
let walking = m.alive() && !m.extra.passenger;
State {
pos: if walking {
// WalkAnimationState.position(partial): babies' legs move 3 times as far.
(m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 }
walk_position(m, alpha)
} else {
0.0
},
@@ -1238,6 +1241,19 @@ mod flight_pose_tests {
assert_eq!(dry.get("head")[3], 30.0f32.to_radians());
}
#[test]
fn walk_position_triples_for_babies() {
// WalkAnimationState.position(partial) = (position - speed * (1 - partial)) * scale.
let mut m = player();
m.limb_position = 2.0;
m.limb_speed = 0.5;
assert!(!m.is_baby());
assert_eq!(walk_position(&m, 0.5), 1.75);
m.extra.age = -100;
assert!(m.is_baby());
assert_eq!(walk_position(&m, 0.5), 5.25);
}
/// A player: a humanoid model with a shield in its main hand.
fn player() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
+11 -4
View File
@@ -399,10 +399,16 @@ fn animation_states(m: &mut Mob, pose_time: i64, sitting: bool) {
st[2].animate_when(transition, age);
}
}
/// `Camel.executeRidersJump`: the dash along the look, once cooled down.
pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) {
if m.extra.anim_ticks > 0 || !m.on_ground || refuse_to_move(m) {
return;
/// `Camel.executeRidersJump`: the dash along the look, once cooled down. Returns whether the
/// jump is used up: it is, by a dash or when the camel cannot dash at all (cooling down,
/// refusing to move: `onPlayerJump` would not have taken it); it waits while the camel is off
/// the ground (`AbstractHorse.tickRidden` runs a pending jump when it lands).
pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) -> bool {
if m.extra.anim_ticks > 0 || refuse_to_move(m) {
return true;
}
if !m.on_ground {
return false;
}
let yaw = m.yaw.to_radians();
let (dx, dz) = (f64::from(-yaw.msin()), f64::from(yaw.mcos()));
@@ -414,6 +420,7 @@ pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) {
m.extra.anim_ticks = DASH_COOLDOWN;
m.extra.flag2 = true;
sound(m, voice(m, 3), events);
true
}
/// `CamelModel.setupAnim`.
pub fn anim(
+732 -88
View File
@@ -1,18 +1,30 @@
//! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/<name>.jem` (or
//! `cem/<name>/<name>.jem`, and Entity Model Features' `emf/cem/`, which wins) maps to one layer
//! (`zombie.jem` to `zombie#main`, `sheep_wool.jem` to `sheep#wool`), its parts to the layer's
//! vanilla parts, and its boxes join or replace theirs. A baby layer without its own model
//! takes the adult's (OptiFine's fallbacks, and Entity Model Features' for horses too).
//! vanilla parts, and its boxes join or replace theirs. A layer without its own model takes the
//! one it falls back to: a baby's adult (OptiFine's fallbacks, and Entity Model Features' for
//! horses too), and the few of [`FALLBACKS`], following their chains (`cat_baby_collar` to
//! `cat_collar` to `cat`).
//!
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
//! nothing is built and every model stays vanilla's.
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
//!
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
//! vanilla texture name, and the texture selection then works on that name as on vanilla's.
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
//! rules (`green2.png` beside `green.png`) follow it; a `.properties` with rules for it is not
//! matched yet, because the random-texture picks are made for the vanilla names. That half
//! belongs with random textures. The `.jem` texture paints the layer the file is for, not the overlays drawn on that layer's model (a wolf's
//! collar, a spider's eyes, the cracks on a wolf's armour): those keep their textures over the
//! new geometry.
use super::{MobType, Part, Polygon};
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
use std::{collections::HashMap, fs, path::Path, sync::Arc};
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fs,
path::Path,
sync::Arc,
};
/// The CEM models of the enabled packs, by model layer (`zombie#main`).
#[derive(Default)]
@@ -30,62 +42,85 @@ pub struct CemLayer {
pub texture: Option<String>,
}
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
const FALLBACK_STEPS: usize = 3;
impl CemModels {
/// The `.jem` files under `root` applied to the layers of `types`.
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
let vanilla: HashMap<&str, &Arc<Part>> = types
.values()
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
.collect();
Self::over(root, &vanilla)
}
/// The `.jem` files under `root` applied to the `vanilla` layers.
fn over(root: &Path, vanilla: &HashMap<&str, &Arc<Part>>) -> Self {
let files = jem_files(root);
if files.is_empty() {
return Self::default();
}
let mut read = |path: &str| read_small(&root.join(path));
let mut layers = HashMap::new();
// A baby layer without its own file takes the adult's: those go after every own file.
let mut fallbacks = Vec::new();
for (name, path) in &files {
let Some(text) = read_small(&root.join(path)) else {
log::warn!(
"Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"
);
continue;
};
let jem = match parse_jem(path, &text, &mut read) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
continue;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
let jem = Arc::new(jem);
Self::from_files(root, &files, &vanilla_layers(types))
}
/// `files` ([`jem_files`]) applied to the `vanilla` layers.
fn from_files(
root: &Path,
files: &BTreeMap<String, Vec<String>>,
vanilla: &HashMap<&str, &Arc<Part>>,
) -> Self {
// Every model name a file can shape: the files' own, and the names that fall back to
// them, a chain of fallbacks deep.
let mut names: BTreeSet<String> = files.keys().cloned().collect();
let mut frontier: Vec<String> = names.iter().cloned().collect();
for _ in 0..FALLBACK_STEPS {
frontier = frontier
.iter()
.flat_map(|name| fallback_names(name))
.filter(|name| names.insert(name.clone()))
.collect();
}
// The files each layer may take, best first: the first that parses wins. Every name of
// the layer's own file in name order, then the files its names fall back to, nearest
// first. Two names of one layer (`llama_decor` and `trader_llama_decor` are both
// `llama#decor`, `skeleton_horse_armor` and `zombie_horse_armor` both
// `undead_horse_armor#main`) give it the first's, and the second's if the first is
// broken; within one name, EMF's file before OptiFine's.
let mut plan: HashMap<String, Vec<(usize, &str)>> = HashMap::new();
for name in &names {
let key = layer_key(name);
if let Some(part) = vanilla.get(key.as_str()) {
layers.insert(key, CemLayer::build(part, &jem, name));
if !vanilla.contains_key(key.as_str()) {
continue;
}
if !name.contains("_baby") {
fallbacks.extend(baby_names(name).map(|baby| (baby, name, jem.clone())));
let chain = std::iter::successors(Some(name.clone()), |n| falls_back_to(n));
let candidates = plan.entry(key).or_default();
for (depth, target) in chain.take(FALLBACK_STEPS + 1).enumerate() {
if let Some((file, _)) = files.get_key_value(&target) {
candidates.push((depth, file.as_str()));
}
}
}
for (baby, adult, jem) in fallbacks {
if files.contains_key(&baby) {
// Each file parsed once, when a layer first tries it.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut layers = HashMap::new();
for (key, mut candidates) in plan {
candidates.sort_by_key(|&(depth, _)| depth);
let found = candidates
.iter()
.flat_map(move |&(_, name)| {
files[name].iter().map(move |path| (name, path.as_str()))
})
.find(|&(_, path)| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some((name, path)) = found else {
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut layer = CemLayer::build(vanilla[key.as_str()], jem, name);
// A fallback onto another layer (the cat's collar from `cat`) takes the geometry
// only: the layer keeps its own texture.
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
layer.texture = None;
}
let key = layer_key(&baby);
if let Some(part) = vanilla.get(key.as_str()) {
layers
.entry(key)
.or_insert_with(|| CemLayer::build(part, &jem, adult));
}
layers.insert(key, layer);
}
Self { layers }
}
@@ -95,6 +130,47 @@ impl CemModels {
}
}
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
/// parsed.
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
let Some(text) = read_small(&root.join(path)) else {
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
return None;
};
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
return None;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
if let Some(texture) = &jem.texture {
if atlas_name(texture).is_none() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} is outside \
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
);
} else if !root.join(texture).is_file() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
so not in the atlas: the vanilla one draws"
);
}
}
Some(jem)
}
/// Every model layer of `types` by its key.
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
types
.values()
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
.collect()
}
/// A pack file's text, None when it's missing or larger than a model may read: its size is
/// checked before anything is read.
fn read_small(path: &Path) -> Option<String> {
@@ -105,26 +181,65 @@ fn read_small(path: &Path) -> Option<String> {
fs::read_to_string(path).ok()
}
/// Every `.jem` of the CEM folders by model name, `emf/cem` over `optifine/cem`.
fn jem_files(root: &Path) -> HashMap<String, String> {
let mut out = HashMap::new();
for base in ["assets/minecraft/optifine/cem", "assets/minecraft/emf/cem"] {
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
/// `<name>.jem` before `<name>/<name>.jem`.
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
let Ok(entries) = fs::read_dir(root.join(base)) else {
continue;
};
let (mut flat, mut nested) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(model) = name.strip_suffix(".jem") {
out.insert(model.to_owned(), format!("{base}/{name}"));
} else if root.join(format!("{base}/{name}/{name}.jem")).is_file() {
out.entry(name.clone())
.or_insert_with(|| format!("{base}/{name}/{name}.jem"));
// A directory named `x.jem` isn't a model.
if e.path().is_file() {
flat.push((model.to_owned(), format!("{base}/{name}")));
}
} else {
let path = format!("{base}/{name}/{name}.jem");
if root.join(&path).is_file() {
nested.push((name, path));
}
}
}
for (model, path) in flat.into_iter().chain(nested) {
out.entry(model).or_default().push(path);
}
}
out
}
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
const LAYERS: &[(&str, &str)] = &[
("sheep_wool", "sheep#wool"),
("creeper_charge", "creeper#armor"),
("slime_outer", "slime#outer"),
("drowned_outer", "drowned#outer"),
("stray_outer", "stray#outer"),
("bogged_outer", "bogged#outer"),
("wither_armor", "wither#armor"),
("breeze_wind", "breeze#wind"),
// Horizon draws both llamas' decor on one layer, and both undead horses' armour.
("llama_decor", "llama#decor"),
("trader_llama_decor", "llama#decor"),
("cat_collar", "cat#collar"),
("pig_saddle", "pig#saddle"),
("strider_saddle", "strider#saddle"),
("nautilus_saddle", "nautilus#saddle"),
("tropical_fish_a", "tropical_fish_small#main"),
("tropical_fish_b", "tropical_fish_large#main"),
("tropical_fish_pattern_a", "tropical_fish_small#pattern"),
("tropical_fish_pattern_b", "tropical_fish_large#pattern"),
("puffer_fish_big", "pufferfish_big#main"),
("puffer_fish_medium", "pufferfish_medium#main"),
("puffer_fish_small", "pufferfish_small#main"),
("skeleton_horse_armor", "undead_horse_armor#main"),
("zombie_horse_armor", "undead_horse_armor#main"),
];
/// The model layer a CEM model name reshapes.
fn layer_key(name: &str) -> String {
// `<model>_baby[_<layer>]`: the adult's layer on the baby model.
@@ -133,31 +248,49 @@ fn layer_key(name: &str) -> String {
let (model, layer) = adult.split_once('#').unwrap_or((&adult, "main"));
return format!("{model}_baby#{layer}");
}
match name {
"sheep_wool" => "sheep#wool",
"creeper_charge" => "creeper#armor",
"slime_outer" => "slime#outer",
"drowned_outer" => "drowned#outer",
"stray_outer" => "stray#outer",
"bogged_outer" => "bogged#outer",
"wither_armor" => "wither#armor",
"breeze_wind" => "breeze#wind",
"llama_decor" | "trader_llama_decor" => "llama#decor",
"cat_collar" => "cat#collar",
"pig_saddle" => "pig#saddle",
"strider_saddle" => "strider#saddle",
"nautilus_saddle" => "nautilus#saddle",
"tropical_fish_a" => "tropical_fish_small#main",
"tropical_fish_b" => "tropical_fish_large#main",
"tropical_fish_pattern_a" => "tropical_fish_small#pattern",
"tropical_fish_pattern_b" => "tropical_fish_large#pattern",
"puffer_fish_big" => "pufferfish_big#main",
"puffer_fish_medium" => "pufferfish_medium#main",
"puffer_fish_small" => "pufferfish_small#main",
"zombie_horse_armor" | "skeleton_horse_armor" => "undead_horse_armor#main",
_ => return format!("{name}#main"),
match LAYERS.iter().find(|(n, _)| *n == name) {
Some((_, key)) => (*key).to_owned(),
None => format!("{name}#main"),
}
.to_owned()
}
/// The layer of a model layer key: `wool` of `sheep#wool`.
fn layer_suffix(key: &str) -> &str {
key.split_once('#').map_or(key, |(_, layer)| layer)
}
/// Models without a file of their own that take another's (OptiFine's and Entity Model
/// Features' tables), besides the babies: (model, the model it takes).
const FALLBACKS: &[(&str, &str)] = &[
("cat_collar", "cat"),
("player_slim", "player"),
("salmon_large", "salmon"),
("salmon_small", "salmon"),
];
/// The model `name` takes when it has no file: [`FALLBACKS`], or a baby's adult (`sheep_wool`
/// for `sheep_baby_wool`). The baby nautilus has none (OptiFine's and Entity Model Features'
/// tables).
fn falls_back_to(name: &str) -> Option<String> {
if let Some((_, to)) = FALLBACKS.iter().find(|(from, _)| *from == name) {
return Some((*to).to_owned());
}
(name.contains("_baby") && !name.starts_with("nautilus_baby"))
.then(|| name.replacen("_baby", "", 1))
}
/// The model names that take `name`'s file when they have none ([`falls_back_to`] the other
/// way): the babies of an adult and [`FALLBACKS`].
fn fallback_names(name: &str) -> Vec<String> {
let mut out: Vec<String> = FALLBACKS
.iter()
.filter(|(_, to)| *to == name)
.map(|(from, _)| (*from).to_owned())
.collect();
if !name.contains("_baby") {
out.extend(baby_names(name).filter(|baby| !baby.starts_with("nautilus_baby")));
}
out
}
/// The baby model names that fall back to the adult `name`: `_baby` before any of its
@@ -286,8 +419,13 @@ fn vanilla_part(model: &str, part: &str) -> String {
("cod" | "tropical_fish_a" | "tropical_fish_b", "fin_back" | "fin_top") => {
Some("top_fin".into())
}
(_, "fin_right") if model.starts_with("puffer") => Some("right_blue_fin".into()),
(_, "fin_left") if model.starts_with("puffer") => Some("left_blue_fin".into()),
// The big and medium pufferfish have blue fins; the small one has plain `right_fin`s
// (the fin animation in aquatic.rs).
("puffer_fish_big" | "puffer_fish_medium", "fin_right") => Some("right_blue_fin".into()),
("puffer_fish_big" | "puffer_fish_medium", "fin_left") => Some("left_blue_fin".into()),
// RabbitModel's hind feet (rabbit.rs).
("rabbit", "left_foot") => Some("left_hind_foot".into()),
("rabbit", "right_foot") => Some("right_hind_foot".into()),
(_, "fin_right") => Some("right_fin".into()),
(_, "fin_left") => Some("left_fin".into()),
(_, "fin_bottom") => Some("bottom_fin".into()),
@@ -325,7 +463,7 @@ impl CemLayer {
let mut entries = Vec::new();
for p in &jem.parts {
let target = vanilla_part(&model, &p.part);
let target = if find(&out, &target) {
let target = if super::render::find_part(&out, &target).is_some() {
target
} else {
p.part.clone()
@@ -369,10 +507,6 @@ fn atlas_name(path: &str) -> Option<String> {
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
}
fn find(part: &Part, name: &str) -> bool {
part.name == name || part.children.iter().any(|c| find(c, name))
}
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
if part.name == name {
@@ -468,6 +602,9 @@ mod tests {
("strider", "hair_left_top", "left_top_bristle"),
("puffer_fish_big", "spikes_front_top", "top_front_fin"),
("squid", "tentacle0", "tentacle0"),
("puffer_fish_medium", "fin_left", "left_blue_fin"),
("puffer_fish_small", "fin_right", "right_fin"),
("rabbit", "left_foot", "left_hind_foot"),
] {
assert_eq!(vanilla_part(model, part), vanilla, "{model} {part}");
}
@@ -510,7 +647,7 @@ mod tests {
]
.into_iter()
.collect();
let models = CemModels::over(&root, &vanilla);
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
fs::remove_dir_all(&root).unwrap();
let adult = models.layer("zombie#main").unwrap();
@@ -530,4 +667,511 @@ mod tests {
assert_eq!((leg.polygons.len(), leg.children.len()), (0, 1));
assert!(models.layer("sheep#main").is_none());
}
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A `.jem` with one entry on `part`, attached or replacing it.
fn jem(part: &str, attach: bool) -> String {
format!(r#"{{"models": [{{"part": "{part}", "attach": {attach}, {BOX}}}]}}"#)
}
/// A fresh pack folder for the test `test` holding `files` (paths under `assets/minecraft/`).
fn pack(test: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("horizon-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// The models of the pack `files` over the `layers`, the same tree for each.
fn models(test: &str, files: &[(&str, &str)], layers: &[&str], tree: &Part) -> CemModels {
let root = pack(test, files);
let tree = Arc::new(tree.clone());
let vanilla: HashMap<&str, &Arc<Part>> = layers.iter().map(|k| (*k, &tree)).collect();
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
fs::remove_dir_all(&root).unwrap();
models
}
/// Whether the layer `key`'s part `name` was replaced (lost its own face) or had a box
/// attached (kept it); None without the layer. Either way the box joins as a child.
fn attached(models: &CemModels, key: &str, name: &str) -> Option<bool> {
let part = get(&models.layer(key)?.model, name);
assert_eq!(part.children.len(), 1, "{key} {name}: one custom box");
Some(part.polygons.len() == 1)
}
#[test]
fn emf_files_win_and_a_flat_file_beats_its_folder() {
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
let files = [
// EMF's folder form over OptiFine's flat file.
("optifine/cem/cow.jem", attach.as_str()),
("emf/cem/cow/cow.jem", replace.as_str()),
// One folder: the flat file over the folder form.
("optifine/cem/pig.jem", replace.as_str()),
("optifine/cem/pig/pig.jem", attach.as_str()),
// A directory named `sheep.jem` is no file: the folder form stands.
("optifine/cem/sheep.jem/readme.txt", ""),
("optifine/cem/sheep/sheep.jem", attach.as_str()),
];
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
let models = models(
"precedence",
&files,
&["cow#main", "pig#main", "sheep#main"],
&tree,
);
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
assert_eq!(
attached(&models, "sheep#main", "right_hind_leg"),
Some(true)
);
}
#[test]
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
let (adult, baby) = (jem("head", false), jem("head", true));
let files = [
("optifine/cem/zombie.jem", adult.as_str()),
("optifine/cem/zombie_baby.jem", baby.as_str()),
];
let tree = plane("root", vec![plane("head", vec![])]);
let models = models("baby", &files, &["zombie#main", "zombie_baby#main"], &tree);
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
assert_eq!(attached(&models, "zombie_baby#main", "head"), Some(true));
}
#[test]
fn two_names_of_one_layer_give_it_the_first_by_name() {
let (llama, trader) = (jem("body", false), jem("body", true));
let files = [
("optifine/cem/trader_llama_decor.jem", trader.as_str()),
("optifine/cem/llama_decor.jem", llama.as_str()),
];
let tree = plane("root", vec![plane("body", vec![])]);
// Written trader first, `llama_decor` still shapes the layer, and the baby's (both
// fall back to the adult files).
let models = models("decor", &files, &["llama#decor", "llama_baby#decor"], &tree);
assert_eq!(attached(&models, "llama#decor", "body"), Some(false));
assert_eq!(attached(&models, "llama_baby#decor", "body"), Some(false));
}
#[test]
fn other_models_fall_back_but_the_baby_nautilus_does_not() {
let (replace, attach) = (jem("body", false), jem("body", true));
let cat = format!(
r#"{{"texture": "textures/entity/cat/custom.png", "models": [
{{"part": "body", {BOX}}}]}}"#
);
let files = [
("optifine/cem/salmon.jem", replace.as_str()),
("optifine/cem/salmon_large.jem", attach.as_str()),
("optifine/cem/nautilus.jem", replace.as_str()),
("optifine/cem/cat.jem", cat.as_str()),
];
let tree = plane("root", vec![plane("body", vec![])]);
let models = models(
"fallbacks",
&files,
&[
"salmon#main",
"salmon_small#main",
"salmon_large#main",
"nautilus#main",
"nautilus_baby#main",
"cat#main",
"cat#collar",
"cat_baby#collar",
],
&tree,
);
// The small salmon has no file: salmon.jem's. The large one has its own.
assert_eq!(attached(&models, "salmon_small#main", "body"), Some(false));
assert_eq!(attached(&models, "salmon_large#main", "body"), Some(true));
assert_eq!(attached(&models, "nautilus#main", "body"), Some(false));
assert!(models.layer("nautilus_baby#main").is_none());
// The collar takes the cat's geometry, but its own collar texture stays.
let texture = |key: &str| models.layer(key).unwrap().texture.clone();
assert_eq!(texture("cat#main").as_deref(), Some("entity/cat/custom"));
assert_eq!(attached(&models, "cat#collar", "body"), Some(false));
assert_eq!(texture("cat#collar"), None);
// So does the baby's, two fallbacks down: `cat_baby_collar`, `cat_collar`, `cat`.
assert_eq!(attached(&models, "cat_baby#collar", "body"), Some(false));
assert_eq!(texture("cat_baby#collar"), None);
}
#[test]
fn a_broken_file_gives_way_to_the_next() {
let (replace, attach) = (jem("body", false), jem("body", true));
let files = [
// The first name of the layer is broken: the second shapes it.
("optifine/cem/llama_decor.jem", "{"),
("optifine/cem/trader_llama_decor.jem", attach.as_str()),
// EMF's file is broken: OptiFine's stands.
("emf/cem/cow.jem", "{"),
("optifine/cem/cow.jem", replace.as_str()),
// The baby's own file is broken: the adult's falls back onto it.
("optifine/cem/zombie_baby.jem", "{"),
("optifine/cem/zombie.jem", attach.as_str()),
];
let tree = plane("root", vec![plane("body", vec![])]);
let models = models(
"broken",
&files,
&["llama#decor", "cow#main", "zombie_baby#main"],
&tree,
);
assert_eq!(attached(&models, "llama#decor", "body"), Some(true));
assert_eq!(attached(&models, "cow#main", "body"), Some(false));
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
}
#[test]
fn an_unknown_part_is_skipped_and_the_rest_applies() {
let file =
format!(r#"{{"models": [{{"part": "wings", {BOX}}}, {{"part": "head", {BOX}}}]}}"#);
let tree = plane("root", vec![plane("head", vec![])]);
let models = models(
"unknown",
&[("optifine/cem/zombie.jem", file.as_str())],
&["zombie#main"],
&tree,
);
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
fn custom(p: &Part) -> usize {
usize::from(p.name.starts_with("cem#")) + p.children.iter().map(custom).sum::<usize>()
}
assert_eq!(custom(&models.layer("zombie#main").unwrap().model), 1);
}
/// A mob type `id` of `species` whose body and `layers` are all `tree`, with the vanilla
/// `texture`.
fn mob_kind(
id: &str,
species: super::super::Species,
anim: super::super::Anim,
texture: &str,
layers: &[&str],
tree: &Part,
) -> Arc<MobType> {
let mut kind = (*super::super::sensor_scan_tests::kind()).clone();
kind.id = id.into();
kind.texture = texture.into();
kind.species = species;
kind.anim = anim;
kind.model = Arc::new(tree.clone());
kind.layers = layers
.iter()
.map(|k| ((*k).to_owned(), Arc::new(tree.clone())))
.collect();
Arc::new(kind)
}
/// How many vertices `render` draws of `mob` in each of `sprites`, laid far apart in the
/// atlas: the sprite a vertex's UV falls in says what it drew with. `sprites[0]`, the
/// `.jem`'s texture, is in the atlas only with `custom`.
fn sprite_counts(
mob: &super::super::Mob,
models: &Arc<CemModels>,
sprites: &[&str],
custom: bool,
) -> Vec<usize> {
let data = super::super::MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: std::sync::RwLock::new(models.clone()),
random_textures: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
// Sprite i spans u and v from 0.2 i to 0.2 i + 0.1.
let atlas = |name: &str| -> Option<[f32; 4]> {
let i = sprites.iter().position(|s| *s == name)?;
let lo = i as f32 * 0.2;
(custom || i != 0).then_some([lo, lo, lo + 0.1, lo + 0.1])
};
let mut vertices = Vec::new();
super::super::render::render(
mob,
0.0,
glam::DVec3::ZERO,
&atlas,
0xf0f0,
&data,
&mut vertices,
);
let mut counts = vec![0; sprites.len()];
for v in &vertices {
let u = v.uv[0];
let i = ((u + 0.05) / 0.2).floor() as usize;
assert!(
i < sprites.len() && u - i as f32 * 0.2 <= 0.1 + 1e-4,
"a UV between the sprites: {u}"
);
counts[i] += 1;
}
counts
}
/// A spider whose `.jem` replaces its body: the body draws with the `.jem`'s texture, the
/// eyes (an overlay on the body's model) with theirs on the new geometry, and a texture the
/// atlas lacks leaves the vanilla one. A sheep's wool, a layer with a model of its own,
/// takes its `.jem`'s texture.
#[test]
fn a_jem_texture_paints_its_layer_and_leaves_the_overlays() {
use super::super::{Anim, Mob, Species};
let tree = |part: &str| Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: vec![plane(part, vec![])],
};
let textured = |texture: &str| {
format!(
r#"{{"texture": "textures/{texture}.png", "models": [
{{"part": "body", {BOX}}}]}}"#
)
};
let spider = textured("entity/spider/custom");
let cem = Arc::new(models(
"render",
&[("optifine/cem/spider.jem", spider.as_str())],
&["spider#main"],
&tree("body1"),
));
let mob = Mob::new(
1,
mob_kind(
"minecraft:spider",
Species::Spider,
Anim::Spider,
"entity/spider/spider",
&["spider#main"],
&tree("body1"),
),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
let sprites = [
"entity/spider/custom",
"entity/spider/spider_eyes",
"entity/spider/spider",
];
// The custom box's six faces, four corners each, for the body and again for the eyes;
// the replaced body part's own face is gone.
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 24, 0]);
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24]);
let wool = textured("entity/sheep/custom_wool");
let cem = Arc::new(models(
"render-sheep",
&[("optifine/cem/sheep_wool.jem", wool.as_str())],
&["sheep#main", "sheep#wool"],
&tree("body"),
));
let mob = Mob::new(
1,
mob_kind(
"minecraft:sheep",
Species::Sheep,
Anim::Sheep,
"entity/sheep/sheep",
&["sheep#main", "sheep#wool"],
&tree("body"),
),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
let sprites = [
"entity/sheep/custom_wool",
"entity/sheep/sheep_wool",
"entity/sheep/sheep",
];
// The wool's custom box in the `.jem`'s texture; the vanilla body's one face.
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 4]);
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 4]);
}
/// A wolf armour `.jem`'s texture paints the armour, the first layer on its model, and
/// not the cracks drawn over it on the same model.
#[test]
fn armour_cracks_keep_their_texture() {
use super::super::{Anim, Mob, Species, WOLF_ARMOR_DURABILITY};
let tree = Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: vec![plane("body", vec![])],
};
let file = format!(
r#"{{"texture": "textures/entity/wolf/custom_armor.png", "models": [
{{"part": "body", {BOX}}}]}}"#
);
let layers = ["wolf#main", "wolf_armor#main"];
let cem = Arc::new(models(
"cracks",
&[("optifine/cem/wolf_armor.jem", file.as_str())],
&layers,
&tree,
));
let mut mob = Mob::new(
1,
mob_kind(
"minecraft:wolf",
Species::Wolf,
Anim::Wolf,
"entity/wolf/wolf",
&layers,
&tree,
),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
mob.extra.tame = true;
mob.extra.head = Some("minecraft:wolf_armor".into());
// Worn through: the high cracks.
mob.extra.body_damage = WOLF_ARMOR_DURABILITY;
let sprites = [
"entity/wolf/custom_armor",
"entity/equipment/wolf_body/armadillo_scute",
"entity/wolf/wolf_armor_crackiness_high",
"entity/wolf/wolf_collar",
];
// The armour's custom box in the `.jem`'s texture, the cracks' over the same box in
// theirs, and the collar on the vanilla body (whose own texture isn't in the atlas).
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 24, 4]);
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24, 4]);
}
/// Every layer of [`LAYERS`] and [`FALLBACKS`] is in Horizon's own models, every baby layer
/// there is one a baby model name maps to, and so are the parts a sample of part names is
/// renamed to.
#[test]
#[ignore = "requires prepared entity models in HORIZON_TEST_RESOURCES"]
fn names_reach_horizons_models() {
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
let types: HashMap<String, Arc<MobType>> = super::super::MobTypes::load(Path::new(&root))
.unwrap()
.types
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.collect();
let vanilla = vanilla_layers(&types);
let mut missing = Vec::new();
for (name, key) in LAYERS {
if !vanilla.contains_key(key) {
missing.push(format!("{name}: no layer {key}"));
}
}
for name in FALLBACKS.iter().flat_map(|(name, takes)| [*name, *takes]) {
let key = layer_key(name);
if !vanilla.contains_key(key.as_str()) {
missing.push(format!("{name}: no layer {key}"));
}
}
// `sheep_baby#wool` is `sheep_baby_wool`'s: the baby name of a name of `sheep#wool`.
for key in vanilla.keys() {
let Some((model, layer)) = key.split_once('#') else {
continue;
};
let Some(adult) = model.strip_suffix("_baby") else {
continue;
};
let adult_key = format!("{adult}#{layer}");
let reached = LAYERS
.iter()
.map(|(name, _)| *name)
.chain([adult])
.filter(|name| layer_key(name) == adult_key)
.flat_map(baby_names)
.any(|baby| layer_key(&baby) == *key);
if !reached {
missing.push(format!("{key}: no baby model name maps to it"));
}
}
// A sample of the renamed parts.
for (model, part) in [
("zombie", "headwear"),
("cow", "leg1"),
("cow", "leg4"),
("camel", "back_left_leg"),
("camel", "front_right_leg"),
("spider", "leg1"),
("spider", "leg8"),
("spider", "neck"),
("spider", "body"),
("villager", "headwear2"),
("villager", "bodywear"),
("witch", "headwear2"),
("witch", "bodywear"),
("chicken", "bill"),
("chicken", "chin"),
("llama", "chest_right"),
("llama", "chest_left"),
("turtle", "body2"),
("ravager", "jaw"),
("bee", "body"),
("bee", "torso"),
("warden", "body"),
("warden", "torso"),
("snow_golem", "body"),
("snow_golem", "body_bottom"),
("snow_golem", "right_hand"),
("snow_golem", "left_hand"),
("slime", "body"),
("magma_cube", "core"),
("magma_cube", "segment1"),
("blaze", "stick1"),
("ghast", "tentacle1"),
("squid", "tentacle1"),
("silverfish", "body1"),
("silverfish", "wing1"),
("endermite", "body1"),
("guardian", "spine1"),
("guardian", "tail1"),
("wither", "body1"),
("wither", "head1"),
("phantom", "left_wing"),
("phantom", "tail2"),
("cat", "tail"),
("ocelot", "tail"),
("bat", "outer_right_wing"),
("cod", "fin_back"),
("cod", "fin_right"),
("cod", "tail"),
("tropical_fish_b", "fin_top"),
("tropical_fish_b", "fin_bottom"),
("puffer_fish_big", "fin_right"),
("puffer_fish_medium", "fin_left"),
("puffer_fish_small", "fin_right"),
("puffer_fish_big", "spikes_front_top"),
("rabbit", "left_foot"),
("rabbit", "right_foot"),
("sniffer", "middle_left_leg"),
("strider", "hair_left_top"),
("horse", "neck"),
("horse", "mouth"),
] {
let key = layer_key(model);
let target = vanilla_part(model, part);
let found = vanilla
.get(key.as_str())
.is_some_and(|tree| super::super::render::find_part(tree, &target).is_some());
if !found {
missing.push(format!("{model} {part}: no part {target} in {key}"));
}
}
assert!(missing.is_empty(), "{missing:#?}");
}
}
+3 -2
View File
@@ -643,8 +643,9 @@ pub fn ridden_step(m: &mut Mob) {
m.ai.zza = forward;
if r.jump > 0.0 {
let mut ev = Vec::new();
super::camel::dash(m, r.jump, &mut ev);
if let Some(rider) = m.rider.as_mut() {
if super::camel::dash(m, r.jump, &mut ev)
&& let Some(rider) = m.rider.as_mut()
{
rider.jump = 0.0;
}
}
+126 -10
View File
@@ -2590,19 +2590,27 @@ impl Goal for EatBlock {
m.position.z.floor() as i32,
);
let here = cx.w.block_name(x, y, z).to_owned();
// EatBlockGoal.tick: both halves of the world change sit behind
// MOB_GRIEFING (`ifeq 89`, `ifeq 182` in the bytecode); `Mob.ate`
// runs either way, so a sheep still sheds its wool and ages up.
if cx.w.has_tag("minecraft:edible_for_sheep", &here) {
cx.events.push(MobEvent::BreakBlock {
pos: [x, y, z],
drop: false,
});
if cx.w.mob_griefing {
cx.events.push(MobEvent::BreakBlock {
pos: [x, y, z],
drop: false,
});
}
super::ai::ate(m);
} else if cx.w.block_name(x, y - 1, z) == "minecraft:grass_block" {
cx.events.push(MobEvent::SetBlockName {
pos: [x, y - 1, z],
name: "minecraft:dirt",
effect: true,
flags: horizon_world::level::flags::UPDATE_CLIENTS,
});
// The particles and the dirt `setBlock` are the same gate.
if cx.w.mob_griefing {
cx.events.push(MobEvent::SetBlockName {
pos: [x, y - 1, z],
name: "minecraft:dirt",
effect: true,
flags: horizon_world::level::flags::UPDATE_CLIENTS,
});
}
super::ai::ate(m);
}
}
@@ -2821,6 +2829,114 @@ impl MoveToBlock {
}
}
#[cfg(test)]
mod eat_block_tests {
use super::*;
use crate::mobs::world::with_built_test_world_rules;
/// A sheep on a wide grass shelf, ticked long enough for its `EatBlockGoal`
/// (1 in 1000 a tick) to fire several times: every event it pushed, with
/// the `mob_griefing` game rule at `griefing`. `tufts` puts short grass at
/// the sheep's own feet, so the goal takes the `destroyBlock` half; without
/// it the sheep stands on the grass and takes the grass-to-dirt half.
fn sheep_events(griefing: bool, tufts: bool) -> Option<Vec<MobEvent>> {
let mut out = None;
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
let grass = registry.default_state("minecraft:grass_block").unwrap();
let tuft = registry.default_state("minecraft:short_grass").unwrap();
for cx in -1..1 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=8 {
for z in -8..=8 {
world.set(x, 63, z, grass).unwrap();
if tufts {
world.set(x, 64, z, tuft).unwrap();
}
}
}
};
with_built_test_world_rules(&[], griefing, build, |w, types| {
let kind = Arc::new(types.types["minecraft:sheep"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut random = LegacyRandom::new(0);
let mut events = Vec::new();
for _ in 0..6000 {
crate::mobs::ai::tick(&mut m, w, &mut random, &mut events);
}
out = Some(events);
});
out
}
/// `EatBlockGoal.tick` puts the `levelEvent` + `setBlock` behind
/// `MOB_GRIEFING` and leaves `Mob.ate` outside it, so with the rule off the
/// goal still runs (`EntityEvent` 10) and still shears the coat, and the
/// grass stays.
#[test]
fn the_eat_goal_turns_grass_to_dirt_only_with_mob_griefing() {
let Some(on) = sheep_events(true, false) else {
return;
};
let Some(off) = sheep_events(false, false) else {
return;
};
let dirt = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::SetBlockName { name, .. } if *name == "minecraft:dirt"))
.count()
};
let ate = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. }))
.count()
};
assert!(dirt(&on) > 0, "mob_griefing on: {}", dirt(&on));
assert_eq!(dirt(&off), 0, "mob_griefing off must leave the grass alone");
assert!(ate(&off) > 0, "the goal still runs with the rule off");
}
/// The edible half of the same gate (`ifeq 89` skips
/// `Level.destroyBlock(pos, false)`): short grass at the sheep's feet
/// disappears with the rule on and stays with it off, while the goal eats
/// either way.
#[test]
fn the_eat_goal_breaks_short_grass_only_with_mob_griefing() {
let Some(on) = sheep_events(true, true) else {
return;
};
let Some(off) = sheep_events(false, true) else {
return;
};
let bitten = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::BreakBlock { drop: false, .. }))
.count()
};
let ate = |events: &[MobEvent]| {
events
.iter()
.filter(|e| matches!(e, MobEvent::EntityEvent { event: 10, .. }))
.count()
};
assert!(bitten(&on) > 0, "mob_griefing on: {}", bitten(&on));
assert_eq!(
bitten(&off),
0,
"mob_griefing off must leave the tufts alone"
);
assert!(ate(&off) > 0, "the goal still runs with the rule off");
}
}
#[cfg(test)]
mod built_by_player_tests {
use super::*;
+13
View File
@@ -64,6 +64,19 @@ pub fn register(m: &mut Mob) {
m.ai.targets.add(1, HurtByTarget::new().alert());
m.ai.targets.add(2, NearestTarget::player(true));
}
// Endermite.registerGoals: the silverfish's list without the stone
// tricks, at vanilla's priorities (stroll 1.0, alert others on hurt).
S::Endermite => {
let g = &mut m.ai.goals;
g.add(1, Float);
g.add(1, ClimbOnTopOfPowderSnow);
g.add(2, MeleeAttack::new(1.0, false));
g.add(3, Stroll::avoiding_water(1.0));
g.add(7, LookAtPlayer::new(8.0));
g.add(8, RandomLookAround::default());
m.ai.targets.add(1, HurtByTarget::new().alert());
m.ai.targets.add(2, NearestTarget::player(true));
}
_ => {}
}
}
+39 -4
View File
@@ -388,14 +388,18 @@ fn cast_fangs(m: &mut Mob, cx: &mut Cx) {
}
}
}
/// `createSpellEntity`: the first sturdy floor going down from `max_y`,
/// standing on the top of any block in the way.
/// `createSpellEntity`: the first block below `max_y` whose up face is
/// sturdy, standing on the top of whatever collision shape is in the way.
///
/// The floor test is `BlockState.isFaceSturdy(.., UP)`, not a full cube: a
/// soul sand or mud block is 14/16 high and still supports a fang, where
/// requiring a full collision shape drops the fang into the block.
fn fang_floor(w: &super::MobWorld, x: f64, z: f64, min_y: f64, max_y: f64) -> Option<f64> {
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
let mut y = max_y.floor() as i32;
while y >= min_y.floor() as i32 - 1 {
let below = w.facts.get(w.world.block(bx, y - 1, bz));
if below.full_collision {
let below = w.world.block(bx, y - 1, bz);
if w.data.state(below).flags & super::data::STURDY_TOP != 0 {
let here = w.world.block(bx, y, bz);
let top = if w.facts.is_air(here) {
0.0
@@ -932,4 +936,35 @@ mod tests {
.is_none_or(|d| d.charged.is_empty())
);
}
/// `EvokerAttackSpellGoal.createSpellEntity` asks the block below for a
/// sturdy up face, not for a full cube. Soul sand is 14/16 high and still
/// supports a fang, which a full-collision test drops into the block.
#[test]
fn a_fang_stands_on_a_soul_sand_floor() {
let build = |world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry| {
let stone = registry.default_state("minecraft:stone").unwrap();
let soul_sand = registry.default_state("minecraft:soul_sand").unwrap();
for cx in -1..1 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -4..4 {
for z in -4..4 {
world.set(x, 63, z, stone).unwrap();
}
}
world.set(0, 64, 0, soul_sand).unwrap();
};
let ran = crate::mobs::world::with_built_test_world(&[], build, |w, _| {
// An evoker standing on the soul sand has its feet at 64.875, so
// the cast asks for a floor from 65 down: the soul sand at 64 is
// the first sturdy block and the fang sits at 65.
assert_eq!(fang_floor(w, 0.5, 0.5, 64.0, 65.875), Some(65.0));
});
let _ = ran;
}
}
+35
View File
@@ -290,6 +290,11 @@ pub fn can_be_leashed(m: &Mob) -> bool {
| S::Pufferfish
| S::Tadpole => false,
S::Wolf => !super::tame::angry(m),
// Nautilus.canBeLeashed and ZombieNautilus.canBeLeashed: never while
// it is aggravated. The zombie also refuses while a mob is riding it
// (`isMobControlled`), which a mob alone cannot see.
// shortcut: isMobControlled, when a mob can ride a zombie nautilus.
S::Nautilus | S::ZombieNautilus => !super::nautilus::aggravated(m),
_ => !m.hostile() && !m.removed,
}
}
@@ -574,6 +579,36 @@ mod tests {
assert!(near(p[2], [lr, y, -fb + f]));
assert!(near(p[3], [lr, y, fb + f]));
}
/// `Nautilus.canBeLeashed` and `ZombieNautilus.canBeLeashed`: an
/// aggravated nautilus, one holding an `ANGRY_AT` or an `ATTACK_TARGET`
/// memory, cannot be put on a lead.
#[test]
fn an_aggravated_nautilus_cannot_be_leashed() {
let ran = crate::mobs::world::with_test_world(&[], |_, types| {
let new = |kind: &str| {
Mob::new(
1,
std::sync::Arc::new(types.types[kind].clone()),
horizon_core::geometry::Vec3::ZERO,
0.0,
)
};
let calm = new("minecraft:nautilus");
assert!(can_be_leashed(&calm), "a calm nautilus");
let mut angry = new("minecraft:nautilus");
angry.extra.anger = 400;
assert!(!can_be_leashed(&angry), "ANGRY_AT");
let mut charging = new("minecraft:zombie_nautilus");
charging.ai.target = Some(crate::mobs::Who::Player);
assert!(!can_be_leashed(&charging), "ATTACK_TARGET");
assert!(
can_be_leashed(&new("minecraft:zombie_nautilus")),
"a calm zombie nautilus"
);
});
let _ = ran;
}
#[test]
fn only_horses_camels_and_sniffers_support_a_quad_lead() {
assert!(supports_quad_leash(Species::Horse));
@@ -350,6 +350,12 @@ pub fn client_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
fn in_water_at(w: &MobWorld, p: Vec3) -> bool {
w.fluid(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32) == Fluid::Water
}
/// `AbstractNautilus.isAggravated`: the brain holds an `ANGRY_AT` or an
/// `ATTACK_TARGET` memory. Horizon keeps those as the anger timer (whose
/// target is `last_hurt_by`) and the current attack target.
pub fn aggravated(m: &Mob) -> bool {
m.extra.anger > 0 || m.ai.target.is_some()
}
/// `NautilusAi.findNearestValidAttackTarget`: whoever it is angry at, else
/// now and then (a coin flip every 2400 to 3600 ticks) the nearest pufferfish.
fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
+32 -3
View File
@@ -122,6 +122,11 @@ impl Navigation {
)
}
}
/// `LivingEntity.getComfortableFallDistance`: the drop an entity accepts on
/// top of what it was asked for.
fn comfortable_fall_distance(f: f32) -> i32 {
(f + 3.0).floor() as i32
}
impl Mob {
pub fn path_mob(&self) -> PathMob {
PathMob {
@@ -140,14 +145,19 @@ impl Mob {
stands_on_lava: self.kind.flags.lava_walker,
}
}
/// Mob.getMaxFallDistance (normal difficulty).
/// `Mob.getMaxFallDistance` (normal difficulty), with `Creeper`'s
/// override: a creeper that has a target falls by its own health, so a
/// full-health one walks off a 22-block drop instead of 3.
pub fn max_fall_distance(&self) -> i32 {
if self.ai.target.is_none() {
return 3;
return comfortable_fall_distance(0.0);
}
if self.species() == super::Species::Creeper {
return comfortable_fall_distance(self.health - 1.0);
}
let mut sacrifice = (self.health - self.attrs.max_health * 0.33) as i32;
sacrifice -= 4;
sacrifice.max(0) + 3
comfortable_fall_distance(sacrifice.max(0) as f32)
}
pub fn malus(&self, t: PathType) -> f32 {
self.ai.malus[t as usize].unwrap_or_else(|| t.malus())
@@ -744,4 +754,23 @@ mod tests {
}
));
}
/// `Creeper.getMaxFallDistance` replaces the shared
/// `health - a third` formula with its own `health - 1`: a creeper
/// chasing something drops 22 blocks at full health and 6 at four,
/// where the shared formula would have given 3.
#[test]
fn a_creeper_falls_by_its_own_health_while_it_has_a_target() {
// Skipped without the extracted vanilla data (CI's verify has none).
crate::mobs::world::with_test_world(&[], |_, types| {
let kind = std::sync::Arc::new(types.types["minecraft:creeper"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert_eq!(m.max_fall_distance(), 3, "no target");
m.ai.target = Some(crate::mobs::Who::Player);
m.health = 20.0;
assert_eq!(m.max_fall_distance(), 22, "full health, has a target");
m.health = 4.0;
assert_eq!(m.max_fall_distance(), 6, "hurt, has a target");
});
}
}
+59
View File
@@ -1703,6 +1703,21 @@ pub fn ai_step(
if !w.nether && !m.extra.flag2 && m.alive() {
m.extra.counter2 += 1;
if m.extra.counter2 > CONVERSION {
// `Piglin.finishConversion` runs `PiglinAi.cancelAdmiring` first:
// the item the piglin is admiring leaves the off hand and lands
// where it stands, so the zombified piglin never carries it.
if m.species() == Species::Piglin {
let admiring = (m.extra.off_hand.is_some() && m.extra.using_item > 0)
|| m.extra.piglin.seen_admire > 0;
if admiring && let Some(item) = m.extra.off_hand.take() {
events.push(MobEvent::DropItem {
item,
count: 1,
at: [m.position.x, m.position.y, m.position.z],
});
m.extra.using_item = 0;
}
}
// `Hoglin.finishConversion` is `ConversionParams.single(this, true,
// false)`, `AbstractPiglin.finishConversion` `(this, true, true)`;
// both leave the new mob nauseous (the game adds it).
@@ -2172,3 +2187,47 @@ mod crossbow_tests {
assert_eq!(f.seen_admire, 40);
}
}
#[cfg(test)]
mod conversion_tests {
use super::*;
use crate::mobs::world::with_test_world;
/// `Piglin.finishConversion` cancels the admire before it converts: the
/// item in the off hand is dropped at the piglin's feet and the zombified
/// piglin does not carry it, where Horizon used to hand it over.
#[test]
fn a_converted_piglin_drops_the_item_it_was_admiring() {
let ran = with_test_world(&[], |w, types| {
let mut m = Mob::new(
1,
std::sync::Arc::new(types.types["minecraft:piglin"].clone()),
Vec3::new(0.5, 64.0, 0.5),
0.0,
);
m.extra.off_hand = Some("minecraft:gold_ingot".into());
m.extra.using_item = 60;
m.extra.counter2 = CONVERSION + 1;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
ai_step(&mut m, w, &mut lr, &mut events);
assert!(
events.iter().any(|e| matches!(
e,
MobEvent::DropItem { item, count, .. }
if &**item == "minecraft:gold_ingot" && *count == 1
)),
"the admired item is dropped"
);
let from = events
.iter()
.find_map(|e| match e {
MobEvent::Convert { from, .. } => Some(from),
_ => None,
})
.expect("the piglin converts");
assert_eq!(from.extra.off_hand, None, "the new mob carries nothing");
});
let _ = ran;
}
}
+12 -9
View File
@@ -86,17 +86,20 @@ impl State {
}
}
/// `Raider.class`: the species a goal that takes `Raider.class` filters out
/// (`HurtByTargetGoal(this, Raider.class)`). A vex is not a raider.
pub const RAIDERS: [Species; 6] = [
Species::Pillager,
Species::Vindicator,
Species::Evoker,
Species::Illusioner,
Species::Witch,
Species::Ravager,
];
/// The raider species (`EntityTypeTags.RAIDERS`).
pub fn is_raider(s: Species) -> bool {
matches!(
s,
Species::Pillager
| Species::Vindicator
| Species::Evoker
| Species::Illusioner
| Species::Witch
| Species::Ravager
)
RAIDERS.contains(&s)
}
/// `canBeLeader`: witches and ravagers never lead.
pub fn can_be_leader(s: Species) -> bool {
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff. Show more