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-6252
# Conflicts: # crates/horizon-client/src/game/explosion.rs
This commit is contained in:
commit
ea514b4c6b
185 files changed
+4335
-691
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
|
||||
|
||||
@@ -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"
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -4079,14 +4080,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);
|
||||
@@ -8246,19 +8251,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);
|
||||
}
|
||||
}
|
||||
@@ -2740,6 +2740,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;
|
||||
}
|
||||
|
||||
@@ -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,25 @@ 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",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
@@ -424,6 +478,7 @@ 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)),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
@@ -483,7 +538,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 +584,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 +642,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 +672,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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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, 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2415,6 +2415,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,
|
||||
@@ -11777,6 +11781,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 +17981,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(),
|
||||
|
||||
@@ -1047,6 +1047,13 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
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;
|
||||
|
||||
@@ -5908,7 +5908,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));
|
||||
|
||||
@@ -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
|
||||
@@ -605,9 +614,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
@@ -1201,6 +1216,20 @@ mod tests {
|
||||
assert_eq!(blast_resistance(4.0, None, false), 4.0, "another blast");
|
||||
}
|
||||
|
||||
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
|
||||
/// off, and is thrown and hurt as usual by one that destroys blocks or by
|
||||
/// any blast with the rule back on.
|
||||
#[test]
|
||||
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
|
||||
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
|
||||
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
|
||||
assert!(!items_ignore_explosion(
|
||||
false,
|
||||
BlockInteraction::DestroyWithDecay
|
||||
));
|
||||
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasts_reach_entities_within_their_diameter() {
|
||||
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1146,6 +1146,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).
|
||||
@@ -2173,7 +2191,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 +2603,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
|
||||
@@ -5452,7 +5494,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, "");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -9567,7 +9572,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 +9610,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 +9619,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
|
||||
@@ -9847,7 +9877,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 +10587,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3177,8 +3177,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 +3185,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 +3205,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 +3710,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, 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
|
||||
@@ -3122,6 +3122,37 @@ mod forgiveness_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod endermite_goal_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
|
||||
/// `Endermite.registerGoals` is the silverfish's family list without the
|
||||
/// stone tricks: a powder-snow climb, a stroll at 1.0 and a
|
||||
/// `HurtByTargetGoal` that alerts the others. The generic monster block
|
||||
/// an unlisted species gets has none of those (stroll 0.8, no alert).
|
||||
#[test]
|
||||
fn an_endermite_takes_the_hostile_goal_list() {
|
||||
let ran = with_test_world(&[], |_, types| {
|
||||
let m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types["minecraft:endermite"].clone()),
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(family(m.species()), Family::Hostile);
|
||||
assert!(
|
||||
m.ai.goals.has("ClimbOnTopOfPowderSnow"),
|
||||
"ClimbOnTopOfPowderSnowGoal"
|
||||
);
|
||||
assert!(m.ai.goals.has("Stroll"), "WaterAvoidingRandomStrollGoal");
|
||||
assert!(m.ai.targets.has("HurtByTarget"));
|
||||
assert!(m.ai.targets.has("NearestTarget"));
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fox_screech_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -64,6 +64,19 @@ pub fn register(m: &mut Mob) {
|
||||
m.ai.targets.add(1, HurtByTarget::new().alert());
|
||||
m.ai.targets.add(2, NearestTarget::player(true));
|
||||
}
|
||||
// Endermite.registerGoals: the silverfish's list without the stone
|
||||
// tricks, at vanilla's priorities (stroll 1.0, alert others on hurt).
|
||||
S::Endermite => {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(1, Float);
|
||||
g.add(1, ClimbOnTopOfPowderSnow);
|
||||
g.add(2, MeleeAttack::new(1.0, false));
|
||||
g.add(3, Stroll::avoiding_water(1.0));
|
||||
g.add(7, LookAtPlayer::new(8.0));
|
||||
g.add(8, RandomLookAround::default());
|
||||
m.ai.targets.add(1, HurtByTarget::new().alert());
|
||||
m.ai.targets.add(2, NearestTarget::player(true));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,14 +388,18 @@ fn cast_fangs(m: &mut Mob, cx: &mut Cx) {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `createSpellEntity`: the first sturdy floor going down from `max_y`,
|
||||
/// standing on the top of any block in the way.
|
||||
/// `createSpellEntity`: the first block below `max_y` whose up face is
|
||||
/// sturdy, standing on the top of whatever collision shape is in the way.
|
||||
///
|
||||
/// The floor test is `BlockState.isFaceSturdy(.., UP)`, not a full cube: a
|
||||
/// soul sand or mud block is 14/16 high and still supports a fang, where
|
||||
/// requiring a full collision shape drops the fang into the block.
|
||||
fn fang_floor(w: &super::MobWorld, x: f64, z: f64, min_y: f64, max_y: f64) -> Option<f64> {
|
||||
let (bx, bz) = (x.floor() as i32, z.floor() as i32);
|
||||
let mut y = max_y.floor() as i32;
|
||||
while y >= min_y.floor() as i32 - 1 {
|
||||
let below = w.facts.get(w.world.block(bx, y - 1, bz));
|
||||
if below.full_collision {
|
||||
let below = w.world.block(bx, y - 1, bz);
|
||||
if w.data.state(below).flags & super::data::STURDY_TOP != 0 {
|
||||
let here = w.world.block(bx, y, bz);
|
||||
let top = if w.facts.is_air(here) {
|
||||
0.0
|
||||
@@ -932,4 +936,35 @@ mod tests {
|
||||
.is_none_or(|d| d.charged.is_empty())
|
||||
);
|
||||
}
|
||||
/// `EvokerAttackSpellGoal.createSpellEntity` asks the block below for a
|
||||
/// sturdy up face, not for a full cube. Soul sand is 14/16 high and still
|
||||
/// supports a fang, which a full-collision test drops into the block.
|
||||
#[test]
|
||||
fn a_fang_stands_on_a_soul_sand_floor() {
|
||||
let build = |world: &mut crate::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry| {
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let soul_sand = registry.default_state("minecraft:soul_sand").unwrap();
|
||||
for cx in -1..1 {
|
||||
for cz in -1..1 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for x in -4..4 {
|
||||
for z in -4..4 {
|
||||
world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
world.set(0, 64, 0, soul_sand).unwrap();
|
||||
};
|
||||
let ran = crate::mobs::world::with_built_test_world(&[], build, |w, _| {
|
||||
// An evoker standing on the soul sand has its feet at 64.875, so
|
||||
// the cast asks for a floor from 65 down: the soul sand at 64 is
|
||||
// the first sturdy block and the fang sits at 65.
|
||||
assert_eq!(fang_floor(w, 0.5, 0.5, 64.0, 65.875), Some(65.0));
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,11 @@ pub fn can_be_leashed(m: &Mob) -> bool {
|
||||
| S::Pufferfish
|
||||
| S::Tadpole => false,
|
||||
S::Wolf => !super::tame::angry(m),
|
||||
// Nautilus.canBeLeashed and ZombieNautilus.canBeLeashed: never while
|
||||
// it is aggravated. The zombie also refuses while a mob is riding it
|
||||
// (`isMobControlled`), which a mob alone cannot see.
|
||||
// shortcut: isMobControlled, when a mob can ride a zombie nautilus.
|
||||
S::Nautilus | S::ZombieNautilus => !super::nautilus::aggravated(m),
|
||||
_ => !m.hostile() && !m.removed,
|
||||
}
|
||||
}
|
||||
@@ -574,6 +579,36 @@ mod tests {
|
||||
assert!(near(p[2], [lr, y, -fb + f]));
|
||||
assert!(near(p[3], [lr, y, fb + f]));
|
||||
}
|
||||
/// `Nautilus.canBeLeashed` and `ZombieNautilus.canBeLeashed`: an
|
||||
/// aggravated nautilus, one holding an `ANGRY_AT` or an `ATTACK_TARGET`
|
||||
/// memory, cannot be put on a lead.
|
||||
#[test]
|
||||
fn an_aggravated_nautilus_cannot_be_leashed() {
|
||||
let ran = crate::mobs::world::with_test_world(&[], |_, types| {
|
||||
let new = |kind: &str| {
|
||||
Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types[kind].clone()),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
let calm = new("minecraft:nautilus");
|
||||
assert!(can_be_leashed(&calm), "a calm nautilus");
|
||||
let mut angry = new("minecraft:nautilus");
|
||||
angry.extra.anger = 400;
|
||||
assert!(!can_be_leashed(&angry), "ANGRY_AT");
|
||||
let mut charging = new("minecraft:zombie_nautilus");
|
||||
charging.ai.target = Some(crate::mobs::Who::Player);
|
||||
assert!(!can_be_leashed(&charging), "ATTACK_TARGET");
|
||||
assert!(
|
||||
can_be_leashed(&new("minecraft:zombie_nautilus")),
|
||||
"a calm zombie nautilus"
|
||||
);
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_horses_camels_and_sniffers_support_a_quad_lead() {
|
||||
assert!(supports_quad_leash(Species::Horse));
|
||||
|
||||
@@ -350,6 +350,12 @@ pub fn client_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
fn in_water_at(w: &MobWorld, p: Vec3) -> bool {
|
||||
w.fluid(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32) == Fluid::Water
|
||||
}
|
||||
/// `AbstractNautilus.isAggravated`: the brain holds an `ANGRY_AT` or an
|
||||
/// `ATTACK_TARGET` memory. Horizon keeps those as the anger timer (whose
|
||||
/// target is `last_hurt_by`) and the current attack target.
|
||||
pub fn aggravated(m: &Mob) -> bool {
|
||||
m.extra.anger > 0 || m.ai.target.is_some()
|
||||
}
|
||||
/// `NautilusAi.findNearestValidAttackTarget`: whoever it is angry at, else
|
||||
/// now and then (a coin flip every 2400 to 3600 ticks) the nearest pufferfish.
|
||||
fn find_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
|
||||
|
||||
@@ -122,6 +122,11 @@ impl Navigation {
|
||||
)
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.getComfortableFallDistance`: the drop an entity accepts on
|
||||
/// top of what it was asked for.
|
||||
fn comfortable_fall_distance(f: f32) -> i32 {
|
||||
(f + 3.0).floor() as i32
|
||||
}
|
||||
impl Mob {
|
||||
pub fn path_mob(&self) -> PathMob {
|
||||
PathMob {
|
||||
@@ -140,14 +145,19 @@ impl Mob {
|
||||
stands_on_lava: self.kind.flags.lava_walker,
|
||||
}
|
||||
}
|
||||
/// Mob.getMaxFallDistance (normal difficulty).
|
||||
/// `Mob.getMaxFallDistance` (normal difficulty), with `Creeper`'s
|
||||
/// override: a creeper that has a target falls by its own health, so a
|
||||
/// full-health one walks off a 22-block drop instead of 3.
|
||||
pub fn max_fall_distance(&self) -> i32 {
|
||||
if self.ai.target.is_none() {
|
||||
return 3;
|
||||
return comfortable_fall_distance(0.0);
|
||||
}
|
||||
if self.species() == super::Species::Creeper {
|
||||
return comfortable_fall_distance(self.health - 1.0);
|
||||
}
|
||||
let mut sacrifice = (self.health - self.attrs.max_health * 0.33) as i32;
|
||||
sacrifice -= 4;
|
||||
sacrifice.max(0) + 3
|
||||
comfortable_fall_distance(sacrifice.max(0) as f32)
|
||||
}
|
||||
pub fn malus(&self, t: PathType) -> f32 {
|
||||
self.ai.malus[t as usize].unwrap_or_else(|| t.malus())
|
||||
@@ -744,4 +754,23 @@ mod tests {
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// `Creeper.getMaxFallDistance` replaces the shared
|
||||
/// `health - a third` formula with its own `health - 1`: a creeper
|
||||
/// chasing something drops 22 blocks at full health and 6 at four,
|
||||
/// where the shared formula would have given 3.
|
||||
#[test]
|
||||
fn a_creeper_falls_by_its_own_health_while_it_has_a_target() {
|
||||
// Skipped without the extracted vanilla data (CI's verify has none).
|
||||
crate::mobs::world::with_test_world(&[], |_, types| {
|
||||
let kind = std::sync::Arc::new(types.types["minecraft:creeper"].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
assert_eq!(m.max_fall_distance(), 3, "no target");
|
||||
m.ai.target = Some(crate::mobs::Who::Player);
|
||||
m.health = 20.0;
|
||||
assert_eq!(m.max_fall_distance(), 22, "full health, has a target");
|
||||
m.health = 4.0;
|
||||
assert_eq!(m.max_fall_distance(), 6, "hurt, has a target");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1703,6 +1703,21 @@ pub fn ai_step(
|
||||
if !w.nether && !m.extra.flag2 && m.alive() {
|
||||
m.extra.counter2 += 1;
|
||||
if m.extra.counter2 > CONVERSION {
|
||||
// `Piglin.finishConversion` runs `PiglinAi.cancelAdmiring` first:
|
||||
// the item the piglin is admiring leaves the off hand and lands
|
||||
// where it stands, so the zombified piglin never carries it.
|
||||
if m.species() == Species::Piglin {
|
||||
let admiring = (m.extra.off_hand.is_some() && m.extra.using_item > 0)
|
||||
|| m.extra.piglin.seen_admire > 0;
|
||||
if admiring && let Some(item) = m.extra.off_hand.take() {
|
||||
events.push(MobEvent::DropItem {
|
||||
item,
|
||||
count: 1,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
});
|
||||
m.extra.using_item = 0;
|
||||
}
|
||||
}
|
||||
// `Hoglin.finishConversion` is `ConversionParams.single(this, true,
|
||||
// false)`, `AbstractPiglin.finishConversion` `(this, true, true)`;
|
||||
// both leave the new mob nauseous (the game adds it).
|
||||
@@ -2172,3 +2187,47 @@ mod crossbow_tests {
|
||||
assert_eq!(f.seen_admire, 40);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conversion_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
|
||||
/// `Piglin.finishConversion` cancels the admire before it converts: the
|
||||
/// item in the off hand is dropped at the piglin's feet and the zombified
|
||||
/// piglin does not carry it, where Horizon used to hand it over.
|
||||
#[test]
|
||||
fn a_converted_piglin_drops_the_item_it_was_admiring() {
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(types.types["minecraft:piglin"].clone()),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
m.extra.off_hand = Some("minecraft:gold_ingot".into());
|
||||
m.extra.using_item = 60;
|
||||
m.extra.counter2 = CONVERSION + 1;
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
ai_step(&mut m, w, &mut lr, &mut events);
|
||||
assert!(
|
||||
events.iter().any(|e| matches!(
|
||||
e,
|
||||
MobEvent::DropItem { item, count, .. }
|
||||
if &**item == "minecraft:gold_ingot" && *count == 1
|
||||
)),
|
||||
"the admired item is dropped"
|
||||
);
|
||||
let from = events
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
MobEvent::Convert { from, .. } => Some(from),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the piglin converts");
|
||||
assert_eq!(from.extra.off_hand, None, "the new mob carries nothing");
|
||||
});
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -86,17 +86,20 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Raider.class`: the species a goal that takes `Raider.class` filters out
|
||||
/// (`HurtByTargetGoal(this, Raider.class)`). A vex is not a raider.
|
||||
pub const RAIDERS: [Species; 6] = [
|
||||
Species::Pillager,
|
||||
Species::Vindicator,
|
||||
Species::Evoker,
|
||||
Species::Illusioner,
|
||||
Species::Witch,
|
||||
Species::Ravager,
|
||||
];
|
||||
|
||||
/// The raider species (`EntityTypeTags.RAIDERS`).
|
||||
pub fn is_raider(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Pillager
|
||||
| Species::Vindicator
|
||||
| Species::Evoker
|
||||
| Species::Illusioner
|
||||
| Species::Witch
|
||||
| Species::Ravager
|
||||
)
|
||||
RAIDERS.contains(&s)
|
||||
}
|
||||
/// `canBeLeader`: witches and ravagers never lead.
|
||||
pub fn can_be_leader(s: Species) -> bool {
|
||||
|
||||
@@ -1646,6 +1646,27 @@ pub fn render_posed(
|
||||
let base = base_transform_at(m, alpha, origin);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
|
||||
}
|
||||
/// The texture a model drawn as `custom` takes: the `.jem`'s when the atlas has it, else
|
||||
/// `vanilla` (a texture the atlas lacks would hide the model).
|
||||
fn cem_texture<'a>(
|
||||
custom: Option<&'a super::cem::CemLayer>,
|
||||
vanilla: &'a str,
|
||||
atlas: Atlas<'_>,
|
||||
) -> &'a str {
|
||||
custom
|
||||
.and_then(|c| c.texture.as_deref())
|
||||
.filter(|t| atlas(t).is_some())
|
||||
.unwrap_or(vanilla)
|
||||
}
|
||||
/// Whether `layers[i]` is the layer its model's `.jem` was made for, so the `.jem`'s texture
|
||||
/// paints it: the first drawn with that model (`sheep#wool`, a wolf's armour). Later layers
|
||||
/// on the same model are overlays (the armour's cracks), like those on the body's model
|
||||
/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures
|
||||
/// over the new geometry.
|
||||
fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool {
|
||||
let key = layers[i].model.as_str();
|
||||
!body.contains(&key) && layers[..i].iter().all(|l| l.model != key)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_visible(
|
||||
m: &Mob,
|
||||
@@ -1673,20 +1694,17 @@ fn render_visible(
|
||||
} else {
|
||||
model_key.as_str()
|
||||
};
|
||||
let body_animation = cem.layer(cem_key).and_then(|c| c.animation.as_ref());
|
||||
let (model, texture) = match cem.layer(cem_key) {
|
||||
Some(custom) => (
|
||||
custom.model.clone(),
|
||||
custom.texture.clone().unwrap_or(texture),
|
||||
),
|
||||
None => (
|
||||
m.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone()),
|
||||
texture,
|
||||
),
|
||||
let custom_body = cem.layer(cem_key);
|
||||
let body_animation = custom_body.and_then(|c| c.animation.as_ref());
|
||||
let model = match custom_body {
|
||||
Some(custom) => custom.model.clone(),
|
||||
None => m
|
||||
.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone()),
|
||||
};
|
||||
let texture = cem_texture(custom_body, &texture, atlas);
|
||||
// OptiFine random textures: the sprite of the variant the mob wears for a texture, when
|
||||
// the atlas has it. The texture keeps its name for everything else.
|
||||
let random = data
|
||||
@@ -1707,7 +1725,7 @@ fn render_visible(
|
||||
let draw_body = |out: &mut Vec<Vertex>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let uv = random_uv(&texture).or_else(|| atlas(&texture)).or_else(|| {
|
||||
let uv = random_uv(texture).or_else(|| atlas(texture)).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
@@ -1757,21 +1775,24 @@ fn render_visible(
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
for mut layer in all_layers {
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem.layer(&layer.model);
|
||||
// `layer.texture` stays the vanilla name (the villager hats below look for its
|
||||
// `/type/` folder).
|
||||
let texture = cem_texture(
|
||||
custom.filter(|_| paints_with_jem(&all_layers, i, body_keys)),
|
||||
&layer.texture,
|
||||
atlas,
|
||||
);
|
||||
let part = match custom {
|
||||
Some(custom) => {
|
||||
if let Some(texture) = &custom.texture {
|
||||
layer.texture.clone_from(texture);
|
||||
}
|
||||
custom.model.clone()
|
||||
}
|
||||
Some(custom) => custom.model.clone(),
|
||||
None => match m.kind.layer(&layer.model) {
|
||||
Some(part) => part.clone(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let Some(uv) = random_uv(&layer.texture).or_else(|| atlas(&layer.texture)) else {
|
||||
let Some(uv) = random_uv(texture).or_else(|| atlas(texture)) else {
|
||||
continue;
|
||||
};
|
||||
layer_poses.reset(&part);
|
||||
@@ -1815,7 +1836,7 @@ fn render_visible(
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
if translucent_layer(m, &layer) {
|
||||
if translucent_layer(m, layer) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
// The eyes render type's glowing eyes also shine in HDR output, at
|
||||
@@ -1927,7 +1948,8 @@ pub fn part_matrix(
|
||||
.iter()
|
||||
.find_map(|c| part_matrix(c, m, poses, name, x_offset))
|
||||
}
|
||||
fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
/// The first part named `name`, depth first.
|
||||
pub(super) fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
if part.name == name {
|
||||
return Some(part);
|
||||
}
|
||||
|
||||
@@ -262,6 +262,13 @@ pub fn drowned_travel(
|
||||
m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9);
|
||||
true
|
||||
}
|
||||
/// `RangedAttackMob.rangedAttackUncertainty`: 14 on peaceful, four less per
|
||||
/// difficulty step, so a hard drowned throws its trident almost straight.
|
||||
/// `Drowned` overrides it to return its own field when that is not negative,
|
||||
/// but nothing in the game sets that field, so the default always applies.
|
||||
fn ranged_attack_uncertainty(difficulty: u8) -> f32 {
|
||||
14.0 - 4.0 * f32::from(difficulty)
|
||||
}
|
||||
/// The trident throw of `Drowned.performRangedAttack`: a `ThrownTrident` of
|
||||
/// the held trident (a plain one when it holds none), owned by the drowned,
|
||||
/// which cannot be picked up and does not return (`AbstractArrow` constructor:
|
||||
@@ -291,7 +298,7 @@ fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
|
||||
&mut crate::projectiles::own_random(m),
|
||||
[xd, yd + dist * f64::from(0.2_f32), zd],
|
||||
1.6,
|
||||
(14 - 2 * 4) as f32,
|
||||
ranged_attack_uncertainty(cx.w.difficulty.id),
|
||||
);
|
||||
cx.events.push(MobEvent::Projectile(p));
|
||||
let pitch = 1.0 / (m.random.next_float() * 0.4 + 0.8);
|
||||
@@ -1353,3 +1360,19 @@ mod conversion_tests {
|
||||
assert_eq!(z.extra.feet.as_deref(), Some("minecraft:golden_boots"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trident_tests {
|
||||
use super::ranged_attack_uncertainty;
|
||||
|
||||
/// `RangedAttackMob.rangedAttackUncertainty` is 14 minus four per
|
||||
/// difficulty step, so a drowned's trident spread shrinks from 14 on
|
||||
/// peaceful to 2 on hard. Horizon used to hard-code the normal value.
|
||||
#[test]
|
||||
fn the_trident_spread_follows_the_difficulty() {
|
||||
assert_eq!(ranged_attack_uncertainty(0), 14.0, "peaceful");
|
||||
assert_eq!(ranged_attack_uncertainty(1), 10.0, "easy");
|
||||
assert_eq!(ranged_attack_uncertainty(2), 6.0, "normal");
|
||||
assert_eq!(ranged_attack_uncertainty(3), 2.0, "hard");
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ pub fn register(m: &mut Mob) {
|
||||
g.add(2, Stroll::avoiding_water(1.0));
|
||||
g.add(3, LookAtPlayer::new(8.0));
|
||||
g.add(3, RandomLookAround::default());
|
||||
m.ai.targets.add(1, HurtByTarget::new());
|
||||
// HurtByTargetGoal(this, Raider.class): a witch ignores the raider that
|
||||
// hit it, so raid friendly fire never turns the raid on itself.
|
||||
m.ai.targets.add(1, hurt_by());
|
||||
m.ai.targets.add(2, HealRaiders::default());
|
||||
// NearestAttackableWitchTargetGoal: only while not healing raiders.
|
||||
m.ai.targets.add(
|
||||
@@ -30,6 +32,10 @@ pub fn register(m: &mut Mob) {
|
||||
.named("NearestAttackableWitchTargetGoal"),
|
||||
);
|
||||
}
|
||||
/// `Witch.registerGoals`' `HurtByTargetGoal(this, Raider.class)`.
|
||||
fn hurt_by() -> HurtByTarget {
|
||||
HurtByTarget::new().ignoring(&super::raider::RAIDERS)
|
||||
}
|
||||
/// `NearestHealableRaiderTargetGoal`: in a raid, target another raider to
|
||||
/// heal, then wait 10 seconds (the cooldown lives in `extra.counter2`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -361,3 +367,42 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
}
|
||||
let _ = Species::Witch;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `Witch.registerGoals` passes `Raider.class` to `HurtByTargetGoal`, so a
|
||||
/// raider that hits a witch is not taken as its target. The list is the
|
||||
/// `Raider` class and nothing else: a vex is not a raider.
|
||||
#[test]
|
||||
fn a_witch_ignores_the_raider_that_hit_it() {
|
||||
let goal = hurt_by();
|
||||
assert_eq!(
|
||||
goal.ignore,
|
||||
vec![
|
||||
Species::Pillager,
|
||||
Species::Vindicator,
|
||||
Species::Evoker,
|
||||
Species::Illusioner,
|
||||
Species::Witch,
|
||||
Species::Ravager,
|
||||
]
|
||||
);
|
||||
assert!(!goal.ignore.contains(&Species::Vex));
|
||||
assert!(!goal.ignore.contains(&Species::Zombie));
|
||||
}
|
||||
|
||||
/// The hoisted list is exactly the set `is_raider` answers yes for.
|
||||
#[test]
|
||||
fn the_raider_list_is_the_raider_class() {
|
||||
for def in crate::mobs::species::ALL {
|
||||
assert_eq!(
|
||||
crate::mobs::raider::is_raider(def.species),
|
||||
super::super::raider::RAIDERS.contains(&def.species),
|
||||
"{:?}",
|
||||
def.species
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,16 @@ pub(crate) fn with_built_test_world(
|
||||
others: &[Seen],
|
||||
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
|
||||
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
|
||||
) -> bool {
|
||||
with_built_test_world_rules(others, true, build, f)
|
||||
}
|
||||
/// `with_built_test_world` with the `mob_griefing` game rule the world reports.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_built_test_world_rules(
|
||||
others: &[Seen],
|
||||
mob_griefing: bool,
|
||||
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
|
||||
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
|
||||
) -> bool {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
@@ -454,7 +464,7 @@ pub(crate) fn with_built_test_world(
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
mob_griefing,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
|
||||
@@ -764,6 +764,25 @@ impl<'a> Plan<'a> {
|
||||
.into());
|
||||
}
|
||||
}
|
||||
// A block's states are its template's times the declared combinations, the
|
||||
// template's properties varying slowest, but the game reads a state table the
|
||||
// way vanilla's `StateDefinition` does: sorted by property name, the first name
|
||||
// slowest. The two agree only when every declared name sorts after the template's,
|
||||
// and a table that disagrees stops the game at load, so such a block is refused.
|
||||
let last = match properties(default) {
|
||||
Some(J::Obj(own)) => own.iter().map(|(k, _)| k.as_str()).max(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(last) = last
|
||||
&& let Some(early) = decl.state_properties.keys().find(|k| k.as_str() < last)
|
||||
{
|
||||
return Err(format!(
|
||||
"{}: state property {early} sorts before {last}, a property of {}: the \
|
||||
game orders states by property name, so name it after {last}",
|
||||
decl.id, decl.template
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let combos = combinations(&decl.state_properties, &decl.default_state)
|
||||
.map_err(|e| format!("{}: {e}", decl.id))?;
|
||||
// A `default_state` key the block itself declares is its own,
|
||||
@@ -966,7 +985,7 @@ impl<'a> Plan<'a> {
|
||||
if !b.decl.item {
|
||||
row.set("item", J::Str("minecraft:air".into()));
|
||||
}
|
||||
self.override_gameplay(b, &mut row);
|
||||
self.override_gameplay(b, combo, &mut row);
|
||||
if let Some(shapes) = &b.shapes {
|
||||
let shape = &shapes[t];
|
||||
let boxes = shape
|
||||
@@ -1003,8 +1022,11 @@ impl<'a> Plan<'a> {
|
||||
}
|
||||
|
||||
/// The declared properties, on a gameplay row.
|
||||
fn override_gameplay(&self, b: &NewBlock, row: &mut J) {
|
||||
fn override_gameplay(&self, b: &NewBlock, combo: &[(String, String)], row: &mut J) {
|
||||
let p = &b.decl.properties;
|
||||
if let Some(ticks) = &p.random_ticks {
|
||||
row.set("random_ticks", J::Bool(ticks.applies(combo)));
|
||||
}
|
||||
if let Some(v) = p.hardness {
|
||||
row.set("hardness", J::f64(v as f64));
|
||||
}
|
||||
|
||||
@@ -389,7 +389,8 @@ fn ruby_mod(dir: &Path) -> ContentMod {
|
||||
(
|
||||
"data/ruby/horizon/block/ruby_block.json",
|
||||
r##"{"template": "minecraft:iron_block", "properties": {"hardness": 4.0, "light": 3,
|
||||
"sounds": "minecraft:amethyst_block", "map_color": 28, "friction": 0.8},
|
||||
"sounds": "minecraft:amethyst_block", "map_color": 28, "friction": 0.8,
|
||||
"random_ticks": true},
|
||||
"components": {"minecraft:max_stack_size": 16,
|
||||
"minecraft:damage_resistant": {"types": "#minecraft:is_fire"}}}"##,
|
||||
),
|
||||
@@ -533,6 +534,12 @@ fn content_root_loads_with_every_table() {
|
||||
horizon_world::level::Behaviours::load_with_gameplay(new, &root, &shared_json, &gameplay)
|
||||
.unwrap();
|
||||
assert_eq!(shared_facts.states.len(), facts.states.len());
|
||||
// `random_ticks` makes the block tick randomly although its template (iron) never does; the
|
||||
// sections' counts read the same flag (`random_tick_candidate`).
|
||||
assert!(independent_behaviours.is_randomly_ticking(ruby));
|
||||
assert!(independent_behaviours.random_tick_candidate(ruby));
|
||||
assert!(!independent_behaviours.is_randomly_ticking(iron));
|
||||
assert!(deferred_behaviours.is_randomly_ticking(ruby));
|
||||
for state in [ruby, ruby_door, BlockStateId(n + 65)] {
|
||||
assert_eq!(shared_facts.get(state).hardness, facts.get(state).hardness);
|
||||
assert_eq!(
|
||||
@@ -2670,6 +2677,24 @@ fn content_block_state_properties_are_validated() {
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
|
||||
// A declared name that sorts before the template's `axis` would make the state table
|
||||
// disagree with vanilla's order and stop the game at load: refused, naming the block.
|
||||
let (base, mods) = block_base(
|
||||
"block-name-order",
|
||||
&[r#"{"template":"minecraft:test","state_properties":{"alpha":{"type":"boolean"}}}"#],
|
||||
);
|
||||
let error = match Plan::new(&base, &mods) {
|
||||
Err(e) => e.to_string(),
|
||||
Ok(_) => panic!("a property sorting before the template's was accepted"),
|
||||
};
|
||||
assert!(
|
||||
error.contains("testmod:") && error.contains("alpha sorts before axis, a property of"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!error.contains(" "), "one space between words: {error}");
|
||||
fs::remove_dir_all(&base).unwrap();
|
||||
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
|
||||
|
||||
// A default is one of the values the block declared.
|
||||
let (base, mods) = block_base(
|
||||
"block-bad-default",
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
text::{Text, format_translation},
|
||||
ui,
|
||||
};
|
||||
use horizon_assets::item_model::{DisplayContext, NoEnv};
|
||||
use horizon_assets::item_model::{DisplayContext, ItemEnv};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::BTreeSet, path::Path};
|
||||
@@ -889,6 +889,26 @@ fn draw_presets(
|
||||
);
|
||||
}
|
||||
|
||||
/// The item environment of the layer icons: only the grass colormap is needed
|
||||
/// (`minecraft:grass` tint of the grass block), as the inventory draws it.
|
||||
struct GrassEnv<'a>(&'a ItemRenderer);
|
||||
impl ItemEnv for GrassEnv<'_> {
|
||||
fn grass_color(&self, temperature: f64, downfall: f64) -> i32 {
|
||||
self.0.grass_color(temperature, downfall)
|
||||
}
|
||||
}
|
||||
/// A layer's item model for the GUI, with the grass colormap in its environment.
|
||||
fn layer_state(renderer: &ItemRenderer, id: &str) -> ItemRenderState {
|
||||
let stack = ItemStack::new(&layer_item(id), 1);
|
||||
renderer.resolve(
|
||||
&item_render::stack_view(&stack),
|
||||
DisplayContext::Gui,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
&GrassEnv(renderer),
|
||||
)
|
||||
}
|
||||
/// The 18x18 slot behind a layer's item icon (`container/slot`), then the item
|
||||
/// (`GuiGraphics.renderItem`): its GUI model, so a block shows as the isometric
|
||||
/// block. Without item models, or for an id with none, the flat sprite stands in.
|
||||
@@ -908,15 +928,7 @@ fn slot(
|
||||
batch.fill(atlas, x + 1.0, y + 1.0, 16.0, 16.0, rgba(55, 55, 55, 255));
|
||||
}
|
||||
if let Some(renderer) = items {
|
||||
let stack = ItemStack::new(&layer_item(id), 1);
|
||||
let state = renderer.resolve(
|
||||
&item_render::stack_view(&stack),
|
||||
DisplayContext::Gui,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
&NoEnv,
|
||||
);
|
||||
let state = layer_state(renderer, id);
|
||||
if !state.is_empty() {
|
||||
pending.push((state, x + 1.0, y + 1.0));
|
||||
return;
|
||||
@@ -1016,6 +1028,30 @@ fn pretty(name: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::layer_item;
|
||||
use crate::item_render::ItemRenderer;
|
||||
use std::path::Path;
|
||||
|
||||
/// The grass block icon of a Superflat layer is tinted by the grass
|
||||
/// colormap (`minecraft:grass` tint source), not the missing-tint magenta.
|
||||
#[test]
|
||||
fn the_grass_block_layer_icon_is_tinted_by_the_colormap() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-models.json").exists() {
|
||||
return;
|
||||
}
|
||||
let renderer = ItemRenderer::load(&root).unwrap();
|
||||
let tinted: Vec<i32> = super::layer_state(&renderer, "minecraft:grass_block")
|
||||
.layers
|
||||
.iter()
|
||||
.flat_map(|layer| layer.tints.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
!tinted.is_empty() && !tinted.contains(&-65281),
|
||||
"{tinted:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_shows_the_item_of_its_block() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Native component registration, value limits and content identity.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
content_identity,
|
||||
mod_content::{ContentMod, accept_mod, build, known_components},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
item::Slot,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{item_sync, items::ItemStack, lan::guest::stack_to_network};
|
||||
use horizon_items::item_nbt::{item_from_nbt, item_to_nbt};
|
||||
use horizon_protocol::{
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! column's status (`ChunkLevel.generationStatus`). The chunk itself and the
|
||||
//! two rings around it are level chunks with their light, the third ring has
|
||||
//! its features and the fourth only its terrain.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! after its own decoration and its eight neighbours', and chunks that
|
||||
//! decoration wrote to are saved rather than generated again without those
|
||||
//! writes. Bastions lost whole chunks of pieces this way.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! handshake, status, offline login, configuration (registries and tags),
|
||||
//! then the play state (login, chunks with light, player list, entities,
|
||||
//! block changes). Needs the prepared resources; skipped without them.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
chunk::{Chunk, MIN_Y},
|
||||
lan::{
|
||||
@@ -1444,3 +1445,46 @@ fn a_respawned_guest_is_immune_until_its_client_loads_the_world() {
|
||||
"the packet marks it loaded"
|
||||
);
|
||||
}
|
||||
|
||||
/// `tickPlayer`'s vehicle check (BoatFly): the driver of a boat its last accepted move left hovering
|
||||
/// over air is kicked once the boat has floated longer than `getMaximumFlyingTicks` (160 ticks at a
|
||||
/// boat's gravity), and a move that doesn't float starts the count again.
|
||||
#[test]
|
||||
fn a_boat_floating_too_long_kicks_its_driver() {
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "Boater", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
let g = &mut s.hub.guests[0];
|
||||
(g.vehicle, g.vehicle_floating, g.vehicle_gravity) = (Some(7), true, 0.04);
|
||||
for _ in 0..100 {
|
||||
s.tick(&[]);
|
||||
}
|
||||
assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 100);
|
||||
// The boat touched water: the next floating move counts from zero.
|
||||
s.hub.guests[0].vehicle_floating = false;
|
||||
s.tick(&[]);
|
||||
assert_eq!(s.hub.guests[0].above_ground_vehicle_ticks, 0);
|
||||
s.hub.guests[0].vehicle_floating = true;
|
||||
for _ in 0..160 {
|
||||
s.tick(&[]);
|
||||
}
|
||||
assert!(
|
||||
s.hub.guests[0].left_reason.is_empty(),
|
||||
"160 ticks are a boat's allowance: {}",
|
||||
s.hub.guests[0].left_reason
|
||||
);
|
||||
s.tick(&[]);
|
||||
assert!(
|
||||
!s.hub.guests[0].left_reason.is_empty(),
|
||||
"the 161st floating tick kicks the driver"
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! came closer, so they stayed invisible until a block in them changed.
|
||||
//! Relit columns remesh only where their light changed (#270), and every
|
||||
//! mesh still matches the world.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! A level taller than vanilla's (To The Max's Overworld: -64..2032, 131
|
||||
//! sections) streams, lights and meshes to its top, and edits near the top
|
||||
//! relight and remesh it.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! content mods can take the registry past 65,536 states. Its own test binary,
|
||||
//! since the palette width is set once for the whole process.
|
||||
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::chunk::{Chunk, Section, block_strategy, set_block_states};
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -176,9 +176,21 @@ mod tests {
|
||||
let (mut ended, _) = l.accept().unwrap();
|
||||
drop(deadline(&kept, Duration::from_millis(100)).unwrap());
|
||||
let _running = deadline(&ended, Duration::from_millis(100)).unwrap();
|
||||
// Read once the limit has passed: a read already blocked when it
|
||||
// passes is not woken on Windows (shutdown doesn't cancel it there).
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
ended
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let started = Instant::now();
|
||||
assert_eq!(ended.read(&mut [0u8; 1]).unwrap(), 0);
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
match ended.read(&mut [0u8; 1]) {
|
||||
Ok(n) => assert_eq!(n, 0),
|
||||
Err(e) => assert!(!matches!(
|
||||
e.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
)),
|
||||
}
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
kept.set_read_timeout(Some(Duration::from_millis(300)))
|
||||
.unwrap();
|
||||
let e = kept.read(&mut [0u8; 1]).unwrap_err();
|
||||
@@ -187,4 +199,32 @@ mod tests {
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_ends_a_peer_that_sends_one_byte_at_a_time() {
|
||||
use std::io::Write;
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut peer = TcpStream::connect(l.local_addr().unwrap()).unwrap();
|
||||
let (mut s, _) = l.accept().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..100 {
|
||||
if peer.write_all(b"x").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
// What the callers do: a read timeout that restarts with every byte.
|
||||
s.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
|
||||
let _running = deadline(&s, Duration::from_millis(300)).unwrap();
|
||||
let started = Instant::now();
|
||||
while let Ok(n) = s.read(&mut [0u8; 1])
|
||||
&& n > 0
|
||||
{}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"{:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
Generated
+34
-1
@@ -306,6 +306,15 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
@@ -319,7 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
@@ -506,6 +515,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -783,6 +793,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -911,6 +931,19 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
|
||||
@@ -86,6 +86,35 @@ pub struct BlockProperties {
|
||||
pub map_color: Option<u8>,
|
||||
/// A vanilla block whose sounds this block uses.
|
||||
pub sounds: Option<String>,
|
||||
/// Which states tick randomly, whatever the template does (`isRandomlyTicking`).
|
||||
pub random_ticks: Option<RandomTicks>,
|
||||
}
|
||||
|
||||
/// Which of a block's states get random ticks (`BlockBehaviour.isRandomlyTicking(state)`), so a
|
||||
/// crop, sapling or soil on a template that doesn't tick still grows. The sections' random tick
|
||||
/// counts follow it, and `native_random_tick` fires for the states it picks.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum RandomTicks {
|
||||
/// Every state (`true`), or none (`false`, for a template that ticks).
|
||||
All(bool),
|
||||
/// The states whose own state property `property` is one of `values`.
|
||||
States {
|
||||
property: String,
|
||||
values: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RandomTicks {
|
||||
/// Whether a state ticks, `combo` being its values of the block's own state properties.
|
||||
pub fn applies(&self, combo: &[(String, String)]) -> bool {
|
||||
match self {
|
||||
RandomTicks::All(all) => *all,
|
||||
RandomTicks::States { property, values } => combo
|
||||
.iter()
|
||||
.any(|(name, value)| name == property && values.contains(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-state outline and collision boxes, selected by one state property.
|
||||
@@ -734,6 +763,19 @@ fn check_block(file: &BlockFile) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
let p = &file.properties;
|
||||
if let Some(RandomTicks::States { property, values }) = &p.random_ticks {
|
||||
let declared = file
|
||||
.state_properties
|
||||
.get(property)
|
||||
.map(StateProperty::values);
|
||||
if declared.is_none_or(|all| {
|
||||
values.is_empty() || values.iter().any(|v| !all.contains(&v.as_str()))
|
||||
}) {
|
||||
return Err(format!(
|
||||
"random_ticks needs values of the block's own state property {property:?} (state_properties)"
|
||||
));
|
||||
}
|
||||
}
|
||||
let finite = |v: Option<f32>| v.is_none_or(f32::is_finite);
|
||||
if !finite(p.hardness) || !finite(p.explosion_resistance) || !finite(p.friction) {
|
||||
return Err("hardness, explosion_resistance and friction are numbers".into());
|
||||
@@ -1118,6 +1160,42 @@ capabilities = ["registries"]
|
||||
assert!(e.contains("already throws another entity type"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_pick_the_states_that_tick_randomly() {
|
||||
let own = r#""state_properties": {"grown": {"type": "boolean"}}"#;
|
||||
let block = |ticks: &str| {
|
||||
let text = format!(
|
||||
r#"{{"template": "minecraft:dirt", {own}, "properties": {{"random_ticks": {ticks}}}}}"#
|
||||
);
|
||||
let files: &[(&str, &[u8])] = &[("data/ruby/horizon/block/crop.json", text.as_bytes())];
|
||||
hmod("random-ticks", MANIFEST_TEXT, files)
|
||||
};
|
||||
let ticks =
|
||||
|c: Result<Content, String>| c.unwrap().blocks[0].properties.random_ticks.clone();
|
||||
let state = |v: &str| [("grown".to_owned(), v.to_owned())];
|
||||
// Every state, whatever the template does; `false` stops a template that ticks.
|
||||
let all = ticks(block("true")).unwrap();
|
||||
assert!(all.applies(&state("true")) && all.applies(&[]));
|
||||
assert!(!ticks(block("false")).unwrap().applies(&state("false")));
|
||||
// Chosen states: a crop that stops ticking once it is grown.
|
||||
let chosen = ticks(block(r#"{"property": "grown", "values": ["false"]}"#)).unwrap();
|
||||
assert!(chosen.applies(&state("false")) && !chosen.applies(&state("true")));
|
||||
assert!(
|
||||
!chosen.applies(&[]),
|
||||
"a state without the property does not tick"
|
||||
);
|
||||
// Not the block's own property, no values, or a value it doesn't have.
|
||||
for bad in [
|
||||
r#"{"property": "age", "values": ["0"]}"#,
|
||||
r#"{"property": "grown", "values": []}"#,
|
||||
r#"{"property": "grown", "values": ["maybe"]}"#,
|
||||
] {
|
||||
let e = block(bad).err().unwrap();
|
||||
assert!(e.contains("random_ticks"), "{bad}: {e}");
|
||||
}
|
||||
assert!(block("3").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_items_patch_their_components() {
|
||||
let block = br#"{"template": "minecraft:iron_block", "components": {
|
||||
|
||||
@@ -1696,6 +1696,178 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `textureGatherOffsets` (GLSL 4.60, 8.9.4), which naga lacks: offsets from a `const`
|
||||
/// array or a constructor, a component, an unsigned texture and the depth compare form, in a
|
||||
/// pack function that takes the sampler too; every form must reach SPIR-V.
|
||||
#[test]
|
||||
fn texture_gather_offsets_translate() {
|
||||
let fsh = "#version 430\nuniform sampler2D colortex0;\nuniform usampler2D colortex5;\n\
|
||||
uniform sampler2DShadow shadowtex0;\nout vec4 c;\n\
|
||||
const ivec2 ring[4] = ivec2[4](ivec2(-1, 0), ivec2(1, 0), ivec2(0, -1), ivec2(0, 1));\n\
|
||||
float edge(sampler2D s, vec2 uv) { return textureGatherOffsets(s, uv, ring, 3).x; }\n\
|
||||
void main() {\n vec2 uv = gl_FragCoord.xy / 64.0;\n \
|
||||
vec4 a = textureGatherOffsets(colortex0, uv, ring);\n \
|
||||
uvec4 b = textureGatherOffsets(colortex5, uv,\n \
|
||||
ivec2[4](ivec2(0), ivec2(2, 0), ivec2(0, 2), ivec2(2)), 1);\n \
|
||||
vec4 d = textureGatherOffsets(shadowtex0, uv, 0.5, ring);\n \
|
||||
c = a + vec4(b) + d + edge(colortex0, uv);\n}\n";
|
||||
let p = program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let info = Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&p.fragment)
|
||||
.unwrap();
|
||||
let entry = &p.fragment.entry_points[0];
|
||||
let pipeline = naga::back::spv::PipelineOptions {
|
||||
shader_stage: entry.stage,
|
||||
entry_point: entry.name.clone(),
|
||||
};
|
||||
naga::back::spv::write_vec(&p.fragment, &info, &Default::default(), Some(&pipeline))
|
||||
.unwrap();
|
||||
// A program that doesn't call it gets none of it.
|
||||
let plain = program(
|
||||
&[
|
||||
("composite.vsh", VSH_120),
|
||||
(
|
||||
"composite.fsh",
|
||||
"#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n",
|
||||
),
|
||||
],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!plain
|
||||
.fragment
|
||||
.functions
|
||||
.iter()
|
||||
.any(|(_, f)| f.name.as_deref() == Some("hz_textureGatherOffsets"))
|
||||
);
|
||||
}
|
||||
|
||||
/// A `return` with code after it in its block (Miniature's hand: `#ifdef GBUFFERS_HAND
|
||||
/// return 0.5 * diffuse; #endif` and the rest of the function), which naga's frontend
|
||||
/// leaves half-built.
|
||||
#[test]
|
||||
fn early_returns_with_values_translate() {
|
||||
let fsh = "#version 120\nuniform sampler2D shadowtex1;\nuniform mat4 shadowModelView;\n\
|
||||
float len2(vec3 v) { return dot(v, v); }\n\
|
||||
float f(float d, vec3 p) {\n d *= 2.0;\n return 0.5 * d;\n \
|
||||
vec3 q = p * len2(p);\n vec4 v = shadowModelView * vec4(q, 1.0);\n \
|
||||
if (dot(q, q) < 4.0 && d > 0.0 && v.z < 1.0) {\n \
|
||||
float s = texture2D(shadowtex1, v.st).x;\n return d * s / shadowModelView[2].z;\n }\n \
|
||||
return d;\n}\nvoid main() { gl_FragColor = vec4(f(0.5, vec3(1.0))); }\n";
|
||||
program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// GLSL's `dot` and `normalize` take a `float` too (genType); naga's take vectors only. A
|
||||
/// pack's own `dot` is kept.
|
||||
#[test]
|
||||
fn scalar_dot_and_normalize_translate() {
|
||||
let fsh = "#version 330\nuniform float k;\nout vec4 c;\n\
|
||||
void main() { c = vec4(dot(k, 2.0), normalize(k - 0.5), dot(vec2(k), vec2(1.0)), 1.0); }\n";
|
||||
let own = "#version 330\nuniform float k;\nout vec4 c;\nfloat dot(float a, float b) { return a + b; }\n\
|
||||
void main() { c = vec4(dot(k, 2.0)); }\n";
|
||||
for fsh in [fsh, own] {
|
||||
program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{fsh}: {e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// GLSL converts a returned value to the function's type (Kappa's `GetEmissionScale`):
|
||||
/// `return 1;` in a `float` function, an `int` or `ivec2` returned as `float` or `vec2`.
|
||||
#[test]
|
||||
fn returns_convert_to_the_function_type() {
|
||||
let fsh = "#version 330\nuniform int k;\nout vec4 c;\n\
|
||||
float one() { return 1; }\nfloat twice(int a) { return a * 2 + 1; }\n\
|
||||
vec2 cell() { ivec2 p = ivec2(k, 3); return p; }\n\
|
||||
float unsigned_value() { uint u = 3u; return u; }\n\
|
||||
float early(float x) {\n if (x > 0.5) return 2;\n return x;\n}\n\
|
||||
void main() { c = vec4(one() + twice(k), cell(), unsigned_value() + early(0.7)); }\n";
|
||||
program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// Returns that are an `if`'s branches, last in a function that returns a value (TAA's
|
||||
/// `ClipAABB` in BSL, Complementary and Solas): naga ends such tail blocks with a `return`
|
||||
/// of its own, which must not land in a guard of ours.
|
||||
#[test]
|
||||
fn returns_as_branches_stay_plain() {
|
||||
let fsh = "#version 120\nuniform float k;\n\
|
||||
vec3 clip(vec3 a, vec3 b) {\n vec3 d = b - a;\n \
|
||||
if (k > 1.0) return a + d / k;\n else return b;\n}\n\
|
||||
float pick(float x) {\n if (x > 0.5) { return 1.0; }\n return x;\n}\n\
|
||||
void main() { gl_FragColor = vec4(clip(vec3(0.0), vec3(1.0)), pick(k)); }\n";
|
||||
program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// `if (c) return; else ...` keeps its `else`: the statements the translation guards with
|
||||
/// `if (true)` must not take an `else` that belongs to the pack's `if`.
|
||||
#[test]
|
||||
fn else_stays_with_its_if() {
|
||||
let fsh = "#version 120\nuniform float k;\n\
|
||||
void g(inout float x) { if (k > 0.5) return; else x = 3.0; }\n\
|
||||
float h(float x) { if (k > 0.5) return 1.0; else x = 2.0; return x; }\n\
|
||||
void main() {\n float x = 0.0;\n g(x);\n \
|
||||
if (k > 0.9) discard; else x += h(x);\n gl_FragColor = vec4(x);\n}\n";
|
||||
let p = program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
// Our guards are `if (true)`: one with an `else` took the pack's.
|
||||
fn guard_with_else(f: &naga::Function, body: &naga::Block) -> bool {
|
||||
body.iter().any(|s| match s {
|
||||
naga::Statement::If {
|
||||
condition,
|
||||
accept,
|
||||
reject,
|
||||
} => {
|
||||
let ours = matches!(
|
||||
f.expressions[*condition],
|
||||
naga::Expression::Literal(naga::Literal::Bool(true))
|
||||
);
|
||||
// naga ends a void function's tail blocks with a `return` of its own.
|
||||
let pack_code = reject.iter().any(|s| {
|
||||
!matches!(s, naga::Statement::Return { .. } | naga::Statement::Emit(_))
|
||||
});
|
||||
(ours && pack_code) || guard_with_else(f, accept) || guard_with_else(f, reject)
|
||||
}
|
||||
naga::Statement::Block(b) => guard_with_else(f, b),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
for name in ["g", "h", "hz_main"] {
|
||||
let f = p
|
||||
.fragment
|
||||
.functions
|
||||
.iter()
|
||||
.find(|(_, f)| f.name.as_deref() == Some(name))
|
||||
.map(|(_, f)| f)
|
||||
.unwrap_or_else(|| panic!("{name} missing"));
|
||||
assert!(
|
||||
!guard_with_else(f, &f.body),
|
||||
"{name}: the pack's else moved into our if (true)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_keeps_its_own_inverse() {
|
||||
let fsh = "#version 120\nuniform mat4 gbufferProjection;\n\
|
||||
|
||||
@@ -43,6 +43,16 @@ impl Stage<'_> {
|
||||
if self.inverse {
|
||||
p.push_str(INVERSE);
|
||||
}
|
||||
if self.gather_offsets {
|
||||
p.push_str(&gather_offsets());
|
||||
}
|
||||
// GLSL's scalar `dot` and `normalize` (genType includes `float`); naga's are vector only.
|
||||
if self.scalar_dot {
|
||||
p.push_str("float dot(float a, float b) { return a * b; }\n");
|
||||
}
|
||||
if self.scalar_normalize {
|
||||
p.push_str("float normalize(float x) { return sign(x); }\n");
|
||||
}
|
||||
if self.gbuffers {
|
||||
// Set by our `main` from the frame's matrices (gbuffers.rs).
|
||||
p.push_str(
|
||||
@@ -100,6 +110,10 @@ impl Stage<'_> {
|
||||
let mut hidden: Vec<(&str, usize)> = Vec::new();
|
||||
let mut depth = 0;
|
||||
let mut skip_until = 0;
|
||||
// The `;` that closes a statement we rewrite, and what is written in its place.
|
||||
let mut statement_close: Option<(usize, &str)> = None;
|
||||
// The type the function being written returns, when GLSL converts to it implicitly.
|
||||
let mut returns: Option<&str> = None;
|
||||
let next = |i: usize| (i + 1..self.tokens.len()).find(|&j| significant(self.tokens[j]));
|
||||
let prev = |i: usize| (0..i).rev().find(|&j| significant(self.tokens[j]));
|
||||
let structs: Vec<&str> = (self.tokens.iter().enumerate())
|
||||
@@ -110,6 +124,13 @@ impl Stage<'_> {
|
||||
if i < skip_until {
|
||||
continue;
|
||||
}
|
||||
if let Some((end, close)) = statement_close
|
||||
&& end == i
|
||||
{
|
||||
out.push_str(close);
|
||||
statement_close = None;
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = self.replaced.get(&i) {
|
||||
out.push_str(text);
|
||||
continue;
|
||||
@@ -158,14 +179,56 @@ impl Stage<'_> {
|
||||
skip_until = j + 1;
|
||||
continue;
|
||||
}
|
||||
// naga's frontend leaves the code after an unconditional `discard` or `return`
|
||||
// unreachable but half-built (a module that fails validation): behind an `if` it
|
||||
// stays reachable as far as naga knows. The braces keep a pack's `else` with the
|
||||
// pack's `if` (`if (c) discard; else ...`). A `return` with a value is guarded only
|
||||
// as a statement of its own with code after it in its block: as an `if`'s branch
|
||||
// the code after it is reachable already, and naga would end our guard's empty
|
||||
// `else` with a `return;` of its own where the function returns a value.
|
||||
// GLSL converts a returned value to the function's type (`return 1;` in a `float`
|
||||
// function); naga does not, so the conversion is written out: `return float(1);`.
|
||||
if matches!(t, "discard" | "return")
|
||||
&& let Some(end) = self.statement_end(i)
|
||||
{
|
||||
let value = t == "return" && next(i) != Some(end);
|
||||
let guard = !value
|
||||
|| (prev(i).is_none_or(|j| matches!(self.tokens[j], ";" | "{" | "}"))
|
||||
&& next(end).is_some_and(|j| self.tokens[j] != "}"));
|
||||
let wrap = returns.filter(|_| value && !self.top_level_comma(i, end));
|
||||
if guard || wrap.is_some() {
|
||||
if guard {
|
||||
out.push_str("{ if (true) ");
|
||||
}
|
||||
out.push_str(t);
|
||||
if let Some(ty) = wrap {
|
||||
write!(out, " {ty}(").unwrap();
|
||||
}
|
||||
statement_close = Some((
|
||||
end,
|
||||
match (wrap.is_some(), guard) {
|
||||
(true, true) => "); }",
|
||||
(true, false) => ");",
|
||||
(false, _) => "; }",
|
||||
},
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let called = next(i).is_some_and(|j| self.tokens[j] == "(");
|
||||
match t {
|
||||
"{" => depth += 1,
|
||||
"{" => {
|
||||
if depth == 0 {
|
||||
returns = self.converted_return(i);
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
"}" => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
params.clear();
|
||||
hidden.clear();
|
||||
returns = None;
|
||||
}
|
||||
}
|
||||
"(" => {
|
||||
@@ -267,15 +330,9 @@ impl Stage<'_> {
|
||||
}
|
||||
let replaced: Option<String> = match t {
|
||||
"main" if depth == 0 && called => Some("hz_main".into()),
|
||||
// naga's frontend leaves the code after an unconditional `discard` or `return;`
|
||||
// unreachable but half-built (a module that fails validation): behind an `if`
|
||||
// it stays reachable as far as naga knows.
|
||||
"discard" => Some("if (true) discard".into()),
|
||||
"return" if next(i).is_some_and(|j| self.tokens[j] == ";") => {
|
||||
Some("if (true) return".into())
|
||||
}
|
||||
// GLSL's `modf`, which naga's frontend lacks.
|
||||
"modf" if called => Some("hz_modf".into()),
|
||||
"textureGatherOffsets" if called => Some("hz_textureGatherOffsets".into()),
|
||||
// Matrix inverse, which naga writes for neither Metal nor WGSL.
|
||||
"inverse" if called && self.inverse => Some("hz_inverse".into()),
|
||||
"texture2D" | "texture1D" | "texture3D" | "textureCube" if called => {
|
||||
@@ -316,8 +373,8 @@ impl Stage<'_> {
|
||||
"gl_FragCoord" if !fragment => None,
|
||||
"gl_FragCoord" => Some("hz_FragCoord".into()),
|
||||
"gl_FrontColor" => Some("hz_FrontColor".into()),
|
||||
"gl_Fog" => Some("hz_Fog".into()),
|
||||
"gl_FogFragCoord" => Some("hz_FogFragCoord".into()),
|
||||
"gl_Fog" => Some("hz_Fog".into()),
|
||||
// Before the compatibility inputs, of which `gl_Vertex` is a prefix.
|
||||
"gl_VertexID" => Some("gl_VertexIndex".into()),
|
||||
"gl_InstanceID" => Some("gl_InstanceIndex".into()),
|
||||
@@ -339,6 +396,74 @@ impl Stage<'_> {
|
||||
|
||||
impl Stage<'_> {
|
||||
/// Whether the declaration after token `i` calls a built-in in `NOT_CONSTANT` before its `;`.
|
||||
/// The return type of the function whose body opens at token `brace`, when it is one GLSL
|
||||
/// converts returned values to (a scalar or vector of `float`, `int` or `uint`).
|
||||
pub(super) fn converted_return(&self, brace: usize) -> Option<&str> {
|
||||
let prev = |i: usize| (0..i).rev().find(|&j| significant(self.tokens[j]));
|
||||
let close = prev(brace).filter(|&j| self.tokens[j] == ")")?;
|
||||
let mut depth = 0usize;
|
||||
let open = (0..=close).rev().find(|&j| match self.tokens[j] {
|
||||
")" => {
|
||||
depth += 1;
|
||||
false
|
||||
}
|
||||
"(" => {
|
||||
depth -= 1;
|
||||
depth == 0
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
let ty = self.tokens[prev(prev(open)?)?];
|
||||
matches!(
|
||||
ty,
|
||||
"float"
|
||||
| "vec2"
|
||||
| "vec3"
|
||||
| "vec4"
|
||||
| "int"
|
||||
| "ivec2"
|
||||
| "ivec3"
|
||||
| "ivec4"
|
||||
| "uint"
|
||||
| "uvec2"
|
||||
| "uvec3"
|
||||
| "uvec4"
|
||||
)
|
||||
.then_some(ty)
|
||||
}
|
||||
|
||||
/// Whether tokens `i..end` hold a comma outside brackets (GLSL's comma operator).
|
||||
pub(super) fn top_level_comma(&self, i: usize, end: usize) -> bool {
|
||||
let mut depth = 0usize;
|
||||
self.tokens[i..end].iter().any(|&t| match t {
|
||||
"(" | "[" => {
|
||||
depth += 1;
|
||||
false
|
||||
}
|
||||
")" | "]" => {
|
||||
depth = depth.saturating_sub(1);
|
||||
false
|
||||
}
|
||||
"," => depth == 0,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `;` that ends the statement starting at token `i` (outside brackets).
|
||||
pub(super) fn statement_end(&self, i: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for j in i + 1..self.tokens.len() {
|
||||
match self.tokens[j] {
|
||||
"(" | "[" => depth += 1,
|
||||
")" | "]" => depth = depth.saturating_sub(1),
|
||||
";" if depth == 0 => return Some(j),
|
||||
"{" | "}" => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn calls_not_constant(&self, i: usize) -> bool {
|
||||
for (j, &t) in self.tokens.iter().enumerate().skip(i + 1) {
|
||||
if matches!(t, ";" | "{" | "}") {
|
||||
@@ -455,6 +580,43 @@ const TYPE_NAMES: [&str; 35] = [
|
||||
];
|
||||
|
||||
/// `0.0`, `0.`, `.0`, `0.0f`: a floating-point literal zero.
|
||||
/// `textureGatherOffsets` (GLSL 4.60, 8.9.4), which naga's frontend lacks: texel `i` of the
|
||||
/// result is texel `i` of the footprint `offsets[i]` texels away. WGSL takes only constant
|
||||
/// offsets, so each footprint is gathered at `P` moved by its offset over the texture's size,
|
||||
/// which picks the same four texels; the component, a constant in GLSL, picks one of four
|
||||
/// gathers.
|
||||
fn gather_offsets() -> String {
|
||||
let mut p = String::new();
|
||||
for prefix in ["", "i", "u"] {
|
||||
let (tex, smp) = (format!("{prefix}texture2D"), format!("{prefix}sampler2D"));
|
||||
let vec = format!("{prefix}vec4");
|
||||
write!(
|
||||
p,
|
||||
"{vec} hz_gather({tex} t, sampler s, vec2 p, int c) {{\n\
|
||||
\x20 if (c == 1) return textureGather({smp}(t, s), p, 1);\n\
|
||||
\x20 if (c == 2) return textureGather({smp}(t, s), p, 2);\n\
|
||||
\x20 if (c == 3) return textureGather({smp}(t, s), p, 3);\n\
|
||||
\x20 return textureGather({smp}(t, s), p, 0);\n}}\n\
|
||||
{vec} hz_textureGatherOffsets({tex} t, sampler s, vec2 p, ivec2 o[4], int c) {{\n\
|
||||
\x20 vec2 px = 1.0 / vec2(textureSize({smp}(t, s), 0));\n\
|
||||
\x20 return {vec}(hz_gather(t, s, p + vec2(o[0]) * px, c).x, hz_gather(t, s, p + vec2(o[1]) * px, c).y,\n\
|
||||
\x20 hz_gather(t, s, p + vec2(o[2]) * px, c).z, hz_gather(t, s, p + vec2(o[3]) * px, c).w);\n}}\n\
|
||||
{vec} hz_textureGatherOffsets({tex} t, sampler s, vec2 p, ivec2 o[4]) {{\n\
|
||||
\x20 return hz_textureGatherOffsets(t, s, p, o, 0);\n}}\n"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
p.push_str(
|
||||
"vec4 hz_textureGatherOffsets(texture2D t, samplerShadow s, vec2 p, float r, ivec2 o[4]) {\n\
|
||||
\x20 vec2 px = 1.0 / vec2(textureSize(sampler2DShadow(t, s), 0));\n\
|
||||
\x20 return vec4(textureGather(sampler2DShadow(t, s), p + vec2(o[0]) * px, r).x,\n\
|
||||
\x20 textureGather(sampler2DShadow(t, s), p + vec2(o[1]) * px, r).y,\n\
|
||||
\x20 textureGather(sampler2DShadow(t, s), p + vec2(o[2]) * px, r).z,\n\
|
||||
\x20 textureGather(sampler2DShadow(t, s), p + vec2(o[3]) * px, r).w);\n}\n",
|
||||
);
|
||||
p
|
||||
}
|
||||
|
||||
fn is_zero_literal(t: &str) -> bool {
|
||||
let t = t.trim_end_matches(['f', 'F']);
|
||||
t.contains('.') && !t.contains(['e', 'E']) && t.chars().all(|c| c == '0' || c == '.')
|
||||
|
||||
@@ -57,6 +57,12 @@ pub(super) struct Stage<'p> {
|
||||
pub(super) out_copies: Vec<String>,
|
||||
/// The stage calls `inverse`, which ours replaces, and doesn't define its own.
|
||||
pub(super) inverse: bool,
|
||||
/// The stage calls `textureGatherOffsets`, which naga lacks: ours is in the prelude.
|
||||
pub(super) gather_offsets: bool,
|
||||
/// The stage calls `dot` / `normalize` and defines neither: the scalar overloads GLSL has
|
||||
/// (`dot(float, float)`, `normalize(float)`) and naga lacks go in the prelude.
|
||||
pub(super) scalar_dot: bool,
|
||||
pub(super) scalar_normalize: bool,
|
||||
/// The pack's functions that take samplers: which parameters are samplers.
|
||||
pub(super) sampler_functions: HashMap<String, Vec<bool>>,
|
||||
/// The pack's structs: each member's type and name with its array suffix.
|
||||
@@ -254,6 +260,9 @@ impl<'p> Stage<'p> {
|
||||
frag_outs: Vec::new(),
|
||||
out_copies: Vec::new(),
|
||||
inverse: false,
|
||||
gather_offsets: false,
|
||||
scalar_dot: false,
|
||||
scalar_normalize: false,
|
||||
sampler_functions: HashMap::new(),
|
||||
structs: HashMap::new(),
|
||||
outputs: BTreeSet::new(),
|
||||
@@ -266,6 +275,7 @@ impl<'p> Stage<'p> {
|
||||
s.rename_vulkan_types();
|
||||
let (mut start, mut depth, mut parens) = (0, 0usize, 0usize);
|
||||
let mut own_inverse = false;
|
||||
let (mut own_dot, mut own_normalize) = (false, false);
|
||||
for i in 0..s.tokens.len() {
|
||||
match s.tokens[i] {
|
||||
"(" => parens += 1,
|
||||
@@ -300,6 +310,22 @@ impl<'p> Stage<'p> {
|
||||
}
|
||||
}
|
||||
"gl_FragCoord" => s.frag_coord = true,
|
||||
"textureGatherOffsets" => s.gather_offsets = true,
|
||||
"dot" | "normalize" => {
|
||||
// A definition at the top level: `float dot(float a, ...)`.
|
||||
let defines = depth == 0
|
||||
&& s.tokens[..i]
|
||||
.iter()
|
||||
.rfind(|t| significant(t))
|
||||
.is_some_and(|t| is_ident(t));
|
||||
if s.tokens[i] == "dot" {
|
||||
s.scalar_dot = true;
|
||||
own_dot |= defines;
|
||||
} else {
|
||||
s.scalar_normalize = true;
|
||||
own_normalize |= defines;
|
||||
}
|
||||
}
|
||||
"inverse" => {
|
||||
s.inverse = true;
|
||||
// `mat4 inverse(mat4 m)`: a pack for GLSL 1.20, which has none, wrote its own.
|
||||
@@ -323,6 +349,14 @@ impl<'p> Stage<'p> {
|
||||
}
|
||||
}
|
||||
s.inverse &= !own_inverse;
|
||||
s.scalar_dot &= !own_dot;
|
||||
s.scalar_normalize &= !own_normalize;
|
||||
if s.gather_offsets {
|
||||
// Its sampler is passed as a texture and a sampler, as to the pack's own functions.
|
||||
s.sampler_functions
|
||||
.entry("textureGatherOffsets".into())
|
||||
.or_insert_with(|| vec![true, false, false, false, false]);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,73 @@ fn number(text: &str) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CPU's name from a Linux `/proc/cpuinfo`: x86's `model name`; else an ARM core named by
|
||||
/// its implementer and part (an arm64 kernel prints only `CPU implementer: 0x41` and `CPU part:
|
||||
/// 0xd0c`, as `lscpu` maps them: `ARM Neoverse-N1`); else the board's `Model` (a Raspberry Pi's
|
||||
/// downstream kernel).
|
||||
pub fn linux_cpu_name(cpuinfo: &str) -> Option<String> {
|
||||
if let Some(name) = field(cpuinfo, "model name").filter(|n| !n.is_empty()) {
|
||||
return Some(name);
|
||||
}
|
||||
let hex = |key: &str| {
|
||||
let text = field(cpuinfo, key)?;
|
||||
u32::from_str_radix(text.strip_prefix("0x")?, 16).ok()
|
||||
};
|
||||
if let (Some(implementer), Some(part)) = (hex("CPU implementer"), hex("CPU part")) {
|
||||
return Some(arm_core_name(implementer, part));
|
||||
}
|
||||
field(cpuinfo, "Model").filter(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// `lscpu`'s `hw_implementer` and `arm_part` tables, for the cores that run Horizon.
|
||||
fn arm_core_name(implementer: u32, part: u32) -> String {
|
||||
let (vendor, core) = match (implementer, part) {
|
||||
(0x41, 0xd01) => ("ARM", "Cortex-A32"),
|
||||
(0x41, 0xd03) => ("ARM", "Cortex-A53"),
|
||||
(0x41, 0xd04) => ("ARM", "Cortex-A35"),
|
||||
(0x41, 0xd05) => ("ARM", "Cortex-A55"),
|
||||
(0x41, 0xd07) => ("ARM", "Cortex-A57"),
|
||||
(0x41, 0xd08) => ("ARM", "Cortex-A72"),
|
||||
(0x41, 0xd09) => ("ARM", "Cortex-A73"),
|
||||
(0x41, 0xd0a) => ("ARM", "Cortex-A75"),
|
||||
(0x41, 0xd0b) => ("ARM", "Cortex-A76"),
|
||||
(0x41, 0xd0c) => ("ARM", "Neoverse-N1"),
|
||||
(0x41, 0xd0d) => ("ARM", "Cortex-A77"),
|
||||
(0x41, 0xd40) => ("ARM", "Neoverse-V1"),
|
||||
(0x41, 0xd41) => ("ARM", "Cortex-A78"),
|
||||
(0x41, 0xd44) => ("ARM", "Cortex-X1"),
|
||||
(0x41, 0xd46) => ("ARM", "Cortex-A510"),
|
||||
(0x41, 0xd47) => ("ARM", "Cortex-A710"),
|
||||
(0x41, 0xd48) => ("ARM", "Cortex-X2"),
|
||||
(0x41, 0xd49) => ("ARM", "Neoverse-N2"),
|
||||
(0x41, 0xd4d) => ("ARM", "Cortex-A715"),
|
||||
(0x41, 0xd4e) => ("ARM", "Cortex-X3"),
|
||||
(0x41, 0xd4f) => ("ARM", "Neoverse-V2"),
|
||||
(0x41, 0xd80) => ("ARM", "Cortex-A520"),
|
||||
(0x41, 0xd81) => ("ARM", "Cortex-A720"),
|
||||
(0x41, 0xd82) => ("ARM", "Cortex-X4"),
|
||||
(0x41, 0xd84) => ("ARM", "Neoverse-V3"),
|
||||
(0x41, 0xd8e) => ("ARM", "Neoverse-N3"),
|
||||
(0x41, _) => ("ARM", ""),
|
||||
(0x42, _) => ("Broadcom", ""),
|
||||
(0x43, _) => ("Cavium", ""),
|
||||
(0x46, _) => ("Fujitsu", ""),
|
||||
(0x48, _) => ("HiSilicon", ""),
|
||||
(0x4e, _) => ("NVIDIA", ""),
|
||||
(0x50, _) => ("APM", ""),
|
||||
(0x51, _) => ("Qualcomm", ""),
|
||||
(0x53, _) => ("Samsung", ""),
|
||||
(0x61, _) => ("Apple", ""),
|
||||
(0xc0, _) => ("Ampere", ""),
|
||||
_ => ("ARM-compatible", ""),
|
||||
};
|
||||
if core.is_empty() {
|
||||
format!("{vendor} (CPU part {part:#05x})")
|
||||
} else {
|
||||
format!("{vendor} {core}")
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
@@ -91,7 +158,7 @@ fn os_version() -> Option<String> {
|
||||
|
||||
fn cpu() -> Option<String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| field(&t, "model name"))
|
||||
read("/proc/cpuinfo").and_then(|t| linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
@@ -608,6 +675,35 @@ pub fn gpu_vendor(name: &str, vendor_id: u32) -> &'static str {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn an_arm64_kernels_cpuinfo_names_the_core_by_implementer_and_part() {
|
||||
// An arm64 kernel prints no `model name` (a Neoverse-N1 and a Cortex-A72 Pi 4).
|
||||
let neoverse = "processor\t: 0\nBogoMIPS\t: 50.00\nFeatures\t: fp asimd\nCPU implementer\t: 0x41\nCPU architecture: 8\nCPU variant\t: 0x3\nCPU part\t: 0xd0c\nCPU revision\t: 1\n";
|
||||
assert_eq!(linux_cpu_name(neoverse).as_deref(), Some("ARM Neoverse-N1"));
|
||||
let pi = "processor\t: 3\nCPU implementer\t: 0x41\nCPU part\t: 0xd08\nCPU revision\t: 3\n\nRevision\t: c03114\nModel\t\t: Raspberry Pi 4 Model B Rev 1.4\n";
|
||||
assert_eq!(linux_cpu_name(pi).as_deref(), Some("ARM Cortex-A72"));
|
||||
// An implementer the table has, a part it does not: the part is kept.
|
||||
let apple = "CPU implementer\t: 0x61\nCPU part\t: 0x023\n";
|
||||
assert_eq!(
|
||||
linux_cpu_name(apple).as_deref(),
|
||||
Some("Apple (CPU part 0x023)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x86_keeps_its_model_name_and_a_board_without_either_its_model() {
|
||||
let ryzen = "processor\t: 0\nmodel name\t: AMD Ryzen 9 7950X 16-Core Processor\n";
|
||||
assert_eq!(
|
||||
linux_cpu_name(ryzen).as_deref(),
|
||||
Some("AMD Ryzen 9 7950X 16-Core Processor")
|
||||
);
|
||||
assert_eq!(
|
||||
linux_cpu_name("Model\t: Some Board\n").as_deref(),
|
||||
Some("Some Board")
|
||||
);
|
||||
assert_eq!(linux_cpu_name("processor\t: 0\n"), None);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ A reference page is written by code from the source it describes, and a test fai
|
||||
| [server-reference.md](server-reference.md) | `horizon-server`'s help, `config::TEMPLATE`, `api::ROUTES` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-server reference` |
|
||||
| [horizon-ctl-commands.md](../horizon-ctl-commands.md) | the command table in `crates/horizon-client/src/bin/horizon-ctl/commands.rs` | `horizon-ctl help --markdown > docs/horizon-ctl-commands.md` |
|
||||
| [launcher-commands.md](../launcher-commands.md) | `launcher/src/cli.rs` | `HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference` |
|
||||
| [launch-reference.md](launch-reference.md) | `crates/horizon-client/src/game/launch_api.rs` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api` |
|
||||
| [launch-reference.md](launch-reference.md) | `crates/horizon-client/src/game/launch_api.rs` | `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --lib launch_api` |
|
||||
|
||||
So a pull request that changes an API also changes its reference, and the reviewer sees the API's diff in the
|
||||
reference's diff. The guides (overview, how-to, examples) are written by hand.
|
||||
|
||||
@@ -19,7 +19,7 @@ serves the same commands as MCP tools. The launcher's MCP server offers them too
|
||||
|
||||
## Guide
|
||||
|
||||
1. Build `horizon-ctl` (`cargo build --profile fast -p horizon-client`), then
|
||||
1. Build `horizon-ctl` (`cargo build --profile fast -p horizon-client-lib --bin horizon-ctl`), then
|
||||
`horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --wait --resources <prepared files>`.
|
||||
2. `horizon-ctl --socket … state`, `keys`, `turn`, `command`, `screenshot`, `quit`.
|
||||
3. For an agent: `horizon-ctl mcp`, or the launcher's `horizon-launcher mcp` with `agent-config` to connect a client
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- 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 -->
|
||||
# Launch API reference
|
||||
|
||||
What `horizon-client` promises a launcher that starts it, Launch API 0.1.0. The guide: [launch.md](launch.md); stability and versions: [stability.md](stability.md).
|
||||
|
||||
+14
-4
@@ -4,6 +4,14 @@ The 40 areas changed most often in the 30 days to 2026-10-23 (commits on main to
|
||||
counted only for the game loop because nearly every gameplay change touches it). The crate map, how `Game` is
|
||||
organised and how to build and test are in [AGENTS.md](../AGENTS.md).
|
||||
|
||||
Gameplay lives in the `horizon-client` library; `main.rs` is a thin executable calling
|
||||
`entry::main`. The reusable modules, control tool and probes belong to `horizon-client-lib`,
|
||||
whose library root is `crates/horizon-client/src/client_lib.rs`. Source paths stay in place.
|
||||
The gameplay library re-exports the reusable API, keeping existing mod targets unchanged.
|
||||
Gameplay tests run in its library test target, separately from the reusable library's tests.
|
||||
There is no copy of gameplay in the executable's test target. Tools and probes depend only
|
||||
on the reusable library, so gameplay edits do not invalidate their builds.
|
||||
|
||||
Paths: `L/` = `crates/horizon-client/src/`, `G/` = `crates/horizon-client/src/game/`, `W/` =
|
||||
`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`,
|
||||
`I/` = `crates/horizon-items/src/`, `R/` = `crates/horizon-client-render/src/`, `N/` = `crates/horizon-client-input/src/`.
|
||||
@@ -12,8 +20,10 @@ Checks: a bare name is a harness (`python3 tools/parity.py <name>`, script `tool
|
||||
|
||||
For the full set of harnesses a file can affect, ask the runner:
|
||||
`printf '%s\n' <paths> > /tmp/p.txt && python3 tools/parity.py --changed-files /tmp/p.txt --list`.
|
||||
Any file of the game binary (`game.rs`, `game/`) selects the ~25 harnesses that run the binary; a client lib
|
||||
file selects ~103.
|
||||
Client probes are built with `-p horizon-client-lib`; the game with `-p horizon-client`.
|
||||
Because the probe library does not compile gameplay, its dep-info does not include `game.rs`
|
||||
or `game/`. Exact harness counts still depend on refreshed records and reachability; use the
|
||||
command above to inspect the selection rather than relying on historical counts.
|
||||
|
||||
| # | Area (commits) | Files | Checks |
|
||||
|---|---|---|---|
|
||||
@@ -22,7 +32,7 @@ file selects ~103.
|
||||
| 3 | World save and load (163) | `L/save.rs`, `G/saving.rs`, `G/persist.rs` (entities), `L/entity_nbt.rs`, `K/block_entity_nbt.rs`, `W/region.rs`, `W/nbt.rs` | save_roundtrip, world_reopen, world, datafix |
|
||||
| 4 | World generation (153) | `W/worldgen.rs`, `W/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `placement/`), `L/vanillagen.rs` | chunk_generation, worldgen, server_world_gen, world_spawn, feature_graph, feature_placement, decoration and the other worldgen harnesses |
|
||||
| 5 | Multiplayer client, packets in (145) | `G/remote.rs` (`fn handle_packet`, `fn tick_remote`), `G/remote_*.rs`, `L/net.rs` | dedicated_server, server_entities, lan_e2e (all) |
|
||||
| 6 | Agent control, horizon-ctl (145) | `L/control.rs`, `L/bin/horizon-ctl/`, `G/control.rs`, `G/agent.rs`, `G/events.rs`, `G/ui_control.rs` | `python3 tools/agent_control_e2e.py`; `cargo test -p horizon-client --bin horizon-ctl` |
|
||||
| 6 | Agent control, horizon-ctl (145) | `L/control.rs`, `L/bin/horizon-ctl/`, `G/control.rs`, `G/agent.rs`, `G/events.rs`, `G/ui_control.rs` | `python3 tools/agent_control_e2e.py`; `cargo test -p horizon-client-lib --bin horizon-ctl` |
|
||||
| 7 | CI and release (134) | `.github/workflows/` (`ci.yml`, `parity.yml`, `release.yml`, `launcher.yml`), `tools/ci/`, `tools/parity.py`, `tools/pr_ready.py` | `python3 -m unittest discover -s tests`; actionlint (`.github/actionlint.yaml`) |
|
||||
| 8 | LAN host: connections, play state, tracking (133) | `L/lan/` (`conn.rs`, `hub.rs` + `hub/`, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`), `G/lan.rs` | lan, lan_e2e (all), `cargo test -p horizon-client --test lan_bot` |
|
||||
| 9 | Renderer core (132) | `R/gfx.rs` (`Renderer::new`, `Renderer::render`), `R/gfx/` (cull, occlusion, translucency, upload, timing, lod) | client_graphics, culling_rays, translucency; `horizon-client --benchmark standard` |
|
||||
@@ -39,7 +49,7 @@ file selects ~103.
|
||||
| 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `R/entity.wgsl` | entity_model |
|
||||
| 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers |
|
||||
| 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `R/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `crates/horizon-assets/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
|
||||
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `R/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
|
||||
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
|
||||
|
||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user