mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
24
Commits
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
|
||||
|
||||
@@ -39,7 +39,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`...) |
|
||||
@@ -84,8 +85,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
|
||||
@@ -102,8 +103,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):
|
||||
@@ -128,8 +132,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 |
|
||||
|---|---|
|
||||
@@ -160,7 +167,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
-3
@@ -1314,6 +1314,7 @@ dependencies = [
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-lib",
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
@@ -1323,7 +1324,6 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1374,6 +1374,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"
|
||||
@@ -1395,7 +1451,6 @@ dependencies = [
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1517,7 +1572,6 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -66,28 +66,3 @@ with Microsoft accounts
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
|
||||
## CIT Resewn
|
||||
|
||||
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
|
||||
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
|
||||
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
|
||||
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2021 SHsuperCM
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -281,13 +281,6 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -17,9 +17,6 @@ horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
# The narrator option's status and whether the narrator is there (Options.narrator).
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -101,8 +101,6 @@ pub struct RenderAssets {
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -242,34 +240,9 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let connected = if draws {
|
||||
if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
@@ -294,7 +267,6 @@ impl RenderAssets {
|
||||
pbr_normals: sprites.normals,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,6 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
|
||||
/// and how far left of its place it starts (GUI pixels).
|
||||
const INTRO_TICKS: f32 = 5.0;
|
||||
const INTRO_WIDTH: f32 = 12.0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -237,10 +233,14 @@ impl Tag {
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: f32, added: u64) -> f32 {
|
||||
let mut t = 1.0 - (ticks - added as f32) / 200.0;
|
||||
t = (t * 10.0).clamp(0.0, 1.0);
|
||||
t * t
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
@@ -256,11 +256,6 @@ pub struct Chat {
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
/// The smooth chat extra: a new line slides in from the left while it
|
||||
/// fades in, instead of appearing whole.
|
||||
pub smooth: bool,
|
||||
/// The smooth chat extra: no indicator bar at the left of a line.
|
||||
pub hide_indicator: bool,
|
||||
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
@@ -560,7 +555,7 @@ impl Chat {
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
@@ -574,47 +569,28 @@ impl Chat {
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
// Vanilla's 200-tick fade reads the whole GUI ticks
|
||||
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
|
||||
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(fade_ticks, line.added)
|
||||
time_alpha(ticks, line.added)
|
||||
}
|
||||
};
|
||||
// The smooth chat extra: how far into its intro a line is, 0 at the
|
||||
// tick it was added and 1 once it has arrived, as (alpha, offset x).
|
||||
let intro_of = |line: &Line| {
|
||||
if focused || !self.smooth {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t) * (1.0 - t);
|
||||
(eased, (eased - 1.0) * INTRO_WIDTH)
|
||||
};
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| {
|
||||
let (eased, slide) = intro_of(line);
|
||||
(i, line, alpha_of(line) * eased, slide)
|
||||
})
|
||||
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha, slide) in &lines {
|
||||
for &(i, _, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -622,15 +598,9 @@ impl Chat {
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
for &(i, line, alpha, slide) in &lines {
|
||||
for &(i, line, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
@@ -642,7 +612,7 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
@@ -652,9 +622,6 @@ impl Chat {
|
||||
color,
|
||||
));
|
||||
}
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
@@ -691,7 +658,7 @@ impl Chat {
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
|
||||
@@ -39,61 +39,6 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
|
||||
let mut chat = Chat {
|
||||
smooth: true,
|
||||
..Chat::default()
|
||||
};
|
||||
chat.lines.push(Line {
|
||||
added: 0,
|
||||
content: Text::literal("hi"),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
});
|
||||
let text_alpha = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { color, .. } => Some(color >> 24),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The slide is the negative translate the line carries while it moves.
|
||||
let slide = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
|
||||
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The tick it arrives: fully faded out, so nothing is drawn.
|
||||
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
|
||||
// Halfway through the intro: left of its place and still faint.
|
||||
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
|
||||
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
|
||||
let faded = text_alpha(&halfway).expect("the line shows halfway through");
|
||||
// Settled: in place and as opaque as vanilla's line.
|
||||
let settled = chat.layout(361, INTRO_TICKS, false);
|
||||
assert_eq!(slide(&settled), None);
|
||||
assert!(text_alpha(&settled).expect("settled") > faded);
|
||||
// The extra off draws the line whole on the tick it arrives, as vanilla.
|
||||
chat.smooth = false;
|
||||
let plain = chat.layout(361, 0.0, false);
|
||||
assert_eq!(slide(&plain), None);
|
||||
assert_eq!(text_alpha(&plain), text_alpha(&settled));
|
||||
// The indicator bar is the -4 to -2 fill at the left of the line.
|
||||
let bar = |draws: &[GuiDraw]| {
|
||||
draws
|
||||
.iter()
|
||||
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
|
||||
};
|
||||
assert!(bar(&plain));
|
||||
chat.hide_indicator = true;
|
||||
assert!(!bar(&chat.layout(361, 0.0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
|
||||
let font = font();
|
||||
@@ -150,7 +95,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
|
||||
for scroll in [0, 3, 12] {
|
||||
chat.scroll = scroll;
|
||||
let mut links = 0;
|
||||
for draw in chat.layout(361, 0.0, true) {
|
||||
for draw in chat.layout(361, 0, true) {
|
||||
if let GuiDraw::Text { text, x, y, .. } = draw {
|
||||
let mut at = x as f64;
|
||||
for (c, style) in text.chars() {
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
|
||||
let bytes = fs::read(root.join(&path)).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
|
||||
root.join(p).is_file()
|
||||
});
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::new(load_rules(root))
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
|
||||
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, Hand::Any),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Option<Hand>,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
|
||||
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
|
||||
//! the short list of rules that can touch it; meshing a face then walks only those lists
|
||||
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
|
||||
//!
|
||||
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
|
||||
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::ctm::{
|
||||
self, Connect, Method, Orient, Rule, Tile,
|
||||
connect::{self as geometry, Axes, Axis, Face},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// A tile as the mesher draws it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TileLayer {
|
||||
Layer {
|
||||
layer: u16,
|
||||
/// The sprite has fractional alpha (drawn in the translucent pass).
|
||||
translucent: bool,
|
||||
/// Every texel is opaque (drawn without an alpha test).
|
||||
opaque: bool,
|
||||
},
|
||||
/// Try the next rule.
|
||||
Skip,
|
||||
/// The face's own sprite.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a face draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
/// Its own sprite.
|
||||
Keep,
|
||||
Tile(TileLayer),
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
}
|
||||
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct StateInfo {
|
||||
/// Index of the state's block (`connect=block`).
|
||||
block: u32,
|
||||
axis: Option<Axis>,
|
||||
/// `half=upper`: `linked` random tiles follow the lower half.
|
||||
upper: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnectedTextures {
|
||||
rules: Vec<LoadedRule>,
|
||||
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
|
||||
by_state: Vec<(u32, u32)>,
|
||||
by_layer: Vec<(u32, u32)>,
|
||||
lists: Vec<u16>,
|
||||
states: Vec<StateInfo>,
|
||||
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
|
||||
/// when no rule connects by tile.
|
||||
face_layers: Vec<[u16; 6]>,
|
||||
}
|
||||
|
||||
/// `list` appended to `lists`, as its range there.
|
||||
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
|
||||
let start = lists.len() as u32;
|
||||
lists.extend_from_slice(list);
|
||||
(start, list.len() as u32)
|
||||
}
|
||||
|
||||
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
|
||||
/// itself (as [`Sprites::insert_file`] names a tile).
|
||||
fn sprite_name(resource: &str) -> String {
|
||||
let parts = resource
|
||||
.strip_prefix("assets/")
|
||||
.and_then(|p| p.split_once('/'))
|
||||
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
|
||||
match parts {
|
||||
Some((ns, path)) => format!("{ns}:{path}"),
|
||||
None => resource.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
|
||||
// meshes see no block entity).
|
||||
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
rules.truncate(usize::from(u16::MAX));
|
||||
if rules.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let loaded = rules
|
||||
.into_iter()
|
||||
.filter_map(|rule| {
|
||||
let tiles = rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|tile| match tile {
|
||||
Tile::Skip => Ok(TileLayer::Skip),
|
||||
Tile::Default => Ok(TileLayer::Default),
|
||||
Tile::Texture(path) => {
|
||||
// A tile among the textures is that sprite, under its own name.
|
||||
let layer = match sprite_name(path) {
|
||||
name if name != *path => sprites.insert(root, &name)?,
|
||||
_ => sprites.insert_file(root, path)?,
|
||||
};
|
||||
Ok(TileLayer::Layer {
|
||||
layer: u16::try_from(layer)?,
|
||||
translucent: sprites.translucent[layer as usize],
|
||||
opaque: sprites.opaque[layer as usize],
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>();
|
||||
match tiles {
|
||||
Ok(tiles) => Some(LoadedRule { rule, tiles }),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
|
||||
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
|
||||
out.face_layers = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
Face::ALL
|
||||
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
|
||||
/// registry skips), `layer` answering a sprite name's layer.
|
||||
fn build<'a>(
|
||||
rules: Vec<LoadedRule>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
layer: impl Fn(&str) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut out = Self {
|
||||
rules,
|
||||
..Self::default()
|
||||
};
|
||||
let mut blocks = HashMap::new();
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
}
|
||||
if by_layer[l].last() != Some(&i) {
|
||||
by_layer[l].push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &r.rule.match_blocks {
|
||||
let list = by_block.entry(m.id.as_str()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for list in by_layer {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
out.by_state.push((0, 0));
|
||||
out.states.push(StateInfo::default());
|
||||
continue;
|
||||
};
|
||||
let next = blocks.len() as u32;
|
||||
let block = *blocks.entry(name).or_insert(next);
|
||||
let prop = |k: &str| props.get(k).map(String::as_str);
|
||||
out.states.push(StateInfo {
|
||||
block,
|
||||
axis: match prop("axis") {
|
||||
Some("x") => Some(Axis::X),
|
||||
Some("y") => Some(Axis::Y),
|
||||
Some("z") => Some(Axis::Z),
|
||||
_ => None,
|
||||
},
|
||||
upper: prop("half") == Some("upper"),
|
||||
});
|
||||
list.clear();
|
||||
if let Some(candidates) = by_block.get(name) {
|
||||
list.extend(
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
|
||||
);
|
||||
}
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
|
||||
/// by rules that test one.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
self.list(&self.by_state, state as usize),
|
||||
self.list(&self.by_layer, layer as usize),
|
||||
);
|
||||
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
|
||||
// both (it is in both lists); one naming either needs that one.
|
||||
loop {
|
||||
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
|
||||
(None, None) => return Pick::Keep,
|
||||
(Some((&x, rest)), Some((&y, other))) if x == y => {
|
||||
(by_state, by_layer) = (rest, other);
|
||||
(x, true, true)
|
||||
}
|
||||
(Some((&x, rest)), Some((&y, _))) if x < y => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(Some((&x, rest)), None) => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(_, Some((&y, rest))) => {
|
||||
by_layer = rest;
|
||||
(y, false, true)
|
||||
}
|
||||
};
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
|
||||
{
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply<'b>(
|
||||
&self,
|
||||
r: &LoadedRule,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
if rule.faces & face.bit() == 0
|
||||
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(biomes) = &rule.biomes
|
||||
&& !biome().is_some_and(|b| biomes.matches(b))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let info = self.states.get(state as usize).copied().unwrap_or_default();
|
||||
let joins = |offset: [i32; 3]| {
|
||||
let other = neighbour(offset);
|
||||
match rule.connect {
|
||||
Connect::Block => {
|
||||
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
|
||||
}
|
||||
Connect::State => other == state,
|
||||
Connect::Tile => self
|
||||
.face_layers
|
||||
.get(other as usize)
|
||||
.is_some_and(|f| f[face as usize] == layer),
|
||||
}
|
||||
};
|
||||
let axes = |face| match (rule.orient, info.axis) {
|
||||
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
|
||||
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
|
||||
let index = match rule.method {
|
||||
Method::Ctm => geometry::ctm_index(mask()),
|
||||
Method::CtmCompact => {
|
||||
let mask = mask();
|
||||
let case = geometry::ctm_index(mask) as u8;
|
||||
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
|
||||
Some(&(_, t)) => t,
|
||||
None => {
|
||||
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::Horizontal => geometry::horizontal(mask()),
|
||||
Method::Vertical => geometry::vertical(mask()),
|
||||
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
|
||||
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
|
||||
Method::Top => {
|
||||
// Only sides join the block above: the top and bottom go on to the next rule.
|
||||
if matches!(face, Face::Up | Face::Down) {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
}
|
||||
0
|
||||
}
|
||||
Method::Fixed => 0,
|
||||
Method::Random => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
let pos = if rule.linked && info.upper {
|
||||
[pos[0], pos[1] - 1, pos[2]]
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
|
||||
}
|
||||
Method::Repeat => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
|
||||
}
|
||||
// Overlays are not loaded yet.
|
||||
_ => return None,
|
||||
};
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
|
||||
/// `1000 * (r + 1) + i`.
|
||||
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(path, text)| {
|
||||
Rule::parse(
|
||||
&format!("assets/minecraft/optifine/ctm/{path}"),
|
||||
&Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
ctm::sort(&mut rules);
|
||||
rules
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| match t {
|
||||
Tile::Skip => TileLayer::Skip,
|
||||
Tile::Default => TileLayer::Default,
|
||||
Tile::Texture(_) => TileLayer::Layer {
|
||||
layer: (1000 * (n + 1) + i) as u16,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
rule,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
|
||||
/// upper); the stone sprite is layer 7.
|
||||
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
let states = [
|
||||
("minecraft:air", props(&[])),
|
||||
("minecraft:glass", props(&[])),
|
||||
("minecraft:stone", props(&[])),
|
||||
("minecraft:oak_log", props(&[("axis", "y")])),
|
||||
("minecraft:oak_log", props(&[("axis", "x")])),
|
||||
("minecraft:oak_door", props(&[("half", "lower")])),
|
||||
("minecraft:oak_door", props(&[("half", "upper")])),
|
||||
];
|
||||
ConnectedTextures::build(
|
||||
rules(files),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|name| (name == "minecraft:block/stone").then_some(7),
|
||||
)
|
||||
}
|
||||
|
||||
fn tile(layer: u16) -> Pick {
|
||||
Pick::Tile(TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
|
||||
fn pick_in(
|
||||
ctm: &ConnectedTextures,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
|
||||
#[test]
|
||||
fn glass_joins_its_neighbours() {
|
||||
let ctm = world(&[("glass.properties", GLASS)]);
|
||||
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1001)
|
||||
);
|
||||
// Glass all around in the face's plane: the middle tile, 26.
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
|
||||
.collect();
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
|
||||
// Stone beside it doesn't join; stone has no rule.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_order_faces_and_skips() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a_sides.properties",
|
||||
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
|
||||
),
|
||||
("b_glass.properties", GLASS),
|
||||
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
|
||||
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
|
||||
]);
|
||||
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
|
||||
// Sides: a_sides skips, so z draws.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
|
||||
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
|
||||
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_quarters_and_overrides() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
|
||||
)]);
|
||||
let layer = |i: u16| TileLayer::Layer {
|
||||
layer: 1000 + i,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[]),
|
||||
Pick::Quarters([layer(0); 4])
|
||||
);
|
||||
// Joined right only is case 1, replaced whole by tile 5.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1005)
|
||||
);
|
||||
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
|
||||
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_turn_with_their_axis() {
|
||||
let ctm = world(&[(
|
||||
"log.properties",
|
||||
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
|
||||
)]);
|
||||
// An upright log with a log above: the bottom of a column.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// A log lying along x: on its north face the texture's up points east, so a log to the
|
||||
// east (same block, other axis state) is above it.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_and_connect_state() {
|
||||
let ctm = world(&[(
|
||||
"block_oak_log.properties",
|
||||
"method=top\ntiles=0\nconnect=state",
|
||||
)]);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// The other axis is another state.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
Pick::Keep
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_random_follows_the_lower_half() {
|
||||
let ctm = world(&[(
|
||||
"door.properties",
|
||||
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
assert!(tiles.len() > 3, "the doors are not all alike");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_and_biomes() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at =
|
||||
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_by_tile_reads_the_neighbours_same_side() {
|
||||
let mut ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
|
||||
)]);
|
||||
// Stone shows the glass sprite (3) on its north side; the door only on its south.
|
||||
let mut faces = vec![[9u16; 6]; 7];
|
||||
faces[1][Face::North as usize] = 3;
|
||||
faces[2][Face::North as usize] = 3;
|
||||
faces[5][Face::South as usize] = 3;
|
||||
ctm.face_layers = faces;
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_and_rows_then_columns() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
assert_eq!(at([0, 63, 0]), tile(1002));
|
||||
let hv = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
|
||||
)]);
|
||||
// Alone in its row, glass above: the column's bottom, tile 4.
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
|
||||
tile(1004)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_defaults_and_narrowed_rules() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
|
||||
),
|
||||
(
|
||||
"c.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
|
||||
),
|
||||
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
|
||||
(
|
||||
"e.properties",
|
||||
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
|
||||
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
|
||||
// glass itself, which stops d.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
let both = world(&[(
|
||||
"e.properties",
|
||||
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
|
||||
)]);
|
||||
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
|
||||
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
// `top` passes the top face on.
|
||||
let top = world(&[
|
||||
("block_glass.properties", "method=top\ntiles=0"),
|
||||
(
|
||||
"z.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/textures/block/dirt.png"),
|
||||
"minecraft:block/dirt"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
|
||||
"assets/minecraft/optifine/ctm/glass/0.png"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -407,30 +407,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
|
||||
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
|
||||
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
|
||||
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
|
||||
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let report = Report {
|
||||
time: Local::now(),
|
||||
description: "RAM limit reached".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
};
|
||||
match report.save(home) {
|
||||
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
|
||||
@@ -111,10 +111,6 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
pub chat_indicator: bool,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
@@ -156,8 +152,6 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
@@ -236,8 +230,6 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
@@ -249,7 +241,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -282,8 +274,6 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
bool_text(self.chat_animation),
|
||||
bool_text(self.chat_indicator),
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
@@ -416,10 +406,6 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// A new chat line slides in and fades in (`Chat::layout`).
|
||||
ChatAnimation,
|
||||
/// The coloured bar at the left of a chat line.
|
||||
ChatIndicator,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
@@ -444,9 +430,6 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("Chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("Sound"),
|
||||
@@ -466,8 +449,6 @@ impl Item {
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::ChatAnimation => "Smooth chat",
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
@@ -481,10 +462,8 @@ impl Item {
|
||||
UiSoundReserve => return None,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -520,10 +499,6 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::ChatAnimation => {
|
||||
"A new chat line slides in from the left while it fades in, instead of appearing whole."
|
||||
}
|
||||
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
@@ -545,7 +520,6 @@ impl Item {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::ChatAnimation => Some(Cost::None),
|
||||
Item::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
@@ -567,8 +541,6 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::ChatAnimation => e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
@@ -599,8 +571,6 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
@@ -663,8 +633,6 @@ mod tests {
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
|
||||
@@ -142,7 +142,6 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
@@ -165,50 +164,6 @@ fn sky_visible(frame: &Frame) -> bool {
|
||||
!frame.title && frame.skybox && sky_open(frame)
|
||||
}
|
||||
|
||||
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
|
||||
/// depth loaded, and its `surface` target, cleared on the first of these passes.
|
||||
fn surface_pass<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
surface: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
clear: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let keep = |load| wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
};
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, pack terrain"),
|
||||
color_attachments: &[
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: keep(wgpu::LoadOp::Load),
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: surface,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: keep(if clear {
|
||||
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
}),
|
||||
}),
|
||||
],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
@@ -663,11 +618,7 @@ pub struct Renderer {
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
/// Frames are finished and submitted on its submit thread ([`submit`]).
|
||||
pub queue: submit::Queue,
|
||||
/// The last frame went to the submit thread; whether it had no window surface.
|
||||
/// The next frame takes it in ([`Self::land_frame`]).
|
||||
deferred: Option<bool>,
|
||||
pub queue: wgpu::Queue,
|
||||
/// Set by the device's lost callback. wgpu reports nothing else when an
|
||||
/// operation fails on a lost device (no error scope sees it).
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
@@ -689,9 +640,6 @@ pub struct Renderer {
|
||||
sides_on: bool,
|
||||
sky: wgpu::RenderPipeline,
|
||||
dark_disc: wgpu::RenderPipeline,
|
||||
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
|
||||
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
|
||||
depth_reset: wgpu::RenderPipeline,
|
||||
lines: wgpu::RenderPipeline,
|
||||
lines_secondary: wgpu::RenderPipeline,
|
||||
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
@@ -2091,40 +2039,6 @@ impl Renderer {
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Hand depth reset"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_sky"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_depth_reset"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &sprite_constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let lines = pipeline(
|
||||
"Block outline",
|
||||
"vs_line",
|
||||
@@ -3444,8 +3358,7 @@ impl Renderer {
|
||||
last_encode: std::time::Duration::ZERO,
|
||||
window,
|
||||
device,
|
||||
queue: submit::Queue::new(queue, submit::wanted()),
|
||||
deferred: None,
|
||||
queue,
|
||||
device_lost,
|
||||
config,
|
||||
solid,
|
||||
@@ -3459,7 +3372,6 @@ impl Renderer {
|
||||
flat,
|
||||
sky,
|
||||
dark_disc,
|
||||
depth_reset,
|
||||
lines,
|
||||
lines_secondary,
|
||||
debug_lines,
|
||||
@@ -3619,14 +3531,6 @@ impl Renderer {
|
||||
present_modes,
|
||||
})
|
||||
}
|
||||
/// The first-person hand's quads, which follow `first` quads of the entity buffer.
|
||||
fn draw_hand(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32, first: u32) {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.hand_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
|
||||
/// secondary stroke first when high contrast is on.
|
||||
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
|
||||
@@ -3674,7 +3578,6 @@ impl Renderer {
|
||||
/// Hand the window surface over to the renderer that replaces this one on
|
||||
/// the same device (it takes `spare_surface`).
|
||||
pub fn release_surface(&mut self) {
|
||||
self.queue.wait();
|
||||
*self
|
||||
.spare_surface
|
||||
.get_mut()
|
||||
@@ -3682,7 +3585,6 @@ impl Renderer {
|
||||
}
|
||||
/// Reattach a window after surface loss or a failed renderer replacement.
|
||||
pub fn recreate_surface(&mut self) -> Result<()> {
|
||||
self.queue.wait();
|
||||
if self.offscreen.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -4403,8 +4305,6 @@ impl Renderer {
|
||||
/// Configures the window surface again. One that fails is still the one
|
||||
/// wgpu configured before, which acquisition reports as a validation error.
|
||||
fn configure_surface(&self) {
|
||||
// The submit thread may still present to the old swapchain.
|
||||
self.queue.wait();
|
||||
if let Some(surface) = &self.surface
|
||||
&& let Err(e) = Self::configure(
|
||||
&self.device,
|
||||
@@ -5044,7 +4944,6 @@ impl Renderer {
|
||||
}
|
||||
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
|
||||
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
|
||||
self.land_frame();
|
||||
// World frames alone follow the shader pack: the game loads the picked one
|
||||
// before it draws one. Title frames draw no terrain and keep the layout
|
||||
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
|
||||
@@ -6292,14 +6191,6 @@ impl Renderer {
|
||||
if culling && let Some(cull) = &mut self.cull {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
// The hand draws at the end of the world pass, after a depth reset, when nothing runs
|
||||
// between them (a scaled world's post pass, HDR's world copy, a native mod's
|
||||
// `native_world_drawn`): one render pass fewer, ~10 µs of wgpu's encoding a frame.
|
||||
let hand_in_world = (frame.hand.len() / 4) > 0
|
||||
&& !pack_draws_hand
|
||||
&& scaled.is_none()
|
||||
&& self.hdr.is_none()
|
||||
&& !native_render::world_drawn_hooked();
|
||||
if oit_on {
|
||||
let size = self
|
||||
.world_post
|
||||
@@ -6628,16 +6519,6 @@ impl Renderer {
|
||||
.frame
|
||||
.fit_opaque(&self.device, world_depth.texture(), color);
|
||||
}
|
||||
if let Some(native) = self.native.as_mut().filter(|_| !frame.title) {
|
||||
let writes = native.material.as_ref().is_some_and(|m| m.writes_surface);
|
||||
let depth = world_depth.texture();
|
||||
let size = if writes {
|
||||
[depth.width(), depth.height()]
|
||||
} else {
|
||||
[1, 1]
|
||||
};
|
||||
native.frame.fit_surface(&self.device, size);
|
||||
}
|
||||
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
|
||||
// which read the pack's frame at group 3.
|
||||
let material = self
|
||||
@@ -6660,17 +6541,6 @@ impl Renderer {
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
|
||||
}
|
||||
};
|
||||
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
|
||||
// passes of its own between the sky and everything else.
|
||||
let surface = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|n| {
|
||||
!frame.title
|
||||
&& !pack_draws_terrain
|
||||
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
|
||||
})
|
||||
.map(|n| n.frame.surface_view.clone());
|
||||
{
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: world_view,
|
||||
@@ -6756,28 +6626,6 @@ impl Renderer {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
let terrain_pass =
|
||||
|encoder: &mut wgpu::CommandEncoder, surface: &wgpu::TextureView, clear: bool| {
|
||||
// Its lifetime forgotten: a closure cannot tie it to `encoder`'s.
|
||||
let mut pass = surface_pass(encoder, world_view, surface, world_depth, clear)
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
};
|
||||
// The occlusion test's pipeline draws into the world's colour alone.
|
||||
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
|
||||
let mut pass =
|
||||
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
};
|
||||
if let Some(surface) = &surface {
|
||||
drop(pass);
|
||||
pass = terrain_pass(&mut encoder, surface, true);
|
||||
}
|
||||
match self
|
||||
.flat
|
||||
.as_ref()
|
||||
@@ -6806,25 +6654,18 @@ impl Renderer {
|
||||
// that the test saw, copied after it.
|
||||
Some(cull) => {
|
||||
draw(&mut pass, &flat.calls[..flat.phase1_calls]);
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder, self.own_timing.as_mut());
|
||||
flat.flatten_phase2(&mut encoder, self.own_timing.as_mut());
|
||||
pass = match &surface {
|
||||
Some(surface) => terrain_pass(&mut encoder, surface, false),
|
||||
None => world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
),
|
||||
};
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
for &pipeline in passes {
|
||||
@@ -6856,24 +6697,17 @@ impl Renderer {
|
||||
pass.set_pipeline(pipeline);
|
||||
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
|
||||
}
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder, self.own_timing.as_mut());
|
||||
pass = match &surface {
|
||||
Some(surface) => terrain_pass(&mut encoder, surface, false),
|
||||
None => world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
),
|
||||
};
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass.set_index_buffer(
|
||||
@@ -6901,11 +6735,6 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
if entity_quads > 0 {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -7368,12 +7197,6 @@ impl Renderer {
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
if hand_in_world {
|
||||
pass.set_pipeline(&self.depth_reset);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
|
||||
}
|
||||
}
|
||||
if let Some((color, depth)) = &scaled
|
||||
&& pack_post
|
||||
@@ -7494,7 +7317,7 @@ impl Renderer {
|
||||
post.run(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(color, depth, &native.frame.surface_view),
|
||||
(color, depth),
|
||||
&view_target,
|
||||
size,
|
||||
history_reset,
|
||||
@@ -7535,7 +7358,7 @@ impl Renderer {
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
|
||||
}
|
||||
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
|
||||
if hand_quads > 0 && !pack_draws_hand {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
resolve_target: None,
|
||||
@@ -7559,7 +7382,15 @@ impl Renderer {
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.hand_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..hand_quads * 6,
|
||||
((world_quads + additive_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
|
||||
@@ -7747,54 +7578,15 @@ impl Renderer {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
// Finished, submitted and presented on the submit thread when nothing after the
|
||||
// submission needs this one: screenshots' readbacks, the error scopes (one per
|
||||
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
|
||||
let (encoder, surface_frame, after) = if readback.is_none()
|
||||
&& hdr_readback.is_none()
|
||||
&& checks.is_none()
|
||||
&& !first_frame
|
||||
&& !reconfigure
|
||||
&& self.hdr_marker.is_none()
|
||||
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
|
||||
{
|
||||
let offscreen = surface_frame.is_none();
|
||||
// Culling reads its results back as soon as the frame is submitted, as here.
|
||||
let after = self
|
||||
.cull
|
||||
.as_mut()
|
||||
.and_then(|c| c.submitted())
|
||||
.map(|f| Box::new(f) as submit::AfterSubmit);
|
||||
let job = submit::Job {
|
||||
encoder,
|
||||
surface: surface_frame,
|
||||
after,
|
||||
};
|
||||
match self.queue.submit_later(job) {
|
||||
Ok(()) => {
|
||||
self.deferred = Some(offscreen);
|
||||
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
|
||||
self.visible = visible;
|
||||
self.visible_lods = visible_lods;
|
||||
self.shadow_visible = shadow_visible;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(back) => (back.encoder, back.surface, back.after),
|
||||
}
|
||||
} else {
|
||||
(encoder, surface_frame, None)
|
||||
};
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
if first_frame {
|
||||
startup_step(format_args!("submitting the first frame"));
|
||||
self.first_frame = FirstFrame::Drawn;
|
||||
}
|
||||
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
|
||||
if let Some(read) = after {
|
||||
read();
|
||||
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
|
||||
read();
|
||||
if let Some(cull) = &mut self.cull {
|
||||
cull.submitted();
|
||||
}
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
@@ -7942,33 +7734,8 @@ pub(crate) fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
fn land_frame(&mut self) {
|
||||
let Some(offscreen) = self.deferred.take() else {
|
||||
return;
|
||||
};
|
||||
let submission = self.queue.landed();
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if offscreen && let Some(submission) = submission {
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_frames();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
self.land_frame();
|
||||
self.wait_frames();
|
||||
}
|
||||
fn wait_frames(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
|
||||
@@ -585,25 +585,23 @@ impl Cull {
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// The frame's results to read back, started by calling it right after the frame's
|
||||
/// submission (on the submit thread, when the frame goes there).
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
/// After the frame's submission: start reading its results back.
|
||||
pub fn submitted(&mut self) {
|
||||
let Some(i) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let r = &self.readbacks[i];
|
||||
let state = r.state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
|
||||
r.buffer
|
||||
.slice(..r.slots.len() as u64 * 4)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..bytes)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn make_group(
|
||||
|
||||
@@ -139,32 +139,17 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@location(0) color: vec4<f32>,
|
||||
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
if horizon_surface.w == 0.0 {
|
||||
set_surface(horizon_material_face_normal, 0.0, 0.0);
|
||||
}
|
||||
var out: HorizonMaterialSolid;
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
@@ -204,8 +189,6 @@ pub struct NativeMaterial {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
|
||||
pub writes_surface: bool,
|
||||
}
|
||||
|
||||
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
|
||||
@@ -237,10 +220,6 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`.
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
|
||||
@@ -271,7 +250,6 @@ impl NativeMaterial {
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
@@ -318,18 +296,6 @@ impl NativeMaterial {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
let solid_targets = [
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.label),
|
||||
layout: Some(layout),
|
||||
@@ -355,11 +321,11 @@ impl NativeMaterial {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
|
||||
&solid_targets
|
||||
} else {
|
||||
&solid_targets[..1]
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -404,7 +370,6 @@ impl NativeMaterial {
|
||||
solid,
|
||||
translucent,
|
||||
flat,
|
||||
writes_surface,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::Frame;
|
||||
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
/// Where a post pass draws.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -103,9 +103,6 @@ pub struct NativePost {
|
||||
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
|
||||
/// what the material stage reads besides.
|
||||
pub struct NativeFrame {
|
||||
/// The solid terrain's second target, the post stage's `scene_surface`.
|
||||
surface: wgpu::Texture,
|
||||
pub surface_view: wgpu::TextureView,
|
||||
pub(super) buffer: wgpu::Buffer,
|
||||
/// Group 3 of the sky and material stages.
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
@@ -273,8 +270,6 @@ impl NativeFrame {
|
||||
label: Some("Native shader pack frame"),
|
||||
entries: &entries,
|
||||
});
|
||||
let surface = surface_target(device, [1, 1]);
|
||||
let surface_view = surface.create_view(&Default::default());
|
||||
let opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
let blank = copy_target(
|
||||
@@ -337,8 +332,6 @@ impl NativeFrame {
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
surface,
|
||||
surface_view,
|
||||
opaque_depth,
|
||||
opaque_color,
|
||||
reflection: blank.clone(),
|
||||
@@ -475,14 +468,6 @@ impl NativeFrame {
|
||||
self.regroup(device);
|
||||
}
|
||||
|
||||
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
|
||||
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if [self.surface.width(), self.surface.height()] != size {
|
||||
self.surface = surface_target(device, size);
|
||||
self.surface_view = self.surface.create_view(&Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
|
||||
/// before the stages read it.
|
||||
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
|
||||
@@ -588,24 +573,6 @@ pub fn present_constants<'a>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The solid terrain's surface target of `size`, drawn and read.
|
||||
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack surface"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: super::native_material::SURFACE_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -685,7 +652,6 @@ impl NativePost {
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries
|
||||
@@ -791,7 +757,7 @@ impl NativePost {
|
||||
fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
) {
|
||||
@@ -864,11 +830,8 @@ impl NativePost {
|
||||
if self
|
||||
.groups
|
||||
.as_ref()
|
||||
.is_some_and(|((color, depth, surface), blank, _)| {
|
||||
color == world.0
|
||||
&& depth == world.1
|
||||
&& surface == world.2
|
||||
&& *blank == blank_history
|
||||
.is_some_and(|((color, depth), blank, _)| {
|
||||
color == world.0 && depth == world.1 && *blank == blank_history
|
||||
})
|
||||
{
|
||||
return;
|
||||
@@ -930,10 +893,6 @@ impl NativePost {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(world.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
@@ -961,11 +920,7 @@ impl NativePost {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.groups = Some((
|
||||
(world.0.clone(), world.1.clone(), world.2.clone()),
|
||||
blank_history,
|
||||
groups,
|
||||
));
|
||||
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
|
||||
}
|
||||
|
||||
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
|
||||
@@ -974,7 +929,7 @@ impl NativePost {
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
@@ -1394,7 +1349,7 @@ mod tests {
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth, &frame.surface_view),
|
||||
(&color, &depth),
|
||||
&view,
|
||||
[4, 4],
|
||||
true,
|
||||
@@ -1501,7 +1456,7 @@ file = \"b.wgsl\"
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth, &frame.surface_view),
|
||||
(&color, &depth),
|
||||
&view,
|
||||
[4, 4],
|
||||
history_reset,
|
||||
|
||||
@@ -6,19 +6,6 @@
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The game was built with native mods (the game sets it at start): one of them may draw in
|
||||
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
|
||||
pub fn set_mods_built_in(on: bool) {
|
||||
MODS_BUILT_IN.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
|
||||
pub fn world_drawn_hooked() -> bool {
|
||||
MODS_BUILT_IN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//! The frame's commands finished, submitted and presented on a thread of their own.
|
||||
//!
|
||||
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
|
||||
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
|
||||
//! would otherwise wait for before building the next frame. The renderer hands the
|
||||
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
|
||||
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
|
||||
//! submissions keep the order they had on one thread.
|
||||
use std::ops::Deref;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
|
||||
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
|
||||
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
|
||||
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
|
||||
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
|
||||
pub fn wanted() -> bool {
|
||||
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
|
||||
Ok("0") => false,
|
||||
Ok("1") => true,
|
||||
_ => cfg!(target_os = "linux"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Work to start right after a frame's submission (a readback's `map_async`).
|
||||
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// A frame for the submit thread: its commands and, in a window, the surface to present.
|
||||
pub struct Job {
|
||||
pub encoder: wgpu::CommandEncoder,
|
||||
pub surface: Option<wgpu::SurfaceTexture>,
|
||||
pub after: Option<AfterSubmit>,
|
||||
}
|
||||
|
||||
struct Thread {
|
||||
jobs: Option<mpsc::Sender<Job>>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
|
||||
struct Landing {
|
||||
done: mpsc::Receiver<wgpu::SubmissionIndex>,
|
||||
landed: Option<wgpu::SubmissionIndex>,
|
||||
}
|
||||
|
||||
/// The device's queue, with frames submitted on the submit thread when it runs.
|
||||
pub struct Queue {
|
||||
queue: wgpu::Queue,
|
||||
thread: Option<Thread>,
|
||||
/// A frame went to the submit thread and has not been waited for.
|
||||
pending: AtomicBool,
|
||||
landing: Mutex<Option<Landing>>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
|
||||
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
|
||||
let mut this = Self {
|
||||
queue,
|
||||
thread: None,
|
||||
pending: AtomicBool::new(false),
|
||||
landing: Mutex::new(None),
|
||||
};
|
||||
if !threaded {
|
||||
return this;
|
||||
}
|
||||
let (jobs, job_rx) = mpsc::channel::<Job>();
|
||||
let (done_tx, done) = mpsc::channel();
|
||||
let queue = this.queue.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Render submit".into())
|
||||
.spawn(move || {
|
||||
for job in job_rx {
|
||||
let commands = job.encoder.finish();
|
||||
let submission = queue.submit([commands]);
|
||||
if let Some(after) = job.after {
|
||||
after();
|
||||
}
|
||||
if let Some(surface) = job.surface {
|
||||
queue.present(surface);
|
||||
}
|
||||
if done_tx.send(submission).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(handle) => {
|
||||
this.thread = Some(Thread {
|
||||
jobs: Some(jobs),
|
||||
handle: Some(handle),
|
||||
});
|
||||
*this.lock() = Some(Landing { done, landed: None });
|
||||
}
|
||||
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
|
||||
}
|
||||
this
|
||||
}
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
|
||||
self.landing
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
/// Whether frames can go to the submit thread.
|
||||
pub fn threaded(&self) -> bool {
|
||||
self.thread.is_some()
|
||||
}
|
||||
/// Finish, submit and present the frame on the submit thread, then run its `after`;
|
||||
/// given back, with nothing done, when there is none.
|
||||
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
|
||||
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
|
||||
return Err(Box::new(job));
|
||||
};
|
||||
self.wait();
|
||||
match jobs.send(job) {
|
||||
Ok(()) => {
|
||||
self.pending.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
Err(mpsc::SendError(job)) => Err(Box::new(job)),
|
||||
}
|
||||
}
|
||||
/// Wait until the frame on the submit thread is submitted and presented.
|
||||
pub fn wait(&self) {
|
||||
if !self.pending.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let mut landing = self.lock();
|
||||
if !self.pending.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Some(l) = landing.as_mut() {
|
||||
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
|
||||
let submission = l
|
||||
.done
|
||||
.recv()
|
||||
.expect("Horizon renderer: the submit thread stopped");
|
||||
l.landed = Some(submission);
|
||||
}
|
||||
self.pending.store(false, Ordering::Release);
|
||||
}
|
||||
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
|
||||
/// made; `None` if it was taken already.
|
||||
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
|
||||
self.wait();
|
||||
self.lock().as_mut().and_then(|l| l.landed.take())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Queue {
|
||||
type Target = wgpu::Queue;
|
||||
fn deref(&self) -> &wgpu::Queue {
|
||||
self.wait();
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Queue {
|
||||
fn drop(&mut self) {
|
||||
self.wait();
|
||||
if let Some(thread) = &mut self.thread {
|
||||
thread.jobs.take();
|
||||
if let Some(handle) = thread.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_after_a_frame_on_the_thread_land_after_it() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let queue = Queue::new(queue, true);
|
||||
assert!(queue.threaded());
|
||||
use wgpu::BufferUsages as U;
|
||||
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
|
||||
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
|
||||
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
|
||||
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
|
||||
for frame in 0..3u8 {
|
||||
// The frame copies `source`; the next frame's write must not reach that copy.
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, ©, 0, 4);
|
||||
let job = Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after: None,
|
||||
};
|
||||
assert!(queue.submit_later(job).is_ok());
|
||||
queue.write_buffer(&source, 0, &[frame + 10; 4]);
|
||||
assert!(queue.landed().is_some(), "frame {frame} submitted");
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(©, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let expected = if frame == 0 {
|
||||
[1, 2, 3, 4]
|
||||
} else {
|
||||
[frame + 9; 4]
|
||||
};
|
||||
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
|
||||
read.unmap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,47 +79,6 @@ impl Preset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity occlusion strategy. The synchronous mode preserves the original
|
||||
/// same-frame CPU test; asynchronous results only hide a mesh when their
|
||||
/// camera, bounds and occluder evidence still match.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum EntityCullingMode {
|
||||
#[default]
|
||||
Off,
|
||||
Synchronous,
|
||||
Asynchronous,
|
||||
}
|
||||
impl EntityCullingMode {
|
||||
pub const ALL: [EntityCullingMode; 3] = [
|
||||
EntityCullingMode::Off,
|
||||
EntityCullingMode::Synchronous,
|
||||
EntityCullingMode::Asynchronous,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::Synchronous => "sync",
|
||||
Self::Asynchronous => "async",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "Off",
|
||||
Self::Synchronous => "Synchronous",
|
||||
Self::Asynchronous => "Asynchronous",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
// Keep graphics files written before the mode selector readable.
|
||||
"true" | "synchronous" | "sync" => Self::Synchronous,
|
||||
"false" | "off" => Self::Off,
|
||||
"asynchronous" | "async" => Self::Asynchronous,
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the swapchain presents frames.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PresentMode {
|
||||
@@ -287,61 +246,6 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the section mesher does with leaf faces inside a canopy. Terrain
|
||||
/// geometry only: entities, particles and block entities are untouched.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeafCullingMode {
|
||||
/// Every leaf face, as vanilla's fancy leaves draw them.
|
||||
#[default]
|
||||
Off,
|
||||
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
|
||||
/// in the opaque layer, so their cutouts no longer show the layers behind.
|
||||
Solid,
|
||||
/// Like `Solid`, but a leaf with one open side draws opaque too: the
|
||||
/// exposed shell thins by a layer and the canopy looks chunkier.
|
||||
SolidAggressive,
|
||||
/// Drops the faces two leaves share. Faces toward air, cavities, partial
|
||||
/// models, unknown neighbours and section boundaries stay.
|
||||
Hollow,
|
||||
}
|
||||
impl LeafCullingMode {
|
||||
pub const ALL: [LeafCullingMode; 4] = [
|
||||
LeafCullingMode::Off,
|
||||
LeafCullingMode::Solid,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
LeafCullingMode::Hollow,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
LeafCullingMode::Off => "off",
|
||||
LeafCullingMode::Solid => "solid",
|
||||
LeafCullingMode::SolidAggressive => "solid_aggressive",
|
||||
LeafCullingMode::Hollow => "hollow",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LeafCullingMode::Off => "Off",
|
||||
LeafCullingMode::Solid => "Solid",
|
||||
LeafCullingMode::SolidAggressive => "Solid Aggressive",
|
||||
LeafCullingMode::Hollow => "Hollow",
|
||||
}
|
||||
}
|
||||
/// Whether this mode draws interior leaf faces in the solid layer.
|
||||
pub fn is_interior(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
|
||||
)
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
|
||||
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -457,46 +361,6 @@ impl Graphics {
|
||||
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
|
||||
lod_distance_capped(self.lod_distance, info)
|
||||
}
|
||||
/// Whether the selected packs leave vanilla's block models in place.
|
||||
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
|
||||
/// for those: a pack can replace an opaque cube's model or a leaf's.
|
||||
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
|
||||
resource_packs.iter().all(|id| id == "vanilla")
|
||||
}
|
||||
/// The same check including packs overlaid by the connected server.
|
||||
/// Their ids are namespaced as `server/...`, so an active server pack
|
||||
/// disables model-dependent occlusion assumptions.
|
||||
pub fn vanilla_models_with_server_packs<'a>(
|
||||
&self,
|
||||
resource_packs: impl IntoIterator<Item = &'a str>,
|
||||
server_packs: impl IntoIterator<Item = &'a str>,
|
||||
) -> bool {
|
||||
resource_packs
|
||||
.into_iter()
|
||||
.chain(server_packs)
|
||||
.all(|id| id == "vanilla")
|
||||
}
|
||||
/// The leaf culling the mesher may apply. A pack that can replace leaf
|
||||
/// models falls back to `Off`, which draws exactly vanilla's leaves.
|
||||
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
|
||||
if self.vanilla_models(resource_packs) {
|
||||
self.leaf_culling
|
||||
} else {
|
||||
LeafCullingMode::Off
|
||||
}
|
||||
}
|
||||
/// The effective mode when local and server resource packs are active.
|
||||
pub fn leaf_culling_effective_with_server_packs<'a>(
|
||||
&self,
|
||||
resource_packs: impl IntoIterator<Item = &'a str>,
|
||||
server_packs: impl IntoIterator<Item = &'a str>,
|
||||
) -> LeafCullingMode {
|
||||
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
|
||||
self.leaf_culling
|
||||
} else {
|
||||
LeafCullingMode::Off
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Graphics {
|
||||
@@ -507,12 +371,8 @@ pub struct Graphics {
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
pub entity_culling: EntityCullingMode,
|
||||
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
@@ -585,8 +445,7 @@ impl Default for Graphics {
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
@@ -655,8 +514,7 @@ impl Graphics {
|
||||
}
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
@@ -722,13 +580,12 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
@@ -884,8 +741,7 @@ mod tests {
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
@@ -916,21 +772,6 @@ mod tests {
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
for mode in EntityCullingMode::ALL {
|
||||
let graphics = Graphics {
|
||||
entity_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
|
||||
}
|
||||
assert_eq!(
|
||||
Graphics::parse("entityCulling:true\n").entity_culling,
|
||||
EntityCullingMode::Synchronous
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("entityCulling:false\n").entity_culling,
|
||||
EntityCullingMode::Off
|
||||
);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// Translucent Batching is opt-in for now: only the file turns it on.
|
||||
assert!(!Graphics::default().translucent_batching);
|
||||
@@ -960,96 +801,11 @@ mod tests {
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse(
|
||||
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
|
||||
),
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_round_trips_its_stable_names() {
|
||||
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
|
||||
for mode in LeafCullingMode::ALL {
|
||||
let g = Graphics {
|
||||
leaf_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
|
||||
assert_eq!(
|
||||
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
|
||||
mode
|
||||
);
|
||||
}
|
||||
for text in [
|
||||
"leafCulling:other\n",
|
||||
"leafCulling:\n",
|
||||
"leafCulling:SOLID\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
Graphics::parse(text).leaf_culling,
|
||||
LeafCullingMode::Off,
|
||||
"{text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_keep_the_leaf_culling_choice() {
|
||||
for mode in LeafCullingMode::ALL {
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
s.horizon.leaf_culling = mode;
|
||||
apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
|
||||
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_packs_turn_leaf_culling_off() {
|
||||
let g = Graphics {
|
||||
leaf_culling: LeafCullingMode::Hollow,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert!(g.vanilla_models(&["vanilla".into()]));
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective(&["vanilla".into()]),
|
||||
LeafCullingMode::Hollow
|
||||
);
|
||||
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
|
||||
LeafCullingMode::Off
|
||||
);
|
||||
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
|
||||
let g = Graphics {
|
||||
leaf_culling: LeafCullingMode::Solid,
|
||||
..Graphics::default()
|
||||
};
|
||||
let selected = ["vanilla"];
|
||||
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
|
||||
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
|
||||
LeafCullingMode::Off,
|
||||
"an applied server pack can replace block models"
|
||||
);
|
||||
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
|
||||
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective_with_server_packs(selected, []),
|
||||
LeafCullingMode::Solid,
|
||||
"removing the server pack restores the selected local mode"
|
||||
);
|
||||
assert!(g.vanilla_models_with_server_packs(selected, []));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
|
||||
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode,
|
||||
Preset, Upscaling,
|
||||
},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
@@ -30,7 +27,6 @@ pub enum H {
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
@@ -191,12 +187,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"How far away mobs and items are drawn, as a share of normal.",
|
||||
),
|
||||
h(
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Culling"),
|
||||
h(
|
||||
H::CaveCulling,
|
||||
Saves,
|
||||
@@ -205,12 +195,12 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
@@ -456,16 +446,6 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
|
||||
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
|
||||
LeafCullingMode::Solid => {
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers."
|
||||
}
|
||||
LeafCullingMode::SolidAggressive => {
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges."
|
||||
}
|
||||
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
|
||||
},
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
@@ -535,8 +515,7 @@ impl H {
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
@@ -669,12 +648,7 @@ impl H {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
}
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
@@ -762,6 +736,7 @@ impl Item {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::EntityCulling) => g.entity_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
@@ -1851,92 +1826,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::LeafCulling);
|
||||
assert_eq!(item.switch(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
|
||||
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
|
||||
assert_eq!(
|
||||
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
|
||||
s.horizon.leaf_culling
|
||||
);
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
|
||||
|
||||
// The three culling settings share one group, below Rendering's chunk
|
||||
// updates and above Quality.
|
||||
let rows = rows(Level::Advanced);
|
||||
let culling = rows
|
||||
.iter()
|
||||
.position(|row| matches!(row, Row::Header("Culling")))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
rows.get(culling + 1),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 2),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 3),
|
||||
Some(Row::Setting(setting)) if setting.item == item
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 4),
|
||||
Some(Row::Header("Quality"))
|
||||
));
|
||||
|
||||
// The description names what the picked mode does.
|
||||
for mode in LeafCullingMode::ALL {
|
||||
s.horizon.leaf_culling = mode;
|
||||
let text = description(item, &s, "fallback");
|
||||
assert_ne!(text, "fallback", "{mode:?}");
|
||||
assert!(text.len() < 80, "{mode:?}: {text}");
|
||||
}
|
||||
|
||||
// A click cycles it and leaves the preset, as the other quality
|
||||
// settings do.
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let control = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
s.horizon.leaf_culling = LeafCullingMode::Off;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
(control.x + 1.0, control.y + 1.0),
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::EntityCulling);
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
|
||||
for mode in EntityCullingMode::ALL {
|
||||
s.horizon.entity_culling = mode;
|
||||
for enabled in [false, true] {
|
||||
s.horizon.entity_culling = enabled;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.entity_culling, mode);
|
||||
assert_eq!(s.horizon.entity_culling, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1948,11 +1847,7 @@ mod tests {
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
let point = (switch.x + 1.0, switch.y + 1.0);
|
||||
for mode in [
|
||||
EntityCullingMode::Off,
|
||||
EntityCullingMode::Synchronous,
|
||||
EntityCullingMode::Asynchronous,
|
||||
] {
|
||||
for enabled in [false, true] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
@@ -1966,10 +1861,10 @@ mod tests {
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(s.horizon.entity_culling, mode);
|
||||
assert_eq!(item.switch(&s), Some(enabled));
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -83,8 +83,6 @@ pub struct HudAtlas {
|
||||
/// A resource pack's own `core/text` shader for the GUI's text, translated;
|
||||
/// none draws the text natively.
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -481,7 +479,6 @@ impl HudAtlas {
|
||||
glint_armor: (vec![0; 4], 1, 1),
|
||||
white,
|
||||
text_shader: None,
|
||||
random_textures: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
@@ -570,41 +567,15 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
let random_textures = if folders.contains(&"entity") {
|
||||
horizon_optifine::random_textures::RandomTextures::scan(root)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
for (file, name) in random_textures.files() {
|
||||
named.insert(root.join(file), name.to_owned());
|
||||
}
|
||||
if !named.is_empty() {
|
||||
// Those beside their texture are walked already.
|
||||
let walked: HashSet<&PathBuf> = files.iter().collect();
|
||||
let mut extra: Vec<PathBuf> = named
|
||||
.keys()
|
||||
.filter(|p| !walked.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
extra.sort();
|
||||
files.extend(extra);
|
||||
}
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = if let Some(name) = named.get(path) {
|
||||
name.clone()
|
||||
} else {
|
||||
match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
let name = match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
@@ -806,7 +777,6 @@ impl HudAtlas {
|
||||
glint,
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
random_textures,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
|
||||
@@ -18,11 +18,9 @@ pub mod camera;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod cit;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
pub mod connected;
|
||||
pub mod containers;
|
||||
pub mod crash;
|
||||
pub mod datapack;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
|
||||
//! follow `FluidRenderer` (see `fluid`).
|
||||
mod fluid;
|
||||
mod leaf_culling;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::{
|
||||
@@ -12,7 +11,6 @@ use crate::{
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -472,9 +470,6 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
|
||||
/// and a full collision shape. The only block leaf culling treats as solid.
|
||||
opaque_cube: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -652,12 +647,6 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
opaque_cube: leaf_culling::opaque_cube(
|
||||
s.render_shape,
|
||||
s.can_occlude,
|
||||
s.solid_render,
|
||||
s.full_collision,
|
||||
),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -697,14 +686,6 @@ impl MeshTables {
|
||||
tint_sources: i.tint_count,
|
||||
})
|
||||
}
|
||||
/// Whether `state` is a full opaque cube: the only block leaf culling
|
||||
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
|
||||
/// that only partly fill their block are not.
|
||||
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
|
||||
self.info
|
||||
.get(state.0 as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
}
|
||||
/// `BlockTintSource.color(state)`: block-model colour without a level.
|
||||
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
|
||||
/// Untinted faces, blank sources and invalid tint indices return `None`.
|
||||
@@ -879,18 +860,14 @@ impl Padded {
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
/// geometry still owned by the renderer, and total about 88 KiB per worker.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
@@ -1102,8 +1079,6 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// What the worker does with leaf faces inside a canopy.
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
@@ -1133,11 +1108,6 @@ impl Mesher<'_> {
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
.leaf
|
||||
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
|
||||
}
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
@@ -1202,21 +1172,13 @@ impl Mesher<'_> {
|
||||
None => [x as f32, y as f32, z as f32],
|
||||
};
|
||||
let mut visible = [false; 6];
|
||||
let interior_leaf = state.leaves
|
||||
&& self.leaf_culling.is_interior()
|
||||
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
|
||||
&& state.leaves
|
||||
&& other.leaves
|
||||
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& !hollow_leaf
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
@@ -1241,7 +1203,6 @@ impl Mesher<'_> {
|
||||
face,
|
||||
ao,
|
||||
state,
|
||||
interior_leaf,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1280,7 +1241,6 @@ impl Mesher<'_> {
|
||||
face: &BakedFace,
|
||||
ao: bool,
|
||||
state: &Info,
|
||||
interior_leaf: bool,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
// A quad's own light (a model's glowing part) may raise the tier; whether it
|
||||
@@ -1315,17 +1275,6 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
&mut out.solid
|
||||
};
|
||||
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
|
||||
// leaf-culling mode proved internal draw in the solid layer: no alpha
|
||||
// cutout. All four vertices of the quad share the layer.
|
||||
let layer = if face.opaque && !face.translucent
|
||||
|| !self.cutout_leaves && state.leaves
|
||||
|| interior_leaf
|
||||
{
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
};
|
||||
for i in 0..4 {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
@@ -1336,7 +1285,13 @@ impl Mesher<'_> {
|
||||
target.push(TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
layer,
|
||||
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
|
||||
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
|
||||
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
|
||||
@@ -1,656 +0,0 @@
|
||||
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
|
||||
//!
|
||||
//! The mask is built on the mesh worker from the same padded block snapshot the
|
||||
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
|
||||
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
|
||||
//! open (air, fluids, partial models, anything else) or unknown (a missing
|
||||
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
|
||||
//! hide or solidify anything; unknown and open cells always expose a face.
|
||||
//!
|
||||
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
|
||||
//! enclosed leaves into the opaque layer without cutout testing. `Solid
|
||||
//! Aggressive` runs a multi-source breadth-first search from leaves touching
|
||||
//! openings, solidifying leaves with a single open face to thin the canopy shell
|
||||
//! by one layer. `Hollow` directly drops faces shared between two leaves within
|
||||
//! the section, preserving boundaries and cavity faces.
|
||||
|
||||
use super::{B, Info, P, Padded, pi};
|
||||
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
|
||||
|
||||
/// A cell of the padded snapshot, as far as leaf culling cares.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Cell {
|
||||
/// Air, a fluid, a partial model, glass or any other non-occluding block.
|
||||
Open,
|
||||
/// `LeavesBlock`.
|
||||
Leaf,
|
||||
/// A full opaque cube (see [`opaque_cube`]).
|
||||
Opaque,
|
||||
/// Missing chunk, missing section or the mask's outer shell.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
|
||||
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
|
||||
/// chests and models that only partly fill their block are not.
|
||||
pub(super) fn opaque_cube(
|
||||
shape: RenderShape,
|
||||
can_occlude: bool,
|
||||
solid_render: bool,
|
||||
full_collision: bool,
|
||||
) -> bool {
|
||||
shape == RenderShape::Model && can_occlude && solid_render && full_collision
|
||||
}
|
||||
|
||||
const UNVISITED: u8 = u8::MAX;
|
||||
|
||||
const STRIDE_X: isize = 1;
|
||||
const STRIDE_Z: isize = P as isize;
|
||||
const STRIDE_Y: isize = (P * P) as isize;
|
||||
|
||||
const ADJACENT_STRIDES: [isize; 6] = [
|
||||
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
|
||||
];
|
||||
|
||||
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
|
||||
/// between sections and modes; `Off` does no work at all.
|
||||
#[derive(Default)]
|
||||
pub(super) struct LeafMask {
|
||||
cells: Vec<Cell>,
|
||||
depth: Vec<u8>,
|
||||
queue: Vec<u16>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl LeafMask {
|
||||
/// Classify the padded snapshot and, for the interior modes, measure how
|
||||
/// deep each leaf sits below the canopy surface.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
mode: LeafCullingMode,
|
||||
pad: &Padded,
|
||||
info: &[Info],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
base_y: i32,
|
||||
) {
|
||||
self.active = mode != LeafCullingMode::Off;
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
// Most sections hold no leaves at all; do not classify 8,000 cells to
|
||||
// learn that. The centre is the only part this mesh can change.
|
||||
let mut leaves = false;
|
||||
'scan: for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
if info
|
||||
.get(pad.blocks[pi(x, y, z)] as usize)
|
||||
.is_some_and(|state| state.leaves)
|
||||
{
|
||||
leaves = true;
|
||||
break 'scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !leaves {
|
||||
self.active = false;
|
||||
return;
|
||||
}
|
||||
if self.cells.len() != P * P * P {
|
||||
self.cells.resize(P * P * P, Cell::Open);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let i = pi(x, y, z);
|
||||
self.cells[i] = if !known(region, base_y, x, y, z) {
|
||||
Cell::Unknown
|
||||
} else {
|
||||
match info.get(pad.blocks[i] as usize) {
|
||||
None => Cell::Unknown,
|
||||
Some(state) if state.leaves => Cell::Leaf,
|
||||
Some(state) if state.opaque_cube => Cell::Opaque,
|
||||
Some(_) => Cell::Open,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Solid is a purely local six-neighbour test. Reserve the breadth-first
|
||||
// search for Solid Aggressive, whose one-open-side rule needs to know
|
||||
// whether a leaf is connected to the canopy surface through other
|
||||
// leaves.
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
self.depths();
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
|
||||
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
|
||||
fn depths(&mut self) {
|
||||
if self.depth.len() != self.cells.len() {
|
||||
self.depth.resize(self.cells.len(), UNVISITED);
|
||||
} else {
|
||||
self.depth.fill(UNVISITED);
|
||||
}
|
||||
self.queue.clear();
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let i = pi(x, y, z);
|
||||
if self.cells[i] != Cell::Leaf {
|
||||
continue;
|
||||
}
|
||||
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
|
||||
let j = (i as isize + stride) as usize;
|
||||
matches!(self.cells[j], Cell::Open | Cell::Unknown)
|
||||
});
|
||||
if touches_open {
|
||||
self.depth[i] = 0;
|
||||
self.queue.push(i as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut head = 0;
|
||||
while head < self.queue.len() {
|
||||
let i = self.queue[head] as usize;
|
||||
head += 1;
|
||||
let next_depth = self.depth[i].saturating_add(1);
|
||||
for stride in ADJACENT_STRIDES {
|
||||
let j = (i as isize + stride) as usize;
|
||||
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
|
||||
self.depth[j] = next_depth;
|
||||
self.queue.push(j as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
|
||||
///
|
||||
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
|
||||
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
|
||||
/// with a single open side joins the opaque core, so the cutout shell is
|
||||
/// one layer thinner. Unknown neighbours never qualify, however many sides
|
||||
/// they leave open. A leaf the search never reached sits inside a closed
|
||||
/// volume: interior for both.
|
||||
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
|
||||
if !self.active {
|
||||
return false;
|
||||
}
|
||||
let aggressive = match mode {
|
||||
LeafCullingMode::Solid => false,
|
||||
LeafCullingMode::SolidAggressive => true,
|
||||
_ => return false,
|
||||
};
|
||||
// A section boundary is not proof of a closed volume: the neighbouring
|
||||
// section may not be drawn yet, and its data is not ours to assume.
|
||||
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
|
||||
return false;
|
||||
}
|
||||
let i = pi(x, y, z);
|
||||
if self.cells.get(i) != Some(&Cell::Leaf) {
|
||||
return false;
|
||||
}
|
||||
if !aggressive {
|
||||
return ADJACENT_STRIDES.iter().all(|&stride| {
|
||||
let j = (i as isize + stride) as usize;
|
||||
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
|
||||
});
|
||||
}
|
||||
let depth = self.depth[i];
|
||||
if depth == UNVISITED || depth >= 1 {
|
||||
return true;
|
||||
}
|
||||
let mut open = 0;
|
||||
for stride in ADJACENT_STRIDES {
|
||||
let j = (i as isize + stride) as usize;
|
||||
match self.cells[j] {
|
||||
Cell::Unknown => return false,
|
||||
Cell::Open => {
|
||||
open += 1;
|
||||
if open > 1 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Cell::Leaf | Cell::Opaque => {}
|
||||
}
|
||||
}
|
||||
open == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells this section's mesh owns. A face on this boundary belongs to the
|
||||
/// neighbouring section as much as to this one; leaf culling leaves it alone.
|
||||
#[inline]
|
||||
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
|
||||
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
|
||||
}
|
||||
|
||||
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
|
||||
/// does not count: `Padded::fill` copies the centre's edge column into it, which
|
||||
/// would make a canopy look enclosed. A Y outside the column counts as unknown
|
||||
/// for the same reason. The mask's outer shell is unknown too, so nothing is
|
||||
/// inferred past the data the worker snapshotted.
|
||||
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
|
||||
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
|
||||
return false;
|
||||
}
|
||||
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl LeafMask {
|
||||
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
|
||||
/// Only a known leaf on the other side counts: open cells, partial models,
|
||||
/// missing neighbours and the mask's edge stay visible.
|
||||
pub(super) fn hides_face(
|
||||
&self,
|
||||
mode: LeafCullingMode,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
d: horizon_core::geometry::Direction,
|
||||
) -> bool {
|
||||
if !self.active || mode != LeafCullingMode::Hollow {
|
||||
return false;
|
||||
}
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
|
||||
return false;
|
||||
}
|
||||
let i = pi(x, y, z);
|
||||
let ni = pi(nx, ny, nz);
|
||||
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
|
||||
checked(x, y, z)
|
||||
.and_then(|i| self.cells.get(i))
|
||||
.copied()
|
||||
.unwrap_or(Cell::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
|
||||
Some(pi(x, y, z))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn unpi(i: usize) -> (i32, i32, i32) {
|
||||
let x = (i % P) as i32 - B;
|
||||
let z = (i / P % P) as i32 - B;
|
||||
let y = (i / (P * P)) as i32 - B;
|
||||
(x, y, z)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::fluid::FluidState;
|
||||
use std::time::Instant;
|
||||
|
||||
fn info(leaves: bool, opaque: bool) -> Info {
|
||||
Info {
|
||||
solid: opaque,
|
||||
full: opaque,
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: true,
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
leaves,
|
||||
opaque_cube: opaque,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
}
|
||||
}
|
||||
|
||||
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
|
||||
fn states() -> Vec<Info> {
|
||||
vec![
|
||||
info(false, false),
|
||||
info(true, false),
|
||||
info(false, true),
|
||||
info(false, false),
|
||||
]
|
||||
}
|
||||
|
||||
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
|
||||
Padded {
|
||||
blocks: (0..P * P * P)
|
||||
.map(|i| {
|
||||
let (x, y, z) = unpi(i);
|
||||
fill(x, y, z)
|
||||
})
|
||||
.collect(),
|
||||
light: vec![0; P * P * P],
|
||||
}
|
||||
}
|
||||
|
||||
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
|
||||
for y in min..=max {
|
||||
for z in min..=max {
|
||||
for x in min..=max {
|
||||
pad.blocks[pi(x, y, z)] = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded() -> Chunk {
|
||||
Chunk::empty(0, 0)
|
||||
}
|
||||
|
||||
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
|
||||
[[Some(chunk); 3]; 3]
|
||||
}
|
||||
|
||||
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(mode, pad, states, ®ion(chunk), 0);
|
||||
mask
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_full_model_cube_is_opaque() {
|
||||
let combinations = [
|
||||
(RenderShape::Model, true, true, true),
|
||||
(RenderShape::Model, true, true, false),
|
||||
(RenderShape::Model, true, false, true),
|
||||
(RenderShape::Model, false, true, true),
|
||||
(RenderShape::Model, false, false, false),
|
||||
(RenderShape::Invisible, true, true, true),
|
||||
(RenderShape::Animated, true, true, true),
|
||||
];
|
||||
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
|
||||
assert!(opaque_cube(
|
||||
shape,
|
||||
can_occlude,
|
||||
solid_render,
|
||||
full_collision
|
||||
));
|
||||
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
|
||||
assert!(
|
||||
!opaque_cube(shape, can_occlude, solid_render, full_collision),
|
||||
"{shape:?} {can_occlude} {solid_render} {full_collision}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_marks_the_cell_inside_a_three_cube() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
|
||||
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
|
||||
// Aggressive also takes the six face-centre leaves, whose only open
|
||||
// side faces out of the cube. Corners and edges keep their cutout.
|
||||
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
|
||||
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
|
||||
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggressive_takes_the_next_shell_layer() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
let chunk = loaded();
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
|
||||
let surface = |v: i32| v == 6 || v == 10;
|
||||
for [x, y, z] in [
|
||||
[6, 8, 8],
|
||||
[10, 8, 8],
|
||||
[6, 6, 8],
|
||||
[6, 6, 6],
|
||||
[7, 8, 8],
|
||||
[8, 8, 8],
|
||||
[7, 7, 7],
|
||||
[8, 8, 7],
|
||||
] {
|
||||
let expected = !surface(x) && !surface(y) && !surface(z);
|
||||
assert_eq!(
|
||||
solid.interior(LeafCullingMode::Solid, x, y, z),
|
||||
expected,
|
||||
"solid {x} {y} {z}"
|
||||
);
|
||||
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
|
||||
let expected = open <= 1;
|
||||
assert_eq!(
|
||||
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
|
||||
expected,
|
||||
"aggressive {x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hollow_hides_only_leaf_to_leaf_faces() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
// Glass (3, a partial model), water and a slab are open: their leaves
|
||||
// must keep the face.
|
||||
pad.blocks[pi(10, 8, 8)] = 3;
|
||||
let chunk = loaded();
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
for d in Direction::ALL {
|
||||
assert!(
|
||||
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
|
||||
// The other modes never hide a quad.
|
||||
for mode in [
|
||||
LeafCullingMode::Off,
|
||||
LeafCullingMode::Solid,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
pad.blocks[pi(8, 8, 8)] = 0;
|
||||
let chunk = loaded();
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
|
||||
// The corner away from the cavity is still two layers in.
|
||||
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opaque_neighbour_does_not_expose_a_leaf() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
|
||||
}
|
||||
pad.blocks[pi(8, 8, 8)] = 1;
|
||||
let chunk = loaded();
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
|
||||
}
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
for d in Direction::ALL {
|
||||
assert!(
|
||||
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leaf_inside_a_closed_volume_is_interior() {
|
||||
let states = states();
|
||||
// A leaf cube inside opaque rock: the search has no opening to start
|
||||
// from, so every leaf stays unvisited and counts as interior.
|
||||
let mut pad = padded(|_, _, _| 2);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
|
||||
}
|
||||
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_chunks_and_sections_are_unknown() {
|
||||
let states = states();
|
||||
let pad = padded(|_, _, _| 1);
|
||||
let chunk = loaded();
|
||||
let mut around = [[Some(&chunk); 3]; 3];
|
||||
around[1][2] = None; // east chunk
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
|
||||
// The leaf at the edge keeps its face toward the missing chunk, even
|
||||
// though `Padded::fill` copied the centre's edge leaf there.
|
||||
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
|
||||
// A leaf one block inside is still between two known leaves.
|
||||
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
|
||||
|
||||
// A section above the column is unknown too: the same leaf one
|
||||
// section up has no data, so it can never be interior.
|
||||
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Solid, &pad, &states, ®ion(&short), 16);
|
||||
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
|
||||
|
||||
// The outer shell has no data beyond it.
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Hollow, &pad, &states, ®ion(&chunk), 0);
|
||||
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
|
||||
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
|
||||
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
|
||||
|
||||
// A section boundary is never enclosed, even with every chunk loaded:
|
||||
// the neighbouring section may not have been meshed yet.
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Solid, &pad, &states, ®ion(&chunk), 0);
|
||||
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
|
||||
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_does_no_work_and_hides_nothing() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Off, &pad, &states, ®ion(&chunk), 0);
|
||||
assert!(!mask.active);
|
||||
assert!(mask.cells.is_empty());
|
||||
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
|
||||
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reused_mask_reclassifies_every_cell() {
|
||||
let states = states();
|
||||
let chunk = loaded();
|
||||
let mut mask = LeafMask::default();
|
||||
let mut leaves = padded(|_, _, _| 0);
|
||||
cube(&mut leaves, 7, 9, 1);
|
||||
mask.prepare(
|
||||
LeafCullingMode::Hollow,
|
||||
&leaves,
|
||||
&states,
|
||||
®ion(&chunk),
|
||||
0,
|
||||
);
|
||||
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
|
||||
let mut other = padded(|_, _, _| 0);
|
||||
cube(&mut other, 10, 12, 1);
|
||||
mask.prepare(LeafCullingMode::Hollow, &other, &states, ®ion(&chunk), 0);
|
||||
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
|
||||
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "microbenchmark; run with --ignored --nocapture"]
|
||||
fn mask_build_benchmark() {
|
||||
let states = states();
|
||||
let mut pad = padded(|x, y, z| {
|
||||
// Alternating leaf slabs make the search visit most of the volume.
|
||||
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
let chunk = loaded();
|
||||
let region = region(&chunk);
|
||||
let iterations = 1000;
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mut mask = LeafMask::default();
|
||||
let started = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
mask.prepare(mode, &pad, &states, ®ion, 0);
|
||||
}
|
||||
let per = started.elapsed() / iterations;
|
||||
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
|
||||
assert!(mask.active);
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
//! Leaf culling through the real mesher: face counts, the opaque layer and the
|
||||
//! conservative cases (cavities, partial neighbours, missing chunks, column
|
||||
//! ends). Needs prepared resources, like the other mesher geometry tests.
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, ColumnLayout},
|
||||
graphics::LeafCullingMode,
|
||||
models,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
assets: Assets,
|
||||
biomes: Biomes,
|
||||
render: RenderAssets,
|
||||
visibility: horizon_world::block_render::FaceVisibility,
|
||||
tables: MeshTables,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
Fixture {
|
||||
assets,
|
||||
biomes,
|
||||
render,
|
||||
visibility,
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(f: &Fixture, name: &str) -> u32 {
|
||||
f.assets.registry.default_state(name).unwrap().0
|
||||
}
|
||||
|
||||
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
|
||||
for z in min..=max {
|
||||
for x in min..=max {
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let state = state(f, name);
|
||||
for y in base..base + (max - min + 1) {
|
||||
leaves_cube(&mut chunk, min, max, y, state);
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
fn mesh(
|
||||
f: &Fixture,
|
||||
_chunk: &Chunk,
|
||||
region: [[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mode: LeafCullingMode,
|
||||
) -> SectionMesh {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
biomes: &f.biomes,
|
||||
visibility: f.visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: mode,
|
||||
};
|
||||
mesher.mesh(®ion, sy)
|
||||
}
|
||||
|
||||
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
|
||||
[[Some(chunk); 3]; 3]
|
||||
}
|
||||
|
||||
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
|
||||
fn quads(mesh: &SectionMesh) -> (usize, usize) {
|
||||
let mut opaque = 0;
|
||||
let mut cutout = 0;
|
||||
for quad in mesh.solid.as_chunks::<4>().0 {
|
||||
let flags = quad
|
||||
.iter()
|
||||
.map(|v| v.layer & OPAQUE_LAYER != 0)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
flags.iter().all(|flag| *flag == flags[0]),
|
||||
"a quad mixed opaque and cutout vertices: {flags:?}"
|
||||
);
|
||||
if flags[0] {
|
||||
opaque += 1;
|
||||
} else {
|
||||
cutout += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(mesh.solid.len() % 4, 0);
|
||||
(opaque, cutout)
|
||||
}
|
||||
|
||||
const BASE: i32 = MIN_Y + 4;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn off_draws_every_fancy_leaf_face() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
|
||||
assert_eq!(quads(&mesh), (0, 162));
|
||||
assert_eq!(mesh.translucent.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn solid_draws_the_fully_enclosed_leaf_opaque() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// Only the centre has all six sides covered: its six quads go opaque.
|
||||
assert_eq!(quads(&solid), (6, 156));
|
||||
|
||||
let aggressive = mesh(
|
||||
&f,
|
||||
&chunk,
|
||||
around(&chunk),
|
||||
0,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
);
|
||||
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
|
||||
assert_eq!(quads(&aggressive), (42, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// The 3x3x3 inside the surface: 27 blocks, six quads each.
|
||||
assert_eq!(quads(&solid), (162, 588));
|
||||
let aggressive = mesh(
|
||||
&f,
|
||||
&chunk,
|
||||
around(&chunk),
|
||||
0,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
);
|
||||
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
|
||||
assert_eq!(quads(&aggressive), (486, 264));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn hollow_drops_only_the_faces_two_leaves_share() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
|
||||
assert_eq!(quads(&hollow), (0, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn mixed_species_count_as_one_leaf_volume() {
|
||||
let f = fixture();
|
||||
let oak = state(&f, "minecraft:oak_leaves");
|
||||
let birch = state(&f, "minecraft:birch_leaves");
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in BASE..BASE + 3 {
|
||||
for z in 6..=8 {
|
||||
for x in 6..=8 {
|
||||
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
|
||||
}
|
||||
}
|
||||
}
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
assert_eq!(quads(&solid), (6, 156));
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
assert_eq!(quads(&hollow), (0, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
|
||||
let f = fixture();
|
||||
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
|
||||
chunk.set(7, BASE + 2, 7, 0);
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
// Five-cube shell (150) plus the six faces around the one-block cavity.
|
||||
assert_eq!(quads(&hollow), (0, 156));
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// The six leaves around the cavity are exposed, so they stay cutout; the
|
||||
// other 20 inner leaves draw solid. The cavity block itself has no quads.
|
||||
assert_eq!(quads(&solid), (120, 624));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
|
||||
let f = fixture();
|
||||
let leaf = state(&f, "minecraft:oak_leaves");
|
||||
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
|
||||
// A loaded east chunk whose x=0 column continues the canopy.
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for y in BASE..BASE + 4 {
|
||||
for z in 12..=15 {
|
||||
east.set(0, y, z, leaf);
|
||||
}
|
||||
}
|
||||
let mut region = around(&chunk);
|
||||
region[1][2] = Some(&east);
|
||||
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
|
||||
let mut region = around(&chunk);
|
||||
region[1][2] = None;
|
||||
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
|
||||
// The east faces of the x=15 leaves are on the section boundary: a loaded
|
||||
// neighbour does not let Hollow drop them, and a missing one does not
|
||||
// change the mesh either.
|
||||
assert_eq!(joined.solid.len(), missing.solid.len());
|
||||
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
assert!(joined.solid.len() < off.solid.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn the_top_of_a_one_section_column_stays_visible() {
|
||||
let f = fixture();
|
||||
let leaf = state(&f, "minecraft:oak_leaves");
|
||||
let layout = ColumnLayout::new(0, 16).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for y in 13..16 {
|
||||
leaves_cube(&mut chunk, 5, 7, y, leaf);
|
||||
}
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// y=15 is the column's top: the faces toward y=16 are unknown, so they
|
||||
// stay, and the y=15 leaves are not enclosed.
|
||||
assert!(!hollow.solid.is_empty());
|
||||
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
|
||||
assert!(
|
||||
solid
|
||||
.solid
|
||||
.iter()
|
||||
.filter(|v| v.pos[1] == 16.0)
|
||||
.all(|v| v.layer & OPAQUE_LAYER == 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_world_without_leaves_meshes_identically_in_every_mode() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in BASE..BASE + 3 {
|
||||
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
|
||||
}
|
||||
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
for mode in LeafCullingMode::ALL {
|
||||
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
|
||||
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
|
||||
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
|
||||
for (a, b) in off.solid.iter().zip(&other.solid) {
|
||||
assert_eq!(
|
||||
(a.pos, a.uv, a.layer, a.light, a.color),
|
||||
(b.pos, b.uv, b.layer, b.light, b.color),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
for (a, b) in off.translucent.iter().zip(&other.translucent) {
|
||||
assert_eq!(
|
||||
(a.pos, a.uv, a.layer, a.light, a.color),
|
||||
(b.pos, b.uv, b.layer, b.light, b.color),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,6 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut colours: Vec<u32> = mesher
|
||||
.mesh(®ion, 0)
|
||||
@@ -157,7 +156,6 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
(&empty, None),
|
||||
|
||||
@@ -88,7 +88,6 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
opaque_cube: solid,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -68,9 +68,6 @@ pub enum Ticking {
|
||||
/// `RidingEntitySoundInstance` for the player riding a happy ghast
|
||||
/// (`underwater_sound` false) or a nautilus (true).
|
||||
RidingEntity { entity: u32, underwater_sound: bool },
|
||||
/// A native mod's music layer (`MusicLayers`), whose volume its owner
|
||||
/// sets each tick.
|
||||
MusicLayer,
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -236,25 +233,6 @@ impl SoundInstance {
|
||||
true,
|
||||
)
|
||||
}
|
||||
/// A native mod's music layer: a looping, unattenuated track on the music
|
||||
/// source that starts silent (`MusicLayers` fades it in).
|
||||
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
id,
|
||||
MUSIC,
|
||||
0.0,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
);
|
||||
s.can_start_silent = true;
|
||||
s.ticking = Ticking::MusicLayer;
|
||||
s
|
||||
}
|
||||
/// `SimpleSoundInstance.forJukeboxSong`.
|
||||
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
|
||||
Self::new(
|
||||
@@ -544,7 +522,7 @@ impl SoundInstance {
|
||||
/// `TickableSoundInstance.tick`.
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::No => {}
|
||||
Ticking::BiomeLoop { fade, direction } => {
|
||||
let stop = *fade < 0;
|
||||
*fade += *direction;
|
||||
|
||||
@@ -349,26 +349,10 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, name, decoded)
|
||||
}
|
||||
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
|
||||
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
|
||||
/// that does not load is an error: the caller drops what needed it. Such files bypass the
|
||||
/// disk cache, whose fingerprint covers only `textures/`.
|
||||
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
|
||||
if let Some(layer) = self.layers.get(resource) {
|
||||
return Ok(*layer);
|
||||
}
|
||||
let decoded = Self::decode_file(&root.join(resource), resource)?;
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
|
||||
Self::decode_file(
|
||||
&root.join(format!("assets/{namespace}/textures/{path}.png")),
|
||||
name,
|
||||
)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
@@ -384,7 +368,7 @@ impl Sprites {
|
||||
.as_deref()
|
||||
.map(AnimationMetadata::from_json)
|
||||
.transpose()?;
|
||||
let (png_width, png_height, pixels) = read_rgba(path)?;
|
||||
let (png_width, png_height, pixels) = read_rgba(&path)?;
|
||||
let (width, height) = if let Some(animation) = &animation {
|
||||
let [width, height] =
|
||||
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
|
||||
@@ -678,33 +662,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn files_outside_textures_load_by_path() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
|
||||
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
|
||||
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
let mut sprites = Sprites::default();
|
||||
let layer = sprites.insert_file(&root, tile).unwrap();
|
||||
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
|
||||
assert!(sprites.translucent[layer as usize]);
|
||||
// A missing tile is an error, not the missing texture.
|
||||
assert!(
|
||||
sprites
|
||||
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(sprites.count(), 1);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn identical_still_textures_share_a_layer() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
graphics::LeafCullingMode,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
@@ -205,7 +204,6 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
leaf_culling: LeafCullingMode,
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
@@ -440,8 +438,6 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
@@ -776,7 +772,6 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
@@ -858,7 +853,6 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
@@ -1718,7 +1712,6 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
leaf_culling: self.leaf_culling,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -591,11 +591,6 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
}
|
||||
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
|
||||
// far plane, written over the whole depth target with no colour.
|
||||
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = view_ray(in.ndc);
|
||||
let dist = disc_distance(d, -4.0);
|
||||
|
||||
@@ -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" }
|
||||
@@ -36,7 +40,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 +107,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
|
||||
|
||||
@@ -203,11 +203,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
|
||||
"horizon.pages.shader_options": "Opciones de sombreadores",
|
||||
"horizon.pages.split_profiles": "¿Quién juega?",
|
||||
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
|
||||
"horizon.shader_options.open": "Opciones de sombreadores…",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
|
||||
}
|
||||
@@ -18,11 +18,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
|
||||
}
|
||||
@@ -202,11 +202,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
|
||||
"horizon.pages.shader_options": "Настройки шейдеров",
|
||||
"horizon.pages.split_profiles": "Кто играет?",
|
||||
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
|
||||
"horizon.shader_options.open": "Настройки шейдеров…",
|
||||
"horizon.shader_options.profile": "Профиль",
|
||||
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
|
||||
}
|
||||
@@ -196,11 +196,5 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
|
||||
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
|
||||
"horizon.pages.split_profiles": "Kim oynuyor?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
|
||||
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
|
||||
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
|
||||
}
|
||||
@@ -18,11 +18,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
|
||||
}
|
||||
@@ -203,11 +203,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
|
||||
"horizon.pages.shader_options": "光影选项",
|
||||
"horizon.pages.split_profiles": "谁在玩?",
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.shader_options.open": "光影选项…",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…"
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
|
||||
}
|
||||
@@ -12,12 +12,12 @@
|
||||
//! 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,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
graphics::LeafCullingMode,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
@@ -140,7 +140,6 @@ fn main() -> Result<(), Error> {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
if let Some(n) = c["scroll"].as_i64() {
|
||||
chat.scroll_by(n as i32, focused);
|
||||
}
|
||||
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
|
||||
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1182,7 +1182,6 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -7,7 +7,6 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
graphics::LeafCullingMode,
|
||||
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
@@ -138,7 +137,6 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! progress (`PlayerAdvancements`) and the predicates criteria test
|
||||
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
|
||||
//! conditions advancements use).
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
@@ -31,9 +30,7 @@ pub struct Display {
|
||||
pub struct Criterion {
|
||||
pub name: String,
|
||||
pub trigger: String,
|
||||
/// Read on first test: ~1,300 recipe advancements' criteria, most never
|
||||
/// tested in a game, took ~4 MB as `Value`s.
|
||||
pub conditions: LazyJson,
|
||||
pub conditions: Value,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rewards {
|
||||
@@ -157,8 +154,7 @@ impl Tree {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
// `read`: the index alone keeps none of the criteria read.
|
||||
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
|
||||
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
|
||||
Some(r) => self
|
||||
.by_recipe
|
||||
.entry(r.to_owned())
|
||||
@@ -212,8 +208,7 @@ impl InventoryIndex {
|
||||
return index;
|
||||
};
|
||||
for &(ai, ci) in list {
|
||||
let conditions = tree.list[ai].criteria[ci].conditions.read();
|
||||
let items = &conditions["items"];
|
||||
let items = &tree.list[ai].criteria[ci].conditions["items"];
|
||||
// `id_matches`: only a string or a list limits the items.
|
||||
let limited = items.as_array().and_then(|a| {
|
||||
a.iter()
|
||||
@@ -271,7 +266,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
.map(|(name, c)| Criterion {
|
||||
name: name.clone(),
|
||||
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
|
||||
conditions: LazyJson::new(&c["conditions"]),
|
||||
conditions: c["conditions"].clone(),
|
||||
})
|
||||
.collect();
|
||||
let requirements = match v["requirements"].as_array() {
|
||||
@@ -1611,7 +1606,7 @@ mod tests {
|
||||
criteria: vec![Criterion {
|
||||
name: "c".into(),
|
||||
trigger: "minecraft:inventory_changed".into(),
|
||||
conditions: LazyJson::new(&conditions),
|
||||
conditions,
|
||||
}],
|
||||
requirements: vec![vec!["c".into()]],
|
||||
rewards: Rewards::default(),
|
||||
@@ -1660,7 +1655,7 @@ mod tests {
|
||||
criteria: vec![Criterion {
|
||||
name: "c".into(),
|
||||
trigger: "minecraft:recipe_unlocked".into(),
|
||||
conditions: LazyJson::new(&conditions),
|
||||
conditions,
|
||||
}],
|
||||
requirements: vec![vec!["c".into()]],
|
||||
rewards: Rewards::default(),
|
||||
|
||||
@@ -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,160 @@
|
||||
//! 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_entities, block_entity_anim,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, 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 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 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;
|
||||
@@ -1219,15 +1219,6 @@ pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
|
||||
Ok(named)
|
||||
}
|
||||
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
|
||||
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
|
||||
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
|
||||
pub fn recipe_id(name: &str) -> String {
|
||||
if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
}
|
||||
}
|
||||
fn custom_type(v: &Value) -> Option<&str> {
|
||||
v["type"]
|
||||
.as_str()
|
||||
@@ -1318,14 +1309,10 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
}
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
Self::from_sources(root, items, overlay, sources)
|
||||
}
|
||||
/// The recipe files the tables are built from, each id once: vanilla's by
|
||||
/// bare name, then the content mods' as `<ns>:<name>`, the world's packs'
|
||||
/// over them (`overlay`), parsed, and only those whose load conditions
|
||||
/// hold. A server sends some of them to its clients (`recipe_content`).
|
||||
pub fn sources(root: &Path, overlay: &PackOverlay) -> Result<Vec<(String, Value)>> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
|
||||
@@ -1370,7 +1357,6 @@ impl Recipes {
|
||||
}
|
||||
}
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
let mut parsed = Vec::with_capacity(sources.len());
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
@@ -1389,26 +1375,13 @@ impl Recipes {
|
||||
continue;
|
||||
}
|
||||
// The data's recipe filters, over the final set.
|
||||
if !filters.apply(&recipe_id(&name), &mut v) {
|
||||
let full_id = match name.contains(':') {
|
||||
true => name.clone(),
|
||||
false => format!("minecraft:{name}"),
|
||||
};
|
||||
if !filters.apply(&full_id, &mut v) {
|
||||
continue;
|
||||
}
|
||||
parsed.push((name, v));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
/// The tables from `sources` (`Recipes::sources`' shape); `root` and
|
||||
/// `overlay` give the enchantment tags the tables keep.
|
||||
pub fn from_sources(
|
||||
root: &Path,
|
||||
items: &Items,
|
||||
overlay: &PackOverlay,
|
||||
sources: Vec<(String, Value)>,
|
||||
) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
for (name, v) in sources {
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
|
||||
@@ -201,7 +201,6 @@ impl ItemEntity {
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -242,20 +241,10 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else if entity_boxes.is_empty() {
|
||||
} else {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -416,71 +405,6 @@ impl ItemEntity {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { cells, boxes }
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = Self::span(area);
|
||||
let mut found: Vec<u32> = Vec::new();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
out.extend(
|
||||
found
|
||||
.into_iter()
|
||||
.map(|i| self.boxes[i as usize])
|
||||
.filter(|b| b.intersects(area)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
|
||||
/// search around an item. Holds indexes into the tick's item list.
|
||||
#[derive(Default)]
|
||||
@@ -1157,12 +1081,6 @@ pub struct ArmorStand {
|
||||
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
|
||||
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
|
||||
pub disabled_slots: i32,
|
||||
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
|
||||
pub enchantment_block: Option<BlockPos>,
|
||||
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -1234,9 +1152,6 @@ impl ArmorStand {
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
disabled_slots: 0,
|
||||
enchantment_block: None,
|
||||
enchantment_fall_distance: 0.0,
|
||||
enchantment_equipment: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
@@ -1806,15 +1721,7 @@ mod honey_slide_tests {
|
||||
item.id = 1;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
// Idle ticks keep gravity; the 4th tick's move hits the floor and
|
||||
@@ -1870,15 +1777,7 @@ mod honey_slide_tests {
|
||||
item.id = 1;
|
||||
let before = item.position.y;
|
||||
for _ in 0..16 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(
|
||||
item.position.y > before + 0.5,
|
||||
@@ -1888,7 +1787,7 @@ mod honey_slide_tests {
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
id: 1,
|
||||
@@ -2096,15 +1995,7 @@ mod slime_bounce_tests {
|
||||
falling.on_ground = false;
|
||||
falling.age = 0;
|
||||
falling.id = 1;
|
||||
falling.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
assert!(falling.on_ground);
|
||||
@@ -2114,15 +2005,7 @@ mod slime_bounce_tests {
|
||||
// does not set needs_sync.
|
||||
falling.velocity = Vec3::ZERO;
|
||||
falling.needs_sync = false;
|
||||
falling.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
}
|
||||
@@ -2177,117 +2060,3 @@ mod unstuck_tests {
|
||||
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_collision_tests {
|
||||
use super::honey_slide_tests::falling_item;
|
||||
use super::*;
|
||||
|
||||
/// `Entity.collide` stops an item at the entities that `canBeCollidedWith` it: dropped
|
||||
/// above a boat's box (or a still happy ghast's) it lands on the box's top and rests,
|
||||
/// where with no box it falls to the floor of the scene.
|
||||
#[test]
|
||||
fn an_item_lands_on_an_entity_box_under_it() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
world.set(20, 3, 20, stone).unwrap();
|
||||
let rest = |boxes: &[Aabb]| {
|
||||
let mut item = falling_item(20.5, 12.0, 20.5, 0.0, 0.0, 0.0);
|
||||
item.id = 1;
|
||||
for _ in 0..80 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
};
|
||||
// The stone's top is at 4.0; a box from 5.0 to 9.0 is in the way.
|
||||
let platform = Aabb::new(Vec3::new(19.0, 5.0, 19.0), Vec3::new(22.0, 9.0, 22.0));
|
||||
assert!((rest(&[]) - 4.0).abs() < 1.0e-6, "on the stone with no box");
|
||||
assert!(
|
||||
(rest(&[platform]) - 9.0).abs() < 1.0e-6,
|
||||
"on top of the box, at its top"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstacle_grid_returns_only_the_boxes_near_an_item() {
|
||||
// A boat every 100 blocks along a line, 1000 of them.
|
||||
let boat = |x: f64| {
|
||||
Aabb::new(
|
||||
Vec3::new(x, 64.0, 0.0),
|
||||
Vec3::new(x + 1.375, 64.5625, 1.375),
|
||||
)
|
||||
};
|
||||
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
|
||||
let mut out = Vec::new();
|
||||
// Around the 7th boat: only it.
|
||||
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![boat(700.0)]);
|
||||
// Between two boats: none.
|
||||
let area = Aabb::new(Vec3::new(750.0, 63.0, 0.0), Vec3::new(751.0, 66.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert!(out.is_empty());
|
||||
// A box spanning cells is found from any of them, once.
|
||||
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
|
||||
let grid = ObstacleGrid::new(vec![wide]);
|
||||
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![wide]);
|
||||
}
|
||||
|
||||
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "timing"]
|
||||
fn obstacle_grid_beats_scanning_every_box_for_a_big_farm() {
|
||||
let boat = |i: u32| {
|
||||
let (x, z) = (f64::from(i % 40) * 6.0, f64::from(i / 40) * 6.0);
|
||||
Aabb::new(
|
||||
Vec3::new(x, 64.0, z),
|
||||
Vec3::new(x + 1.375, 64.5625, z + 1.375),
|
||||
)
|
||||
};
|
||||
let boxes: Vec<Aabb> = (0..1000).map(boat).collect();
|
||||
let items: Vec<Aabb> = (0..5000)
|
||||
.map(|i| {
|
||||
let (x, z) = (f64::from(i % 70) * 3.4, f64::from(i / 70) * 3.4);
|
||||
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.25, 64.25, z + 0.25))
|
||||
})
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
let mut hits = 0;
|
||||
for item in &items {
|
||||
let area = item.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
hits += boxes.iter().filter(|b| b.intersects(area)).count();
|
||||
}
|
||||
let scan = start.elapsed();
|
||||
let grid = ObstacleGrid::new(boxes);
|
||||
let start = std::time::Instant::now();
|
||||
let (mut out, mut found) = (Vec::new(), 0);
|
||||
for item in &items {
|
||||
grid.near(item.inflate(Vec3::new(1.0, 1.0, 1.0)), &mut out);
|
||||
found += out.len();
|
||||
}
|
||||
let binned = start.elapsed();
|
||||
assert_eq!(hits, found);
|
||||
println!("scan {scan:?}, grid {binned:?}");
|
||||
}
|
||||
}
|
||||
@@ -246,41 +246,17 @@ mod tests {
|
||||
needs_sync: false,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
));
|
||||
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
));
|
||||
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
@@ -40,39 +40,23 @@ impl Culler {
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if self.remaining.get() == 0 {
|
||||
if start >= vertices.len() || self.remaining.get() == 0 {
|
||||
return false;
|
||||
}
|
||||
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
|
||||
return false;
|
||||
};
|
||||
self.occluded(min, max, opaque)
|
||||
}
|
||||
|
||||
/// Camera-relative bounds grown to include floating-point and rasterization
|
||||
/// edges. The asynchronous path sends these small queries to its worker.
|
||||
pub fn bounds(
|
||||
camera: [f64; 3],
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
if start >= vertices.len() {
|
||||
return None;
|
||||
}
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for vertex in &vertices[start..] {
|
||||
for a in 0..3 {
|
||||
let value = f64::from(vertex.pos[a]) + camera[a];
|
||||
let value = f64::from(vertex.pos[a]) + self.camera[a];
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
return false;
|
||||
}
|
||||
min[a] = min[a].min(value);
|
||||
max[a] = max[a].max(value);
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
Some((min.map(|v| v - 0.05), max.map(|v| v + 0.05)))
|
||||
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Startup shared by the gameplay library and its executable.
|
||||
use crate::{crash, game, logging};
|
||||
|
||||
/// 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);
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,6 @@ mod names;
|
||||
mod narrator;
|
||||
mod native_biome_colors;
|
||||
mod native_block_entity_draw;
|
||||
mod native_claimed_items;
|
||||
mod native_components;
|
||||
mod native_criteria;
|
||||
mod native_entities;
|
||||
@@ -238,8 +237,6 @@ mod prediction;
|
||||
mod pumpkin;
|
||||
mod quick_actions;
|
||||
mod raids;
|
||||
mod ram_limit;
|
||||
mod random_textures;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_camera;
|
||||
@@ -271,7 +268,6 @@ mod shield;
|
||||
mod shoulders;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
mod social;
|
||||
mod spawner;
|
||||
mod spear;
|
||||
mod spectator;
|
||||
@@ -764,8 +760,6 @@ enum Screen {
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (`friends_screen`, game/friends.rs).
|
||||
Friends,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// A game master block's edit screen (game/block_editor.rs).
|
||||
@@ -802,7 +796,6 @@ impl Screen {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world_options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::CommandBlock => "command_block",
|
||||
Screen::BlockEditor => "block_editor",
|
||||
Screen::ServerLinks => "server_links",
|
||||
@@ -2205,7 +2198,6 @@ struct Game<'a> {
|
||||
// Physical input is separate from remapped and toggled movement keys.
|
||||
physical_keys: HashSet<KeyCode>,
|
||||
physical_buttons: HashSet<MouseButton>,
|
||||
drag_button: Option<MouseButton>,
|
||||
/// Control + left click as a right click (macOS).
|
||||
right_click_emulation: horizon_client_input::mouse::RightClickEmulation,
|
||||
left: bool,
|
||||
@@ -2309,8 +2301,6 @@ struct Game<'a> {
|
||||
session_lock: Option<horizon_client::save::SessionLock>,
|
||||
audio: Audio,
|
||||
music: horizon_client::music::MusicManager,
|
||||
/// The tracks native mods play over the music (`native_music_layers`).
|
||||
music_layers: horizon_client::music::MusicLayers,
|
||||
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
|
||||
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
|
||||
/// The Music Toast option the toast was last set up for.
|
||||
@@ -2628,8 +2618,6 @@ struct Game<'a> {
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
/// The screen of a mod block's menu open on this client (`native_screen`).
|
||||
mod_screen: Option<native_hooks::OpenNativeScreen>,
|
||||
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
|
||||
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
/// Where item entities are, while the block entities tick (hoppers).
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
@@ -2656,8 +2644,6 @@ struct Game<'a> {
|
||||
panel: native_panel::Panel,
|
||||
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
|
||||
native_shaders: native_shaders::NativeShaders,
|
||||
/// The items native client mods claim and the events they haven't polled (game/native_claimed_items.rs).
|
||||
native_input: native_claimed_items::ModInput,
|
||||
/// The native lifecycle bookkeeping.
|
||||
native_lifecycle: native_lifecycle::NativeLifecycle,
|
||||
/// Native mods' messages waiting (`game/native_net.rs`).
|
||||
@@ -2669,8 +2655,6 @@ struct Game<'a> {
|
||||
/// This frame's view frustum, for culling entities and block entities.
|
||||
view_frustum: Option<horizon_client::gfx::Frustum>,
|
||||
entity_culler: Option<horizon_client::entity_culling::Culler>,
|
||||
async_entity_culler: Option<entity_culling::AsyncCuller>,
|
||||
async_entity_culling_failed: bool,
|
||||
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
|
||||
/// build the player's model in first person); (false, false) without a shader pack.
|
||||
pack_shadow_casters: (bool, bool),
|
||||
@@ -2701,8 +2685,6 @@ struct Game<'a> {
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
friends: Option<friends::Sharing>,
|
||||
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
|
||||
social: social::Social,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
|
||||
options_to_world_options: bool,
|
||||
@@ -2731,8 +2713,6 @@ struct Game<'a> {
|
||||
debug_options: Option<debug_options::DebugOptionsUi>,
|
||||
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
|
||||
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
|
||||
/// How far memory holds the game below the player's settings (`game/ram_limit.rs`).
|
||||
ram_limit: horizon_client::ram_limit::Governor,
|
||||
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
|
||||
/// CPU time, in percent.
|
||||
gpu_utilization: f64,
|
||||
@@ -3707,8 +3687,6 @@ impl Game<'_> {
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
for i in 0..items.len() {
|
||||
if removed[i] {
|
||||
continue;
|
||||
@@ -3733,14 +3711,6 @@ impl Game<'_> {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick (a block of slack for the next velocity).
|
||||
let reach = Vec3::new(
|
||||
item.velocity.x.abs() + 1.0,
|
||||
item.velocity.y.abs() + 1.0,
|
||||
item.velocity.z.abs() + 1.0,
|
||||
);
|
||||
obstacles.near(item.bounds().inflate(reach), &mut nearby);
|
||||
let alive = item.tick(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
@@ -3748,7 +3718,6 @@ impl Game<'_> {
|
||||
self.collision,
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
&nearby,
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
@@ -5607,11 +5576,10 @@ impl Game<'_> {
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
// An EntityHitResult may fall through to Item.use, never to useItemOn.
|
||||
let block_hit = match guest {
|
||||
Some(GuestUse::OnBlock(_)) => self.pick(),
|
||||
Some(GuestUse::Item(_)) => None,
|
||||
_ if self.entity_targeted() => None,
|
||||
_ => self.pick(),
|
||||
let block_hit = if matches!(guest, Some(GuestUse::Item(_))) || self.entity_targeted() {
|
||||
None
|
||||
} else {
|
||||
self.pick()
|
||||
};
|
||||
let hands = match guest {
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
|
||||
@@ -5890,9 +5858,7 @@ impl Game<'_> {
|
||||
self.trigger_default_block_use(hit.pos);
|
||||
}
|
||||
}
|
||||
if result.should_swing() {
|
||||
self.swing_arm();
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -5990,7 +5956,9 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
if (!on_block && self.use_potion_item(&stack)?)
|
||||
|| ((!on_block || (spawn_egg && block_hit.is_some())) && self.throw_item(&stack)?)
|
||||
|| ((block_hit.is_some() || !spawn_egg)
|
||||
&& (!on_block || spawn_egg)
|
||||
&& self.throw_item(&stack)?)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -7406,36 +7374,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Apply a physical key to every mapping bound to it.
|
||||
fn mapped_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(k, pressed, repeat)?;
|
||||
}
|
||||
Err(button) => {
|
||||
let action = match (pressed, repeat) {
|
||||
(false, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
if let Some(pressed) = keyboard_button(
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.dialog_open(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
self.play_mouse(button, pressed)?;
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A key event after rebinding, as the default key of its mapping (the
|
||||
/// keyboard and controllers both end here).
|
||||
fn mapped_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
|
||||
@@ -7541,7 +7479,6 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
@@ -8241,7 +8178,6 @@ impl Game<'_> {
|
||||
Screen::Mod => {}
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::Social => self.draw_social_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
@@ -10508,19 +10444,14 @@ impl Game<'_> {
|
||||
self.res.block_entity_render().renders(*state)
|
||||
&& (off_screen(*state) || in_view(p))
|
||||
});
|
||||
if self.entity_culler.is_some()
|
||||
|| self.async_entity_culler.is_some()
|
||||
|| self.pack_ids.is_some()
|
||||
{
|
||||
if self.entity_culler.is_some() || self.pack_ids.is_some() {
|
||||
for block in visible {
|
||||
let start = out.len();
|
||||
self.mark_block_entity(start, block.1);
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.render(&scene, std::iter::once(block), &mut out);
|
||||
if !off_screen(block.1)
|
||||
&& (self.entity_culler.is_some() || self.async_entity_culler.is_some())
|
||||
{
|
||||
if !off_screen(block.1) && self.entity_culler.is_some() {
|
||||
self.cull_entity_vertices(&mut out, start);
|
||||
}
|
||||
}
|
||||
@@ -10717,7 +10648,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.finish_async_entity_culling();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -11534,7 +11464,6 @@ impl Game<'_> {
|
||||
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
|
||||
// its chunks and players are.
|
||||
self.native_server_data_loaded(false);
|
||||
self.sync_recipe_content();
|
||||
self.bosses = Default::default();
|
||||
reset_world_progress(&mut self.adv, &mut self.stats);
|
||||
self.prefetch_advancement_tree();
|
||||
@@ -13409,26 +13338,15 @@ impl Game<'_> {
|
||||
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
|
||||
let abilities = self.chat_abilities();
|
||||
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
|
||||
self.streaming.radius = self.render_distance_in_use();
|
||||
// Options.biomeBlendRadius, ambientOcclusion, cutoutLeaves and the
|
||||
// leaf-culling mode: LevelExtractor.allChanged when they change.
|
||||
let leaf_culling = self.effective_leaf_culling();
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
|
||||
// LevelExtractor.allChanged when they change.
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let remesh = (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
leaf_culling,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.leaf_culling,
|
||||
);
|
||||
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
|
||||
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.leaf_culling = leaf_culling;
|
||||
if remesh {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
@@ -13881,8 +13799,6 @@ impl Game<'_> {
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
|
||||
/// container screen's hover target for their highlight and tooltip.
|
||||
fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
|
||||
screen_info::begin_container("creative");
|
||||
@@ -13978,9 +13894,6 @@ impl Game<'_> {
|
||||
}
|
||||
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
|
||||
let mut hovered: Option<(ItemStack, bool)> = None;
|
||||
// Use the same padded slot bounds as clicks and the other menus, so
|
||||
// highlights and tooltips remain visible between adjacent items.
|
||||
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
@@ -14011,7 +13924,10 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
|
||||
let over = self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0;
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
@@ -14025,7 +13941,10 @@ impl Game<'_> {
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let over = hovered_slot == Some(c);
|
||||
let over = self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0;
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
@@ -15324,7 +15243,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::Social => self.close_social(),
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
@@ -15367,11 +15285,6 @@ impl Game<'_> {
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
|
||||
// key.quickActions: only open if a datapack provides dialog actions.
|
||||
KeyCode::KeyG if self.screen == Screen::Playing => {
|
||||
@@ -15564,13 +15477,9 @@ impl Game<'_> {
|
||||
fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
let distance = if self.ram_limit.far_off() {
|
||||
0
|
||||
} else {
|
||||
match self.server_far_terrain() {
|
||||
Some(offer) => g.lod_distance.min(offer.distance),
|
||||
None => g.lod_distance,
|
||||
}
|
||||
let distance = match self.server_far_terrain() {
|
||||
Some(offer) => g.lod_distance.min(offer.distance),
|
||||
None => g.lod_distance,
|
||||
};
|
||||
(distance, reduced)
|
||||
}
|
||||
@@ -15910,21 +15819,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.previous = Instant::now();
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
|
||||
&& let WindowEvent::KeyboardInput {
|
||||
event,
|
||||
is_synthetic: false,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
Some((event.clone(), glfw_modifiers(self.modifiers)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.handle_window_event(event_loop, id, event);
|
||||
if let Some((event, modifiers)) = key {
|
||||
self.native_input_key(&event, modifiers);
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
|
||||
// The real mouse never steers an offscreen game: its pointer is not grabbed.
|
||||
@@ -16027,7 +15922,6 @@ impl Game<'_> {
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.physical_keys.clear();
|
||||
self.physical_buttons.clear();
|
||||
self.drag_button = None;
|
||||
}
|
||||
if let WindowEvent::ModifiersChanged(m) = &event {
|
||||
self.modifiers = m.state();
|
||||
@@ -16294,21 +16188,6 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let result: Result<()> = (|| {
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event
|
||||
&& !self.pad_synthetic_input()
|
||||
{
|
||||
self.track_drag_button(*button, *state == ElementState::Pressed);
|
||||
}
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& event.state == ElementState::Released
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.screen_key_released(key)
|
||||
{
|
||||
// Screen cancellation must not leave a held keyboard mapping down.
|
||||
self.keys.remove(&key);
|
||||
self.mapped_key_event(key, false, false)?;
|
||||
return Ok(());
|
||||
}
|
||||
// ScreenEvent.MouseButtonPressed.Pre / MouseButtonReleased.Pre:
|
||||
// the open screen hears of the press before anything reads it, and
|
||||
// a cancelled one goes no further — no hotbar key, no slot, no
|
||||
@@ -16401,11 +16280,7 @@ impl Game<'_> {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
if let Some(r) = &self.renderer {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.dialog_open() {
|
||||
self.dialog_drag();
|
||||
}
|
||||
@@ -16505,28 +16380,10 @@ impl Game<'_> {
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.native_wheel_event(d)
|
||||
&& let Some((wheel, (horizontal_steps, vertical_steps))) = self
|
||||
.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
{
|
||||
if !self.pad_synthetic_input() {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_scrolling(
|
||||
[scaled(raw_x), scaled(raw_y)],
|
||||
[horizontal_steps, vertical_steps],
|
||||
[
|
||||
self.physical_buttons.contains(&MouseButton::Left),
|
||||
self.physical_buttons.contains(&MouseButton::Middle),
|
||||
self.physical_buttons.contains(&MouseButton::Right),
|
||||
],
|
||||
[self.cursor.0, self.cursor.1],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
@@ -16548,9 +16405,6 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Chat && d != 0.0 {
|
||||
self.chat_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Social && d != 0.0 {
|
||||
self.social_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock && d != 0.0 {
|
||||
self.command_block_scroll(d);
|
||||
}
|
||||
@@ -16800,9 +16654,6 @@ impl Game<'_> {
|
||||
Screen::Friends if pressed && button == MouseButton::Left => {
|
||||
self.friends_screen_click()
|
||||
}
|
||||
Screen::Social if pressed && button == MouseButton::Left => {
|
||||
self.social_click()
|
||||
}
|
||||
Screen::CommandBlock if pressed && button == MouseButton::Left => {
|
||||
self.command_block_click()
|
||||
}
|
||||
@@ -16960,9 +16811,6 @@ impl Game<'_> {
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
|
||||
self.win_key(&event)?
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Social => {
|
||||
self.social_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
@@ -17103,7 +16951,38 @@ impl Game<'_> {
|
||||
{
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
|
||||
// Key binds: act as the default key of every mapping bound
|
||||
// to it (KeyMapping.set drives them all).
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(
|
||||
k,
|
||||
event.state == ElementState::Pressed,
|
||||
event.repeat,
|
||||
)?;
|
||||
}
|
||||
Err(button) => {
|
||||
let action = match (event.state, event.repeat) {
|
||||
(ElementState::Released, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
if let Some(pressed) = keyboard_button(
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.dialog_open(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
self.play_mouse(button, pressed)?;
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.observe_fullscreen();
|
||||
@@ -17234,13 +17113,12 @@ impl Game<'_> {
|
||||
self.frame_stats.record_gpu(&r.gpu_times);
|
||||
r.gpu_times.clear();
|
||||
}
|
||||
horizon_client::ram_limit::frame();
|
||||
self.limit_frame();
|
||||
self.pause = self.pauses_game();
|
||||
if let Some(server) = &self.integrated {
|
||||
server.set_paused(self.pause);
|
||||
server.set_distances(
|
||||
self.render_distance_in_use(),
|
||||
self.settings.render_distance,
|
||||
self.settings.simulation_distance,
|
||||
self.settings.entity_distance,
|
||||
);
|
||||
@@ -17543,7 +17421,6 @@ impl<'a> Game<'a> {
|
||||
keys: HashSet::new(),
|
||||
physical_keys: HashSet::new(),
|
||||
physical_buttons: HashSet::new(),
|
||||
drag_button: None,
|
||||
right_click_emulation: Default::default(),
|
||||
left: false,
|
||||
right: false,
|
||||
@@ -17609,7 +17486,6 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
music
|
||||
},
|
||||
music_layers: Default::default(),
|
||||
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
|
||||
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
|
||||
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
|
||||
@@ -17798,7 +17674,6 @@ impl<'a> Game<'a> {
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mod_screen: None,
|
||||
mod_container_viewers: HashMap::new(),
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
control: None,
|
||||
@@ -17814,8 +17689,6 @@ impl<'a> Game<'a> {
|
||||
agent: agent::Agent::default(),
|
||||
view_frustum: None,
|
||||
entity_culler: None,
|
||||
async_entity_culler: None,
|
||||
async_entity_culling_failed: false,
|
||||
pack_shadow_casters: (false, false),
|
||||
pending_uploads: Vec::new(),
|
||||
frame_graph: Default::default(),
|
||||
@@ -17824,7 +17697,6 @@ impl<'a> Game<'a> {
|
||||
recipe_tables: Default::default(),
|
||||
panel: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
native_input: Default::default(),
|
||||
native_lifecycle: Default::default(),
|
||||
native_net: Default::default(),
|
||||
native_mod_screen: None,
|
||||
@@ -17847,7 +17719,6 @@ impl<'a> Game<'a> {
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
friends: None,
|
||||
social: Default::default(),
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
debug_entries: horizon_client::debug_entries::DebugEntries::load(
|
||||
@@ -17855,7 +17726,6 @@ impl<'a> Game<'a> {
|
||||
),
|
||||
debug_options: None,
|
||||
memory_meter: Default::default(),
|
||||
ram_limit: Default::default(),
|
||||
gpu_utilization: 0.0,
|
||||
rendered_mobs: Default::default(),
|
||||
spawn_counts: HashMap::new(),
|
||||
@@ -18213,7 +18083,7 @@ impl Game<'_> {
|
||||
fn recipes(&self) -> &Recipes {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.recipes,
|
||||
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
|
||||
None => self.res.recipes(),
|
||||
}
|
||||
}
|
||||
fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
@@ -18286,8 +18156,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.world_tables = self.build_world_tables();
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
// Before the players are sent the reloaded data below.
|
||||
self.sync_recipe_content();
|
||||
// PlayerList.reloadResources: the tags and recipes go to the players
|
||||
// and their advancements reload. The synchronized registries (the
|
||||
// dialogs, and the commands' ids of them) stay the hub's.
|
||||
@@ -20257,7 +20125,6 @@ mod tests {
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::Social,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
Screen::ServerLinks,
|
||||
|
||||
@@ -2006,7 +2006,7 @@ impl Game<'_> {
|
||||
criteria.push(adv::Criterion {
|
||||
name: name.clone(),
|
||||
trigger: String::new(),
|
||||
conditions: Value::Null.into(),
|
||||
conditions: Value::Null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,6 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
|
||||
@@ -727,20 +727,20 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_armor_stands(&mut self) {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut dead = Vec::new();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for i in 0..self.armor_stands.len() {
|
||||
let at = self.armor_stands[i].position;
|
||||
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
self.armor_stands[i].previous = at;
|
||||
self.armor_stands[i].previous_yaw = self.armor_stands[i].yaw;
|
||||
continue;
|
||||
}
|
||||
self.armor_stand_location_effects(i, false);
|
||||
let a = &mut self.armor_stands[i];
|
||||
for (i, a) in self.armor_stands.iter_mut().enumerate() {
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
@@ -748,31 +748,14 @@ impl Game<'_> {
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
);
|
||||
});
|
||||
if in_water {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
} else {
|
||||
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
|
||||
}
|
||||
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
|
||||
if a.on_ground {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
}
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
}
|
||||
if landed {
|
||||
self.armor_stand_location_effects(i, true);
|
||||
}
|
||||
}
|
||||
for i in dead.into_iter().rev() {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
|
||||
@@ -57,8 +57,8 @@ pub(super) struct Boat {
|
||||
above_bubble_column: bool,
|
||||
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
|
||||
bubble_down: bool,
|
||||
/// The server asked a rider whose client moves it (a player) to get out:
|
||||
/// `guest_vehicle_tick` honors it (`Entity.ejectPassengers`).
|
||||
/// A bubble column's drag asked a rider the server can't move itself (a
|
||||
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
|
||||
pub eject_riders: bool,
|
||||
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
|
||||
tick_count: i32,
|
||||
@@ -101,17 +101,6 @@ pub(super) const BUBBLE_UP_EVENT: i8 = 72;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
|
||||
/// underwater ticks; any other status resets the counter.
|
||||
fn tick_submersion(&mut self) {
|
||||
self.out_of_control =
|
||||
if matches!(self.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
self.out_of_control + 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -570,7 +559,11 @@ impl Game<'_> {
|
||||
}
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.tick_submersion();
|
||||
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
b.out_of_control + 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
if !controlled {
|
||||
@@ -758,13 +751,8 @@ impl Game<'_> {
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
// its controlling client owns the boat's movement.
|
||||
if driven.contains(&self.boats[i].id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_riders(i);
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_bubble_tick(i);
|
||||
self.boat_contacts(i);
|
||||
@@ -788,7 +776,6 @@ impl Game<'_> {
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
self.boats[i].tick_submersion();
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
|
||||
@@ -895,8 +882,8 @@ impl Game<'_> {
|
||||
v.y = 0.6;
|
||||
}
|
||||
}
|
||||
/// `Entity.ejectPassengers`: the mobs get out here, this window's player
|
||||
/// gets out, and a server's player is asked to
|
||||
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
|
||||
/// window's player gets out, and a server's player is asked to
|
||||
/// (`guest_vehicle_tick` lets it out through its client).
|
||||
fn eject_boat_riders(&mut self, i: usize) {
|
||||
self.eject_boat_mobs(i, false);
|
||||
@@ -931,6 +918,10 @@ impl Game<'_> {
|
||||
/// window is its own client.
|
||||
fn boat_bubble_tick(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
// The boat a server's player drives is not ticked here: its status is.
|
||||
if self.guest_vehicles().contains(&id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
// An ejection nobody took (its player left the boat first) is void.
|
||||
if !self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = false;
|
||||
|
||||
@@ -63,8 +63,6 @@ impl Game<'_> {
|
||||
) -> horizon_client::command::Dispatcher {
|
||||
let mut names = Vec::new();
|
||||
self.native_commands(&mut names);
|
||||
// The window answers this one itself, as it does a client mod's.
|
||||
names.push(horizon_client::ram_limit::COMMAND);
|
||||
let base = match base {
|
||||
Some(tree) => tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
@@ -1094,12 +1092,9 @@ impl Game<'_> {
|
||||
if self.ui.hide_gui && !focused {
|
||||
return;
|
||||
}
|
||||
self.ui.chat.smooth = self.settings.extras.chat_animation;
|
||||
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
|
||||
// The fractional tick makes the smooth chat extra's slide move every
|
||||
// frame; vanilla's fade reads it too.
|
||||
let now = self.ticks as f32 + self.clock.fraction() as f32;
|
||||
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
|
||||
self.ui
|
||||
.chat
|
||||
.draw(batch, &self.gui.atlas, self.ticks, focused);
|
||||
}
|
||||
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
|
||||
/// event has one, the entity type and the UUID.
|
||||
@@ -1560,17 +1555,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ram_limit_command_is_known_to_the_chat_box() {
|
||||
let reg = Registries::default();
|
||||
let vanilla = horizon_client::command::vanilla::game_dispatcher();
|
||||
assert!(vanilla.find(horizon_client::ram_limit::COMMAND).is_none());
|
||||
let tree = with_native_commands(vanilla, &[horizon_client::ram_limit::COMMAND]);
|
||||
for typed in ["ramlimit", "ramlimit 2048", "ramlimit off"] {
|
||||
assert!(tree.parse(typed, ®).executable().is_ok(), "{typed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's command is in the tree the chat box uses (#456): known
|
||||
/// rather than red, and offered as it types. With no mod claiming one the
|
||||
/// tree is the game's own, as it was before the mods were asked.
|
||||
|
||||
@@ -183,10 +183,6 @@ impl Game<'_> {
|
||||
self.mod_browser_text(None, true);
|
||||
return Ok(());
|
||||
}
|
||||
Screen::Social => {
|
||||
self.social_backspace();
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +205,6 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::Social => self.social_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
@@ -1057,7 +1052,6 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
|
||||
@@ -771,7 +771,6 @@ impl Game<'_> {
|
||||
thundering: weather && thunder > 0.9,
|
||||
nether: self.dim == Dim::Nether,
|
||||
block_tags: &self.items().block_tags,
|
||||
landing: &self.motion.landing,
|
||||
biomes: world_biomes!(self),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
universal_anger: self.ui.rules.bool("universal_anger"),
|
||||
@@ -1775,40 +1774,6 @@ impl Game<'_> {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
MobEvent::ElderCurse { at } => self.elder_curse(at),
|
||||
MobEvent::SetVelocity { target, velocity } => {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = v;
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::RemoveKilled { target } => {
|
||||
// The bite that killed it ran `LivingEntity.die` (loot, such
|
||||
// as a magma cube's froglight, and experience) before
|
||||
// `eatEntity` removes it: a removed mob never reaches the
|
||||
// next tick's deaths, so it dies here first.
|
||||
if let Some(m) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == target && !m.alive() && !m.removed)
|
||||
.cloned()
|
||||
{
|
||||
if self.died.insert(target) {
|
||||
self.mob_died(&m);
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
m.removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Push { target, velocity } => {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
match target {
|
||||
@@ -5765,29 +5730,13 @@ impl Game<'_> {
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let mob_type = parent.kind.clone();
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
let id = if species == Species::Piglin {
|
||||
// SpawnEggItem.spawnOffspringFromSpawnEgg: a non-AgeableMob is
|
||||
// constructed, then setBaby(true), without finalizeSpawn. Giving
|
||||
// it adult spawn gear first leaves the baby holding a weapon.
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
self.mobs
|
||||
.push(Mob::new(id, mob_type, Vec3::new(at[0], at[1], at[2]), 0.0));
|
||||
id
|
||||
} else {
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return false;
|
||||
};
|
||||
id
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
if species == Species::Piglin {
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
@@ -5801,49 +5750,17 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
|
||||
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
|
||||
let hit = self.bucket_hit()?;
|
||||
matches!(
|
||||
&*self.block_name(hit.pos),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
)
|
||||
.then_some(hit)
|
||||
}
|
||||
/// Shared peaceful-mode eligibility for spawn eggs.
|
||||
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
|
||||
let kind = item.trim_end_matches("_spawn_egg");
|
||||
self.ui.difficulty != 0
|
||||
|| self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
|
||||
}
|
||||
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
|
||||
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
|
||||
let Some(hit) = (if fluid {
|
||||
self.spawn_egg_fluid_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
}) else {
|
||||
let Some(hit) = self.pick() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
|
||||
if !self.spawn_egg_allowed(&stack.item) {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if fluid
|
||||
&& !self.may_build()
|
||||
&& !self.can_place_on_in_adventure([
|
||||
hit.pos[0] - hit.face[0],
|
||||
hit.pos[1] - hit.face[1],
|
||||
hit.pos[2] - hit.face[2],
|
||||
])
|
||||
// EntityType.canSpawn: no monsters in peaceful (FAIL ends the click).
|
||||
let id = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
|
||||
if self.ui.difficulty == 0
|
||||
&& let Some(t) = self.res.mob_types().get(&id)
|
||||
&& !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
|
||||
{
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
@@ -5859,15 +5776,11 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
let p = if fluid {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
};
|
||||
let p = [
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
];
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
@@ -5882,8 +5795,6 @@ impl Game<'_> {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
@@ -5998,21 +5909,6 @@ impl Game<'_> {
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
day_time: self.world.day_time as f64 + alpha,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0.0,
|
||||
Dim::Nether => -1.0,
|
||||
Dim::End => 1.0,
|
||||
},
|
||||
player: self.player.as_ref().map_or(camera.to_array(), |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
@@ -6897,7 +6793,6 @@ mod pearl_tests {
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -990,8 +990,6 @@ impl Game<'_> {
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
let mut next = Instant::now();
|
||||
// The panic that ended the tick loop, when one did.
|
||||
let mut crashed: Option<String> = None;
|
||||
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
|
||||
let watchdog = Watchdog::start(props.max_tick_time);
|
||||
let mut braked = Instant::now();
|
||||
@@ -1000,18 +998,10 @@ impl Game<'_> {
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.tick_due(next, self.clock.tick_duration());
|
||||
}
|
||||
// The console's lines and the control socket run in the tick too
|
||||
// (`handleConsoleInputs`): a panic there is the same crash.
|
||||
let prelude = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
while let Ok(line) = state.console.try_recv() {
|
||||
self.console_line(&mut state, &line);
|
||||
}
|
||||
self.poll_server_control(&mut state);
|
||||
}));
|
||||
if let Err(payload) = prelude {
|
||||
crashed = Some(panic_message(payload.as_ref()));
|
||||
break;
|
||||
while let Ok(line) = state.console.try_recv() {
|
||||
self.console_line(&mut state, &line);
|
||||
}
|
||||
self.poll_server_control(&mut state);
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
if pause_after > 0 {
|
||||
// A `/pregen` task keeps the server busy as a player would:
|
||||
@@ -1039,23 +1029,11 @@ impl Game<'_> {
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
|
||||
// report and still stops the server (saves). A panic here does the same.
|
||||
let ticked =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
match &mut state.report {
|
||||
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
|
||||
recording.add(wall, cpu, spans)
|
||||
}),
|
||||
None => self.tick_server_timed(paused),
|
||||
}
|
||||
}));
|
||||
match ticked {
|
||||
Ok(result) => result?,
|
||||
Err(payload) => {
|
||||
crashed = Some(panic_message(payload.as_ref()));
|
||||
break;
|
||||
}
|
||||
match &mut state.report {
|
||||
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
|
||||
recording.add(wall, cpu, spans)
|
||||
})?,
|
||||
None => self.tick_server_timed(paused)?,
|
||||
}
|
||||
if state.report.as_ref().is_some_and(report::Recording::done)
|
||||
&& let Some(recording) = state.report.take()
|
||||
@@ -1089,10 +1067,7 @@ impl Game<'_> {
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
match crashed {
|
||||
Some(message) => self.stop_after_crash(&message),
|
||||
None => self.stop_dedicated(),
|
||||
}
|
||||
self.stop_dedicated()
|
||||
}
|
||||
|
||||
/// A dedicated server of its own, not single player's server thread
|
||||
@@ -1262,7 +1237,6 @@ impl Game<'_> {
|
||||
self.console_command(state, line);
|
||||
self.console_broadcast = None;
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
crash_fixture("hub", || host.hub.guests.iter().any(|g| g.placed));
|
||||
self.run_level_teleports(&mut host);
|
||||
self.send_admin_feedback(&mut host, None);
|
||||
self.lan = Some(host);
|
||||
@@ -1844,41 +1818,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecraftServer.runServer`'s `finally`: a tick that panicked still saves. What the panic
|
||||
/// interrupted is not trusted: a guest's turn (`as_guest`) holds the game's player state
|
||||
/// swapped in, so its record is older than what the game holds, and writing it would put
|
||||
/// back older data than the last autosave; that guest keeps the file it has. A hub that the
|
||||
/// panic took with it (`self.lan.take()` was in progress) saves no players at all. The
|
||||
/// levels, the scoreboard and the other state are saved as in a normal stop.
|
||||
fn stop_after_crash(&mut self, message: &str) -> Result<()> {
|
||||
log("ERROR", &format!("The server thread panicked: {message}"));
|
||||
let acting = self.lan_acting;
|
||||
let hub_lost = self.lan.is_none();
|
||||
if let (Some(conn), Some(host)) = (acting, self.lan.as_mut())
|
||||
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
{
|
||||
guest.placed = false;
|
||||
}
|
||||
if hub_lost {
|
||||
log(
|
||||
"WARN",
|
||||
"The panic interrupted the network tick: players are not saved, only the world",
|
||||
);
|
||||
} else if acting.is_some() {
|
||||
log(
|
||||
"WARN",
|
||||
"The panic interrupted a player's turn: that player keeps the last saved file",
|
||||
);
|
||||
}
|
||||
self.native_lifecycle.begin_close();
|
||||
log("INFO", "Saving worlds");
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
self.native_lifecycle_world_closed();
|
||||
self.lan_close(false);
|
||||
Err(format!("the server thread panicked: {message}").into())
|
||||
}
|
||||
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
// The close request starts before player and world saves.
|
||||
self.native_lifecycle.begin_close();
|
||||
@@ -1916,28 +1855,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tools/test_dedicated_crash_save.py`'s fixture: with `HORIZON_CRASH_FIXTURE` naming `point`
|
||||
/// (`guest`: during a placed guest's turn, `hub`: while a console line has the hub out and a
|
||||
/// player is in), the tick panics there, to see what `stop_after_crash` keeps.
|
||||
/// `armed` is asked only when the variable names `point`, so the tick pays one
|
||||
/// load of a `OnceLock` without it.
|
||||
pub(super) fn crash_fixture(point: &str, armed: impl FnOnce() -> bool) {
|
||||
static FIXTURE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
let fixture = FIXTURE.get_or_init(|| std::env::var("HORIZON_CRASH_FIXTURE").ok());
|
||||
if fixture.as_deref() == Some(point) && armed() {
|
||||
panic!("crash fixture at {point}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a panic payload (`panic!("..")` is a `&str`, a formatted one a `String`).
|
||||
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| (*s).to_owned())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".to_owned())
|
||||
}
|
||||
|
||||
/// The width and height of a PNG (its IHDR chunk).
|
||||
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
|
||||
@@ -1950,7 +1867,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from, panic_message};
|
||||
use super::{capture_log, log, log_from};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
@@ -1958,19 +1875,6 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
|
||||
/// named as unknown, so the crash line is never empty.
|
||||
#[test]
|
||||
fn a_panic_payload_is_read_as_text() {
|
||||
let literal = std::panic::catch_unwind(|| panic!("a literal")).unwrap_err();
|
||||
assert_eq!(panic_message(literal.as_ref()), "a literal");
|
||||
let n = 3;
|
||||
let formatted = std::panic::catch_unwind(|| panic!("tick {n}")).unwrap_err();
|
||||
assert_eq!(panic_message(formatted.as_ref()), "tick 3");
|
||||
let other = std::panic::catch_unwind(|| std::panic::panic_any(7_u8)).unwrap_err();
|
||||
assert_eq!(panic_message(other.as_ref()), "unknown panic");
|
||||
}
|
||||
|
||||
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
|
||||
const WARNING: &str = "Authentication warning";
|
||||
const READY: &str = "horizon-console-log-ready\n";
|
||||
|
||||
@@ -790,73 +790,40 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let disks = location_disks(
|
||||
&self.stations().enchantments,
|
||||
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
|
||||
on_ground,
|
||||
riding,
|
||||
);
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at, Who::Player);
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.onChangedBlock`, landing and `collectEquipmentChanges`
|
||||
/// for an armor stand: run location effects before travel (including a
|
||||
/// piston push), on landing, and for newly equipped items.
|
||||
pub(super) fn armor_stand_location_effects(&mut self, index: usize, landed: bool) {
|
||||
let stand = &mut self.armor_stands[index];
|
||||
let block = BlockPos::containing(stand.position);
|
||||
let moved = !landed && stand.enchantment_block.replace(block) != Some(block);
|
||||
let changed: [bool; 6] =
|
||||
std::array::from_fn(|i| stand.equipment[i] != stand.enchantment_equipment[i]);
|
||||
if !moved && !landed && !changed.iter().any(|c| *c) {
|
||||
return;
|
||||
}
|
||||
stand.enchantment_equipment.clone_from(&stand.equipment);
|
||||
let stand = &self.armor_stands[index];
|
||||
let at = [stand.position.x, stand.position.y, stand.position.z];
|
||||
let who = Who::Mob(stand.id);
|
||||
let equipment = stand
|
||||
.equipment
|
||||
.iter()
|
||||
.zip(horizon_client::entities::ArmorStand::SLOTS)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| moved || landed || changed[*i])
|
||||
.filter(|(_, (stack, _))| {
|
||||
moved
|
||||
|| landed
|
||||
|| !stack.as_ref().is_some_and(|stack| {
|
||||
let data = stack.data();
|
||||
let component = |id| data.and_then(|data| data.other(id));
|
||||
let max_damage = match component("minecraft:max_damage") {
|
||||
Some(horizon_world::nbt::Tag::Int(value)) => *value as u32,
|
||||
_ => self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage),
|
||||
};
|
||||
max_damage > 0
|
||||
&& component("minecraft:unbreakable").is_none()
|
||||
&& component("!minecraft:max_damage").is_none()
|
||||
&& component("!minecraft:damage").is_none()
|
||||
&& stack.damage >= max_damage
|
||||
})
|
||||
})
|
||||
.map(|(_, item)| item);
|
||||
let disks = location_disks(
|
||||
&self.stations().enchantments,
|
||||
equipment,
|
||||
stand.on_ground,
|
||||
stand.vehicle.is_some(),
|
||||
);
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at, who);
|
||||
self.replace_disk(&effect, level, at);
|
||||
}
|
||||
}
|
||||
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
|
||||
/// predicate become the effect's state, and each placement is a game event.
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3], who: Who) {
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let spec = &effect["block_state"];
|
||||
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
|
||||
@@ -894,7 +861,7 @@ impl Game<'_> {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
pos,
|
||||
Some(who),
|
||||
Some(Who::Player),
|
||||
Some(new),
|
||||
);
|
||||
}
|
||||
@@ -999,44 +966,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnchantmentHelper.runLocationChangedEffects`: the replace-disk effects of
|
||||
/// active equipment slots, with the wearer's ground/passenger requirements.
|
||||
fn location_disks<'a>(
|
||||
enchantments: &Enchantments,
|
||||
equipment: impl IntoIterator<Item = (&'a Option<ItemStack>, &'static str)>,
|
||||
on_ground: bool,
|
||||
riding: bool,
|
||||
) -> Vec<(Value, u32)> {
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in equipment {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
|
||||
let hand = matches!(slot, "mainhand" | "offhand");
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
disks
|
||||
}
|
||||
|
||||
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
|
||||
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
|
||||
fn soul_speed_modifiers(
|
||||
|
||||
@@ -1,335 +1,9 @@
|
||||
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
|
||||
use super::{Game, Vertex};
|
||||
use horizon_client::{
|
||||
blocks::{BlockFacts, RenderShape, StateFacts},
|
||||
chunk::Chunk,
|
||||
blocks::{RenderShape, StateFacts},
|
||||
entity_culling::Culler,
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
Arc, Weak,
|
||||
mpsc::{self, Receiver, SyncSender, TrySendError},
|
||||
},
|
||||
thread::JoinHandle,
|
||||
};
|
||||
|
||||
type ChunkKey = (i32, i32);
|
||||
type SectionKey = (i32, i32, i32);
|
||||
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct CullKey {
|
||||
camera: [u64; 3],
|
||||
min: [u64; 3],
|
||||
max: [u64; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct SnapshotKey {
|
||||
world_revision: u64,
|
||||
chunks_revision: u64,
|
||||
bottom_y: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct ChunkBounds {
|
||||
min_x: i32,
|
||||
max_x: i32,
|
||||
min_z: i32,
|
||||
max_z: i32,
|
||||
}
|
||||
|
||||
/// Sorted weak chunk references avoid holding gameplay chunks or forcing
|
||||
/// copy-on-write. A worker keeps only its current chunk strongly referenced.
|
||||
#[derive(Clone)]
|
||||
struct OcclusionSnapshot {
|
||||
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
|
||||
opaque_states: Arc<[bool]>,
|
||||
}
|
||||
|
||||
impl OcclusionSnapshot {
|
||||
fn chunk(&self, key: ChunkKey) -> Option<&Weak<Chunk>> {
|
||||
self.chunks
|
||||
.binary_search_by_key(&key, |(key, _)| *key)
|
||||
.ok()
|
||||
.map(|index| &self.chunks[index].1)
|
||||
}
|
||||
|
||||
fn refresh_chunks(&mut self, world: &World, bounds: ChunkBounds) {
|
||||
self.chunks.clear();
|
||||
for x in bounds.min_x..=bounds.max_x {
|
||||
for z in bounds.min_z..=bounds.max_z {
|
||||
if let Some(chunk) = world.chunks.get(&(x, z)) {
|
||||
self.chunks.push(((x, z), Arc::downgrade(chunk)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chunks_match(&self, world: &World) -> bool {
|
||||
self.chunks.iter().all(|(key, expected)| {
|
||||
world
|
||||
.chunks
|
||||
.get(key)
|
||||
.is_some_and(|current| std::ptr::eq(expected.as_ptr(), Arc::as_ptr(current)))
|
||||
})
|
||||
}
|
||||
|
||||
fn opaque(&self, pos: [i32; 3], last_chunk: &mut LastChunk) -> bool {
|
||||
let key = (pos[0] >> 4, pos[2] >> 4);
|
||||
let Some(chunk) = last_chunk.get(self, key) else {
|
||||
return false;
|
||||
};
|
||||
let state = chunk.block(pos[0] & 15, pos[1], pos[2] & 15);
|
||||
self.opaque_states
|
||||
.get(state.0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LastChunk {
|
||||
key: Option<ChunkKey>,
|
||||
value: Option<Arc<Chunk>>,
|
||||
}
|
||||
|
||||
impl LastChunk {
|
||||
fn get(&mut self, snapshot: &OcclusionSnapshot, key: ChunkKey) -> Option<&Arc<Chunk>> {
|
||||
if self.key != Some(key) {
|
||||
self.key = Some(key);
|
||||
self.value = snapshot.chunk(key).and_then(Weak::upgrade);
|
||||
}
|
||||
self.value.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
struct OcclusionProof {
|
||||
sections: Vec<SectionKey>,
|
||||
chunks: Vec<(ChunkKey, Weak<Chunk>)>,
|
||||
}
|
||||
|
||||
struct CullResults {
|
||||
stamp: SnapshotKey,
|
||||
hidden: Vec<(CullKey, OcclusionProof)>,
|
||||
scratch_queries: Vec<CullKey>,
|
||||
}
|
||||
|
||||
struct CullJob {
|
||||
stamp: SnapshotKey,
|
||||
snapshot: Arc<OcclusionSnapshot>,
|
||||
queries: Vec<CullKey>,
|
||||
}
|
||||
|
||||
/// Non-blocking entity occlusion. Results are reused only for byte-identical
|
||||
/// camera and mesh bounds, with the block chunks and rendered sections that
|
||||
/// proved occlusion revalidated before hiding anything.
|
||||
pub(super) struct AsyncCuller {
|
||||
jobs: SyncSender<CullJob>,
|
||||
results_rx: Receiver<CullResults>,
|
||||
_thread: JoinHandle<()>,
|
||||
snapshot: Option<(SnapshotKey, ChunkBounds, Arc<OcclusionSnapshot>)>,
|
||||
opaque_states: Option<Arc<[bool]>>,
|
||||
results: Option<CullResults>,
|
||||
queries: RefCell<Vec<CullKey>>,
|
||||
camera: [f64; 3],
|
||||
stamp: SnapshotKey,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl AsyncCuller {
|
||||
pub(super) fn new() -> std::io::Result<Self> {
|
||||
let (job_tx, job_rx) = mpsc::sync_channel::<CullJob>(1);
|
||||
let (result_tx, result_rx) = mpsc::channel::<CullResults>();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("entity-culling".into())
|
||||
.spawn(move || {
|
||||
while let Ok(job) = job_rx.recv() {
|
||||
if result_tx.send(cull_job(job)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
jobs: job_tx,
|
||||
results_rx: result_rx,
|
||||
_thread: thread,
|
||||
snapshot: None,
|
||||
opaque_states: None,
|
||||
results: None,
|
||||
queries: RefCell::new(Vec::new()),
|
||||
camera: [0.0; 3],
|
||||
stamp: SnapshotKey {
|
||||
world_revision: 0,
|
||||
chunks_revision: 0,
|
||||
bottom_y: 0,
|
||||
},
|
||||
pending: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
|
||||
while let Ok(mut results) = self.results_rx.try_recv() {
|
||||
self.pending = false;
|
||||
let scratch = std::mem::take(&mut results.scratch_queries);
|
||||
if scratch.capacity() > self.queries.get_mut().capacity() {
|
||||
*self.queries.get_mut() = scratch;
|
||||
}
|
||||
self.results = Some(results);
|
||||
}
|
||||
self.camera = camera.to_array();
|
||||
self.stamp = SnapshotKey {
|
||||
world_revision: world.revision,
|
||||
chunks_revision: world.chunks.membership_revision(),
|
||||
bottom_y: world.bottom_y(),
|
||||
};
|
||||
if self
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|result| result.stamp != self.stamp)
|
||||
{
|
||||
self.results = None;
|
||||
}
|
||||
self.queries.get_mut().clear();
|
||||
}
|
||||
|
||||
fn query(&self, vertices: &[Vertex], start: usize) -> Option<CullKey> {
|
||||
let (min, max) = Culler::bounds(self.camera, vertices, start)?;
|
||||
Some(CullKey {
|
||||
camera: self.camera.map(f64::to_bits),
|
||||
min: min.map(f64::to_bits),
|
||||
max: max.map(f64::to_bits),
|
||||
})
|
||||
}
|
||||
|
||||
fn proof(&self, key: &CullKey) -> Option<&OcclusionProof> {
|
||||
let hidden = &self.results.as_ref()?.hidden;
|
||||
let index = hidden.binary_search_by_key(key, |(key, _)| *key).ok()?;
|
||||
Some(&hidden[index].1)
|
||||
}
|
||||
|
||||
fn queue(&self, key: CullKey) {
|
||||
self.queries.borrow_mut().push(key);
|
||||
}
|
||||
|
||||
fn finish_frame(&mut self, world: &World, facts: &BlockFacts) {
|
||||
let queries = self.queries.get_mut();
|
||||
if self.pending {
|
||||
queries.clear();
|
||||
return;
|
||||
}
|
||||
if queries.is_empty() {
|
||||
return;
|
||||
}
|
||||
queries.sort_unstable();
|
||||
queries.dedup();
|
||||
let Some(bounds) = snapshot_bounds(self.camera, queries) else {
|
||||
queries.clear();
|
||||
return;
|
||||
};
|
||||
let opaque_states = Arc::clone(
|
||||
self.opaque_states
|
||||
.get_or_insert_with(|| facts.states.iter().map(opaque).collect::<Vec<_>>().into()),
|
||||
);
|
||||
let snapshot = match &mut self.snapshot {
|
||||
Some((stamp, cached_bounds, snapshot)) => {
|
||||
if stamp != &self.stamp || *cached_bounds != bounds || !snapshot.chunks_match(world)
|
||||
{
|
||||
let snapshot = Arc::make_mut(snapshot);
|
||||
snapshot.opaque_states = Arc::clone(&opaque_states);
|
||||
snapshot.refresh_chunks(world, bounds);
|
||||
*stamp = self.stamp;
|
||||
*cached_bounds = bounds;
|
||||
}
|
||||
Arc::clone(snapshot)
|
||||
}
|
||||
None => {
|
||||
let mut snapshot = OcclusionSnapshot {
|
||||
chunks: Vec::new(),
|
||||
opaque_states,
|
||||
};
|
||||
snapshot.refresh_chunks(world, bounds);
|
||||
let snapshot = Arc::new(snapshot);
|
||||
self.snapshot = Some((self.stamp, bounds, Arc::clone(&snapshot)));
|
||||
snapshot
|
||||
}
|
||||
};
|
||||
let queries = std::mem::take(queries);
|
||||
let job = CullJob {
|
||||
stamp: self.stamp,
|
||||
snapshot,
|
||||
queries,
|
||||
};
|
||||
match self.jobs.try_send(job) {
|
||||
Ok(()) => self.pending = true,
|
||||
Err(TrySendError::Full(job) | TrySendError::Disconnected(job)) => {
|
||||
*self.queries.get_mut() = job.queries;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cull_job(mut job: CullJob) -> CullResults {
|
||||
let camera = job.queries[0].camera.map(f64::from_bits);
|
||||
let culler = Culler::new(camera, 8_192);
|
||||
let mut last_chunk = LastChunk::default();
|
||||
let mut hidden = Vec::with_capacity(job.queries.len());
|
||||
let mut covered = Vec::new();
|
||||
for &key in &job.queries {
|
||||
covered.clear();
|
||||
let min = key.min.map(f64::from_bits);
|
||||
let max = key.max.map(f64::from_bits);
|
||||
if culler.occluded(min, max, |pos| {
|
||||
let opaque = job.snapshot.opaque(pos, &mut last_chunk);
|
||||
if opaque {
|
||||
covered.push(pos);
|
||||
}
|
||||
opaque
|
||||
}) {
|
||||
let mut sections = Vec::with_capacity(covered.len());
|
||||
let mut proof_chunks: Vec<(ChunkKey, Weak<Chunk>)> = Vec::new();
|
||||
let mut valid = true;
|
||||
for &pos in &covered {
|
||||
let chunk_key = (pos[0] >> 4, pos[2] >> 4);
|
||||
let Some(y) = pos[1].checked_sub(job.stamp.bottom_y) else {
|
||||
valid = false;
|
||||
break;
|
||||
};
|
||||
sections.push((chunk_key.0, y.div_euclid(16), chunk_key.1));
|
||||
if let Err(index) = proof_chunks.binary_search_by_key(&chunk_key, |(key, _)| *key) {
|
||||
let Some(chunk) = job.snapshot.chunk(chunk_key) else {
|
||||
valid = false;
|
||||
break;
|
||||
};
|
||||
if chunk.strong_count() == 0 {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
proof_chunks.insert(index, (chunk_key, chunk.clone()));
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
sections.sort_unstable();
|
||||
sections.dedup();
|
||||
hidden.push((
|
||||
key,
|
||||
OcclusionProof {
|
||||
sections,
|
||||
chunks: proof_chunks,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
job.queries.clear();
|
||||
CullResults {
|
||||
stamp: job.stamp,
|
||||
hidden,
|
||||
scratch_queries: job.queries,
|
||||
}
|
||||
}
|
||||
|
||||
fn opaque(facts: &StateFacts) -> bool {
|
||||
facts.render_shape == RenderShape::Model
|
||||
@@ -338,44 +12,6 @@ fn opaque(facts: &StateFacts) -> bool {
|
||||
&& facts.full_collision
|
||||
}
|
||||
|
||||
fn snapshot_bounds(camera: [f64; 3], queries: &[CullKey]) -> Option<ChunkBounds> {
|
||||
let mut min_x = camera[0];
|
||||
let mut max_x = camera[0];
|
||||
let mut min_z = camera[2];
|
||||
let mut max_z = camera[2];
|
||||
for query in queries {
|
||||
let min = query.min.map(f64::from_bits);
|
||||
let max = query.max.map(f64::from_bits);
|
||||
min_x = min_x.min(min[0]);
|
||||
max_x = max_x.max(max[0]);
|
||||
min_z = min_z.min(min[2]);
|
||||
max_z = max_z.max(max[2]);
|
||||
}
|
||||
// Culler::occluded expands projected cells by EPSILON. One block of slack
|
||||
// also covers the camera's first cell when it lies exactly on a boundary.
|
||||
let min_x = snapshot_cell(min_x - 1.0)? >> 4;
|
||||
let max_x = snapshot_cell(max_x + 1.0)? >> 4;
|
||||
let min_z = snapshot_cell(min_z - 1.0)? >> 4;
|
||||
let max_z = snapshot_cell(max_z + 1.0)? >> 4;
|
||||
let width = i64::from(max_x) - i64::from(min_x) + 1;
|
||||
let depth = i64::from(max_z) - i64::from(min_z) + 1;
|
||||
if width <= 0 || depth <= 0 || width.checked_mul(depth)? > MAX_SNAPSHOT_CHUNKS {
|
||||
return None;
|
||||
}
|
||||
Some(ChunkBounds {
|
||||
min_x,
|
||||
max_x,
|
||||
min_z,
|
||||
max_z,
|
||||
})
|
||||
}
|
||||
|
||||
fn snapshot_cell(value: f64) -> Option<i32> {
|
||||
let cell = value.floor();
|
||||
(cell.is_finite() && cell >= f64::from(i32::MIN) && cell <= f64::from(i32::MAX))
|
||||
.then_some(cell as i32)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn prepare_entity_culling(&mut self, camera: glam::DVec3) {
|
||||
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
|
||||
@@ -387,124 +23,41 @@ impl Game<'_> {
|
||||
_ => (false, false),
|
||||
};
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
let eligible = self.settings.horizon.entity_culling
|
||||
!= horizon_client::graphics::EntityCullingMode::Off
|
||||
self.entity_culler = (self.settings.horizon.entity_culling
|
||||
// Packs can replace an opaque block's model or texture.
|
||||
&& self.model_packs_are_vanilla()
|
||||
&& self.settings.resource_packs.iter().all(|id| id == "vanilla")
|
||||
&& self.player.is_some()
|
||||
&& !self.spectator()
|
||||
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2])));
|
||||
if !eligible {
|
||||
self.entity_culler = None;
|
||||
self.async_entity_culler = None;
|
||||
self.async_entity_culling_failed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
match self.settings.horizon.entity_culling {
|
||||
horizon_client::graphics::EntityCullingMode::Off => {
|
||||
self.entity_culler = None;
|
||||
self.async_entity_culler = None;
|
||||
self.async_entity_culling_failed = false;
|
||||
}
|
||||
horizon_client::graphics::EntityCullingMode::Synchronous => {
|
||||
self.entity_culler = Some(Culler::new(camera.to_array(), 8_192));
|
||||
self.async_entity_culler = None;
|
||||
self.async_entity_culling_failed = false;
|
||||
}
|
||||
horizon_client::graphics::EntityCullingMode::Asynchronous => {
|
||||
self.entity_culler = None;
|
||||
if self.async_entity_culler.is_none() && !self.async_entity_culling_failed {
|
||||
match AsyncCuller::new() {
|
||||
Ok(culler) => self.async_entity_culler = Some(culler),
|
||||
Err(error) => {
|
||||
log::warn!("Horizon: cannot start entity-culling worker: {error}");
|
||||
self.async_entity_culling_failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(culler) = &mut self.async_entity_culler {
|
||||
culler.begin_frame(camera, &self.world);
|
||||
} else {
|
||||
// If the worker cannot start, use the existing same-frame path.
|
||||
self.entity_culler = Some(Culler::new(camera.to_array(), 8_192));
|
||||
}
|
||||
}
|
||||
}
|
||||
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2]))))
|
||||
.then(|| Culler::new(camera.to_array(), 8_192));
|
||||
}
|
||||
|
||||
/// Use complete generated geometry so equipment, animation and custom
|
||||
/// models cannot stick out of an assumed collision-sized culling box.
|
||||
pub(super) fn cull_entity_vertices(&self, vertices: &mut Vec<Vertex>, start: usize) {
|
||||
if let Some(culler) = &self.entity_culler {
|
||||
let hidden = culler.hidden(vertices, start, |pos| self.occluder_ready(pos));
|
||||
// A shader pack's shadow pass still draws what the camera cannot see.
|
||||
if hidden
|
||||
&& !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len()))
|
||||
{
|
||||
vertices.truncate(start);
|
||||
let Some(culler) = &self.entity_culler else {
|
||||
return;
|
||||
};
|
||||
let hidden = culler.hidden(vertices, start, |pos| {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(culler) = &self.async_entity_culler else {
|
||||
return;
|
||||
};
|
||||
let Some(query) = culler.query(vertices, start) else {
|
||||
return;
|
||||
};
|
||||
let hidden = self
|
||||
.async_entity_culler
|
||||
.as_ref()
|
||||
.and_then(|culler| culler.proof(&query))
|
||||
.is_some_and(|proof| self.async_proof_is_current(proof));
|
||||
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
if let Some(culler) = &self.async_entity_culler {
|
||||
culler.queue(query);
|
||||
}
|
||||
}
|
||||
|
||||
fn async_proof_is_current(&self, proof: &OcclusionProof) -> bool {
|
||||
for (key, expected) in &proof.chunks {
|
||||
let Some(current) = self.world.chunks.get(key) else {
|
||||
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
|
||||
return false;
|
||||
};
|
||||
if !std::ptr::eq(Arc::as_ptr(current), expected.as_ptr()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
proof.sections.iter().all(|§ion| {
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
// Newly edited or not-yet-uploaded terrain is not an occluder.
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
})
|
||||
}
|
||||
|
||||
fn occluder_ready(&self, pos: [i32; 3]) -> bool {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !opaque(self.res.facts().get(state)) {
|
||||
return false;
|
||||
}
|
||||
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
|
||||
return false;
|
||||
};
|
||||
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.has_section(section))
|
||||
&& !self.world.dirty_sections.contains(§ion)
|
||||
&& !self.streaming.is_meshing(section)
|
||||
&& !self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
}
|
||||
|
||||
pub(super) fn finish_async_entity_culling(&mut self) {
|
||||
if let Some(culler) = &mut self.async_entity_culler {
|
||||
culler.finish_frame(&self.world, self.res.facts());
|
||||
});
|
||||
// A shader pack's shadow pass still draws what the camera cannot see.
|
||||
if hidden && !(self.pack_shadow_casters.0 && self.hide_spans_from(start, vertices.len())) {
|
||||
vertices.truncate(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,99 +117,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod async_tests {
|
||||
use super::*;
|
||||
use horizon_client::chunk::ColumnLayout;
|
||||
|
||||
fn wall(hole: Option<[i32; 3]>) -> (Arc<OcclusionSnapshot>, Vec<Arc<Chunk>>) {
|
||||
let mut live = Vec::new();
|
||||
let mut chunks = Vec::new();
|
||||
for chunk_z in [-1, 0] {
|
||||
let mut chunk = Chunk::with_layout(0, chunk_z, ColumnLayout::VANILLA);
|
||||
for y in -1..=0 {
|
||||
for z in -1..=0 {
|
||||
let pos = [4, y, z];
|
||||
if hole != Some(pos) {
|
||||
chunk.set(pos[0] & 15, pos[1], pos[2] & 15, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let chunk = Arc::new(chunk);
|
||||
chunks.push(((0, chunk_z), Arc::downgrade(&chunk)));
|
||||
live.push(chunk);
|
||||
}
|
||||
chunks.sort_unstable_by_key(|(key, _)| *key);
|
||||
(
|
||||
Arc::new(OcclusionSnapshot {
|
||||
chunks,
|
||||
opaque_states: Arc::from([false, true]),
|
||||
}),
|
||||
live,
|
||||
)
|
||||
}
|
||||
|
||||
fn job(snapshot: Arc<OcclusionSnapshot>) -> CullJob {
|
||||
let camera = [0.0; 3];
|
||||
let min = [7.95, -0.55, -0.55];
|
||||
let max = [10.05, 0.55, 0.55];
|
||||
CullJob {
|
||||
stamp: SnapshotKey {
|
||||
world_revision: 1,
|
||||
chunks_revision: 1,
|
||||
bottom_y: ColumnLayout::VANILLA.min_y(),
|
||||
},
|
||||
snapshot,
|
||||
queries: vec![CullKey {
|
||||
camera: camera.map(f64::to_bits),
|
||||
min: min.map(f64::to_bits),
|
||||
max: max.map(f64::to_bits),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_snapshot_is_bounded_and_handles_negative_chunk_edges() {
|
||||
let camera = [-16.0, 0.0, -16.0];
|
||||
let key = CullKey {
|
||||
camera: camera.map(f64::to_bits),
|
||||
min: [-8.0, -1.0, -8.0].map(f64::to_bits),
|
||||
max: [8.0, 1.0, 8.0].map(f64::to_bits),
|
||||
};
|
||||
assert_eq!(
|
||||
snapshot_bounds(camera, &[key]),
|
||||
Some(ChunkBounds {
|
||||
min_x: -2,
|
||||
max_x: 0,
|
||||
min_z: -2,
|
||||
max_z: 0,
|
||||
})
|
||||
);
|
||||
|
||||
let huge = CullKey {
|
||||
camera: [0.0; 3].map(f64::to_bits),
|
||||
min: [5_000.0, 0.0, 5_000.0].map(f64::to_bits),
|
||||
max: [5_001.0, 1.0, 5_001.0].map(f64::to_bits),
|
||||
};
|
||||
assert_eq!(snapshot_bounds([0.0; 3], &[huge]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_worker_only_hides_bounds_behind_a_complete_opaque_plane() {
|
||||
let (snapshot, live) = wall(None);
|
||||
let results = cull_job(job(snapshot));
|
||||
assert_eq!(results.hidden.len(), 1);
|
||||
assert_eq!(results.hidden[0].1.sections.len(), 4);
|
||||
assert_eq!(results.hidden[0].1.chunks.len(), 2);
|
||||
drop(live);
|
||||
|
||||
let (snapshot, live) = wall(Some([4, 0, 0]));
|
||||
let results = cull_job(job(snapshot));
|
||||
assert!(results.hidden.is_empty());
|
||||
drop(live);
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity's position as a `DVec3`.
|
||||
pub(super) fn at(p: horizon_core::geometry::Vec3) -> glam::DVec3 {
|
||||
glam::DVec3::new(p.x, p.y, p.z)
|
||||
|
||||
@@ -698,74 +698,6 @@ impl Game<'_> {
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
|
||||
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
|
||||
// does nothing while it is in the ground; `hurtServer` never hurts them.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
|
||||
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
|
||||
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye_height = p.kind.eye_height();
|
||||
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
let feet = r.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.125, feet.y, feet.z - 0.125],
|
||||
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
r.velocity = Vec3::new(
|
||||
r.velocity.x + dir[0] * power,
|
||||
r.velocity.y + dir[1] * power,
|
||||
r.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
|
||||
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
|
||||
// (`AbstractArrow.push`) not pushed while in the ground.
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
|
||||
let feet = t.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.25, feet.y, feet.z - 0.25],
|
||||
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x + dir[0] * power,
|
||||
t.velocity.y + dir[1] * power,
|
||||
t.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.tridents = tridents;
|
||||
// Frames, paintings, cushions and armour stands last: vanilla lists
|
||||
// the entities in reach before hurting any, so the items they drop
|
||||
// here are neither destroyed nor pushed by the same blast.
|
||||
|
||||
@@ -62,7 +62,6 @@ impl Game<'_> {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
self.native_frame(elapsed);
|
||||
self.recipes_received_now();
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
|
||||
@@ -580,12 +580,6 @@ impl Game<'_> {
|
||||
.map(|i| i.id)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[h.position.x, h.position.y, h.position.z],
|
||||
);
|
||||
h.state = State::Hooked(id);
|
||||
h.velocity = Vec3::ZERO;
|
||||
self.hook = Some(h);
|
||||
@@ -611,12 +605,6 @@ impl Game<'_> {
|
||||
shape.clip(h.position, to, p)
|
||||
});
|
||||
if let Some(hit) = hit.ok().flatten() {
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[hit.location.x, hit.location.y, hit.location.z],
|
||||
);
|
||||
h.velocity = rebound(h.velocity, hit.location, h.position);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,25 +35,6 @@ impl Game<'_> {
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
|
||||
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
|
||||
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
|
||||
/// back keeps it still, so an item dropped there stays on top.
|
||||
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
(m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& m.alive()
|
||||
&& !m.is_baby()
|
||||
&& happy_ghast::on_still_timeout(m))
|
||||
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
pub(super) fn interact_happy_ghast(&mut self, index: usize, item: &str) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat | Screen::CommandBlock | Screen::Social => true,
|
||||
Screen::Chat | Screen::CommandBlock => true,
|
||||
Screen::DebugOptions => self
|
||||
.debug_options
|
||||
.as_ref()
|
||||
@@ -117,7 +117,6 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(&text),
|
||||
Screen::CommandBlock => self.command_block_type(&text),
|
||||
Screen::Social => self.social_type(&text),
|
||||
Screen::DebugOptions => self.debug_options_type(&text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
|
||||
@@ -32,37 +32,8 @@ use horizon_protocol::{
|
||||
text::literal,
|
||||
};
|
||||
use horizon_world::entity_move::EntityMoveWorld;
|
||||
use std::collections::HashMap;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
|
||||
/// changes as `(pos, previous, viewers)`, in position order. A container that
|
||||
/// is no longer counted goes to 0 and leaves `known`.
|
||||
pub(super) fn viewer_changes(
|
||||
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
dim: &'static str,
|
||||
mut now: HashMap<(i32, i32, i32), u32>,
|
||||
) -> Vec<((i32, i32, i32), u32, u32)> {
|
||||
let mut changes = Vec::new();
|
||||
known.retain(|&(d, pos), previous| {
|
||||
if d != dim {
|
||||
return true;
|
||||
}
|
||||
let viewers = now.remove(&pos).unwrap_or(0);
|
||||
if viewers != *previous {
|
||||
changes.push((pos, *previous, viewers));
|
||||
*previous = viewers;
|
||||
}
|
||||
viewers > 0
|
||||
});
|
||||
for (pos, viewers) in now {
|
||||
known.insert((dim, pos), viewers);
|
||||
changes.push((pos, 0, viewers));
|
||||
}
|
||||
changes.sort_unstable();
|
||||
changes
|
||||
}
|
||||
|
||||
/// Per-guest state of the gameplay code that lives outside `Guest`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GuestPlay {
|
||||
@@ -326,9 +297,6 @@ impl Game<'_> {
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
super::dedicated::crash_fixture("guest", || {
|
||||
host.hub.guests.iter().any(|g| g.conn == conn && g.placed)
|
||||
});
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
@@ -1395,27 +1363,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerOpenersCounter` for the mod containers of the level in place:
|
||||
/// the players with each one open, recounted from their menus, so a
|
||||
/// change is heard whichever way a player left (`native_mod_container_viewers`).
|
||||
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
|
||||
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
|
||||
if !g.spectator()
|
||||
&& let Some(m) = &g.menu
|
||||
&& let Slots::Block(pos) = m.slots
|
||||
&& self.mod_menu_at(pos).is_some()
|
||||
{
|
||||
*now.entry(pos).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let dim = self.dim.id();
|
||||
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
|
||||
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// `stillValid` of the guest's open container: its blocks are still there
|
||||
/// and in reach, or its vehicle exists and is in reach. True with none open.
|
||||
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
@@ -1922,23 +1869,6 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
// SpawnEggItem.use calls ServerLevel.mayInteract at its own
|
||||
// fluid ray's position, unlike useOn's packet-supplied block.
|
||||
let held = if hand == Hand::Off {
|
||||
g.offhand.as_ref()
|
||||
} else {
|
||||
g.inventory.get(g.selected).and_then(Option::as_ref)
|
||||
};
|
||||
if held.is_some_and(|s| s.item.ends_with("_spawn_egg")) {
|
||||
let hit = self
|
||||
.as_guest(host, conn, |g| {
|
||||
g.with_hand(hand, |g| g.spawn_egg_fluid_hit())
|
||||
})
|
||||
.flatten();
|
||||
if hit.is_some_and(|h| self.under_spawn_protection(host, gi, h.pos)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
// A horn's sound, inline instruments included.
|
||||
@@ -3305,7 +3235,6 @@ impl Game<'_> {
|
||||
self.lan_stations_tick(host);
|
||||
self.lan_tables_tick(host);
|
||||
self.lan_containers_tick(host);
|
||||
self.tick_mod_container_viewers(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
|
||||
@@ -133,9 +133,8 @@ impl Game<'_> {
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// `Entity.ejectPassengers`: every player in the boat must see
|
||||
// the request; the common boat tick clears it once nobody rides.
|
||||
ejected = b.eject_riders;
|
||||
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
|
||||
ejected = std::mem::take(&mut b.eject_riders);
|
||||
}
|
||||
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
|
||||
// is where it is now, so a move the server made (a piston, a push)
|
||||
|
||||
@@ -549,8 +549,6 @@ impl Game<'_> {
|
||||
.is_some_and(|p| !p.streaming.forced.is_empty())
|
||||
};
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
// A viewer count not yet told to 0 (`tick_mod_container_viewers`).
|
||||
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|
||||
|| self.player_bed_levels().any(|b| b == *d)
|
||||
|| forced
|
||||
// A game test runs in its own level, players or not.
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
pub(super) struct Bolt {
|
||||
/// The entity id a server's players know it by (0 until it has one).
|
||||
@@ -456,26 +452,10 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
|
||||
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
|
||||
/// positions). Returns the mobs in that box before the hit, as
|
||||
/// `CHANNELED_LIGHTNING` counts them. Boats, minecarts and experience
|
||||
/// orbs are hit too (`VehicleEntity.hurtServer` adds 5 x 10 > 40 and
|
||||
/// destroys them; an orb has 5 health).
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
|
||||
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
|
||||
let at = b.at;
|
||||
let strike = Aabb::new(
|
||||
Vec3::new(at[0] - 3.0, at[1] - 3.0, at[2] - 3.0),
|
||||
Vec3::new(at[0] + 3.0, at[1] + 9.0, at[2] + 3.0),
|
||||
);
|
||||
// Whether the box of an entity standing at `p` (half width `w`,
|
||||
// height `h`) meets the strike box.
|
||||
let meets = move |p: [f64; 3], w: f64, h: f64| {
|
||||
strike.intersects(Aabb::new(
|
||||
Vec3::new(p[0] - w, p[1], p[2] - w),
|
||||
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
|
||||
))
|
||||
};
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
&& (p[2] - at[2]).abs() <= 3.0
|
||||
@@ -485,14 +465,7 @@ impl Game<'_> {
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.alive()
|
||||
&& meets(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width / 2.0,
|
||||
m.kind.height,
|
||||
)
|
||||
})
|
||||
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
|
||||
.map(|m| (m.id, self.mob_entity(m)))
|
||||
.collect();
|
||||
b.hit.extend(struck.iter().map(|(id, _)| *id));
|
||||
@@ -504,11 +477,7 @@ impl Game<'_> {
|
||||
let mut events = Vec::new();
|
||||
let peaceful = self.ui.difficulty == 0;
|
||||
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
|
||||
if !meets(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width / 2.0,
|
||||
m.kind.height,
|
||||
) {
|
||||
if !inside([m.position.x, m.position.y, m.position.z]) {
|
||||
continue;
|
||||
}
|
||||
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
|
||||
@@ -585,34 +554,10 @@ impl Game<'_> {
|
||||
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
|
||||
}
|
||||
}
|
||||
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
|
||||
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
|
||||
// fire source, so a TNT cart does not prime.
|
||||
let mut i = self.boats.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let bb = self.boats[i].bounds();
|
||||
if strike.intersects(bb) {
|
||||
self.hurt_boat(i, 5.0, false);
|
||||
}
|
||||
}
|
||||
let mut i = self.carts.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let bb = self.carts[i].bounds();
|
||||
if strike.intersects(bb) {
|
||||
self.hurt_cart(i, 5.0, false, false);
|
||||
}
|
||||
}
|
||||
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
|
||||
let orbs_before = self.orb_ids();
|
||||
self.orbs
|
||||
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
// Every player in the box, the server's players too (0.6 x 1.8).
|
||||
let strike_players = move |g: &mut Self| {
|
||||
// Every player in the box, the server's players too.
|
||||
let strike = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
&& meets([p.x, p.y, p.z], 0.3, 1.8)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
{
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
@@ -625,18 +570,18 @@ impl Game<'_> {
|
||||
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
};
|
||||
strike_players(self);
|
||||
self.each_guest_hit(strike_players);
|
||||
strike(self);
|
||||
self.each_guest_hit(strike);
|
||||
b.hit_items.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
|
||||
.filter(|e| inside([e.position.x, e.position.y, e.position.z]))
|
||||
.map(|e| e.id),
|
||||
);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
let before = self.dropped_ids();
|
||||
self.dropped.retain(|e| {
|
||||
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
|| e.stack.item.contains("netherite")
|
||||
|| (e.age < 20 && b.spared.contains(&e.id))
|
||||
});
|
||||
|
||||
@@ -414,68 +414,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.rail_pos(x, y, z)
|
||||
}
|
||||
/// `AbstractMinecart.canCollideWith`, for `Entity.collide`'s `getEntityCollisions`:
|
||||
/// `AbstractBoat.canVehicleCollide` takes any other cart, boat, shulker or pushable mob and
|
||||
/// player, except one riding this cart. A cart that runs into one has a horizontal
|
||||
/// collision, which sets a TNT minecart off.
|
||||
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
|
||||
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
let mut boxes: Vec<Aabb> = self
|
||||
.carts
|
||||
.iter()
|
||||
.filter(|o| o.id != c.id)
|
||||
.map(Minecart::bounds)
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.chain(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& m.extra.mount != Some(c.id)
|
||||
&& (m.pushable()
|
||||
|| m.species() == horizon_client::mobs::Species::Shulker
|
||||
&& m.alive())
|
||||
})
|
||||
.map(|m| m.bounds()),
|
||||
)
|
||||
.collect();
|
||||
if self.riding != Some(c.id)
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
{
|
||||
boxes.push(p.bounds());
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
boxes.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(c.id)
|
||||
})
|
||||
.map(|g| g.bounds()),
|
||||
);
|
||||
}
|
||||
boxes.retain(|b| b.intersects(near));
|
||||
boxes
|
||||
}
|
||||
/// Entity.move for a cart: collisions zero the blocked components.
|
||||
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
|
||||
let scene = SceneCollisions {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let entities = self.cart_entity_collisions(c);
|
||||
let mut scene = horizon_client::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
|
||||
c.position = Vec3::new(
|
||||
c.position.x + moved.x,
|
||||
|
||||
@@ -185,12 +185,6 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
|
||||
fn built_with_mods(built: &str) -> bool {
|
||||
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
|
||||
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
|
||||
}
|
||||
|
||||
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
|
||||
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row
|
||||
/// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
|
||||
@@ -278,15 +272,6 @@ impl Game<'_> {
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.unwrap_or_default();
|
||||
horizon_client::gfx::native_render::set_mods_built_in(
|
||||
built_with_mods(&built)
|
||||
|| self
|
||||
.settings
|
||||
.mods
|
||||
.installed
|
||||
.iter()
|
||||
.any(|m| m.native.is_some()),
|
||||
);
|
||||
let native_dir = Self::game_dir().join(mod_list::NATIVE_DIR);
|
||||
let unbuilt = not_built(&native_dir, &built, &self.settings.mods.installed);
|
||||
self.settings.mods.installed.extend(unbuilt);
|
||||
|
||||
@@ -14,12 +14,7 @@ impl Game<'_> {
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let (mut music, mut volume) = self.situational_music();
|
||||
// A native mod's own choice of track and volume, then its layers.
|
||||
self.native_music(&mut music, &mut volume);
|
||||
let mut layers = Vec::new();
|
||||
self.native_music_layers(&mut layers);
|
||||
self.music_layers.tick(&layers, &mut self.audio);
|
||||
let (music, volume) = self.situational_music();
|
||||
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
|
||||
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
|
||||
if frequency != self.music.frequency {
|
||||
|
||||
@@ -260,7 +260,7 @@ impl Game<'_> {
|
||||
Screen::Chat if self.title.is_none() => true,
|
||||
Screen::Options => self.ui.options.typing(),
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::CommandBlock | Screen::Social => true,
|
||||
Screen::CommandBlock => true,
|
||||
Screen::Inventory => self.station_typing() || self.mod_browser_typing(),
|
||||
_ => self
|
||||
.title
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
//! A client mod's view and its own items (docs/native-mods.md, "A client mod's view and items"): the
|
||||
//! first block along the view, across the distant terrain past the loaded chunks, and items whose
|
||||
//! right-click is the mod's alone, heard as use events the mod polls.
|
||||
use super::Game;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A stack as a client mod's use events carry it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHeldItem {
|
||||
pub(crate) id: String,
|
||||
pub(crate) count: u32,
|
||||
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
|
||||
pub(crate) slot: u32,
|
||||
/// `minecraft:custom_name` as SNBT, when the stack has one.
|
||||
pub(crate) custom_name: Option<String>,
|
||||
/// `minecraft:custom_data` as SNBT, when the stack has some.
|
||||
pub(crate) custom_data: Option<String>,
|
||||
}
|
||||
/// The block a ray along the view meets.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHit {
|
||||
pub(crate) block_x: i32,
|
||||
pub(crate) block_y: i32,
|
||||
pub(crate) block_z: i32,
|
||||
/// Where the ray meets the block.
|
||||
pub(crate) x: f64,
|
||||
pub(crate) y: f64,
|
||||
pub(crate) z: f64,
|
||||
/// The block's id (`minecraft:stone`).
|
||||
pub(crate) block: String,
|
||||
}
|
||||
/// What happened to a mod's claimed item, oldest first.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum NativeModEvent {
|
||||
/// A right-click with the item began its use: the stack, and 0 ticks.
|
||||
UseStart(NativeHeldItem, u32),
|
||||
/// The use ended: the stack it began with, and the ticks it was held. It
|
||||
/// ends when the button comes up, the hand's item changes or leaves the
|
||||
/// claim, the player respawns, or the game joins a server.
|
||||
UseStop(NativeHeldItem, u32),
|
||||
}
|
||||
/// The farthest a ray along the view reaches, in blocks.
|
||||
pub(crate) const RAYCAST_MAX: f32 = 1024.0;
|
||||
/// The ids a mod may claim.
|
||||
const CLAIMS_MAX: usize = 64;
|
||||
/// Events kept for a mod between two polls.
|
||||
const EVENTS_MAX: usize = 256;
|
||||
|
||||
/// A claimed item's use under way.
|
||||
struct ModUse {
|
||||
mod_id: String,
|
||||
item: NativeHeldItem,
|
||||
/// The player's `using` while it is this use: its slot and ticks.
|
||||
slot: usize,
|
||||
ticks: u32,
|
||||
}
|
||||
|
||||
/// The items client mods claim, their events waiting for a poll, and the use under way.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModInput {
|
||||
claims: BTreeMap<String, Vec<String>>,
|
||||
events: BTreeMap<String, Vec<NativeModEvent>>,
|
||||
using: Option<ModUse>,
|
||||
}
|
||||
|
||||
impl ModInput {
|
||||
fn claim(&mut self, mod_id: &str, ids: &[String]) {
|
||||
let ids = ids.iter().take(CLAIMS_MAX).cloned().collect();
|
||||
self.claims.insert(mod_id.to_owned(), ids);
|
||||
}
|
||||
/// The mod that claims `item`, the first in id order.
|
||||
pub(super) fn claimant(&self, item: &str) -> Option<&str> {
|
||||
self.claims
|
||||
.iter()
|
||||
.find(|(_, ids)| ids.iter().any(|i| i == item))
|
||||
.map(|(id, _)| id.as_str())
|
||||
}
|
||||
fn push(&mut self, mod_id: &str, event: NativeModEvent) {
|
||||
let events = self.events.entry(mod_id.to_owned()).or_default();
|
||||
if events.len() < EVENTS_MAX {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.events.remove(mod_id).unwrap_or_default()
|
||||
}
|
||||
/// Mod `mod_id`'s use of `item` in `slot` begins, ending any other first.
|
||||
fn start(&mut self, mod_id: &str, item: NativeHeldItem, slot: usize) {
|
||||
self.end();
|
||||
self.push(mod_id, NativeModEvent::UseStart(item.clone(), 0));
|
||||
self.using = Some(ModUse {
|
||||
mod_id: mod_id.to_owned(),
|
||||
item,
|
||||
slot,
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// Whether the player's use (`Game.using`) is the mod use under way. A
|
||||
/// use the game reset and another item's begun since are not.
|
||||
fn owns(&self, using: Option<(usize, u32)>) -> bool {
|
||||
self.using
|
||||
.as_ref()
|
||||
.is_some_and(|u| using == Some((u.slot, u.ticks)))
|
||||
}
|
||||
fn tick(&mut self) {
|
||||
if let Some(u) = &mut self.using {
|
||||
u.ticks += 1;
|
||||
}
|
||||
}
|
||||
/// The use under way ends: its mod hears `UseStop`.
|
||||
fn end(&mut self) {
|
||||
if let Some(u) = self.using.take() {
|
||||
self.push(&u.mod_id, NativeModEvent::UseStop(u.item, u.ticks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The stack in inventory slot `slot` as a mod's events carry it.
|
||||
fn mod_held(&self, slot: usize) -> Option<NativeHeldItem> {
|
||||
let stack = self.stack_in(slot)?;
|
||||
let extra = stack.extra.as_deref();
|
||||
Some(NativeHeldItem {
|
||||
id: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
slot: slot as u32,
|
||||
custom_name: extra
|
||||
.and_then(|e| e.custom_name.as_ref())
|
||||
.map(horizon_client::snbt::to_snbt),
|
||||
custom_data: extra
|
||||
.and_then(|e| e.other("minecraft:custom_data"))
|
||||
.map(horizon_client::snbt::to_snbt),
|
||||
})
|
||||
}
|
||||
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API @API:native_claimed_items@.
|
||||
pub(super) fn native_raycast(&self, distance: f32) -> Option<NativeHit> {
|
||||
let (d, hit) = self.native_view_hit()?;
|
||||
(d <= distance.clamp(0.0, RAYCAST_MAX)).then_some(hit)
|
||||
}
|
||||
/// The first block along the view within `RAYCAST_MAX`, and its distance. API @API:native_claimed_items@.
|
||||
pub(super) fn native_view_hit(&self) -> Option<(f32, NativeHit)> {
|
||||
self.player.as_ref()?;
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let reach = f64::from(RAYCAST_MAX);
|
||||
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
|
||||
Some(hit) => (
|
||||
hit.pos,
|
||||
hit.point,
|
||||
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
),
|
||||
None => self.lod_ray(eye, dir, reach)?,
|
||||
};
|
||||
let distance = (0..3)
|
||||
.map(|i| (point[i] - eye[i]).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
Some((
|
||||
distance as f32,
|
||||
NativeHit {
|
||||
block_x: pos[0],
|
||||
block_y: pos[1],
|
||||
block_z: pos[2],
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
z: point[2],
|
||||
block: self.res.facts().get(state).name.to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
/// A ray over the distant terrain (`Lod::ground`) past the loaded chunks, a block at a time.
|
||||
fn lod_ray(
|
||||
&self,
|
||||
eye: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
|
||||
let lod = self.lod.as_ref()?;
|
||||
let mut t = 0.0;
|
||||
while t <= reach {
|
||||
let p = [
|
||||
eye[0] + dir[0] * t,
|
||||
eye[1] + dir[1] * t,
|
||||
eye[2] + dir[2] * t,
|
||||
];
|
||||
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
|
||||
if !self.world.loaded(x, z)
|
||||
&& let Some((height, state)) = lod.ground(x, z)
|
||||
&& p[1] <= f64::from(height)
|
||||
{
|
||||
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
|
||||
}
|
||||
t += 1.0;
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API @API:native_claimed_items@.
|
||||
pub(super) fn native_claim_items(&mut self, mod_id: &str, ids: &[String]) {
|
||||
self.native_input.claim(mod_id, ids);
|
||||
}
|
||||
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API @API:native_claimed_items@.
|
||||
pub(super) fn native_poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.native_input.poll(mod_id)
|
||||
}
|
||||
/// `use_hand`: a right-click with an item a mod claims starts its use for
|
||||
/// that mod and nothing else happens. True when a mod took it.
|
||||
pub(super) fn mod_use_start(&mut self) -> bool {
|
||||
if self.guest_use.is_some() || self.using.is_some() {
|
||||
return false;
|
||||
}
|
||||
// With no use, a mod use the game reset without telling it is over.
|
||||
self.native_input.end();
|
||||
let slot = self.held_slot();
|
||||
let Some(item) = self.mod_held(slot) else {
|
||||
return false;
|
||||
};
|
||||
let Some(id) = self.native_input.claimant(&item.id).map(str::to_owned) else {
|
||||
return false;
|
||||
};
|
||||
self.native_input.start(&id, item, slot);
|
||||
self.using = Some((slot, 0));
|
||||
true
|
||||
}
|
||||
/// `tick_used_item` for a claimed item: counts the ticks held and ends
|
||||
/// the use when the button comes up. True when the player's use is a
|
||||
/// mod's; a mod use the game reset ends, and the player's own use goes on.
|
||||
pub(super) fn mod_use_tick(&mut self, slot: usize, ticks: u32, released: bool) -> bool {
|
||||
if !self.native_input.owns(Some((slot, ticks))) {
|
||||
self.native_input.end();
|
||||
return false;
|
||||
}
|
||||
let still_claimed = self.native_input.using.as_ref().is_some_and(|u| {
|
||||
self.stack_in(slot)
|
||||
.is_some_and(|s| self.native_input.claimant(&s.item) == Some(u.mod_id.as_str()))
|
||||
});
|
||||
if released || !still_claimed {
|
||||
self.native_input.end();
|
||||
self.using = None;
|
||||
} else {
|
||||
self.native_input.tick();
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The player's use was reset (a respawn, a join, the hand's item
|
||||
/// changed): a mod use under way ends and its mod hears `UseStop`.
|
||||
pub(super) fn end_mod_use(&mut self) {
|
||||
self.native_input.end();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod input_tests {
|
||||
use super::*;
|
||||
|
||||
fn stick(slot: u32) -> NativeHeldItem {
|
||||
NativeHeldItem {
|
||||
id: "minecraft:stick".into(),
|
||||
count: 1,
|
||||
slot,
|
||||
custom_name: None,
|
||||
custom_data: Some("{orbital:1b}".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_are_claimed_by_the_first_mod_in_id_order() {
|
||||
let mut input = ModInput::default();
|
||||
let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
input.claim("zeta", &ids(&["orbital:remote", "minecraft:stick"]));
|
||||
input.claim("alpha", &ids(&["minecraft:stick"]));
|
||||
assert_eq!(input.claimant("minecraft:stick"), Some("alpha"));
|
||||
assert_eq!(input.claimant("orbital:remote"), Some("zeta"));
|
||||
assert_eq!(input.claimant("minecraft:dirt"), None);
|
||||
input.claim("alpha", &[]);
|
||||
assert_eq!(input.claimant("minecraft:stick"), Some("zeta"));
|
||||
let many: Vec<String> = (0..100).map(|i| format!("m:item{i}")).collect();
|
||||
input.claim("alpha", &many);
|
||||
assert_eq!(input.claimant("m:item63"), Some("alpha"));
|
||||
assert_eq!(input.claimant("m:item64"), None, "64 at most");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_wait_for_their_mod_up_to_a_limit() {
|
||||
let mut input = ModInput::default();
|
||||
for _ in 0..EVENTS_MAX + 10 {
|
||||
input.push("a", NativeModEvent::UseStart(stick(0), 0));
|
||||
}
|
||||
input.push("b", NativeModEvent::UseStop(stick(0), 3));
|
||||
assert_eq!(input.poll("a").len(), EVENTS_MAX);
|
||||
assert!(input.poll("a").is_empty());
|
||||
assert_eq!(input.poll("b"), vec![NativeModEvent::UseStop(stick(0), 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claimed_use_is_the_mods_while_it_runs() {
|
||||
let mut input = ModInput::default();
|
||||
input.start("orbital", stick(2), 2);
|
||||
assert!(input.owns(Some((2, 0))));
|
||||
input.tick();
|
||||
input.tick();
|
||||
assert!(input.owns(Some((2, 2))));
|
||||
input.end();
|
||||
assert!(!input.owns(Some((2, 2))));
|
||||
assert_eq!(
|
||||
input.poll("orbital"),
|
||||
vec![
|
||||
NativeModEvent::UseStart(stick(2), 0),
|
||||
NativeModEvent::UseStop(stick(2), 2),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_claimed_use_leaves_the_next_use_alone() {
|
||||
// A respawn ends the use: the mod hears it stop, and a vanilla use
|
||||
// begun next in the same slot is not taken for the mod's.
|
||||
let mut input = ModInput::default();
|
||||
input.start("orbital", stick(0), 0);
|
||||
input.tick();
|
||||
input.end();
|
||||
assert!(!input.owns(Some((0, 0))));
|
||||
assert_eq!(
|
||||
input.poll("orbital").last(),
|
||||
Some(&NativeModEvent::UseStop(stick(0), 1))
|
||||
);
|
||||
// A reset the mod use never heard of (`Game.using` cleared alone):
|
||||
// the player's next use, from 0 ticks or in another slot, is still
|
||||
// not the mod's, and ending it then tells the mod once.
|
||||
input.start("orbital", stick(0), 0);
|
||||
input.tick();
|
||||
input.tick();
|
||||
assert!(!input.owns(None));
|
||||
assert!(!input.owns(Some((0, 0))), "a vanilla use from 0 ticks");
|
||||
assert!(!input.owns(Some((1, 2))), "another slot");
|
||||
input.end();
|
||||
input.end();
|
||||
let events = input.poll("orbital");
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[1], NativeModEvent::UseStop(stick(0), 2));
|
||||
}
|
||||
}
|
||||
@@ -298,7 +298,6 @@ use horizon_client::{
|
||||
keybinds,
|
||||
mobs::{Damage, Who},
|
||||
mod_list::{self, Installed, NativePage},
|
||||
music::{Music, MusicLayer},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
@@ -1186,35 +1185,6 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The server is about to send its data to the players (NeoForge's
|
||||
/// `OnDatapackSyncEvent`, its `sendRecipes`): push the recipe types
|
||||
/// (`minecraft:smelting`, `mymod:crusher`) whose recipes your client half
|
||||
/// needs. Horizon sends their final recipes — the world's data packs
|
||||
/// merged, load conditions held — to every Horizon client when it joins
|
||||
/// and again after each `/reload`; a vanilla client is sent nothing new.
|
||||
/// Asked once per load, when the server's data tables are built, and the
|
||||
/// same types go to every player: unlike NeoForge, which asks again for
|
||||
/// each joining player, it can't differ between players. Push nothing and
|
||||
/// nothing is sent; push nothing after a load that pushed some and the
|
||||
/// clients go back to their own recipes. Server-side (the single
|
||||
/// player's integrated server included). API @API:recipe_sync@.
|
||||
pub(super) fn native_datapack_sync(&mut self, recipe_types: &mut Vec<String>) {
|
||||
let _ = recipe_types;
|
||||
}
|
||||
/// The server's recipes of `recipe_types` arrived (NeoForge's
|
||||
/// `RecipesReceivedEvent`): from now on the recipe helpers
|
||||
/// (`native_recipes`, `native_recipes_of_type`) give the server's recipes
|
||||
/// of those types in place of the window's own, so rebuild what you made
|
||||
/// of them. It comes after a join, once the window has its player, and
|
||||
/// after every `/reload`, on a server whose mods asked for them
|
||||
/// (`native_datapack_sync`), single player included; with no types when
|
||||
/// the server stopped sending them and the window is back on its own
|
||||
/// recipes. A recipe the
|
||||
/// window's own recipe book doesn't know has `book_id` -1. Client-side.
|
||||
/// API @API:recipe_sync@.
|
||||
pub(super) fn native_recipes_received(&mut self, recipe_types: &[String]) {
|
||||
let _ = recipe_types;
|
||||
}
|
||||
/// The server's data tables are built (NeoForge's `TagsUpdatedEvent`
|
||||
/// with `SERVER_DATA_LOAD`): the recipes, tags, loot and advancements of
|
||||
/// the world's data packs, and the files mods added through
|
||||
@@ -1353,12 +1323,6 @@ impl Game<'_> {
|
||||
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
|
||||
let _ = (key, pressed);
|
||||
}
|
||||
/// A real window key event after handling, including repeats and consumed shortcuts.
|
||||
/// Receives the original physical/logical key, text, location, state and repeat flag.
|
||||
/// Modifier bits use the screen hooks' GLFW encoding. Not cancellable.
|
||||
pub(super) fn native_input_key(&mut self, event: &winit::event::KeyEvent, modifiers: u32) {
|
||||
let _ = (event, modifiers);
|
||||
}
|
||||
/// Raw mouse button input on any screen, before dispatch; cancel skips dispatch and Post.
|
||||
/// Button/modifiers use the same GLFW encoding as screen hooks. Synthetic clicks skip it.
|
||||
pub(super) fn native_mouse_button_pre(
|
||||
@@ -1378,20 +1342,6 @@ impl Game<'_> {
|
||||
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
|
||||
let _ = (steps, claimed);
|
||||
}
|
||||
/// Gameplay scrolling after whole steps accumulate, before hotbar/spectator handling.
|
||||
/// `scroll` is scaled x/y, `accumulated` whole x/y steps, `buttons` left/middle/right,
|
||||
/// and `cursor` the GUI position. Cancel skips gameplay scrolling; synthetic input skips it.
|
||||
pub(super) fn native_mouse_scrolling(
|
||||
&mut self,
|
||||
scroll: [f64; 2],
|
||||
accumulated: [i32; 2],
|
||||
buttons: [bool; 3],
|
||||
cursor: [f32; 2],
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
|
||||
}
|
||||
/// A command typed or clicked in chat, without its slash, before the
|
||||
/// server and Horizon's commands see it: set `ran` when the mod ran it.
|
||||
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
|
||||
@@ -1427,18 +1377,6 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (partial, camera, fog_type, rgb);
|
||||
}
|
||||
/// Final world fog, after the computed color hook, ordinary calculations and Horizon's fog
|
||||
/// option, before upload. Title panoramas skip it. Invalid color or distance values retain the
|
||||
/// original whole fog.
|
||||
pub(super) fn native_render_fog(
|
||||
&self,
|
||||
partial: f32,
|
||||
camera: [f64; 3],
|
||||
fog_type: horizon_client::fog::FogType,
|
||||
fog: &mut horizon_client::fog::FogData,
|
||||
) {
|
||||
let _ = (partial, camera, fog_type, fog);
|
||||
}
|
||||
/// The camera shake native mods ask for in this frame, `partial` ticks
|
||||
/// past the last tick (0 to 1): add your pitch, yaw and roll in degrees
|
||||
/// and your offset in blocks, along the camera's right, up and back, to
|
||||
@@ -1476,6 +1414,13 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (partial, mirrored, entity, distance);
|
||||
}
|
||||
/// The values native mods hand their shader packs in this frame,
|
||||
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
|
||||
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
|
||||
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
|
||||
/// one that pack is for. Asked once a frame while a pack is on; a value
|
||||
/// that isn't finite reads as 0 (`native_shader_params_now`). API
|
||||
/// @API:native_shader_params@.
|
||||
/// Change the colours of `biome` (`minecraft:plains`) on this client: the
|
||||
/// grass, foliage (leaves, vines), dry foliage (leaf litter) and water
|
||||
/// tints, 0xRRGGBB, as its biome file and the colormaps set them. Asked
|
||||
@@ -1487,36 +1432,6 @@ impl Game<'_> {
|
||||
pub(super) fn native_biome_colors(&self, biome: &str, colors: &mut BiomeColors) {
|
||||
let _ = (biome, colors);
|
||||
}
|
||||
/// Choose this client's music, once a client tick: `music` and `volume`
|
||||
/// hold what vanilla chose (`Minecraft.getSituationalMusic` and
|
||||
/// `getMusicVolume`: the biome's track, the creative or underwater one, a
|
||||
/// boss's, the menu's; None for silence). Set `music` to a track of your
|
||||
/// own, a sound event of your content mod's `sounds.json` such as
|
||||
/// `Music::new("mymod:music.spring", 1200, 6000, false)` (the delays in
|
||||
/// ticks between songs, and whether it cuts off a different song playing),
|
||||
/// None to keep vanilla's quiet, or change `volume` (0 to 1) to fade it. API
|
||||
/// @API:music@.
|
||||
pub(super) fn native_music(&self, music: &mut Option<Music>, volume: &mut f32) {
|
||||
let _ = (music, volume);
|
||||
}
|
||||
/// The tracks to play over (or instead of) the music on this client,
|
||||
/// asked once a client tick: push a `MusicLayer` (a sound event, looped,
|
||||
/// and its volume, 0 to 1) for each. A layer eases to a new volume over up
|
||||
/// to two seconds, and one left out fades out and stops; layers that
|
||||
/// first appear in the same tick start together, so list every stem of a
|
||||
/// piece from the start, the silent ones at volume 0, and raise them (a
|
||||
/// fuller arrangement as a village grows). Their sound events should be
|
||||
/// `stream: true` in `sounds.json`. API @API:music@.
|
||||
pub(super) fn native_music_layers(&self, layers: &mut Vec<MusicLayer>) {
|
||||
let _ = layers;
|
||||
}
|
||||
/// The values native mods hand their shader packs in this frame,
|
||||
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
|
||||
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
|
||||
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
|
||||
/// one that pack is for. Asked once a frame while a pack is on; a value
|
||||
/// that isn't finite reads as 0 (`native_shader_params_now`). API
|
||||
/// @API:native_shader_params@.
|
||||
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
|
||||
let _ = (partial, params);
|
||||
}
|
||||
@@ -1810,19 +1725,6 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (screen, button, modifiers, canceled);
|
||||
}
|
||||
/// Before a real held-button movement reaches screen drag handlers.
|
||||
/// `screen.mouse` is the current GUI position; `delta` is its GUI-space movement.
|
||||
/// Cancel keeps the new cursor position but skips all screen drag handlers.
|
||||
pub(super) fn native_screen_mouse_dragged_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
button: i32,
|
||||
delta: [f32; 2],
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, button, delta, modifiers, canceled);
|
||||
}
|
||||
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
|
||||
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
|
||||
/// `screen.mouse` where the pointer is (so there is no position of its
|
||||
@@ -1890,17 +1792,6 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (screen, key, repeat, modifiers, canceled);
|
||||
}
|
||||
/// Before an open screen handles a named key's release (same screen gate as key presses).
|
||||
/// Cancel bypasses screen handling; physical keys and mapped keyboard hold/toggle state release.
|
||||
pub(super) fn native_screen_key_released_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
key: &str,
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, key, modifiers, canceled);
|
||||
}
|
||||
/// A character was typed at the open screen
|
||||
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
|
||||
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
|
||||
@@ -1966,34 +1857,6 @@ impl Game<'_> {
|
||||
image.height as f32,
|
||||
]
|
||||
}
|
||||
pub(super) fn track_drag_button(&mut self, button: MouseButton, pressed: bool) {
|
||||
// Any release ends the drag, as vanilla's `MouseHandler.onButton` clears `activeButton`:
|
||||
// left down, right down, left up sends no drag while right is still held.
|
||||
self.drag_button = pressed.then_some(button);
|
||||
}
|
||||
pub(super) fn screen_mouse_dragged(&self, previous: (f32, f32)) -> bool {
|
||||
if self.pad_synthetic_input() || self.cursor == previous {
|
||||
return false;
|
||||
}
|
||||
let Some(button) = self
|
||||
.drag_button
|
||||
.filter(|b| self.physical_buttons.contains(b))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return false;
|
||||
};
|
||||
let mut canceled = false;
|
||||
self.native_screen_mouse_dragged_pre(
|
||||
&screen,
|
||||
super::glfw_button(button),
|
||||
[self.cursor.0 - previous.0, self.cursor.1 - previous.1],
|
||||
super::glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// A screen the native mods' input hooks are for is open: the window has a
|
||||
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
|
||||
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
|
||||
@@ -2041,22 +1904,6 @@ impl Game<'_> {
|
||||
);
|
||||
canceled
|
||||
}
|
||||
pub(super) fn screen_key_released(&self, key: KeyCode) -> bool {
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return false;
|
||||
};
|
||||
let mut canceled = false;
|
||||
self.native_screen_key_released_pre(
|
||||
&screen,
|
||||
name,
|
||||
super::glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// Text on its way to a text box after the open screen's character hooks
|
||||
/// (`native_screen_char_typed_pre` per printable character): the cancelled
|
||||
/// characters are gone and every other one stays, control characters
|
||||
@@ -2539,25 +2386,6 @@ impl Game<'_> {
|
||||
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
|
||||
let _ = (player, name);
|
||||
}
|
||||
/// The number of players with the mod container at `pos` of `dimension`
|
||||
/// open went from `previous` to `viewers` (vanilla's
|
||||
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
|
||||
/// `onOpen`, back to 0 is `onClose`, any change `openerCountChanged`, so a
|
||||
/// block can show itself open while `viewers` is not 0. Counted every
|
||||
/// tick from the players' open menus, so a player who leaves without
|
||||
/// closing (a lost connection, death, another dimension, out of reach, the
|
||||
/// block gone) is heard too (`recheckOpeners`). Once for each change, with
|
||||
/// no player's state swapped in. Server-side.
|
||||
/// @API:mod_container_viewers@.
|
||||
pub(super) fn native_mod_container_viewers(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
viewers: u32,
|
||||
previous: u32,
|
||||
) {
|
||||
let _ = (dimension, pos, viewers, previous);
|
||||
}
|
||||
/// `player` died: where `ServerPlayer.die` starts, with the death
|
||||
/// message's key when the game knows it (`death.attack.lava`; none for a
|
||||
/// message it builds from the combat). It only hears. Server-side, for
|
||||
@@ -3856,8 +3684,7 @@ impl Game<'_> {
|
||||
/// nobody uses and outside a world. These are the server's table on the
|
||||
/// server thread, a dedicated server or a LAN host; a client, the
|
||||
/// single-player window included, sees only the content mods' recipes, not
|
||||
/// the world's data packs', unless a mod had the server send it the type
|
||||
/// (`native_datapack_sync`). API 36.
|
||||
/// the world's data packs'. API 36.
|
||||
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
|
||||
if self.outside_world() {
|
||||
return &[];
|
||||
@@ -3916,10 +3743,9 @@ impl Game<'_> {
|
||||
self.player.is_none() && self.options.execution_role.is_client()
|
||||
}
|
||||
/// Every item and recipe the game knows, made once (the same for every
|
||||
/// call); none outside a world. They are the window's own (the content
|
||||
/// mods' and vanilla's), with the server's recipes of the types a mod had
|
||||
/// it send (`native_datapack_sync`) in their place; made again when those
|
||||
/// arrive (`native_recipes_received`).
|
||||
/// call); none outside a world. They are the window's own: the world's
|
||||
/// in single player, vanilla's on a server, which sends a client no
|
||||
/// others.
|
||||
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
|
||||
self.player.as_ref()?;
|
||||
self.made_recipes()
|
||||
@@ -5175,7 +5001,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();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Every item and recipe the game knows, made once for the mods that read
|
||||
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
|
||||
//! mods, docs/mods.md), and the recipe book's known displays; placing a
|
||||
//! recipe and creative gifts. The recipes are the window's own data — the
|
||||
//! content root's — with the server's recipes of the types its native mods
|
||||
//! had it send (`horizon:recipe_content`) in their place, while the known
|
||||
//! displays follow the server's book as the recipe book does.
|
||||
//! recipe and creative gifts. The recipes are the window's own data: the
|
||||
//! world's in single player, vanilla's on a server (a client is sent no
|
||||
//! others), while the known displays follow the server's book as the recipe
|
||||
//! book does.
|
||||
use super::{
|
||||
Game,
|
||||
native_hooks::{
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
},
|
||||
};
|
||||
use horizon_client::{
|
||||
crafting::{Ingredient, recipe_id},
|
||||
crafting::Ingredient,
|
||||
items::ItemStack,
|
||||
recipe_book::{Category, Display},
|
||||
stations::Smithing,
|
||||
@@ -22,22 +22,10 @@ use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// The tables, made once, and the recipe book's known displays with the
|
||||
/// book's version they are of.
|
||||
/// The most recipes a server's recipe content may carry.
|
||||
const MAX_SYNCED_RECIPES: usize = 1_000_000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RecipeTables {
|
||||
tables: Option<Arc<Recipes>>,
|
||||
known: (Option<u64>, Arc<[i32]>),
|
||||
/// The server's recipes of the types its native mods sent (`horizon:
|
||||
/// recipe_content`) over the window's own: what `Game::recipes` reads on
|
||||
/// a client once they came.
|
||||
synced: Option<horizon_client::crafting::Recipes>,
|
||||
/// The recipe content's parts so far, until its last.
|
||||
incoming: Vec<(String, serde_json::Value)>,
|
||||
/// The types that came before the player did: the mods hear of them
|
||||
/// once the helpers can answer (`recipes_received_now`).
|
||||
received: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Item id -> its index in [`Recipes::items`].
|
||||
@@ -199,10 +187,11 @@ impl Game<'_> {
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let cooking: HashMap<String, (u32, f32)> = self
|
||||
.res
|
||||
.recipes()
|
||||
.cooking
|
||||
.iter()
|
||||
.map(|c| (recipe_id(&c.id), (c.time, c.experience)))
|
||||
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
|
||||
.collect();
|
||||
for e in self.recipe_book_entries() {
|
||||
let furnace = |c: Category| match c.name().split('_').next() {
|
||||
@@ -254,7 +243,7 @@ impl Game<'_> {
|
||||
}
|
||||
for c in &self.recipes().cooking {
|
||||
if c.kind == "minecraft:campfire_cooking" {
|
||||
let mut r = recipe(recipe_id(&c.id), RecipeKind::Campfire);
|
||||
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
|
||||
r.group = c.group.clone();
|
||||
r.slots = vec![index.any(&c.ingredient)];
|
||||
r.results = index.of(&c.result).into_iter().collect();
|
||||
@@ -331,107 +320,3 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The server's recipes a client was sent, over its own; None on a
|
||||
/// server and until a server sends any.
|
||||
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
|
||||
self.recipe_tables.synced.as_ref()
|
||||
}
|
||||
/// A new play session (`Login`): what the last server sent is gone.
|
||||
pub(super) fn forget_synced_recipes(&mut self) {
|
||||
self.recipe_tables.synced = None;
|
||||
self.recipe_tables.incoming.clear();
|
||||
self.recipe_tables.received = None;
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
}
|
||||
/// `native_recipes_received` for the types that came, once the window
|
||||
/// has its player: the recipes of a join come before it, and the helpers
|
||||
/// answer nothing until then. Once a frame.
|
||||
pub(super) fn recipes_received_now(&mut self) {
|
||||
if self.player.is_some()
|
||||
&& let Some(types) = self.recipe_tables.received.take()
|
||||
{
|
||||
self.native_recipes_received(&types);
|
||||
}
|
||||
}
|
||||
/// `OnDatapackSyncEvent.sendRecipes`: the recipe types the native mods
|
||||
/// want their clients to have, from this load's tables, become what the
|
||||
/// server sends its Horizon clients at a join and after `/reload`. Called
|
||||
/// once a load's tables are built, before any player hears of them.
|
||||
pub(super) fn sync_recipe_content(&mut self) {
|
||||
use horizon_client::lan::recipes;
|
||||
let mut types = Vec::new();
|
||||
self.native_datapack_sync(&mut types);
|
||||
types.sort();
|
||||
types.dedup();
|
||||
let sources = if types.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
horizon_client::crafting::Recipes::sources(&self.options.resources, &self.pack_files())
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: the recipes for the clients were not read: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
recipes::set_content(recipes::encode_content(
|
||||
&types,
|
||||
&sources,
|
||||
recipes::sent_before(),
|
||||
));
|
||||
}
|
||||
/// A `horizon:recipe_content` part the server sent the window. On the
|
||||
/// last, the window's tables take the server's recipes of those types in
|
||||
/// place of its own, and the mods hear of it (`RecipesReceivedEvent`).
|
||||
pub(super) fn remote_recipe_content(&mut self, data: &[u8]) {
|
||||
let part: horizon_client::lan::recipes::ContentPart = match serde_json::from_slice(data) {
|
||||
Ok(part) => part,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: bad recipe content from the server: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.recipe_tables.incoming.extend(part.recipes);
|
||||
// The server is trusted to end its parts; one that never does is
|
||||
// dropped, not kept growing.
|
||||
if self.recipe_tables.incoming.len() > MAX_SYNCED_RECIPES {
|
||||
log::warn!("Horizon: the server sent more than {MAX_SYNCED_RECIPES} recipes; dropped");
|
||||
self.recipe_tables.incoming = Vec::new();
|
||||
return;
|
||||
}
|
||||
let Some(types) = part.last else {
|
||||
return;
|
||||
};
|
||||
let received = std::mem::take(&mut self.recipe_tables.incoming);
|
||||
// No type any more: back on the window's own recipes.
|
||||
if types.is_empty() {
|
||||
if self.recipe_tables.synced.take().is_some() {
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
self.recipe_tables.received = Some(types);
|
||||
self.recipes_received_now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let root = &self.options.resources;
|
||||
let overlay = horizon_world::loot::PackOverlay::NONE;
|
||||
let built = horizon_client::crafting::Recipes::sources(root, &overlay).and_then(|own| {
|
||||
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
|
||||
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
|
||||
all.extend(received);
|
||||
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
|
||||
});
|
||||
match built {
|
||||
Ok(recipes) => {
|
||||
self.recipe_tables.synced = Some(recipes);
|
||||
// What was made of the window's own recipes is made again.
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
self.recipe_tables.received = Some(types);
|
||||
self.recipes_received_now();
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: the server's recipes were not taken: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,8 +628,7 @@ fn add_to(
|
||||
/// screen as any change to their inventory does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack: its item, count, and whether it is plain. On a
|
||||
/// client, the player's own.
|
||||
/// The main hand's stack: its item, count, and whether it is plain.
|
||||
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
|
||||
let s = self.inventory.get(self.selected)?.as_ref()?;
|
||||
Some((s.item.to_string(), s.count, plain(s)))
|
||||
|
||||
@@ -148,23 +148,6 @@ impl Game<'_> {
|
||||
self.ui.vanilla_root.clone()
|
||||
}
|
||||
}
|
||||
/// Whether all active packs preserve vanilla's block models. The server
|
||||
/// overlay sits above the local selection and must be included too.
|
||||
pub(super) fn model_packs_are_vanilla(&self) -> bool {
|
||||
self.settings.horizon.vanilla_models_with_server_packs(
|
||||
self.settings.resource_packs.iter().map(String::as_str),
|
||||
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
|
||||
)
|
||||
}
|
||||
/// The leaf mode that is safe for the current local and server packs.
|
||||
pub(super) fn effective_leaf_culling(&self) -> horizon_client::graphics::LeafCullingMode {
|
||||
self.settings
|
||||
.horizon
|
||||
.leaf_culling_effective_with_server_packs(
|
||||
self.settings.resource_packs.iter().map(String::as_str),
|
||||
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
|
||||
)
|
||||
}
|
||||
/// The packs the pack screen offers and the current selection
|
||||
/// (`PackSelectionScreen` makes its `PackSelectionModel` from them).
|
||||
pub(super) fn refresh_pack_lists(&mut self) {
|
||||
@@ -372,25 +355,11 @@ impl Game<'_> {
|
||||
.cem
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
|
||||
// The random entity textures the new atlas packs: every mob chooses again.
|
||||
*self
|
||||
.res
|
||||
.mob_data()
|
||||
.random_textures
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
std::mem::take(&mut self.gui.atlas.random_textures);
|
||||
for m in &mut self.mobs {
|
||||
m.random_textures.forget_picks();
|
||||
}
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(&root);
|
||||
self.clear_lod();
|
||||
// A pack that may replace leaf models falls back to `Off` before the
|
||||
// reload marks every section dirty.
|
||||
self.streaming.leaf_culling = self.effective_leaf_culling();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
// New GPU textures: a renderer built from the reloaded resources; the
|
||||
|
||||
@@ -1416,7 +1416,6 @@ mod preview_tests {
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ use horizon_client::{
|
||||
mobs::{
|
||||
Mob, Species, Who,
|
||||
finalize::{
|
||||
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
|
||||
set_slot_enchantments, slot_enchantments, stored_drop_chance,
|
||||
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
|
||||
stored_drop_chance,
|
||||
},
|
||||
piglin::{ADMIRE, ATE_RECENTLY},
|
||||
},
|
||||
@@ -27,9 +27,8 @@ impl Game<'_> {
|
||||
self.item_in_tag("minecraft:piglin_loved", item)
|
||||
}
|
||||
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
|
||||
fn piglin_wanted(&self, m: &Mob, item: &str) -> bool {
|
||||
self.piglin_loved(item)
|
||||
|| !m.is_baby() && self.item_in_tag("minecraft:piglin_preferred_weapons", item)
|
||||
fn piglin_wanted(&self, item: &str) -> bool {
|
||||
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
|
||||
}
|
||||
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
|
||||
/// to its slot, anything else to the main hand.
|
||||
@@ -51,7 +50,7 @@ impl Game<'_> {
|
||||
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
|
||||
return true;
|
||||
};
|
||||
self.piglin_wanted(m, item) && !self.piglin_wanted(m, ¤t)
|
||||
self.piglin_wanted(item) && !self.piglin_wanted(¤t)
|
||||
}
|
||||
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
|
||||
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
|
||||
@@ -63,13 +62,11 @@ impl Game<'_> {
|
||||
fn piglin_max_stack(&self, item: &str) -> u32 {
|
||||
self.items().get(item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
/// `PiglinAi.wantsToPickup`: babies ignore their own item tag, but can
|
||||
/// pick up currency and other loved items just like adults.
|
||||
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
|
||||
/// the baby rule, which babies skip entirely here.
|
||||
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
|
||||
let item = &*stack.item;
|
||||
if m.is_baby() && self.item_in_tag("minecraft:ignored_by_piglin_babies", item)
|
||||
|| self.item_in_tag("minecraft:piglin_repellents", item)
|
||||
{
|
||||
if self.item_in_tag("minecraft:piglin_repellents", item) {
|
||||
return false;
|
||||
}
|
||||
let holds_loved = m
|
||||
@@ -194,7 +191,8 @@ impl Game<'_> {
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
|
||||
if m.species() != Species::Piglin || !m.alive() || m.is_baby() || m.extra.using_item > 0
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let pos = m.position;
|
||||
@@ -304,8 +302,8 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
|
||||
/// roll of the bartering table for adults. Babies equip the admired item,
|
||||
/// or hold it in the main hand after stashing or throwing the old item.
|
||||
/// roll of the bartering table, thrown toward the player; any other loved
|
||||
/// item it picked up is kept (worn if it can be, else stored).
|
||||
pub(super) fn finish_admiring(&mut self, mob: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
@@ -313,40 +311,22 @@ impl Game<'_> {
|
||||
let Some(item) = self.mobs[i].extra.off_hand.take() else {
|
||||
return;
|
||||
};
|
||||
if self.mobs[i].is_baby() {
|
||||
let stack = ItemStack::new(&item, 1);
|
||||
if !self.piglin_equip(i, &stack) {
|
||||
if let Some(old) = item_stack(&self.mobs[i], 0) {
|
||||
if self.piglin_loved(&old.item) {
|
||||
self.piglin_stash(i, old);
|
||||
} else {
|
||||
self.piglin_throw_items(i, vec![old]);
|
||||
}
|
||||
}
|
||||
let m = &mut self.mobs[i];
|
||||
set_item_stack(m, 0, stack);
|
||||
set_drop_chance(m, 0, 2.0);
|
||||
m.persistent = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*item != "minecraft:gold_ingot" {
|
||||
self.piglin_keep(i, ItemStack::new(&item, 1));
|
||||
return;
|
||||
}
|
||||
let items = world_items!(self).loot_table(
|
||||
"minecraft:gameplay/piglin_bartering",
|
||||
false,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.piglin_throw_items(i, items);
|
||||
}
|
||||
/// `PiglinAi.throwItems`: barters and a baby's unwanted main-hand item
|
||||
/// use the same player-directed throw.
|
||||
fn piglin_throw_items(&mut self, i: usize, items: Vec<ItemStack>) {
|
||||
let m = &self.mobs[i];
|
||||
let from = [m.position.x, m.position.y + 1.3, m.position.z];
|
||||
let items = if !m.is_baby() {
|
||||
world_items!(self).loot_table(
|
||||
"minecraft:gameplay/piglin_bartering",
|
||||
false,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
)
|
||||
} else {
|
||||
vec![ItemStack::new(&item, 1)]
|
||||
};
|
||||
// BehaviorUtils.throwItem toward the nearest visible player
|
||||
// (NEAREST_VISIBLE_PLAYER: within 16, alive, no spectators; the
|
||||
// guests count as much as the host), else nearby.
|
||||
|
||||
@@ -507,12 +507,6 @@ impl Game<'_> {
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
// Entity.move updates an authoritative living entity's ground flag
|
||||
// even for a horizontal piston push. Keeping the old support flag
|
||||
// would run Frost Walker while the stand is moving over open water.
|
||||
if let Target::ArmorStand(i) = target {
|
||||
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
|
||||
{
|
||||
|
||||
@@ -1140,15 +1140,6 @@ impl Game<'_> {
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, mob, block, age, owner) in landed {
|
||||
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
|
||||
// is heard at its centre, an entity at the hit.
|
||||
let heard = if mob.is_some() {
|
||||
[at.x, at.y, at.z]
|
||||
} else {
|
||||
[at.x.floor() + 0.5, at.y.floor() + 0.5, at.z.floor() + 0.5]
|
||||
};
|
||||
let thrower = owner.is_some().then_some(Who::Player);
|
||||
self.projectile_event("minecraft:projectile_land", thrower, heard);
|
||||
self.splash(&stack, at, mob, block, age, owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
//! The window's side of the RAM limit (`horizon_client::ram_limit`): a look at the memory once a
|
||||
//! second, the render distance and far terrain in use, and `/ramlimit`'s answer.
|
||||
use super::Game;
|
||||
use horizon_client::ram_limit::{self, Step};
|
||||
use horizon_client::text::Text;
|
||||
use std::time::Instant;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The render distance in use: the player's, unless memory holds it lower.
|
||||
pub(super) fn render_distance_in_use(&self) -> i32 {
|
||||
self.ram_limit.distance(self.settings.render_distance)
|
||||
}
|
||||
|
||||
/// Once a second, on the window's game while a world is open.
|
||||
pub(super) fn tick_ram_limit(&mut self) {
|
||||
if !self.ticks.is_multiple_of(20) {
|
||||
return;
|
||||
}
|
||||
let chosen = self.settings.render_distance;
|
||||
let Some(limit) = ram_limit::limit() else {
|
||||
if self.ram_limit.reset() {
|
||||
self.apply_render_distance_in_use();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let has_far = self.settings.horizon.lod_distance > 0;
|
||||
let pressure = ram_limit::pressure(limit, self.fps);
|
||||
let step = self
|
||||
.ram_limit
|
||||
.step(Instant::now(), pressure, chosen, has_far);
|
||||
if !self.ram_limit.at_lowest(chosen, has_far) {
|
||||
ram_limit::can_shrink();
|
||||
}
|
||||
let Some(step) = step else {
|
||||
return;
|
||||
};
|
||||
self.apply_render_distance_in_use();
|
||||
let said = match step {
|
||||
Step::FarTerrainOff => "Far terrain is off to stay under the RAM limit.".to_owned(),
|
||||
Step::FarTerrainBack => "Far terrain is back on.".to_owned(),
|
||||
Step::Lowered(d) => {
|
||||
format!("Render distance lowered to {d} to stay under the RAM limit.")
|
||||
}
|
||||
Step::Raised(d) if d >= chosen => format!("Render distance is back to {d}."),
|
||||
// Going back up step by step needs no line for each step.
|
||||
Step::Raised(_) => return,
|
||||
};
|
||||
log::info!("Horizon: {said} {pressure:?}");
|
||||
self.system_message(Text::literal(said.as_str()));
|
||||
}
|
||||
|
||||
/// The streaming radius follows the distance in use, within what the server sends; the server
|
||||
/// hears of it with the next `ClientInformation` or `set_distances`.
|
||||
fn apply_render_distance_in_use(&mut self) {
|
||||
let distance = self.render_distance_in_use();
|
||||
self.streaming.radius = match &self.remote {
|
||||
Some(r) if r.logged_in => distance.min(r.view_distance + 1).max(2),
|
||||
_ => distance,
|
||||
};
|
||||
}
|
||||
|
||||
/// `/ramlimit`, answered here on any server. False: another command.
|
||||
pub(super) fn ram_limit_command(&mut self, command: &str) -> bool {
|
||||
let Some(answer) = ram_limit::run(command) else {
|
||||
return false;
|
||||
};
|
||||
self.system_message(Text::literal(answer.as_str()));
|
||||
let (in_use, chosen) = (self.render_distance_in_use(), self.settings.render_distance);
|
||||
if in_use < chosen {
|
||||
let line = format!("Render distance is {in_use} for now; yours is {chosen}.");
|
||||
self.system_message(Text::literal(line.as_str()));
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user