mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into fix-6264
# Conflicts: # crates/horizon-client-render/src/sprites.rs # crates/horizon-client/src/lib.rs
This commit is contained in:
commit
17e6fd55bf
228 files changed
+7870
-909
No files matched your search
@@ -150,7 +150,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: pack load bench
|
||||
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
|
||||
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
|
||||
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
|
||||
# start a workflow its main lacks), against horizon-rust/horizon's main.
|
||||
on:
|
||||
push:
|
||||
branches: ['pack-bench/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base:
|
||||
description: Ref to compare against
|
||||
default: main
|
||||
scale:
|
||||
description: How many times the default pack sizes
|
||||
default: '1'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
CARGO_TERM_COLOR: never
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust and the ALSA and udev headers
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: pack-bench
|
||||
- name: Bench the branch
|
||||
env:
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
|
||||
- name: Bench the base, with the branch's bench
|
||||
env:
|
||||
BASE: ${{ inputs.base || 'main' }}
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
|
||||
git worktree add ../base FETCH_HEAD
|
||||
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
|
||||
# A bench row that times a function the base lacks keeps it from building there: then
|
||||
# the branch's own rows compare the two ways.
|
||||
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
|
||||
--example pack_load_bench -- --scale "$SCALE") > base.md \
|
||||
|| echo "The bench does not build on the base." > base.md
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
|
||||
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -40,7 +40,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
|
||||
| Crate | Owns |
|
||||
|---|---|
|
||||
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
|
||||
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
|
||||
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
|
||||
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
@@ -85,8 +86,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -103,8 +104,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
|
||||
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
|
||||
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
|
||||
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
|
||||
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
|
||||
excludes `game.rs` and `game/`, so gameplay edits do not rebuild or select those probes.
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
@@ -129,8 +133,11 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
## horizon-client: where things live
|
||||
|
||||
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
|
||||
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
|
||||
`Game`; bin code reaches the lib as `horizon_client::<module>`.
|
||||
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
|
||||
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
|
||||
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
|
||||
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
|
||||
state stays separate from the library tests. This compiles gameplay twice for executable tests.
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
@@ -152,7 +159,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
@@ -161,7 +168,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
|
||||
Generated
+57
@@ -1314,6 +1314,7 @@ dependencies = [
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-lib",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
@@ -1374,6 +1375,62 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-lib"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"naga",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"png",
|
||||
"pollster",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"unicode-bidi",
|
||||
"ureq",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/horizon-hdr",
|
||||
"crates/horizon-world",
|
||||
"crates/horizon-client",
|
||||
"crates/horizon-client-lib",
|
||||
"crates/horizon-client-core",
|
||||
"crates/horizon-client-input",
|
||||
"crates/horizon-client-render",
|
||||
|
||||
@@ -202,10 +202,8 @@ fn os_version() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
[package]
|
||||
name = "horizon-client-lib"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
path = "../horizon-client/src/client_lib.rs"
|
||||
|
||||
[dependencies]
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
glam = "0.30"
|
||||
png = "0.18"
|
||||
sha2 = "0.10.9"
|
||||
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
|
||||
sha1 = "0.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
|
||||
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
miniz_oxide = "0.8"
|
||||
ab_glyph = "0.2.32"
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
|
||||
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
|
||||
# `above_menu_bar`); all already in the tree through winit and wgpu.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
|
||||
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
|
||||
|
||||
[[example]]
|
||||
name = "chunk_load_bench"
|
||||
path = "../horizon-client/examples/chunk_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gameplay_decode_profile"
|
||||
path = "../horizon-client/examples/gameplay_decode_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "json_load_bench"
|
||||
path = "../horizon-client/examples/json_load_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "mod_port_probe"
|
||||
path = "../horizon-client/examples/mod_port_probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_library_bench"
|
||||
path = "../horizon-client/examples/model_library_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "model_states_bench"
|
||||
path = "../horizon-client/examples/model_states_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "old_world_fixture"
|
||||
path = "../horizon-client/examples/old_world_fixture.rs"
|
||||
|
||||
[[example]]
|
||||
name = "portal_scan_bench"
|
||||
path = "../horizon-client/examples/portal_scan_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "random_tick_eligibility_bench"
|
||||
path = "../horizon-client/examples/random_tick_eligibility_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "resource_memory"
|
||||
path = "../horizon-client/examples/resource_memory.rs"
|
||||
|
||||
[[example]]
|
||||
name = "save_batch_bench"
|
||||
path = "../horizon-client/examples/save_batch_bench.rs"
|
||||
|
||||
[[example]]
|
||||
name = "shared_json_startup_profile"
|
||||
path = "../horizon-client/examples/shared_json_startup_profile.rs"
|
||||
|
||||
[[example]]
|
||||
name = "sprite_cache_parity"
|
||||
path = "../horizon-client/examples/sprite_cache_parity.rs"
|
||||
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "../horizon-client/examples/probe/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_api"
|
||||
path = "../horizon-client/tests/custom_component_api.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_unregistered"
|
||||
path = "../horizon-client/tests/custom_component_unregistered.rs"
|
||||
|
||||
[[test]]
|
||||
name = "custom_component_wire"
|
||||
path = "../horizon-client/tests/custom_component_wire.rs"
|
||||
|
||||
[[test]]
|
||||
name = "forced_chunks"
|
||||
path = "../horizon-client/tests/forced_chunks.rs"
|
||||
|
||||
[[test]]
|
||||
name = "generation_saves"
|
||||
path = "../horizon-client/tests/generation_saves.rs"
|
||||
|
||||
[[test]]
|
||||
name = "lan_bot"
|
||||
path = "../horizon-client/tests/lan_bot.rs"
|
||||
|
||||
[[test]]
|
||||
name = "streaming_meshes"
|
||||
path = "../horizon-client/tests/streaming_meshes.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tall_column"
|
||||
path = "../horizon-client/tests/tall_column.rs"
|
||||
|
||||
[[test]]
|
||||
name = "wide_states"
|
||||
path = "../horizon-client/tests/wide_states.rs"
|
||||
@@ -0,0 +1,436 @@
|
||||
//! How long a big resource pack takes to load and reload, on generated files only (no game
|
||||
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
|
||||
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
|
||||
//! is printed as a Markdown table.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
|
||||
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
|
||||
use horizon_client_render::{
|
||||
hud::HudAtlas,
|
||||
model_library::ParsedModels,
|
||||
packs::{self, Pack},
|
||||
sprites::Sprites,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// A small deterministic generator: the same files on every run and every branch.
|
||||
struct Noise(u64);
|
||||
impl Noise {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
|
||||
/// about as well as real textures do.
|
||||
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
|
||||
let mut colour = [0u8; 4];
|
||||
for i in 0..side * side * frames {
|
||||
if i % 3 == 0 {
|
||||
let n = noise.next();
|
||||
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
|
||||
if alpha && n >> 40 & 3 == 0 {
|
||||
colour[3] = 0;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&colour);
|
||||
}
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
|
||||
let n = noise.next() % 4;
|
||||
format!(
|
||||
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
|
||||
h = 4 + n * 4
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, bytes: &[u8]) {
|
||||
let path = root.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
|
||||
/// entries.
|
||||
#[derive(Default)]
|
||||
struct ZipOut {
|
||||
out: Vec<u8>,
|
||||
central: Vec<u8>,
|
||||
count: u16,
|
||||
}
|
||||
impl ZipOut {
|
||||
fn put(&mut self, name: &str, body: &[u8]) {
|
||||
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
|
||||
let offset = u32::try_from(self.out.len()).unwrap();
|
||||
let sizes = [data.len() as u32, body.len() as u32];
|
||||
self.out.extend(0x0403_4b50u32.to_le_bytes());
|
||||
self.out.extend([20, 0, 0, 0, 8, 0]);
|
||||
self.out.extend([0; 8]);
|
||||
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
|
||||
self.out.extend((name.len() as u16).to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
self.out.extend(name.as_bytes());
|
||||
self.out.extend(&data);
|
||||
self.central.extend(0x0201_4b50u32.to_le_bytes());
|
||||
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
|
||||
self.central.extend([0; 8]);
|
||||
sizes
|
||||
.iter()
|
||||
.for_each(|s| self.central.extend(s.to_le_bytes()));
|
||||
self.central.extend((name.len() as u16).to_le_bytes());
|
||||
self.central.extend([0; 12]);
|
||||
self.central.extend(offset.to_le_bytes());
|
||||
self.central.extend(name.as_bytes());
|
||||
self.count = self.count.checked_add(1).expect("too many zip entries");
|
||||
}
|
||||
fn finish(mut self, path: &Path) {
|
||||
let start = self.out.len() as u32;
|
||||
self.out.extend(&self.central);
|
||||
self.out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
self.out.extend([0; 4]);
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend((self.central.len() as u32).to_le_bytes());
|
||||
self.out.extend(start.to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
fs::write(path, self.out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
|
||||
|
||||
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
|
||||
/// files (lang, sounds, fonts, atlases) every reload merges.
|
||||
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
|
||||
let mut blocks = Vec::new();
|
||||
for i in 0..1100 * scale {
|
||||
let name = format!("block/b{i}");
|
||||
let frames = if i % 50 == 0 { 8 } else { 1 };
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png"),
|
||||
&png(noise, 16, frames, i % 7 == 0),
|
||||
);
|
||||
if frames > 1 {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
|
||||
br#"{"animation":{"frametime":2}}"#,
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/block/b{i}.json"),
|
||||
&model(noise, "minecraft", &name),
|
||||
);
|
||||
blocks.push(format!("minecraft:{name}"));
|
||||
}
|
||||
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
|
||||
for i in 0..count * scale {
|
||||
let sub = if folder == "gui" { "sprites/" } else { "" };
|
||||
write(
|
||||
root,
|
||||
&format!(
|
||||
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
|
||||
i % 20
|
||||
),
|
||||
&png(noise, side, 1, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..1500 * scale {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/item/i{i}.json"),
|
||||
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
|
||||
);
|
||||
}
|
||||
let lang: String = (0..4000)
|
||||
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/sounds.json",
|
||||
br#"{"a":{"sounds":["a"]}}"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/atlases/blocks.json",
|
||||
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
|
||||
);
|
||||
write(root, "pack.mcmeta", META);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
|
||||
/// with its own items' textures and models, its sounds and its lang files.
|
||||
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for n in 0..190 {
|
||||
let ns = format!("plugin{n}");
|
||||
for i in 0..50 * scale {
|
||||
let texture = format!("item/{}/t{i}", i % 5);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/textures/{texture}.png"),
|
||||
&png(noise, 32, 1, true),
|
||||
);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
|
||||
&model(noise, &ns, &texture),
|
||||
);
|
||||
if i % 10 == 0 {
|
||||
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
|
||||
}
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/{ns}/lang/en_us.json"),
|
||||
br#"{"item.custom":"Custom"}"#,
|
||||
);
|
||||
}
|
||||
let lang: String = (0..2000)
|
||||
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
zip.put(
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
|
||||
/// tiles beside them: what the big OptiFine packs look like on disk.
|
||||
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for i in 0..1100 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
&png(noise, 64, 1, i % 7 == 0),
|
||||
);
|
||||
if i % 4 == 0 {
|
||||
for tile in 0..12 {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
|
||||
&png(noise, 64, 1, false),
|
||||
);
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
|
||||
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..750 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
|
||||
&png(noise, 64, 1, true),
|
||||
);
|
||||
}
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
fn median(mut ms: Vec<f64>) -> f64 {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
ms[ms.len() / 2]
|
||||
}
|
||||
|
||||
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
|
||||
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
|
||||
median(
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
before();
|
||||
let started = Instant::now();
|
||||
f();
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
// Read the way `packs::folder` reads a `resourcepacks/` zip.
|
||||
let dir = path.parent().unwrap().join(format!("{id}-dir"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let at = dir.join(path.file_name().unwrap());
|
||||
fs::rename(&path, &at).unwrap();
|
||||
packs::folder(&dir, packs::RESOURCE_FORMAT)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("{id} is not a pack"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
.position(|a| a == "--scale")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map_or(1, |s| s.parse().unwrap());
|
||||
let runs = 3;
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
fs::create_dir_all(&scratch).unwrap();
|
||||
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
|
||||
let vanilla_root = scratch.join("vanilla");
|
||||
let blocks = vanilla(&vanilla_root, scale, &mut noise);
|
||||
server_zip(&scratch.join("server.zip"), scale, &mut noise);
|
||||
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
|
||||
let server = zip_pack("server", scratch.join("server.zip"));
|
||||
let hd = zip_pack("hd", scratch.join("hd.zip"));
|
||||
let cache = scratch.join("cache");
|
||||
let both = [hd.clone(), server.clone()];
|
||||
|
||||
let mut rows: Vec<(String, f64)> = Vec::new();
|
||||
let mut row = |name: &str, ms: f64| {
|
||||
eprintln!("{name}: {ms:.1} ms");
|
||||
rows.push((name.to_owned(), ms));
|
||||
};
|
||||
let fresh = || {
|
||||
let _ = fs::remove_dir_all(&cache);
|
||||
};
|
||||
row(
|
||||
"overlay: build, both packs (cold)",
|
||||
time(runs, fresh, || {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
}),
|
||||
);
|
||||
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
row(
|
||||
"overlay: reuse, both packs (cache hit)",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
// The pack screen: one pack turned off, then on again.
|
||||
row(
|
||||
"overlay: toggle the server pack off (new selection)",
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
let only =
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
fs::remove_dir_all(&only).unwrap();
|
||||
},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"models: parse every model",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
std::hint::black_box(ParsedModels::load(&root));
|
||||
},
|
||||
),
|
||||
);
|
||||
let sprite_cache = root.join("horizon-sprites.cache");
|
||||
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
|
||||
let tiles: Vec<String> = (0..1100 * scale)
|
||||
.step_by(4)
|
||||
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
|
||||
.collect();
|
||||
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
if cold {
|
||||
let _ = fs::remove_file(&sprite_cache);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
let mut sprites = Sprites::with_disk_cache(&root);
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str));
|
||||
}
|
||||
for name in names {
|
||||
if files {
|
||||
sprites.insert_file(&root, name).unwrap();
|
||||
} else {
|
||||
sprites.insert(&root, name).unwrap();
|
||||
}
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
std::hint::black_box(&sprites.pixels);
|
||||
},
|
||||
)
|
||||
};
|
||||
row(
|
||||
"block sprites: one by one (no cache)",
|
||||
sprites(true, false, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: prefetched (no cache)",
|
||||
sprites(true, true, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: from the disk cache",
|
||||
sprites(false, false, &blocks, false),
|
||||
);
|
||||
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
|
||||
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
|
||||
row(
|
||||
"GUI atlas: decode and stitch, both packs",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
|
||||
eprintln!("GUI atlas failed: {e}");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"GUI atlas: vanilla alone",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
let _ = HudAtlas::build(&vanilla_root);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
|
||||
println!("|---|---:|");
|
||||
for (name, ms) in &rows {
|
||||
println!("| {name} | {ms:.0} |");
|
||||
}
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -135,6 +135,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
|
||||
|
||||
@@ -106,6 +106,17 @@ impl ConnectedTextures {
|
||||
if rules.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The tiles decode side by side; the loop below inserts them in rule order, under the
|
||||
// same names.
|
||||
let tiles: Vec<String> = rules
|
||||
.iter()
|
||||
.flat_map(|rule| &rule.tiles)
|
||||
.filter_map(|tile| match tile {
|
||||
Tile::Texture(path) => Some(sprite_name(path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
sprites.prefetch(root, tiles.iter().map(String::as_str));
|
||||
let loaded = rules
|
||||
.into_iter()
|
||||
.filter_map(|rule| {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
ModEnergyBuffer, ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -227,12 +227,7 @@ fn operating_system() -> String {
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| {
|
||||
t.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_owned())
|
||||
})
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
|
||||
@@ -575,7 +575,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -584,7 +584,7 @@ pub struct DataStore {
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" => kind,
|
||||
| "villager_trade" | "dialog" | "data_maps" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -623,6 +623,7 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
@@ -882,6 +883,15 @@ pub fn worldgen_overrides(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
|
||||
assert_eq!(
|
||||
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
|
||||
Some("data_maps/mymod:item/fuel")
|
||||
);
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -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", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
|
||||
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
|
||||
//! target, copied to one of a few small buffers and read back frames later without waiting for
|
||||
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
const SLOTS: usize = 3;
|
||||
const FREE: u8 = 0;
|
||||
/// Copied this frame: mapped once the frame is submitted.
|
||||
const COPIED: u8 = 1;
|
||||
const PENDING: u8 = 2;
|
||||
const MAPPED: u8 = 3;
|
||||
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
|
||||
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
|
||||
const SHADER: &str = "
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let centre = vec2<i32>(textureDimensions(depth) / 2u);
|
||||
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
pub struct CenterDepth {
|
||||
slots: Vec<Slot>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// The 1x1 target the centre texel is loaded into.
|
||||
texel: wgpu::Texture,
|
||||
texel_view: wgpu::TextureView,
|
||||
/// The bind group of the depth texture it reads, made again when that texture changes.
|
||||
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
|
||||
/// The depth last read back: GL window depth, 0..1.
|
||||
latest: Option<f32>,
|
||||
}
|
||||
|
||||
impl CenterDepth {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let slots = (0..SLOTS)
|
||||
.map(|_| Slot {
|
||||
buffer: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: u64::from(ROW),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let format = wgpu::TextureFormat::R32Float;
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let texel = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let texel_view = texel.create_view(&Default::default());
|
||||
Self {
|
||||
slots,
|
||||
pipeline,
|
||||
layout,
|
||||
texel,
|
||||
texel_view,
|
||||
group: None,
|
||||
latest: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
|
||||
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
|
||||
pub fn copy(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::Texture,
|
||||
) {
|
||||
let Some(slot) = self
|
||||
.slots
|
||||
.iter()
|
||||
.find(|s| s.state.load(Ordering::Acquire) == FREE)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
|
||||
let view = depth.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: &self.layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
}],
|
||||
});
|
||||
self.group = Some((depth.clone(), group));
|
||||
}
|
||||
let Some((_, group)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.texel_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
self.texel.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &slot.buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(ROW),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
self.texel.size(),
|
||||
);
|
||||
slot.state.store(COPIED, Ordering::Release);
|
||||
}
|
||||
|
||||
/// After the frame's submission: start reading back what it copied.
|
||||
pub fn submitted(&mut self) {
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != COPIED {
|
||||
continue;
|
||||
}
|
||||
slot.state.store(PENDING, Ordering::Release);
|
||||
let state = slot.state.clone();
|
||||
slot.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
let next = if result.is_ok() { MAPPED } else { FREE };
|
||||
state.store(next, Ordering::Release);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest depth read back so far, `None` before the first arrives.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
let depth = slot
|
||||
.buffer
|
||||
.slice(..4)
|
||||
.get_mapped_range()
|
||||
.ok()
|
||||
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
if let Some(depth) = depth.filter(|d| d.is_finite()) {
|
||||
self.latest = Some(depth.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
self.latest
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
|
||||
/// later, without the frame waiting for it.
|
||||
#[test]
|
||||
fn the_centre_depth_comes_back() {
|
||||
let Some((device, queue)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 9,
|
||||
height: 7,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = depth.create_view(&Default::default());
|
||||
let mut center = CenterDepth::new(&device);
|
||||
assert_eq!(center.collect(&device), None);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.375),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
// Its slot is free again: every slot can be used once more.
|
||||
for _ in 0..SLOTS {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
}
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
}
|
||||
}
|
||||
@@ -512,6 +512,8 @@ pub struct PackPost {
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
center_depth: Option<super::pack_center_depth::CenterDepth>,
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
@@ -2242,6 +2244,7 @@ impl PackPost {
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
lightmap: world.lightmap,
|
||||
@@ -2740,6 +2743,13 @@ impl PackPost {
|
||||
let rp = &mut open.as_mut().expect("open").0;
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, group, &[]);
|
||||
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
|
||||
// sun's fallback) binds them, as the other passes do.
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
sky.geometry.bind(rp, part.textured);
|
||||
open.as_mut().expect("open").1 = i;
|
||||
}
|
||||
@@ -2896,6 +2906,13 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
|
||||
/// After the frame's submission: the centre depth it copied starts its way back.
|
||||
pub fn submitted(&mut self) {
|
||||
if let Some(center) = &mut self.center_depth {
|
||||
center.submitted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pack draws entities or block entities itself.
|
||||
pub fn has_entities(&self) -> bool {
|
||||
self.entities.is_some()
|
||||
@@ -3613,6 +3630,10 @@ impl PackPost {
|
||||
if let Some(t) = &mut self.timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
self.uniforms.center_depth_raw = self
|
||||
.center_depth
|
||||
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
|
||||
.collect(device);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
// Until the camera's biome is known, `smooth` waits for the world's values.
|
||||
if !inputs.biome_known {
|
||||
@@ -3851,6 +3872,10 @@ impl PackPost {
|
||||
self.draw_translucent(device, queue, encoder, size, terrain);
|
||||
}
|
||||
let targets = self.targets.as_ref().expect("resized");
|
||||
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
|
||||
if let Some(center) = &mut self.center_depth {
|
||||
center.copy(device, encoder, &targets.depth_texture);
|
||||
}
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
.as_ref()
|
||||
|
||||
@@ -51,6 +51,12 @@ pub struct PackInputs {
|
||||
pub cloud_height: f32,
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
/// The player's state, for Iris's status uniforms.
|
||||
pub status: PlayerStatus,
|
||||
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
|
||||
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
|
||||
pub end_flash: f32,
|
||||
pub end_flash_direction: Vec3,
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
|
||||
@@ -64,6 +70,35 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
|
||||
/// `isSpectator`, `is_burning`...).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PlayerStatus {
|
||||
/// Survival or adventure: the vitals are -1 in creative and spectator.
|
||||
pub survival: bool,
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
pub food: f32,
|
||||
/// Air supply ticks, of 300.
|
||||
pub air: f32,
|
||||
/// Armor points (the `armor` attribute).
|
||||
pub armor: f32,
|
||||
pub spectator: bool,
|
||||
pub riding: bool,
|
||||
pub feet_in_water: bool,
|
||||
pub swimming: bool,
|
||||
pub burning: bool,
|
||||
pub hurt: bool,
|
||||
pub invisible: bool,
|
||||
pub sneaking: bool,
|
||||
pub sprinting: bool,
|
||||
}
|
||||
|
||||
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
|
||||
const MAX_HUNGER: f32 = 20.0;
|
||||
const MAX_AIR: f32 = 300.0;
|
||||
const MAX_ARMOR: f32 = 50.0;
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Value {
|
||||
@@ -198,6 +233,26 @@ pub const NAMES: &[&str] = &[
|
||||
"playerLookVector",
|
||||
"playerBodyVector",
|
||||
"firstPersonCamera",
|
||||
"currentPlayerHealth",
|
||||
"maxPlayerHealth",
|
||||
"currentPlayerHunger",
|
||||
"maxPlayerHunger",
|
||||
"currentPlayerAir",
|
||||
"maxPlayerAir",
|
||||
"currentPlayerArmor",
|
||||
"maxPlayerArmor",
|
||||
"isSpectator",
|
||||
"isRiding",
|
||||
"feetInWater",
|
||||
"inSwimmingAnimation",
|
||||
"is_burning",
|
||||
"is_hurt",
|
||||
"is_invisible",
|
||||
"is_sneaking",
|
||||
"is_sprinting",
|
||||
"endFlashIntensity",
|
||||
"endFlashPosition",
|
||||
"centerDepthSmooth",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
@@ -220,6 +275,9 @@ pub struct FrameUniforms {
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
|
||||
pub center_depth_raw: Option<f32>,
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
}
|
||||
@@ -235,6 +293,8 @@ impl Default for FrameUniforms {
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
center_depth_raw: None,
|
||||
center_depth: None,
|
||||
shadow: Shadow::default(),
|
||||
}
|
||||
}
|
||||
@@ -379,6 +439,15 @@ impl FrameUniforms {
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
|
||||
// read back is taken as it is.
|
||||
if let Some(raw) = self.center_depth_raw {
|
||||
self.center_depth = Some(match self.center_depth {
|
||||
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
|
||||
None => raw,
|
||||
});
|
||||
}
|
||||
let center_depth = self.center_depth.unwrap_or(1.0);
|
||||
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
|
||||
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
|
||||
// of one with the nearer end of the other made fog begin at the camera on clear days.
|
||||
@@ -424,6 +493,8 @@ impl FrameUniforms {
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
|
||||
"centerDepthSmooth" => Value::f(&[center_depth]),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
@@ -483,7 +554,7 @@ impl FrameUniforms {
|
||||
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
|
||||
"cloudHeight" => Value::f(&[inputs.cloud_height]),
|
||||
// renderStage is NONE outside gbuffers draws.
|
||||
_ => Value::i(&[0]),
|
||||
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
|
||||
}
|
||||
};
|
||||
self.values.clear();
|
||||
@@ -529,6 +600,46 @@ impl FrameUniforms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
|
||||
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
|
||||
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
|
||||
let status = inputs.status;
|
||||
let vital = |v: f32| {
|
||||
Value::f(&[if status.survival {
|
||||
v.clamp(0.0, 1.0)
|
||||
} else {
|
||||
-1.0
|
||||
}])
|
||||
};
|
||||
let flag = |b: bool| Value::i(&[i32::from(b)]);
|
||||
Some(match name {
|
||||
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
|
||||
"maxPlayerHealth" => Value::f(&[if status.survival {
|
||||
status.max_health
|
||||
} else {
|
||||
-1.0
|
||||
}]),
|
||||
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
|
||||
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
|
||||
"currentPlayerAir" => vital(status.air / MAX_AIR),
|
||||
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
|
||||
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
|
||||
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
|
||||
"isSpectator" => flag(status.spectator),
|
||||
"isRiding" => flag(status.riding),
|
||||
"feetInWater" => flag(status.feet_in_water),
|
||||
"inSwimmingAnimation" => flag(status.swimming),
|
||||
"is_burning" => flag(status.burning),
|
||||
"is_hurt" => flag(status.hurt),
|
||||
// Spectators count as invisible.
|
||||
"is_invisible" => flag(status.invisible || status.spectator),
|
||||
"is_sneaking" => flag(status.sneaking),
|
||||
"is_sprinting" => flag(status.sprinting),
|
||||
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many components the uniform `name` has (16 for a matrix).
|
||||
fn components(name: &str) -> usize {
|
||||
match name {
|
||||
@@ -547,7 +658,8 @@ fn components(name: &str) -> usize {
|
||||
| "moonPosition"
|
||||
| "shadowLightPosition"
|
||||
| "fogColor"
|
||||
| "skyColor" => 3,
|
||||
| "skyColor"
|
||||
| "endFlashPosition" => 3,
|
||||
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
|
||||
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
|
||||
_ => 1,
|
||||
@@ -576,6 +688,77 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
|
||||
Some(Value::F(f)) => f[0],
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
|
||||
Some(Value::I(i)) => i[0],
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let mut inputs = PackInputs {
|
||||
status: PlayerStatus {
|
||||
survival: true,
|
||||
health: 15.0,
|
||||
max_health: 20.0,
|
||||
food: 10.0,
|
||||
air: 150.0,
|
||||
armor: 20.0,
|
||||
sneaking: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// The vitals are fractions of their maximums.
|
||||
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
|
||||
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
|
||||
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
|
||||
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
|
||||
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
|
||||
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
|
||||
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
|
||||
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
|
||||
assert_eq!(int("is_sneaking", &inputs), 1);
|
||||
assert_eq!(int("is_invisible", &inputs), 0);
|
||||
// Creative and spectator: -1 for the vitals and the maximums of health and air;
|
||||
// spectators are invisible.
|
||||
inputs.status.survival = false;
|
||||
inputs.status.spectator = true;
|
||||
for name in [
|
||||
"currentPlayerHealth",
|
||||
"maxPlayerHealth",
|
||||
"currentPlayerHunger",
|
||||
"currentPlayerAir",
|
||||
"maxPlayerAir",
|
||||
"currentPlayerArmor",
|
||||
] {
|
||||
assert_eq!(float(name, &inputs), -1.0, "{name}");
|
||||
}
|
||||
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
|
||||
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
|
||||
assert_eq!(int("isSpectator", &inputs), 1);
|
||||
assert_eq!(int("is_invisible", &inputs), 1);
|
||||
// Each is provided, not left at zero for the pack.
|
||||
for name in [
|
||||
"currentPlayerHealth",
|
||||
"isSpectator",
|
||||
"is_sprinting",
|
||||
"bedrockLevel",
|
||||
] {
|
||||
assert!(index(name).is_some(), "{name} is not provided");
|
||||
}
|
||||
assert!(
|
||||
NAMES
|
||||
.iter()
|
||||
.filter(|n| status_value(n, &inputs).is_some())
|
||||
.count()
|
||||
== 18
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_height_needs_a_cloud_color() {
|
||||
assert_eq!(cloud_height(0.8, 192.33), 192.33);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,10 +219,24 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let resolved: Vec<_> = variants
|
||||
.iter()
|
||||
.map(|(name, ..)| library.resolve(name))
|
||||
.collect();
|
||||
// Every texture the models name, decoded side by side before the bake adds them one
|
||||
// by one in its own order.
|
||||
sprites.prefetch(
|
||||
root,
|
||||
resolved
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|model| model.textures.values())
|
||||
.map(|material| material.sprite.as_str()),
|
||||
);
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
|
||||
let model = model?;
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
|
||||
@@ -695,18 +695,20 @@ fn build_overlay_uncached(
|
||||
merged.insert(name.clone(), bytes);
|
||||
}
|
||||
}
|
||||
/// A zip entry to write: its pack, its name in the zip and where it goes.
|
||||
type Extract<'a> = (usize, &'a str, PathBuf);
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mirror(
|
||||
fn mirror<'a>(
|
||||
rel: &str,
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
files: &'a BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
zips: &[Option<Zip>],
|
||||
dependencies: &mut cache::Dependencies,
|
||||
extract: &mut Vec<Extract<'a>>,
|
||||
) -> io::Result<()> {
|
||||
let src = if rel.is_empty() {
|
||||
vanilla.to_path_buf()
|
||||
@@ -733,7 +735,13 @@ fn build_overlay_uncached(
|
||||
} else {
|
||||
format!("{rel}/")
|
||||
};
|
||||
for f in files.keys().filter_map(|f| f.strip_prefix(&prefix)) {
|
||||
// The files under this directory are one run of the sorted map: walking only that run
|
||||
// keeps a pack of tens of thousands of files in thousands of folders from being
|
||||
// scanned once per folder.
|
||||
for f in files
|
||||
.range(prefix.clone()..)
|
||||
.map_while(|(f, _)| f.strip_prefix(&prefix))
|
||||
{
|
||||
names.insert(f.split('/').next().unwrap_or("").to_owned());
|
||||
}
|
||||
for name in names {
|
||||
@@ -747,19 +755,8 @@ fn build_overlay_uncached(
|
||||
let target = out.join(&child);
|
||||
match path {
|
||||
Some(p) => link(p, &target)?,
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inflated and written after the walk, on every thread.
|
||||
None => extract.push((*owner, inner.as_str(), target)),
|
||||
}
|
||||
} else if filters.is_empty()
|
||||
&& !touched.contains(&child)
|
||||
@@ -783,8 +780,8 @@ fn build_overlay_uncached(
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
zips,
|
||||
dependencies,
|
||||
extract,
|
||||
)?;
|
||||
} else if filters.iter().any(|f| f.blocks(&child)) {
|
||||
// Hidden by a pack's filter.
|
||||
@@ -798,6 +795,7 @@ fn build_overlay_uncached(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut extract = Vec::new();
|
||||
mirror(
|
||||
"",
|
||||
vanilla,
|
||||
@@ -807,9 +805,27 @@ fn build_overlay_uncached(
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
&zips,
|
||||
dependencies,
|
||||
)
|
||||
&mut extract,
|
||||
)?;
|
||||
// Inflating and writing a big pack's files one by one was most of a cold build; its
|
||||
// folders all exist by now.
|
||||
crate::par::par_map(&extract, |(owner, inner, target)| {
|
||||
let Some(Some(z)) = zips.get(*owner) else {
|
||||
return Ok(());
|
||||
};
|
||||
// One unreadable entry costs that file, not the pack: an error here
|
||||
// failed the build and the reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(target, bytes),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
|
||||
/// than from the top pack only.
|
||||
@@ -1489,6 +1505,72 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
// Folders whose names start alike (`item`, `item_extra`, `item2`) and files beside
|
||||
// folders of almost their name: what a prefix lookup could mix up.
|
||||
let names: Vec<String> = (0..400)
|
||||
.map(|i| {
|
||||
let folder = ["item", "item_extra", "item2", "item/sub", "block"][i % 5];
|
||||
format!("assets/ns{}/textures/{folder}/f{i}.png", i % 3)
|
||||
})
|
||||
.chain(["assets/ns0/textures/item.png".to_owned()])
|
||||
.collect();
|
||||
let mut expected: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
for name in names.iter().step_by(4) {
|
||||
let path = vanilla.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, format!("vanilla {name}")).unwrap();
|
||||
expected.insert(name.clone(), format!("vanilla {name}").into_bytes());
|
||||
}
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
|
||||
// Packs a and b are zips, c a folder; each provides a different part of the names.
|
||||
fs::create_dir_all(base.join("packs/c")).unwrap();
|
||||
fs::write(base.join("packs/c/pack.mcmeta"), meta).unwrap();
|
||||
for (pack, every) in [("a", 2), ("b", 3)] {
|
||||
let file = fs::File::create(base.join(format!("packs/{pack}.zip"))).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(meta).unwrap();
|
||||
for name in names.iter().step_by(every) {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(format!("{pack} {name}").as_bytes()).unwrap();
|
||||
expected.insert(name.clone(), format!("{pack} {name}").into_bytes());
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
for name in names.iter().skip(1).step_by(5) {
|
||||
let path = base.join("packs/c").join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, format!("c {name}")).unwrap();
|
||||
expected.insert(name.clone(), format!("c {name}").into_bytes());
|
||||
}
|
||||
let all = folder(&base.join("packs"), RESOURCE_FORMAT);
|
||||
let ids = ["file/a.zip", "file/b.zip", "file/c"].map(String::from);
|
||||
let out = base.join("overlay");
|
||||
build_overlay(&vanilla, &enabled(&all, &ids), &out).unwrap();
|
||||
fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(dir).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, root, out);
|
||||
} else {
|
||||
let name = path.strip_prefix(root).unwrap().to_string_lossy();
|
||||
out.insert(name.replace('\\', "/"), fs::read(&path).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut built = BTreeMap::new();
|
||||
walk(&out.join("assets"), &out, &mut built);
|
||||
assert_eq!(built, expected);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
|
||||
@@ -99,6 +99,9 @@ pub struct Sprites {
|
||||
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
|
||||
/// resource path.
|
||||
ready: std::collections::HashMap<String, crate::sprite_cache::Decoded>,
|
||||
}
|
||||
/// Edge length of vanilla's block textures: the smallest array layer.
|
||||
pub const SPRITE_SIZE: u32 = 16;
|
||||
@@ -242,6 +245,8 @@ impl Sprites {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
// Prefetched textures nothing inserted.
|
||||
self.ready = Default::default();
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -335,6 +340,36 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Decode on every thread the textures about to be inserted: sprite names, or resource
|
||||
/// paths outside `textures/` (as [`Sprites::insert_file`] takes them). A loader that adds
|
||||
/// many (the block models, an OptiFine pack's tiles) calls this first and then inserts
|
||||
/// them in its own order, so the layers are the same as without it. What is already a
|
||||
/// layer or in the disk cache is skipped; what fails to decode is left for the insert,
|
||||
/// which reports it.
|
||||
pub fn prefetch<'a>(&mut self, root: &Path, names: impl IntoIterator<Item = &'a str>) {
|
||||
let names: Vec<&str> = names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
!self.layers.contains_key(*name)
|
||||
&& !self.ready.contains_key(*name)
|
||||
&& self.disk.as_ref().is_none_or(|d| d.get(name).is_none())
|
||||
})
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let decoded = crate::par::par_map(&names, |name| {
|
||||
if name.contains(':') {
|
||||
Self::decode(root, name).ok()
|
||||
} else {
|
||||
Self::decode_file(&root.join(name), name).ok()
|
||||
}
|
||||
});
|
||||
for (name, decoded) in names.into_iter().zip(decoded) {
|
||||
if let Some(decoded) = decoded {
|
||||
self.ready.insert(name.to_owned(), decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
|
||||
// Custom Item Textures' files are outside `textures/`, which the disk cache's
|
||||
// fingerprint covers: like `insert_file`'s, they skip it.
|
||||
@@ -344,7 +379,10 @@ impl Sprites {
|
||||
let decoded = match cached {
|
||||
Some(decoded) => decoded,
|
||||
None => {
|
||||
let decoded = Self::decode(root, name)?;
|
||||
let decoded = match self.ready.remove(name) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode(root, name)?,
|
||||
};
|
||||
if let Some(disk) = disk {
|
||||
disk.insert(name, decoded.clone());
|
||||
}
|
||||
@@ -361,7 +399,10 @@ impl Sprites {
|
||||
if let Some(layer) = self.layers.get(resource) {
|
||||
return Ok(*layer);
|
||||
}
|
||||
let decoded = Self::decode_file(&root.join(resource), resource)?;
|
||||
let decoded = match self.ready.remove(resource) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode_file(&root.join(resource), resource)?,
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
@@ -806,6 +847,79 @@ mod tests {
|
||||
assert_eq!(cit, item);
|
||||
}
|
||||
#[test]
|
||||
fn prefetched_textures_take_the_layers_inserting_alone_gives() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id()));
|
||||
let png = |path: &str, side: u32, seed: u8| {
|
||||
let file = root.join(path);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(file).unwrap(), side, side);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..side * side)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
seed,
|
||||
i as u8,
|
||||
(i >> 8) as u8,
|
||||
if i % 3 == 0 { 0 } else { 255 },
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
for i in 0..40u8 {
|
||||
png(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
16 << (i % 3),
|
||||
i,
|
||||
);
|
||||
}
|
||||
// Two textures alike share a layer; a tile outside `textures/`, a broken file and a
|
||||
// missing one take the paths they take without prefetching.
|
||||
png("assets/minecraft/textures/block/same.png", 16, 3);
|
||||
png("assets/minecraft/optifine/ctm/glass/0.png", 32, 77);
|
||||
fs::write(
|
||||
root.join("assets/minecraft/textures/block/broken.png"),
|
||||
b"no png",
|
||||
)
|
||||
.unwrap();
|
||||
let names: Vec<String> = (0..40)
|
||||
.map(|i| format!("minecraft:block/b{}", (i * 7) % 40))
|
||||
.chain(["minecraft:block/same", "minecraft:block/broken"].map(String::from))
|
||||
.chain(["minecraft:block/missing", "minecraft:block/b3"].map(String::from))
|
||||
.collect();
|
||||
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
|
||||
let load = |prefetch: bool| {
|
||||
let mut sprites = Sprites::default();
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str).chain([tile]));
|
||||
}
|
||||
let mut layers: Vec<u32> = names
|
||||
.iter()
|
||||
.map(|name| sprites.insert(&root, name).unwrap())
|
||||
.collect();
|
||||
layers.push(sprites.insert_file(&root, tile).unwrap());
|
||||
sprites.finish();
|
||||
(
|
||||
layers,
|
||||
sprites.layers,
|
||||
sprites.pixels,
|
||||
sprites.translucent,
|
||||
sprites.size,
|
||||
)
|
||||
};
|
||||
let alone = load(false);
|
||||
assert_eq!(load(true), alone);
|
||||
// The textures alike and the broken one's missing texture: fewer layers than names.
|
||||
assert!(alone.1.values().max().unwrap() + 1 < names.len() as u32);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn identical_still_textures_share_a_layer() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
|
||||
|
||||
@@ -6,8 +6,12 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
description = "Horizon native development client"
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
|
||||
[dependencies]
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
|
||||
horizon-assets = { path = "../horizon-assets" }
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
@@ -15,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# OptiFine-format pack files: custom entity models, random entity textures.
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
@@ -36,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -104,20 +109,23 @@ winresource = "0.1"
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
name = "client_probe"
|
||||
path = "examples/probe/main.rs"
|
||||
[[bin]]
|
||||
name = "horizon-client"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "launch_api"
|
||||
path = "tests/launch_api.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Profile the two mandatory passes over gameplay-facts.json independently.
|
||||
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
|
||||
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[allow(dead_code)]
|
||||
#[path = "../../horizon-items/src/blocks/decode.rs"]
|
||||
mod decode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the read + Value parse stages in BlockFacts::load and Assets::load.
|
||||
//! Usage: json_load_bench RESOURCE_ROOT [REPETITIONS]; see tools/json_load_fixtures.py.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//!
|
||||
//! WORK is a `horizon-port-pack` work folder: `oracle/facts.json` and
|
||||
//! `mods/<id>/carry/`. The root is built in `WORK/horizon-root`.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::Assets,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! for live requested heap bytes; leave it unset for latency measurements.
|
||||
//! cargo run --profile fast -p horizon-client --example model_library_bench -- ROOT [--parity]
|
||||
//! Run fresh processes, warm up twice, then compare multiple alternating baseline/candidate runs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, item_render::ItemRenderer};
|
||||
use std::{
|
||||
alloc::{GlobalAlloc, Layout, System},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compare the old two-parse loading path with catalog construction from shared facts.
|
||||
//! Run: cargo run -p horizon-client --profile fast --example model_states_bench -- 15
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{Result, models::StateModels, sprites::Sprites};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! generated around them blend with them (`Blender.of`).
|
||||
//!
|
||||
//! old_world_fixture <source world> <destination world> <x0,z0,x1,z1>
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_world::{
|
||||
nbt::{Limits, Tag},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! entity reaches, with the per-tick palette map against the resident cache.
|
||||
//! Run with prepared client resources:
|
||||
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, SECTIONS, Section},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//!
|
||||
//! This file only lists the probes, so that adding one reruns no harness: tools/parity.py leaves it out of
|
||||
//! their dependencies. How a probe runs is in dispatch.rs.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
#[macro_use]
|
||||
mod dispatch;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Selection cost only (not block simulation). Run with prepared client resources.
|
||||
//! cargo run --profile fast -p horizon-client --example random_tick_eligibility_bench -- RESOURCES
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, MIN_Y, Section},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! and the process RSS (what the allocator holds on to). On macOS it ends
|
||||
//! with `footprint`'s physical footprint, the number the OS charges: RSS also
|
||||
//! counts freed pages the allocator marked reusable.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets, biome::Biomes, blocks::BlockFacts, collisions, crafting::Recipes, items::Items,
|
||||
light::LightTables, mesher::MeshTables, models, motion::MotionResources,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! full chunk NBT encoder. Run with `cargo run --profile fast -p horizon-client
|
||||
//! --example save_batch_bench`. Add `-- --nbt` to also time serialization.
|
||||
//! Exact NBT equality is always validated. No GPU, compression or disk I/O timed.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
biome::Biomes,
|
||||
chunk::{Chunk, block_strategy},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Run each mode in a separate process to capture CPU usage and peak RSS.
|
||||
//! The deferred mode evaluates both phases contiguously for fair loader comparison;
|
||||
//! actual client startup performs other initialization between these phases.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{assets::Assets, blocks::BlockFacts};
|
||||
use horizon_world::level::Behaviours;
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
|
||||
//! and writes it), loads again (reads it) and compares the sprite arrays.
|
||||
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::assets::{Assets, RenderAssets};
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
|
||||
@@ -505,6 +505,24 @@ fn signum(v: f64) -> f64 {
|
||||
/// entity stops there and is not kept: the game hands it to
|
||||
/// `finish_entity_hit` once it has hurt the entity.
|
||||
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
|
||||
tick_with(p, scene, targets, true)
|
||||
}
|
||||
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
|
||||
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
|
||||
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
|
||||
pub fn tick_through(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
targets: &[(Who, Aabb)],
|
||||
) -> (bool, Hit) {
|
||||
tick_with(p, scene, targets, false)
|
||||
}
|
||||
fn tick_with(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> (bool, Hit) {
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return (false, Hit::None);
|
||||
};
|
||||
@@ -517,7 +535,7 @@ pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) ->
|
||||
st: &mut st,
|
||||
scene,
|
||||
}
|
||||
.tick(targets);
|
||||
.tick(targets, impact);
|
||||
p.stuck = st.in_ground;
|
||||
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
|
||||
p.arrow = Some(st);
|
||||
@@ -600,7 +618,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
fn trident(&self) -> bool {
|
||||
matches!(self.p.kind, ProjectileKind::Trident)
|
||||
}
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit {
|
||||
// ThrownTrident.tick.
|
||||
if self.trident() && self.st.in_ground_time > 4 {
|
||||
self.st.dealt_damage = true;
|
||||
@@ -711,7 +729,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
original.z + movement.z,
|
||||
);
|
||||
if physics {
|
||||
hit = self.step_move_and_hit(original, end, targets);
|
||||
hit = self.step_move_and_hit(original, end, targets, impact);
|
||||
if matches!(hit, Hit::Entity { .. }) {
|
||||
return hit;
|
||||
}
|
||||
@@ -795,7 +813,13 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
|
||||
/// block. A piercing arrow that hits an entity here goes on from it next
|
||||
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
|
||||
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
|
||||
fn step_move_and_hit(
|
||||
&mut self,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
// findHitEntities: ProjectileUtil.computeMargin and the search box.
|
||||
@@ -828,6 +852,10 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position = next;
|
||||
self.apply_effects_from_blocks(from, next);
|
||||
if let Some((who, at, _)) = best {
|
||||
// A canceled impact ends the search, the arrow where it would have hit.
|
||||
if !impact {
|
||||
return Hit::None;
|
||||
}
|
||||
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
|
||||
// arrow is discarded before it hurts the next one.
|
||||
if self.st.pierce > 0 {
|
||||
@@ -849,6 +877,9 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if self.scene.is_air(state) {
|
||||
return Hit::None;
|
||||
}
|
||||
if !impact {
|
||||
return Hit::None;
|
||||
}
|
||||
self.on_hit_block(pos, state);
|
||||
Hit::Block {
|
||||
pos: [pos.x, pos.y, pos.z],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! horizon-ctl: play and script a running Horizon client through its control
|
||||
//! socket, from a shell, a script or an AI agent (`horizon-ctl mcp`).
|
||||
#![forbid(unsafe_code)]
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
mod commands;
|
||||
mod launch;
|
||||
mod mcp;
|
||||
|
||||
@@ -0,0 +1,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, cit_assets, clouds, collisions, command_block, containers, crash, datapack,
|
||||
dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics,
|
||||
graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects,
|
||||
java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
|
||||
options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections,
|
||||
post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk,
|
||||
section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
|
||||
trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod cauldron;
|
||||
pub mod chunk_io;
|
||||
pub mod clock;
|
||||
pub mod command_storage;
|
||||
pub mod confirm_screen;
|
||||
pub mod container_screen;
|
||||
pub mod content_identity;
|
||||
pub mod control;
|
||||
pub mod crafting;
|
||||
pub mod creative;
|
||||
pub mod cushion;
|
||||
pub mod data_cmd;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_entries;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
pub mod extras_hud;
|
||||
pub mod falling_block;
|
||||
pub mod fire_render;
|
||||
pub mod first_person;
|
||||
pub mod flatgen;
|
||||
pub mod friends_screen;
|
||||
pub mod furnace;
|
||||
pub mod game_mode_switcher;
|
||||
pub mod game_rules_screen;
|
||||
pub mod gizmos;
|
||||
pub mod gossip;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod hotbar;
|
||||
pub mod hunger;
|
||||
pub mod inv_sort;
|
||||
pub mod item_activation;
|
||||
pub mod item_render;
|
||||
pub mod item_sync;
|
||||
pub mod item_tooltip;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
pub mod patrol;
|
||||
pub mod perf_overlay;
|
||||
pub mod pick_item;
|
||||
pub mod player_sync;
|
||||
pub mod portal_sections;
|
||||
pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod ram_limit;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
pub mod reconnect;
|
||||
pub mod save;
|
||||
pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
pub mod spear;
|
||||
pub mod spectator_menu;
|
||||
pub mod sprint;
|
||||
pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
pub mod title;
|
||||
pub mod toasts;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod win_screen;
|
||||
pub mod world_spawn;
|
||||
pub use horizon_client_core::Result;
|
||||
pub use horizon_client_core::block_entity_nbt;
|
||||
pub use horizon_client_core::bossbar;
|
||||
pub use horizon_client_core::component;
|
||||
pub use horizon_client_core::fastutil_order;
|
||||
pub use horizon_client_core::gamerules;
|
||||
pub use horizon_client_core::lang;
|
||||
pub use horizon_client_core::nbt_path;
|
||||
pub use horizon_client_core::scoreboard;
|
||||
pub use horizon_client_core::snbt;
|
||||
pub use horizon_client_core::text;
|
||||
pub use horizon_client_input::clipboard;
|
||||
pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
pub use horizon_items::bundle;
|
||||
pub use horizon_items::cooldowns;
|
||||
pub use horizon_items::enchant;
|
||||
pub use horizon_items::flowers;
|
||||
pub use horizon_items::item_data;
|
||||
pub use horizon_items::items;
|
||||
pub use horizon_items::random_sequences;
|
||||
@@ -2322,6 +2322,12 @@ mod armor_stand_motion_tests {
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
// A summoned stand has no motion yet: the first tick only builds it
|
||||
// (vanilla moves by the velocity, then applies gravity), so it falls
|
||||
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.previous, start);
|
||||
assert!(
|
||||
|
||||
@@ -796,7 +796,24 @@ pub fn mob_to_nbt(
|
||||
put_memories(map, m);
|
||||
crate::mobs::villager::write_nbt(m, map);
|
||||
}
|
||||
"minecraft:wandering_trader" => put_offers(map, m),
|
||||
"minecraft:wandering_trader" => {
|
||||
put_offers(map, m);
|
||||
// WanderingTrader.addAdditionalSaveData: the visit's countdown and
|
||||
// the place it is walking to, a BlockPos codec (an int array).
|
||||
map.insert("DespawnDelay".into(), Tag::Int(m.extra.ints[5]));
|
||||
if let Some(p) = m.extra.point {
|
||||
// The trader keeps its target as a position; the tag is a
|
||||
// BlockPos, so only the block it is in is saved.
|
||||
map.insert(
|
||||
"wander_target".into(),
|
||||
Tag::IntArray(vec![
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:axolotl" => {
|
||||
write_axolotl_memories(m, map);
|
||||
map.insert(
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Startup shared by the gameplay library and its executable.
|
||||
use crate::{crash, game, logging, ram_limit};
|
||||
|
||||
/// Start the game. The executable supplies the allocator and retains the mod API marker.
|
||||
pub fn main() {
|
||||
horizon_threads::configure_allocator();
|
||||
horizon_client::telemetry::mark_start();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
.clone()
|
||||
.any(|a| a == "--help" || a == "-h" || a == "--launch-api");
|
||||
// --home before the log opens in it.
|
||||
if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) {
|
||||
logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
// The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home.
|
||||
let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") {
|
||||
let folder = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--game-dir" && *a != "--gameDir")
|
||||
.nth(1)
|
||||
.and_then(|dir| std::path::absolute(dir).ok())
|
||||
.unwrap_or_else(|| ".".into());
|
||||
(folder, "server", "Server thread")
|
||||
} else {
|
||||
// --player-dir too (a game's, not a server's): a player of a shared game folder keeps
|
||||
// its log and crash reports in its own; crash telemetry stays with the home's consent.
|
||||
if let Some(dir) = game_args
|
||||
.clone()
|
||||
.skip_while(|a| *a != "--player-dir")
|
||||
.nth(1)
|
||||
{
|
||||
logging::set_player_dir(std::path::absolute(dir).unwrap_or_else(|_| dir.into()));
|
||||
}
|
||||
(logging::game_home(), "client", "Render thread")
|
||||
};
|
||||
if !help {
|
||||
logging::init(
|
||||
logging::player_dir_override().unwrap_or(home.as_path()),
|
||||
thread,
|
||||
);
|
||||
// The log's first line names the build, so a bug report that carries the log says which one it was.
|
||||
log::info!("Horizon {}", crash::version());
|
||||
crash::install(home.clone(), side);
|
||||
ram_limit::start(&home, side);
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
let usage = error.is::<game::UsageError>();
|
||||
log::error!("Horizon: {error}");
|
||||
logging::flush();
|
||||
// The report's path is the last line on stderr (crash::SAVED_LINE).
|
||||
if !help && !usage {
|
||||
crash::report_error(&home, side, &*error);
|
||||
}
|
||||
// Started from Explorer, the console window closes with the game:
|
||||
// keep the message on screen until it is read.
|
||||
#[cfg(windows)]
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
eprintln!("Press Enter to close.");
|
||||
let _ = std::io::stdin().read_line(&mut String::new());
|
||||
}
|
||||
std::process::exit(if usage {
|
||||
game::exit_code::USAGE
|
||||
} else {
|
||||
game::exit_code::ERROR
|
||||
});
|
||||
}
|
||||
logging::flush();
|
||||
}
|
||||
|
||||
/// The JVM raises its open-file limit to the hard limit as it starts
|
||||
/// (`-XX:+MaxFDLimit`, on by default), so a vanilla server may hold as many
|
||||
/// sockets and region files as the host allows, not the soft default (often
|
||||
/// 1024 in a container). The same here; macOS caps it at `OPEN_MAX`, as the
|
||||
/// JVM does there.
|
||||
fn raise_open_file_limit() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use rustix::process::{Resource, Rlimit, getrlimit, setrlimit};
|
||||
let limit = getrlimit(Resource::Nofile);
|
||||
let hard = if cfg!(target_os = "macos") {
|
||||
Some(limit.maximum.map_or(10_240, |h| h.min(10_240)))
|
||||
} else {
|
||||
limit.maximum
|
||||
};
|
||||
let raised = match (limit.current, hard) {
|
||||
(Some(soft), Some(hard)) => hard > soft,
|
||||
(Some(_), None) => true,
|
||||
(None, _) => false,
|
||||
};
|
||||
if raised {
|
||||
let to = Rlimit {
|
||||
current: hard,
|
||||
maximum: limit.maximum,
|
||||
};
|
||||
match setrlimit(Resource::Nofile, to) {
|
||||
Ok(()) => log::info!(
|
||||
"Open file limit raised from {:?} to {hard:?}",
|
||||
limit.current
|
||||
),
|
||||
Err(e) => log::warn!("Open file limit stays at {:?}: {e}", limit.current),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use super::{Game, creatures::seen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
chunk_map::{ChunkMap, ChunkMembershipDelta},
|
||||
containers::{BlockEntity, CreakingHeart},
|
||||
mobs::{
|
||||
Mob, MobType,
|
||||
@@ -302,24 +303,30 @@ impl Game<'_> {
|
||||
&& self.ui.rules.bool("spawn_monsters")
|
||||
}
|
||||
/// Hearts that came with generated or loaded chunks get their block
|
||||
/// entity (`newBlockEntity`) the first time their chunk is seen.
|
||||
/// entity (`newBlockEntity`) the first time their chunk is seen lit.
|
||||
/// Only the chunks that arrived since are looked at: a server holds tens
|
||||
/// of thousands, and reading each one every tick cost milliseconds.
|
||||
pub(super) fn scan_for_hearts(&mut self) {
|
||||
let Some(heart) = self
|
||||
if self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:creaking_heart")
|
||||
else {
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
};
|
||||
let keys: Vec<(i32, i32)> = self
|
||||
.world
|
||||
.chunks
|
||||
}
|
||||
self.creaking.follow_chunks(&self.world.chunks);
|
||||
let mut keys: Vec<(i32, i32)> = self
|
||||
.creaking
|
||||
.unscanned
|
||||
.iter()
|
||||
.filter(|(k, c)| c.lit && !self.creaking.scanned.contains(k))
|
||||
.map(|(k, _)| *k)
|
||||
.copied()
|
||||
.filter(|k| self.world.chunks.get(k).is_some_and(|c| c.lit))
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
for key in keys {
|
||||
self.creaking.unscanned.remove(&key);
|
||||
self.creaking.scanned.insert(key);
|
||||
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
|
||||
continue;
|
||||
@@ -334,7 +341,6 @@ impl Game<'_> {
|
||||
.collect();
|
||||
}
|
||||
let hearts = &self.creaking.hearts;
|
||||
let _ = heart;
|
||||
let is_heart = |id: u32| hearts.get(id as usize).copied().unwrap_or(false);
|
||||
for (si, section) in chunk.sections.iter().enumerate() {
|
||||
// Only a section whose palette holds a heart state can have
|
||||
@@ -359,8 +365,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunks = &self.world.chunks;
|
||||
self.creaking.scanned.retain(|k| chunks.contains_key(k));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +373,10 @@ impl Game<'_> {
|
||||
pub(super) struct CreakingState {
|
||||
/// Chunks already scanned for heart blocks.
|
||||
scanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Resident chunks not scanned yet (not lit yet, or just arrived).
|
||||
unscanned: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// The chunk map's membership revision the two sets follow.
|
||||
revision: Option<u64>,
|
||||
/// Which block states are creaking hearts, by state id.
|
||||
hearts: Vec<bool>,
|
||||
}
|
||||
@@ -378,5 +386,100 @@ impl CreakingState {
|
||||
/// (game/levels.rs).
|
||||
pub(super) fn swap_level(&mut self, other: &mut Self) {
|
||||
std::mem::swap(&mut self.scanned, &mut other.scanned);
|
||||
std::mem::swap(&mut self.unscanned, &mut other.unscanned);
|
||||
std::mem::swap(&mut self.revision, &mut other.revision);
|
||||
}
|
||||
/// Bring `scanned` and `unscanned` up to the chunks resident now: a
|
||||
/// chunk that left is forgotten (it is scanned again if it comes back
|
||||
/// later), one that arrived waits to be scanned. From the map's
|
||||
/// membership changes, or from all its keys when they can't be replayed.
|
||||
fn follow_chunks(&mut self, chunks: &ChunkMap) {
|
||||
let revision = chunks.membership_revision();
|
||||
if self.revision == Some(revision) {
|
||||
return;
|
||||
}
|
||||
let deltas = self.revision.and_then(|r| chunks.deltas_since(r));
|
||||
match deltas {
|
||||
Some(deltas) => {
|
||||
for (_, delta) in deltas {
|
||||
match delta {
|
||||
ChunkMembershipDelta::Added(key) => {
|
||||
if chunks.contains_key(&key) && !self.scanned.contains(&key) {
|
||||
self.unscanned.insert(key);
|
||||
}
|
||||
}
|
||||
ChunkMembershipDelta::Removed(key) => {
|
||||
if !chunks.contains_key(&key) {
|
||||
self.scanned.remove(&key);
|
||||
self.unscanned.remove(&key);
|
||||
}
|
||||
}
|
||||
ChunkMembershipDelta::Reset => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.scanned.retain(|k| chunks.contains_key(k));
|
||||
self.unscanned = chunks
|
||||
.keys()
|
||||
.filter(|k| !self.scanned.contains(k))
|
||||
.copied()
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
self.revision = Some(revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::CreakingState;
|
||||
use horizon_client::{chunk::Chunk, chunk_map::ChunkMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn add(chunks: &mut ChunkMap, key: (i32, i32)) {
|
||||
chunks.insert(key, Arc::new(Chunk::empty(key.0, key.1)));
|
||||
}
|
||||
fn sorted(set: &horizon_client::fast_hash::FxHashSet<(i32, i32)>) -> Vec<(i32, i32)> {
|
||||
let mut keys: Vec<_> = set.iter().copied().collect();
|
||||
keys.sort_unstable();
|
||||
keys
|
||||
}
|
||||
|
||||
/// The heart scan follows the chunks that come and go, as the scan of
|
||||
/// every resident chunk each tick did: an arrival waits, a scanned chunk
|
||||
/// that stayed is not scanned again, and one that left is forgotten.
|
||||
#[test]
|
||||
fn the_heart_scan_follows_arrivals_and_departures() {
|
||||
let mut chunks = ChunkMap::default();
|
||||
let mut state = CreakingState::default();
|
||||
add(&mut chunks, (0, 0));
|
||||
add(&mut chunks, (1, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.unscanned), [(0, 0), (1, 0)]);
|
||||
// Scanned, as scan_for_hearts does once a chunk is lit.
|
||||
state.unscanned.remove(&(0, 0));
|
||||
state.scanned.insert((0, 0));
|
||||
add(&mut chunks, (2, 0));
|
||||
chunks.remove(&(1, 0));
|
||||
// Gone and back between two ticks: still scanned, as before.
|
||||
chunks.remove(&(0, 0));
|
||||
add(&mut chunks, (0, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.scanned), [(0, 0)]);
|
||||
assert_eq!(sorted(&state.unscanned), [(2, 0)]);
|
||||
// Gone over a tick: forgotten, and scanned again when it returns.
|
||||
chunks.remove(&(0, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert!(state.scanned.is_empty());
|
||||
add(&mut chunks, (0, 0));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.unscanned), [(0, 0), (2, 0)]);
|
||||
// A cleared map can't be replayed: rebuilt from its keys.
|
||||
chunks.clear();
|
||||
add(&mut chunks, (5, 5));
|
||||
state.follow_chunks(&chunks);
|
||||
assert_eq!(sorted(&state.unscanned), [(5, 5)]);
|
||||
assert!(state.scanned.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
[
|
||||
|
||||
@@ -43,6 +43,29 @@ fn projectile_slot(
|
||||
})
|
||||
}
|
||||
|
||||
/// `CrossbowItem.onUseTick`'s loading sounds for a charge now at `charge`
|
||||
/// (of `getChargeDuration`) that has played `played` so far: the start and
|
||||
/// middle sounds each play once the charge reaches their point, and both
|
||||
/// count as unheard again while the charge is under the first one. A tick
|
||||
/// that jumped the charge past a point still plays that point's sound, as
|
||||
/// vanilla's two flags do. Returns the flags for the next tick and the
|
||||
/// sounds to play now.
|
||||
fn charge_sounds(
|
||||
charge: f32,
|
||||
played: [bool; 2],
|
||||
sounds: [&'static str; 3],
|
||||
) -> ([bool; 2], Option<&'static str>, Option<&'static str>) {
|
||||
let mut played = if charge < 0.2 { [false; 2] } else { played };
|
||||
let mut play = [None; 2];
|
||||
for (i, point) in [0.2, 0.5].into_iter().enumerate() {
|
||||
if charge >= point && !played[i] && !sounds[i].is_empty() {
|
||||
played[i] = true;
|
||||
play[i] = Some(sounds[i]);
|
||||
}
|
||||
}
|
||||
(played, play[0], play[1])
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn charge_ticks(stack: &ItemStack) -> u32 {
|
||||
// crossbow_charge_time: 1.25 s, minus 0.25 s per Quick Charge level.
|
||||
@@ -100,17 +123,19 @@ impl Game<'_> {
|
||||
let held = self.ticks_held(stack, ticks);
|
||||
self.using = Some((slot, ticks + 1));
|
||||
let charge = Self::charge_ticks(stack).max(1) as f32;
|
||||
let (before, now) = (held as f32 / charge, (held + 1) as f32 / charge);
|
||||
let now = (held + 1) as f32 / charge;
|
||||
let sounds = Self::charging_sounds(stack);
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
if before < 0.2 && now >= 0.2 && !sounds[0].is_empty() {
|
||||
self.audio.play(sounds[0], at, 0.5, 1.0);
|
||||
let (played, start, middle) = charge_sounds(now, self.crossbow_sounds, sounds);
|
||||
self.crossbow_sounds = played;
|
||||
if let Some(sound) = start {
|
||||
self.audio.play(sound, at, 0.5, 1.0);
|
||||
}
|
||||
if before < 0.5 && now >= 0.5 && !sounds[1].is_empty() {
|
||||
self.audio.play(sounds[1], at, 0.5, 1.0);
|
||||
if let Some(sound) = middle {
|
||||
self.audio.play(sound, at, 0.5, 1.0);
|
||||
}
|
||||
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
|
||||
if now >= 1.0 && !charged {
|
||||
@@ -254,6 +279,44 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A charge that crosses a point between two ticks plays that point's
|
||||
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
|
||||
/// cutting the ticks left) still plays every point it passed, as
|
||||
/// `CrossbowItem.onUseTick`'s two flags do.
|
||||
#[test]
|
||||
fn a_jumped_charge_still_plays_the_loading_sounds_it_passed() {
|
||||
let sounds = [
|
||||
"minecraft:item.crossbow.loading_start",
|
||||
"minecraft:item.crossbow.loading_middle",
|
||||
"minecraft:item.crossbow.loading_end",
|
||||
];
|
||||
// Each step: the charge and the sounds it plays, from the flags the
|
||||
// step before left.
|
||||
let mut played = [false; 2];
|
||||
let mut step = |charge: f32| {
|
||||
let (next, start, middle) = charge_sounds(charge, played, sounds);
|
||||
played = next;
|
||||
[start, middle]
|
||||
};
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.25), [Some(sounds[0]), None]);
|
||||
assert_eq!(step(0.25), [None, None]);
|
||||
assert_eq!(step(0.55), [None, Some(sounds[1])]);
|
||||
assert_eq!(step(1.0), [None, None]);
|
||||
// A charge below the first point is a new charge: both sound again.
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.6), [Some(sounds[0]), Some(sounds[1])]);
|
||||
// One tick from the start past every point, as a mod's tick makes it.
|
||||
assert_eq!(step(0.1), [None, None]);
|
||||
assert_eq!(step(0.9), [Some(sounds[0]), Some(sounds[1])]);
|
||||
// Quick Charge 1 and up have no middle sound.
|
||||
let quick = [sounds[0], "", sounds[2]];
|
||||
assert_eq!(
|
||||
charge_sounds(0.9, [false; 2], quick),
|
||||
([true, false], Some(sounds[0]), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_projectiles_come_before_the_inventory() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
|
||||
@@ -338,6 +338,9 @@ impl Game<'_> {
|
||||
}
|
||||
// `Entity.startRiding`: a passenger first gets off what it rides
|
||||
// (`stopRiding`), so a seated player moves to this cushion.
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.stop_riding();
|
||||
self.riding = Some(id);
|
||||
let p = self.cushions[i].position;
|
||||
@@ -454,6 +457,9 @@ impl Game<'_> {
|
||||
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
|
||||
/// rather than one whose cushion was killed.
|
||||
pub(super) fn get_up_from_cushion(&mut self, c: &Cushion) {
|
||||
if self.riding == Some(c.id) && !self.native_may_leave(c.id) {
|
||||
return;
|
||||
}
|
||||
let at = c.dismount();
|
||||
self.riding = None;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
|
||||
@@ -27,7 +27,7 @@ use horizon_world::nbt::Tag;
|
||||
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
|
||||
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
|
||||
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
|
||||
const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
pub(super) const CRYSTAL_IDS: u32 = SPECIAL + 16;
|
||||
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
|
||||
const MAX_HEALTH: f32 = 200.0;
|
||||
/// `Mob.getAmbientSoundInterval`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -90,6 +90,9 @@ impl Game<'_> {
|
||||
if harnessed && !sneaking {
|
||||
// doPlayerRide.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
let was_empty = self.mobs[index].rider.is_none();
|
||||
// addPassenger: the rider is not standing on top, so no still
|
||||
|
||||
@@ -207,7 +207,7 @@ impl Game<'_> {
|
||||
self.process_level_events()
|
||||
}
|
||||
/// The block at `pos` has no collision shape.
|
||||
fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
pub(super) fn collision_empty(&self, pos: [i32; 3]) -> bool {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
|
||||
@@ -869,7 +869,12 @@ impl Game<'_> {
|
||||
self.lan = Some(host);
|
||||
let lines = self.as_console(|g| {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
g.run_command(&command);
|
||||
// A command a native mod registered goes to the mods
|
||||
// first, as the owner's own chat does (the console is
|
||||
// level 4, so every registered usage is allowed).
|
||||
if !g.perform_native_command(conn, 4, command.trim()) {
|
||||
g.run_command(&command);
|
||||
}
|
||||
g.chat_capture.take().unwrap_or_default()
|
||||
});
|
||||
host = self.lan.take().expect("a command puts the hub back");
|
||||
|
||||
@@ -32,6 +32,9 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
self.use_ticks_set = ticks_set;
|
||||
// A new charge hears the crossbow's loading sounds again
|
||||
// (`game/crossbow.rs`).
|
||||
self.crossbow_sounds = [false; 2];
|
||||
true
|
||||
}
|
||||
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
|
||||
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
|
||||
use horizon_client::collisions::SceneCollisions;
|
||||
use horizon_client::lan::hub::no_blocks_in;
|
||||
use horizon_client::mobs::equine;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::geometry::{Aabb, Axis, Vec3};
|
||||
use horizon_protocol::{
|
||||
packets::play::{self, Clientbound as C},
|
||||
text::translatable,
|
||||
@@ -367,7 +368,31 @@ impl Game<'_> {
|
||||
back(host, old, yaw);
|
||||
return;
|
||||
}
|
||||
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
|
||||
// clientVehicleIsFloating: the move didn't go down a step, the vehicle hadn't come down onto
|
||||
// something on its last move (`verticalCollisionBelow` before this one), and nothing but air is
|
||||
// around its box grown by 0.0625 and stretched 0.55 down (water holds a boat up). A flying or
|
||||
// no-gravity mount, and a server that allows flight, never float.
|
||||
let (gravity, no_gravity) = match mount {
|
||||
Some(i) => (0.08, self.mobs[i].no_gravity),
|
||||
None => (0.04, false),
|
||||
};
|
||||
let around = moved_box
|
||||
.inflate(Vec3::new(0.0625, 0.0625, 0.0625))
|
||||
.expand_towards(Vec3::new(0.0, -0.55, 0.0));
|
||||
let corner = |max: bool| {
|
||||
[Axis::X, Axis::Y, Axis::Z].map(|a| if max { around.max(a) } else { around.min(a) })
|
||||
};
|
||||
let no_blocks = no_blocks_in(&self.world, corner(false), corner(true));
|
||||
let allow_flight = host.hub.config.allow_flight;
|
||||
let g = &mut host.hub.guests[gi];
|
||||
g.vehicle_good = Some([target.x, target.y, target.z]);
|
||||
let rested = std::mem::replace(
|
||||
&mut g.vehicle_rests,
|
||||
wanted.y < 0.0 && resolved.y != wanted.y,
|
||||
);
|
||||
g.vehicle_floating =
|
||||
wanted.y >= -0.03125 && !rested && !allow_flight && !no_gravity && no_blocks;
|
||||
g.vehicle_gravity = gravity;
|
||||
if let Some(i) = mount {
|
||||
// absSnapTo: where and how its rider's client turned it; the
|
||||
// rider sits on it from its own ride tick.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
|
||||
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
|
||||
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api`.
|
||||
|
||||
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
|
||||
pub const VERSION: &str = "0.1.0";
|
||||
@@ -155,7 +155,7 @@ pub fn markdown() -> String {
|
||||
let w = &mut out;
|
||||
let _ = writeln!(
|
||||
w,
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
|
||||
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api -->"
|
||||
);
|
||||
let _ = writeln!(w, "# Launch API reference\n");
|
||||
let _ = writeln!(
|
||||
@@ -265,7 +265,7 @@ mod tests {
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
|
||||
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub(super) struct Parked {
|
||||
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
|
||||
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
|
||||
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
|
||||
/// The hub's tracked entities of this level (`LanHost::states`), as its last tick described
|
||||
/// them: what its players' moves are checked against before this tick describes them again.
|
||||
lan_states: horizon_client::lan::entity::States,
|
||||
/// Only the per-level parts of these are used (`swap_level`).
|
||||
anims: BlockEntityAnims,
|
||||
raiding: Raiding,
|
||||
@@ -118,6 +121,9 @@ impl Default for Pass {
|
||||
#[derive(Default)]
|
||||
pub(super) struct Levels {
|
||||
parked: HashMap<Dim, Parked>,
|
||||
/// The levels whose open the mods heard (`native_level_load`), each to
|
||||
/// be told closed once.
|
||||
heard_open: Vec<Dim>,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
@@ -213,6 +219,7 @@ impl Game<'_> {
|
||||
lan_block_events: Vec::new(),
|
||||
lan_entity_events: Vec::new(),
|
||||
lan_critical_hits: Vec::new(),
|
||||
lan_states: Default::default(),
|
||||
anims: Default::default(),
|
||||
raiding: Default::default(),
|
||||
sculk: Default::default(),
|
||||
@@ -291,7 +298,11 @@ impl Game<'_> {
|
||||
/// The level that was in place is kept with the others; the shared
|
||||
/// clock and weather go with the swap. `host` is the hub when the caller
|
||||
/// holds it (`self.lan` is taken during the network tick).
|
||||
pub(super) fn enter_level(&mut self, host: Option<&mut LanHost>, target: Dim) -> Result<()> {
|
||||
pub(super) fn enter_level(
|
||||
&mut self,
|
||||
mut host: Option<&mut LanHost>,
|
||||
target: Dim,
|
||||
) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
@@ -310,12 +321,23 @@ impl Game<'_> {
|
||||
// The sculk tables' watched blocks are built once per game.
|
||||
p.world.watched = std::mem::take(&mut self.world.watched);
|
||||
self.swap_level(&mut p);
|
||||
// The tracked entities go with their level: a level's moves, read before its tick
|
||||
// describes its entities again, would otherwise meet the last level's (the anticheat
|
||||
// missed the boat under a player while anyone was in another dimension).
|
||||
let states = match host.as_deref_mut() {
|
||||
Some(h) => Some(&mut h.states),
|
||||
None => self.lan.as_mut().map(|h| &mut h.states),
|
||||
};
|
||||
if let Some(states) = states {
|
||||
std::mem::swap(states, &mut p.lan_states);
|
||||
}
|
||||
p.idle = 0;
|
||||
let left = std::mem::replace(&mut self.dim, target);
|
||||
self.levels.parked.insert(left, p);
|
||||
if opened {
|
||||
// The new streaming's radius and meshing options.
|
||||
self.apply_settings();
|
||||
self.native_level_opened(target);
|
||||
}
|
||||
}
|
||||
let host = match host {
|
||||
@@ -617,10 +639,42 @@ impl Game<'_> {
|
||||
self.save_level(true)?;
|
||||
self.enter_level(None, back)?;
|
||||
self.levels.parked.remove(&dim);
|
||||
self.native_level_closed(dim);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mods hear `dim`'s level open (`LevelEvent.Load`), on a server.
|
||||
pub(super) fn native_level_opened(&mut self, dim: Dim) {
|
||||
if self.remote.is_some()
|
||||
|| self.options.execution_role.is_client()
|
||||
|| self.levels.heard_open.contains(&dim)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.levels.heard_open.push(dim);
|
||||
self.native_level_load(dim.id());
|
||||
}
|
||||
|
||||
/// The mods hear `dim`'s level close (`LevelEvent.Unload`), once for
|
||||
/// each open they heard.
|
||||
fn native_level_closed(&mut self, dim: Dim) {
|
||||
let Some(i) = self.levels.heard_open.iter().position(|d| *d == dim) else {
|
||||
return;
|
||||
};
|
||||
self.levels.heard_open.remove(i);
|
||||
self.native_level_unload(dim.id());
|
||||
}
|
||||
|
||||
/// Every level still open closes with the server, however it ends
|
||||
/// (`MinecraftServer.stopServer`'s `level.close()` loop), in vanilla's
|
||||
/// order of its levels: the Overworld, the Nether, the End.
|
||||
pub(super) fn native_levels_closing(&mut self) {
|
||||
for dim in [Dim::Overworld, Dim::Nether, Dim::End] {
|
||||
self.native_level_closed(dim);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save every parked level (`MinecraftServer.saveAllChunks`); the level
|
||||
/// in place is the caller's to save.
|
||||
pub(super) fn save_parked_levels(&mut self, wait: bool) -> Result<()> {
|
||||
|
||||
@@ -130,6 +130,9 @@ impl Game<'_> {
|
||||
}
|
||||
// doPlayerRide; mounting drops a lead.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.flag = false;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::{Game, storage::is_shulker_box};
|
||||
|
||||
mod crafter;
|
||||
pub(in crate::game) mod native_energy;
|
||||
pub(in crate::game) mod native_fluids;
|
||||
pub(in crate::game) mod native_items;
|
||||
use horizon_client::{
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Energy in a block's buffer for native server mods (docs/native-mods.md):
|
||||
//! the buffer a mod block declared through `native_block_entity_energy`, read
|
||||
//! through a side, filled, drained and moved to another block. Vanilla has no
|
||||
//! energy; the contract is NeoForge's `EnergyHandler` (`SimpleEnergyHandler`
|
||||
//! and `EnergyHandlerUtil.move`). Like the fluid helpers, they act where the
|
||||
//! world is simulated, never on a client's copy, and only on the level in
|
||||
//! place, which the readers name by their `dimension`
|
||||
//! (`minecraft:overworld`), as `native_set_block` does.
|
||||
use super::Game;
|
||||
use horizon_client::containers::ModEnergyBuffer;
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A block's energy as a side reaches it (`native_energy`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeEnergy {
|
||||
pub(crate) amount: i32,
|
||||
pub(crate) capacity: i32,
|
||||
/// The most one insert moves.
|
||||
pub(crate) max_insert: i32,
|
||||
/// The most one extract moves.
|
||||
pub(crate) max_extract: i32,
|
||||
/// Whether a fill may come through this side (and moves more than 0).
|
||||
pub(crate) can_insert: bool,
|
||||
/// Whether a drain may leave by this side (and moves more than 0).
|
||||
pub(crate) can_extract: bool,
|
||||
}
|
||||
|
||||
/// The amount in `nbt`: the int `energy`, as `SimpleEnergyHandler` saves it;
|
||||
/// anything else, or a negative, reads as empty.
|
||||
fn read_energy(nbt: &Compound) -> i32 {
|
||||
match nbt.get(&JavaString::from("energy")) {
|
||||
Some(Tag::Int(energy)) => (*energy).max(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `SimpleEnergyHandler.insert`: the room left (none when the amount is
|
||||
/// over the capacity), the call's limit and what was offered, whichever is
|
||||
/// least.
|
||||
fn insert_amount(buffer: &ModEnergyBuffer, energy: i32, offered: i32) -> i32 {
|
||||
(buffer.capacity.saturating_sub(energy))
|
||||
.min(offered)
|
||||
.min(buffer.max_insert)
|
||||
.max(0)
|
||||
}
|
||||
|
||||
/// `SimpleEnergyHandler.extract`: what is stored, the call's limit and what
|
||||
/// was asked, whichever is least.
|
||||
fn extract_amount(buffer: &ModEnergyBuffer, energy: i32, asked: i32) -> i32 {
|
||||
energy.min(asked).min(buffer.max_extract).max(0)
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The buffer of the mod block at `pos` in the level in place,
|
||||
/// `dimension`, and the energy it holds (its block entity's, and not the
|
||||
/// placeholder standing in while it ticks).
|
||||
fn native_energy_at(
|
||||
&self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
) -> Option<(Arc<ModEnergyBuffer>, i32)> {
|
||||
if !self.native_simulates() || dimension != self.dim.id() {
|
||||
return None;
|
||||
}
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let buffer = self.world.block_entities.mod_energy(state.0)?;
|
||||
// The block entity is out of the world while it ticks.
|
||||
match self.world.block_entities.get(&key) {
|
||||
Some(horizon_client::containers::BlockEntity::Mod(m)) if !m.id.is_empty() => {
|
||||
Some((Arc::clone(buffer), read_energy(&m.nbt)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Writes `energy` into the block entity at `pos`, saved with its chunk.
|
||||
fn native_energy_store(&mut self, pos: [i32; 3], energy: i32) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if let Some(horizon_client::containers::BlockEntity::Mod(m)) =
|
||||
self.world.block_entities.get_mut(&key)
|
||||
{
|
||||
m.nbt
|
||||
.insert(JavaString::from("energy"), Tag::Int(energy.max(0)));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
}
|
||||
/// What the buffer at `pos` takes (`insert`) or gives through `face` of
|
||||
/// up to `amount`, written unless `simulate`. Nothing where the side
|
||||
/// doesn't reach the buffer for that.
|
||||
fn native_energy_transfer(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
insert: bool,
|
||||
simulate: bool,
|
||||
) -> i32 {
|
||||
if amount <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let Some((buffer, energy)) = self.native_energy_at(dimension, pos) else {
|
||||
return 0;
|
||||
};
|
||||
let moved = if insert {
|
||||
if !buffer.insert_faces[face as usize] {
|
||||
return 0;
|
||||
}
|
||||
insert_amount(&buffer, energy, amount)
|
||||
} else {
|
||||
if !buffer.extract_faces[face as usize] {
|
||||
return 0;
|
||||
}
|
||||
extract_amount(&buffer, energy, amount)
|
||||
};
|
||||
if moved > 0 && !simulate {
|
||||
let after = if insert {
|
||||
energy.saturating_add(moved)
|
||||
} else {
|
||||
energy - moved
|
||||
};
|
||||
self.native_energy_store(pos, after);
|
||||
}
|
||||
moved
|
||||
}
|
||||
/// The energy buffer of the block at `pos` in the level in place,
|
||||
/// `dimension`, as its side `face` reaches it: what it holds, its
|
||||
/// capacity and its limits per call. None where no buffer is: another
|
||||
/// dimension, a chunk that isn't loaded, a block without one, a side
|
||||
/// that reaches neither a fill nor a drain, or the mod block entity
|
||||
/// ticking now (it's out of the world). `Capabilities.Energy.BLOCK`.
|
||||
pub(in crate::game) fn native_energy(
|
||||
&self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
) -> Option<NativeEnergy> {
|
||||
let (buffer, amount) = self.native_energy_at(dimension, pos)?;
|
||||
let (fill, drain) = (
|
||||
buffer.insert_faces[face as usize],
|
||||
buffer.extract_faces[face as usize],
|
||||
);
|
||||
(fill || drain).then(|| NativeEnergy {
|
||||
amount,
|
||||
capacity: buffer.capacity,
|
||||
max_insert: buffer.max_insert,
|
||||
max_extract: buffer.max_extract,
|
||||
can_insert: fill && buffer.max_insert > 0,
|
||||
can_extract: drain && buffer.max_extract > 0,
|
||||
})
|
||||
}
|
||||
/// `EnergyHandler.insert`: up to `amount` into the buffer of the block at
|
||||
/// `pos` through its side `face`, in the level in place: no more than
|
||||
/// its room and its limit per call. With `simulate` nothing changes and
|
||||
/// the answer says what would. The amount that went in.
|
||||
pub(in crate::game) fn native_energy_insert(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
simulate: bool,
|
||||
) -> i32 {
|
||||
let dimension = self.dim.id();
|
||||
self.native_energy_transfer(dimension, pos, face, amount, true, simulate)
|
||||
}
|
||||
/// `EnergyHandler.extract`: up to `amount` out of the buffer of the block
|
||||
/// at `pos` in the level in place, `dimension`, through its side `face`:
|
||||
/// no more than it holds and its limit per call. With `simulate` nothing
|
||||
/// changes and the answer says what would. The amount that came out.
|
||||
pub(in crate::game) fn native_energy_extract(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
amount: i32,
|
||||
simulate: bool,
|
||||
) -> i32 {
|
||||
self.native_energy_transfer(dimension, pos, face, amount, false, simulate)
|
||||
}
|
||||
/// `EnergyHandlerUtil.move`: up to `max` from the block at `from`,
|
||||
/// through its side `from_face`, into the one at `to`, through
|
||||
/// `to_face`, in the level in place, `dimension`. It asks what the
|
||||
/// source can give, offers that to the target, and takes from the source
|
||||
/// exactly what the target accepted, so none is lost. The amount moved;
|
||||
/// 0 where either has no buffer for it, and within one block.
|
||||
pub(in crate::game) fn native_move_energy(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
from: [i32; 3],
|
||||
from_face: Direction,
|
||||
to: [i32; 3],
|
||||
to_face: Direction,
|
||||
max: i32,
|
||||
) -> i32 {
|
||||
if from == to || max <= 0 {
|
||||
return 0;
|
||||
}
|
||||
let offered = self.native_energy_transfer(dimension, from, from_face, max, false, true);
|
||||
let accepted = self.native_energy_transfer(dimension, to, to_face, offered, true, true);
|
||||
if accepted == 0 {
|
||||
return 0;
|
||||
}
|
||||
let taken = self.native_energy_transfer(dimension, from, from_face, accepted, false, false);
|
||||
let put = self.native_energy_transfer(dimension, to, to_face, taken, true, false);
|
||||
if put < taken {
|
||||
// Both were just simulated and nothing ran between; a buffer
|
||||
// that answers differently is a bug, and its energy goes back.
|
||||
log::warn!("Horizon: an energy move lost {} energy", taken - put);
|
||||
let _ =
|
||||
self.native_energy_transfer(dimension, from, from_face, taken - put, true, false);
|
||||
}
|
||||
put
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn buffer(capacity: i32, max_insert: i32, max_extract: i32) -> ModEnergyBuffer {
|
||||
ModEnergyBuffer {
|
||||
capacity,
|
||||
max_insert,
|
||||
max_extract,
|
||||
insert_faces: [true; 6],
|
||||
extract_faces: [true; 6],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_insert_stops_at_the_room_the_limit_and_the_offer() {
|
||||
let b = buffer(1000, 100, 50);
|
||||
assert_eq!(insert_amount(&b, 0, 40), 40);
|
||||
assert_eq!(insert_amount(&b, 0, 500), 100);
|
||||
assert_eq!(insert_amount(&b, 950, 500), 50);
|
||||
assert_eq!(insert_amount(&b, 1000, 500), 0);
|
||||
// Over the capacity after a mod update shrank it: nothing goes in.
|
||||
assert_eq!(insert_amount(&b, 1500, 500), 0);
|
||||
assert_eq!(insert_amount(&b, 0, i32::MAX), 100);
|
||||
assert_eq!(
|
||||
insert_amount(&buffer(i32::MAX, i32::MAX, 0), 5, i32::MAX),
|
||||
i32::MAX - 5
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extract_stops_at_what_is_stored_the_limit_and_the_ask() {
|
||||
let b = buffer(1000, 100, 50);
|
||||
assert_eq!(extract_amount(&b, 800, 30), 30);
|
||||
assert_eq!(extract_amount(&b, 800, 500), 50);
|
||||
assert_eq!(extract_amount(&b, 20, 500), 20);
|
||||
assert_eq!(extract_amount(&b, 0, 500), 0);
|
||||
assert_eq!(extract_amount(&buffer(1000, 100, 0), 800, 500), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amount_reads_as_the_saved_int_and_anything_else_as_empty() {
|
||||
let mut nbt = Compound::new();
|
||||
assert_eq!(read_energy(&nbt), 0);
|
||||
nbt.insert(JavaString::from("energy"), Tag::Int(750));
|
||||
assert_eq!(read_energy(&nbt), 750);
|
||||
nbt.insert(JavaString::from("energy"), Tag::Int(-5));
|
||||
assert_eq!(read_energy(&nbt), 0);
|
||||
nbt.insert(
|
||||
JavaString::from("energy"),
|
||||
Tag::String(JavaString::from("lots")),
|
||||
);
|
||||
assert_eq!(read_energy(&nbt), 0);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,42 @@ pub(super) struct Decoration {
|
||||
pub name: Option<String>,
|
||||
pub key: Key,
|
||||
}
|
||||
/// A map as a native mod sees it when `native_map_decorations` is asked.
|
||||
// Native mods read it; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeMapInfo<'a> {
|
||||
/// The map's id (`map_<id>`).
|
||||
pub id: i32,
|
||||
/// The map's centre block `[x, z]`.
|
||||
pub center: [i32; 2],
|
||||
/// `MapItemSavedData.scale`: a map pixel is `1 << scale` blocks.
|
||||
pub scale: u8,
|
||||
/// The dimension the map shows (`minecraft:overworld`).
|
||||
pub dimension: &'a str,
|
||||
pub locked: bool,
|
||||
/// Whether it is drawn in an item frame, not held or on the cartography screen.
|
||||
pub in_frame: bool,
|
||||
}
|
||||
/// An icon a native mod puts on a map: the same place and pose as vanilla's
|
||||
/// decorations (`MapDecoration`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NativeMapDecoration {
|
||||
/// An atlas sprite: vanilla's `map/decorations/<name>`, or a content
|
||||
/// mod's texture. An unknown sprite is left out.
|
||||
pub sprite: String,
|
||||
/// The position on the map in half map pixels, -128 (left, top) to 127.
|
||||
/// For the world position `(wx, wz)`: `(wx - center_x) / (1 << scale) * 2`,
|
||||
/// and the same for z, floored and clamped.
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
/// 0 to 15, in 22.5 degree steps clockwise (only the low four bits count).
|
||||
pub rot: u8,
|
||||
/// Whether it shows in an item frame and on the cartography screen
|
||||
/// (`MapDecorationType.showOnItemFrame`); a held map shows all.
|
||||
pub shown_on_frame: bool,
|
||||
}
|
||||
|
||||
/// `MapBanner`: a banner a map marks, as `banners` saves it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct MapBanner {
|
||||
@@ -675,11 +711,15 @@ const DECORATION_TYPES: [(&str, &str); 40] = [
|
||||
/// `MapRenderer.render`'s pose for decoration `d` in map pixels (0..128,
|
||||
/// y down) at depth `z`: its quad is (-1, -1) to (1, 1) under it.
|
||||
pub(super) fn decoration_pose(d: &Decoration, z: f32) -> glam::Mat4 {
|
||||
decoration_pose_at(d.x, d.y, d.rot, z)
|
||||
}
|
||||
/// [`decoration_pose`] for a decoration at `x`, `y` (half map pixels) turned `rot`.
|
||||
pub(super) fn decoration_pose_at(x: i8, y: i8, rot: u8, z: f32) -> glam::Mat4 {
|
||||
glam::Mat4::from_translation(glam::Vec3::new(
|
||||
f32::from(d.x) / 2.0 + 64.0,
|
||||
f32::from(d.y) / 2.0 + 64.0,
|
||||
f32::from(x) / 2.0 + 64.0,
|
||||
f32::from(y) / 2.0 + 64.0,
|
||||
z,
|
||||
)) * glam::Mat4::from_rotation_z((f32::from(d.rot) * 360.0 / 16.0).to_radians())
|
||||
)) * glam::Mat4::from_rotation_z((f32::from(rot) * 360.0 / 16.0).to_radians())
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(4.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(-0.125, 0.125, 0.0))
|
||||
}
|
||||
@@ -1736,6 +1776,54 @@ impl Game<'_> {
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
// A native mod's own icons, over the vanilla ones.
|
||||
for d in self.mod_map_decorations(id, show_only_frame) {
|
||||
let Some(s) = self.gui.atlas.sprites.get(d.sprite.as_str()).copied() else {
|
||||
continue;
|
||||
};
|
||||
let dm = decoration_pose_at(d.x, d.y, d.rot, -0.02 - count as f32 * 0.001);
|
||||
let p = |x: f32, y: f32| dm.transform_point3(glam::Vec3::new(x, y, 0.0)).to_array();
|
||||
let corners = [p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)];
|
||||
let uvs = [[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]];
|
||||
for (c, uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(glam::Vec3::from_array(c)).to_array(),
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
/// The icons `native_map_decorations` adds to map `id`, as it is drawn
|
||||
/// (`frame`: in an item frame or on a screen, which show only what
|
||||
/// `shown_on_frame` allows).
|
||||
pub(super) fn mod_map_decorations(&self, id: i32, frame: bool) -> Vec<NativeMapDecoration> {
|
||||
let Some(map) = self.maps.data.get(&id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let info = NativeMapInfo {
|
||||
id,
|
||||
center: map.center,
|
||||
scale: map.scale,
|
||||
dimension: &map.dimension,
|
||||
locked: map.locked,
|
||||
in_frame: frame,
|
||||
};
|
||||
let mut decorations = Vec::new();
|
||||
self.native_map_decorations(&info, &mut decorations);
|
||||
decorations
|
||||
.into_iter()
|
||||
.filter(|d| !frame || d.shown_on_frame)
|
||||
.map(|d| NativeMapDecoration {
|
||||
rot: d.rot & 15,
|
||||
..d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
|
||||
/// arms under it: `first_person::map_arm_poses`).
|
||||
@@ -1803,6 +1891,28 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_icon_sits_where_a_vanilla_decoration_would() {
|
||||
// Half pixels from the map's centre: x 20 is 10 map pixels right of it.
|
||||
let vanilla = Decoration {
|
||||
sprite: "map/decorations/frame",
|
||||
x: 20,
|
||||
y: -30,
|
||||
rot: 4,
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
};
|
||||
assert_eq!(
|
||||
decoration_pose(&vanilla, -0.02),
|
||||
decoration_pose_at(20, -30, 4, -0.02)
|
||||
);
|
||||
let at = decoration_pose_at(20, -30, 0, 0.0).transform_point3(glam::Vec3::ZERO);
|
||||
assert!(
|
||||
(at.x - 74.0).abs() < 0.6 && (at.y - 49.0).abs() < 0.6,
|
||||
"{at:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_target_decoration_type_has_its_sprite() {
|
||||
// MapDecorationTypes: (name, asset).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -286,7 +288,9 @@ use horizon_client::{
|
||||
biome::BiomeColors,
|
||||
block_entities::Pos,
|
||||
chat::ChatSource,
|
||||
containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModFluidTanks},
|
||||
containers::{
|
||||
BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks,
|
||||
},
|
||||
crafting::{CustomRecipe, Ingredient, recipe_result},
|
||||
extras::Corner,
|
||||
extras_hud::Corners,
|
||||
@@ -1146,6 +1150,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).
|
||||
@@ -1246,6 +1268,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`
|
||||
@@ -1294,6 +1332,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
|
||||
@@ -1394,6 +1463,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) {
|
||||
@@ -1620,6 +1710,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
|
||||
@@ -1718,6 +1824,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
|
||||
@@ -2173,7 +2299,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,
|
||||
@@ -2583,6 +2711,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
|
||||
@@ -2649,6 +2799,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
|
||||
@@ -2706,6 +2896,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
|
||||
@@ -2945,6 +3169,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.
|
||||
@@ -3739,6 +4018,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.
|
||||
@@ -4504,6 +4798,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;
|
||||
@@ -4951,6 +5273,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);
|
||||
@@ -5452,7 +5791,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;
|
||||
|
||||
@@ -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
|
||||
@@ -2176,19 +2193,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);
|
||||
@@ -6895,6 +6900,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;
|
||||
@@ -7855,7 +7868,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 {
|
||||
@@ -8136,16 +8155,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,
|
||||
@@ -8283,9 +8311,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, cit_assets, clouds, collisions, command_block, containers, crash, datapack,
|
||||
dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics,
|
||||
graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects,
|
||||
java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
|
||||
options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections,
|
||||
post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk,
|
||||
section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
|
||||
trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
pub mod auto_jump;
|
||||
pub mod banner;
|
||||
pub mod beehive;
|
||||
pub mod benchmark;
|
||||
pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod 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,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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -1184,6 +1184,12 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
Species::Guardian if !m.fluids.in_water => "entity.guardian.ambient_land",
|
||||
Species::ElderGuardian if !m.fluids.in_water => "entity.elder_guardian.ambient_land",
|
||||
Species::Axolotl if !m.fluids.in_water => "entity.axolotl.idle_air",
|
||||
// Villager.getAmbientSound: silent while asleep, the trade sound
|
||||
// while a player has its menu open.
|
||||
Species::Villager if super::villager::sleeping(m) => return,
|
||||
Species::Villager if m.extra.flag2 => "entity.villager.trade",
|
||||
// WanderingTrader.getAmbientSound: only the trade branch, it never sleeps.
|
||||
Species::WanderingTrader if m.extra.flag2 => "entity.wandering_trader.trade",
|
||||
Species::Bat if m.extra.flag && m.random.next_int_bound(4).unwrap_or(1) != 0 => return,
|
||||
Species::Allay if m.extra.main_hand.is_some() => "entity.allay.ambient_with_item",
|
||||
// Strider.getAmbientSound: quiet while panicking or tempted.
|
||||
@@ -3122,6 +3128,100 @@ 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 villager_ambient_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
|
||||
/// The ambient sound one tick of `play_ambient` makes, after `setup`.
|
||||
fn sound(
|
||||
w: &MobWorld<'_>,
|
||||
types: &crate::mobs::MobTypes,
|
||||
kind: &str,
|
||||
setup: impl FnOnce(&mut Mob),
|
||||
) -> Option<&'static str> {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types[kind].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
setup(&mut m);
|
||||
let mut events = Vec::new();
|
||||
play_ambient(&mut m, w, &mut events);
|
||||
events.into_iter().find_map(|e| match e {
|
||||
MobEvent::Sound(s, ..) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `Villager.getAmbientSound`: silent while asleep, the trade sound while a
|
||||
/// player has its menu open, ambient otherwise. `WanderingTrader` only has
|
||||
/// the trade branch.
|
||||
#[test]
|
||||
fn a_villager_is_silent_asleep_and_noisy_while_trading() {
|
||||
with_test_world(&[], |w, types| {
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |_| {}),
|
||||
Some("entity.villager.ambient")
|
||||
);
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |m| m.extra.flag2 = true),
|
||||
Some("entity.villager.trade")
|
||||
);
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |m| m.extra.ints[1] = 1),
|
||||
None,
|
||||
"asleep"
|
||||
);
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:villager", |m| {
|
||||
m.extra.ints[1] = 1;
|
||||
m.extra.flag2 = true;
|
||||
}),
|
||||
None,
|
||||
"sleeping wins over trading"
|
||||
);
|
||||
let trading = |m: &mut Mob| m.extra.flag2 = true;
|
||||
assert_eq!(
|
||||
sound(w, types, "minecraft:wandering_trader", trading),
|
||||
Some("entity.wandering_trader.trade")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fox_screech_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:#?}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user