Merge main into #10383: keep the craft-menu and map-file ops beside map_carried and treasure map copies

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Horizon agentandClaude Opus 5.5 committed 2026-11-07 11:08:30 +01:00
commit db673e9b76
682 files changed
+39102 -3390

No files matched your search

+6 -6
View File
@@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `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`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings), modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -92,8 +92,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 -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 test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 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
@@ -105,8 +105,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
- A harness = `tools/test_<name>_parity.py` + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (mostly horizon-world and horizon-client),
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.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
@@ -176,7 +176,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
- `struct Game` (game.rs) holds everything. Its methods are spread over many 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 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*`;
+1 -1
View File
@@ -33,7 +33,7 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
and compares their outputs. There are about 290 of them.
and compares their outputs.
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
Generated
+113 -95
View File
@@ -131,9 +131,9 @@ dependencies = [
[[package]]
name = "arboard"
version = "3.6.1"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"log",
@@ -142,7 +142,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -153,6 +153,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -485,6 +491,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
@@ -540,6 +547,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.8"
@@ -946,6 +959,18 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1137,6 +1162,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.2"
@@ -1266,6 +1301,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1605,7 +1649,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2 0.5.10",
"socket2",
"spake2",
]
@@ -1662,11 +1706,15 @@ dependencies = [
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"gif",
"mysql",
"quinn",
"redis",
"rusqlite",
"rusty-opus",
"snap",
"tungstenite",
"yaml-rust2",
]
[[package]]
@@ -2004,12 +2052,12 @@ dependencies = [
[[package]]
name = "if-addrs"
version = "0.13.4"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2079,7 +2127,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2 0.6.5",
"socket2",
"widestring",
"windows-registry",
"windows-result 0.4.1",
@@ -2271,6 +2319,17 @@ dependencies = [
"redox_syscall 0.9.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
@@ -2441,7 +2500,7 @@ dependencies = [
"percent-encoding",
"rustls",
"rustls-pemfile",
"socket2 0.6.5",
"socket2",
"twox-hash",
"url",
"webpki",
@@ -2459,6 +2518,7 @@ dependencies = [
"btoi",
"byteorder",
"bytes",
"chrono",
"crc32fast",
"flate2",
"getrandom 0.3.4",
@@ -3376,7 +3436,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2 0.6.5",
"socket2",
"thiserror 2.0.21",
"tokio",
"tracing",
@@ -3414,7 +3474,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.5",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
@@ -3532,7 +3592,7 @@ dependencies = [
"itoa",
"percent-encoding",
"ryu",
"socket2 0.6.5",
"socket2",
"url",
"xxhash-rust",
]
@@ -3613,6 +3673,20 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3965,14 +4039,10 @@ dependencies = [
]
[[package]]
name = "socket2"
version = "0.5.10"
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
@@ -4294,7 +4364,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.5",
"socket2",
"windows-sys 0.61.2",
]
@@ -4506,6 +4576,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
@@ -4769,6 +4845,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -5145,15 +5227,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5187,30 +5260,13 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
@@ -5232,12 +5288,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5250,12 +5300,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5268,24 +5312,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5298,12 +5330,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5316,12 +5342,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5334,12 +5354,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5352,12 +5366,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
@@ -5565,6 +5573,16 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"hashlink",
]
[[package]]
name = "yasna"
version = "0.6.0"
+1 -1
View File
@@ -47,7 +47,7 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.85"
rust-version = "1.95"
# Ports mirror vanilla's Java signatures, index loops and nested generic types
# one to one so they can be checked against the decompiled sources.
-10
View File
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
</details>
#### Ely.by and other sign-in providers
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
<details><summary>Technical</summary>
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
#### Join servers on older versions
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
+14
View File
@@ -1,5 +1,15 @@
# Third-party notices
## Catnip / Ponder
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
development-only oracle calls the unchanged helper from the player's local artifact. No
original classes, game shaders, textures or prepared resources are distributed here.
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
@@ -212,3 +222,7 @@ 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.
```
## Just Enough Items
The Recipes settings icon is from [Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems) by mezz and contributors. It is distributed under the MIT license in `crates/horizon-client-render/src/jei_recipes/LICENSE.txt`.
+1 -1
View File
@@ -3,7 +3,7 @@ name = "cargo-horizon-mod"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.86"
rust-version.workspace = true
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
rust-version.workspace = true
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
@@ -34,7 +34,10 @@ pub fn element(v: &Element) -> Value {
.collect();
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
}
#[allow(dead_code)] // Shared by fixtures which normalize different model stages.
#[allow(
dead_code,
reason = "shared by fixtures which normalize different model stages"
)]
pub fn model(v: &CuboidModel) -> Value {
let textures: BTreeMap<_, _> = v
.textures
+9 -9
View File
@@ -179,19 +179,19 @@ impl Condition {
let fields = object(raw)?;
if fields.len() == 1 {
for name in ["OR", "AND"] {
if let Some(raw) = fields.get(name) {
if let Ok(values) = array(raw).and_then(|values| {
if let Some(raw) = fields.get(name)
&& let Ok(values) = array(raw).and_then(|values| {
values
.into_iter()
.map(Self::decode)
.collect::<Result<Vec<_>>>()
}) {
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
})
{
return Ok(if name == "OR" {
Self::Or(values)
} else {
Self::And(values)
});
}
}
}
+1
View File
@@ -2,6 +2,7 @@
name = "horizon-bench"
version = "0.1.0"
edition = "2021"
rust-version.workspace = true
license = "MIT"
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
+142 -18
View File
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
other swing, landing it while falling: a critical hit, then a sweep from the
ground (give them swords); for PvP benches (crit and sweep particles)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
@@ -165,6 +168,8 @@ struct Options {
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
crits: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
o.no_swing = true;
continue;
}
if arg == "--crits" {
o.crits = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
const SWORD_COOLDOWN_TICKS: u32 = 13;
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
const JUMP_POWER: f64 = 0.42;
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
#[derive(Default)]
struct Crits {
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
jump: Option<f64>,
ground: f64,
/// The next swing is the jumped one.
crit_next: bool,
}
impl Crits {
fn airborne(&self) -> bool {
self.jump.is_some()
}
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
fn tick(
&mut self,
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since: &mut u32,
) -> Option<i32> {
if let Some(v) = self.jump {
pos[1] += v;
if pos[1] <= self.ground {
pos[1] = self.ground;
self.jump = None;
} else {
self.jump = Some((v - 0.08) * 0.98);
}
} else {
self.ground = pos[1];
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
self.jump = Some(JUMP_POWER);
}
}
let falling = self.jump.is_some_and(|v| v < 0.0);
if self.crit_next && !falling {
*since = since.saturating_add(1);
fight_step(pos, target.1);
return None;
}
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
if hit.is_some() {
self.crit_next = !self.crit_next;
}
hit
}
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
fn fight_tick(
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since_attack: &mut u32,
cooldown: u32,
) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
fight_step(pos, at);
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
@@ -616,7 +694,7 @@ fn costly(
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
if i.is_multiple_of(2) {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
@@ -645,7 +723,7 @@ fn costly(
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
let text = if i.is_multiple_of(2) {
"/"
} else {
LONG_COMMAND.as_str()
@@ -1124,6 +1202,7 @@ fn run_bot(
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let mut crits = Crits::default();
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -1181,11 +1260,16 @@ fn run_bot(
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
let hit = if o.crits {
crits.tick(&mut pos, near, &mut since_attack)
} else {
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
};
if let Some(victim) = hit {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
) && send(play::Serverbound::Punch(Empty).encode());
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
@@ -1300,9 +1384,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
// Fight bots stand on the ground; the others fly.
// Fight bots stand on the ground (but in a --crits jump); the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|| o.ground,
),
})
.encode(),
@@ -1470,10 +1555,10 @@ fn main() {
});
if o.report.as_deref() == Some("-") {
println!("{summary:#}");
} else if let Some(path) = &o.report {
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
eprintln!("horizon-bots: {path}: {e}");
}
} else if let Some(path) = &o.report
&& let Err(e) = std::fs::write(path, format!("{summary:#}\n"))
{
eprintln!("horizon-bots: {path}: {e}");
}
let errors: Vec<String> = bots
.iter()
@@ -1600,7 +1685,9 @@ mod tests {
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.filter_map(|tick| {
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
})
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
@@ -1617,7 +1704,7 @@ mod tests {
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
first = Some(tick);
break;
}
@@ -1627,6 +1714,43 @@ mod tests {
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
let mut last_y = pos[1];
let mut hits = Vec::new();
for tick in 0..120u32 {
let hit = crits.tick(&mut pos, target, &mut since);
if hit.is_some() {
hits.push((tick, crits_state(pos[1], last_y)));
}
last_y = pos[1];
}
assert!(hits.len() >= 6, "{hits:?}");
for (i, w) in hits.windows(2).enumerate() {
assert!(
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
"full strength each time: {hits:?}"
);
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
}
// A crit lands in the air, on the way down; a sweep from the ground.
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
assert!(pos[1] >= 64.0);
}
fn crits_state(y: f64, before: f64) -> &'static str {
if y == 64.0 {
"ground"
} else if y < before {
"falling"
} else {
"rising"
}
}
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-chat-filter"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.88"
rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
+38 -31
View File
@@ -393,38 +393,17 @@ impl FullscreenState {
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
if wanted && window.is_maximized() {
window.set_maximized(false);
window.set_fullscreen(Some(fullscreen_request(
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => enter_fullscreen(
window,
exclusive,
preferred.as_ref(),
)));
} else {
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None if reapply || mode_change => {
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
)));
}
None => {}
}
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None if reapply || mode_change => enter_fullscreen(
window,
fullscreen_request(window, exclusive, preferred.as_ref()),
),
None => {}
}
}
@@ -602,6 +581,34 @@ fn monitor_current_mode(
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
/// state first: winit keeps a maximized window's client area inside the work area, so a
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
#[cfg(target_os = "windows")]
if window.is_maximized() {
window.set_maximized(false);
}
window.set_fullscreen(Some(fullscreen));
}
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
winit::window::Fullscreen::Borderless(
window
.current_monitor()
.or_else(|| window.primary_monitor()),
)
}
/// A window created fullscreen that the system also marked maximized enters it again
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
enter_fullscreen(window, borderless_fullscreen(window));
}
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
+1 -1
View File
@@ -6,7 +6,7 @@ autotests = false
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "Reusable client systems, control tool and parity probes"
[lib]
+1 -1
View File
@@ -3,7 +3,7 @@ name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
@@ -66,6 +66,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -224,6 +224,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.translucent_batching,
false,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -126,6 +126,8 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
false,
))?;
renderer.resize(320, 180);
assert!(
+76 -12
View File
@@ -261,6 +261,40 @@ pub struct Audio {
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
/// The look at the output devices running on its own thread, until a tick
/// takes its answer: listing them can take 100 ms (#11490).
#[cfg(feature = "audio")]
device_probe: Option<PendingProbe>,
}
/// What one look at the output devices found: the system default, the
/// fingerprint, and the device list when those say it may have changed.
#[cfg(feature = "audio")]
struct DeviceProbe {
default: Option<String>,
cards: Option<String>,
devices: Option<Vec<String>>,
}
#[cfg(feature = "audio")]
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
/// Looks at the output devices as `should_change_device` did on the render
/// thread: the device list only when the default or the fingerprint moved.
#[cfg(feature = "audio")]
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them; on Windows
// every endpoint, with no fingerprint to skip it); only the fingerprint's
// devices can change the list, and it is cheap.
let devices = (default != default_seen
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
.then(output_devices);
DeviceProbe {
default,
cards,
devices,
}
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
use symphonia::core::{
@@ -1051,6 +1085,8 @@ impl Audio {
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
#[cfg(not(feature = "audio"))]
@@ -1211,6 +1247,8 @@ impl Audio {
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
#[cfg(feature = "audio")]
device_probe: None,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -1232,7 +1270,8 @@ impl Audio {
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// at once a second.
/// at once a second, on a thread of their own: a later tick reads what it
/// found.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
@@ -1246,29 +1285,54 @@ impl Audio {
};
if lost {
log::info!("Horizon: audio device was lost");
self.device_probe = None;
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
if let Some(pending) = &self.device_probe {
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
return false;
};
self.device_probe = None;
return self.devices_changed(probe, current);
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
let pending = PendingProbe::default();
let out = Arc::clone(&pending);
let seen = (self.default_seen.clone(), self.cards_seen.clone());
let started = std::thread::Builder::new()
.name("audio-devices".into())
.spawn(move || {
let probe = probe_devices(seen.0, seen.1);
if let Ok(mut found) = out.lock() {
*found = Some(probe);
}
});
match started {
Ok(_) => self.device_probe = Some(pending),
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
}
false
}
/// The rest of `shouldChangeDevice` with what a look at the devices found,
/// `current` being the device playing now.
#[cfg(feature = "audio")]
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
let DeviceProbe {
default,
cards,
devices,
} = probe;
let Some(devices) = devices else {
return false;
};
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
if default == self.default_seen && devices == self.devices_seen {
@@ -538,6 +538,11 @@ impl BlockEntities {
});
Arc::clone(&self.tickers)
}
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
/// the last [`Self::tickers`]: what a diagnostics mod counts.
pub fn ticking_len(&self) -> usize {
self.live.len()
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
+7 -7
View File
@@ -114,12 +114,8 @@ mod tests {
ambient_occlusion: true,
particle: None,
cube: None,
faces: vec![BakedFace {
layer: 0,
opaque: true,
translucent: false,
fusion: None,
quad: ModelQuad {
faces: vec![BakedFace::new(
ModelQuad {
positions: [
[0.0, 0.5, 0.0],
[0.0, 0.5, 1.0],
@@ -142,7 +138,11 @@ mod tests {
render_type: None,
shade: true,
},
}],
0,
true,
false,
None,
)],
}
}
#[test]
+54 -4
View File
@@ -1193,6 +1193,10 @@ impl SuggestLayout {
/// the suggestion list, Tab completion, history).
pub struct ChatScreen {
pub input: TextField,
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
/// has not been edited since. A draft draws grey italic, and the first
/// Backspace clears the whole box instead of one character.
is_draft: bool,
width: i32,
height: i32,
history_buffer: String,
@@ -1300,11 +1304,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
pub fn new(
m: &Measure,
c: &Commands,
initial: &str,
is_draft: bool,
history_len: usize,
width: i32,
height: i32,
@@ -1317,6 +1323,7 @@ impl ChatScreen {
input.set_value(m, initial);
let mut s = Self {
input,
is_draft,
width,
height,
history_buffer: String::new(),
@@ -1353,6 +1360,7 @@ impl ChatScreen {
input.set_value(m, command);
let mut s = Self {
input,
is_draft: false,
width,
height,
history_buffer: String::new(),
@@ -1382,7 +1390,8 @@ impl ChatScreen {
self.input.set_value(m, command);
self.edited(m, c);
}
/// `ChatScreen.resize`: a new screen with the current text.
/// `ChatScreen.resize`: a new screen with the current text, still a draft
/// while it has not been edited (`ChatScreen.resize` keeps the field).
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
@@ -1390,7 +1399,16 @@ impl ChatScreen {
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
Self::new(
m,
c,
&value,
self.is_draft,
self.history_pos,
width,
height,
abilities,
)
} else {
Self::command_block(m, c, &value, width, height)
};
@@ -1402,6 +1420,7 @@ impl ChatScreen {
fn edited(&mut self, m: &Measure, c: &Commands) {
self.allow_suggestions = true;
self.update_command_info(m, c);
self.is_draft = false;
}
/// Ctrl+A/C/V/X in the input box.
pub fn shortcut(
@@ -1415,6 +1434,24 @@ impl ChatScreen {
self.edited(m, c);
}
}
/// Edit the existing input and refresh its command responder when it changes.
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
let before = (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
);
f(&mut self.input);
if before
!= (
self.input.value.clone(),
self.input.cursor,
self.input.highlight,
)
{
self.edited(m, c);
}
}
/// Typed text (`charTyped` per character).
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
for ch in text.chars() {
@@ -1729,6 +1766,14 @@ impl ChatScreen {
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
// ChatScreen.keyPressed: while the box holds an unedited draft, the
// first Backspace clears all of it rather than one character.
if self.is_draft && key == K::Backspace {
self.input.set_value(m, "");
self.is_draft = false;
self.edited(m, c);
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
@@ -1862,8 +1907,13 @@ impl ChatScreen {
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input.
/// `CommandSuggestions.formatText`: argument colours over the input. The
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
/// and wins while the draft is unedited: the whole text is grey italic.
fn format(&self, value: &str, offset: usize) -> Text {
if self.is_draft {
return Text::literal(value).with_color(text::GRAY).italic();
}
let gray = Style::color(text::GRAY);
let Some(parse) = &self.parse else {
return Text::literal(value);
+104 -7
View File
@@ -201,7 +201,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -242,6 +242,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
&m,
&commands,
"unfinished draft",
false,
history.len(),
640,
360,
@@ -263,6 +264,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
}
}
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
/// and the first Backspace clears the whole draft instead of one character.
/// Editing the draft (`onEdited`) ends both.
#[test]
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
use winit::keyboard::KeyCode::Backspace;
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
// The input's own text as the game draws it: x 4, y height - 12.
let input_text = |screen: &mut ChatScreen| -> Text {
screen
.draw(&m, (0, 0), 0, i32::MIN)
.into_iter()
.find_map(|draw| match draw {
Draw::Text {
text, x: 4, y: 348, ..
} => Some(text),
_ => None,
})
.expect("the input draws its text")
};
let draft_style = |t: &Text| {
t.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
};
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(screen.value(), "half-written");
assert!(
draft_style(&input_text(&mut screen)),
"the restored draft draws grey italic"
);
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
// eats one character again.
screen.type_text(&m, &c, "!");
assert_eq!(screen.value(), "half-written!");
let typed = input_text(&mut screen);
assert!(
!typed.spans.iter().any(|s| s.style.is_italic()),
"an edited draft is not grey italic"
);
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-written");
// A draft left alone clears whole on its first Backspace.
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
// The flag is gone with it: typing is ordinary text, and Backspace eats
// one character.
screen.type_text(&m, &c, "hi");
assert_eq!(screen.value(), "hi");
let typed = input_text(&mut screen);
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "h");
// A box opened on ordinary text deletes one character at a time.
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
assert!(!draft_style(&input_text(&mut screen)));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-writte");
// A command draft is grey italic as a whole: `formatChat` is registered
// before `CommandSuggestions.formatText`, so it wins over the argument
// colours.
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
assert!(draft_style(&input_text(&mut screen)));
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
@@ -283,7 +371,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -333,7 +421,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
@@ -381,7 +469,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -403,7 +491,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -473,7 +561,7 @@ fn the_chat_option_decides_what_may_be_sent() {
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
@@ -499,7 +587,16 @@ fn restricted_input_says_why_it_will_not_send() {
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
let mut screen = ChatScreen::new(
&m,
&c,
"",
false,
0,
640,
360,
ChatAbilities::new(visibility),
);
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
@@ -11,7 +11,7 @@ use horizon_world::{
use std::{
collections::HashMap,
sync::{
Arc,
Arc, Mutex, OnceLock, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
@@ -108,6 +108,9 @@ pub enum BlockEntity {
pub struct ModBlockEntity {
fields: ModBlockEntityFields,
revision: ModRevision,
// A client update can consume transient effects without changing saved
// fields. Most entities never use this; do not allocate their queue.
client_update_fields: OnceLock<Mutex<Compound>>,
}
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
@@ -138,11 +141,55 @@ impl ModBlockEntity {
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
changes: 0,
},
client_update_fields: OnceLock::new(),
}
}
pub fn revision(&self) -> ModRevision {
self.revision
}
/// Replace the owner's pending, one-shot client update fields. These
/// fields are not saved, cloned, compared or retained for drawing, and
/// changing them does not change this entity's revision. The owner must
/// explicitly take and merge them into its update tag. Calling this does
/// not schedule a block update.
///
/// An empty batch clears a pending batch without allocating an idle queue.
pub fn set_client_update_fields(&mut self, fields: Compound) {
if fields.is_empty() {
self.clear_client_update_fields();
return;
}
*self
.client_update_fields
.get_or_init(|| Mutex::new(Compound::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner) = fields;
}
/// Discard the owner's pending client update fields, without changing
/// saved data, revision or a queue that has never been used.
pub fn clear_client_update_fields(&mut self) {
if let Some(fields) = self.client_update_fields.get() {
fields
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
}
}
/// Atomically take the owner's pending fields at update-tag construction.
/// A loaded chunk and a block change use this same boundary. Construct a
/// tag once, then send that tag to every recipient; another construction
/// sees an empty batch. A save or a clone does not consume this batch.
///
/// This is safe for an update-tag hook receiving only a shared reference,
/// including a saved chunk encoded by a worker thread. If a previous lock
/// holder panicked, preserve its pending data instead of dropping it.
pub fn take_client_update_fields(&self) -> Compound {
self.client_update_fields
.get()
.map_or_else(Compound::new, |fields| {
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
})
}
/// A client's copy loads the tag the server sent (`loadWithComponents`
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
/// replace its own of the same names. One it already has as sent is
@@ -706,6 +753,85 @@ pub struct CommandBlock {
mod tests {
use super::*;
#[test]
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
id: "test:machine".into(),
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
..Default::default()
});
let revision = machine.revision();
let saved = machine.fields.clone();
let debug = format!("{machine:?}");
machine.clear_client_update_fields();
machine.set_client_update_fields(Compound::new());
assert!(machine.take_client_update_fields().is_empty());
assert!(machine.client_update_fields.get().is_none());
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
let copy = machine.clone();
assert_eq!(machine, copy, "transient fields are not retained contents");
assert_ne!(machine.revision(), copy.revision());
assert!(copy.client_update_fields.get().is_none());
assert!(copy.take_client_update_fields().is_empty());
assert_eq!(machine.take_client_update_fields(), expected);
machine.set_client_update_fields(expected);
machine.clear_client_update_fields();
assert!(machine.take_client_update_fields().is_empty());
assert_eq!(machine.fields, saved);
assert_eq!(machine.revision(), revision);
assert_eq!(format!("{machine:?}"), debug);
}
#[test]
fn concurrent_client_update_constructions_consume_one_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
let received = std::thread::scope(|scope| {
let handles = (0..16)
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
.collect::<Vec<_>>();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect::<Vec<_>>()
});
let nonempty = received
.into_iter()
.filter(|c| !c.is_empty())
.collect::<Vec<_>>();
assert_eq!(nonempty, [expected]);
}
#[test]
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
let mut machine = ModBlockEntity::default();
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
machine.set_client_update_fields(expected.clone());
std::thread::scope(|scope| {
assert!(
scope
.spawn(|| {
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
panic!("interrupt before consuming the queued batch");
})
.join()
.is_err()
);
});
assert_eq!(machine.take_client_update_fields(), expected);
assert!(machine.take_client_update_fields().is_empty());
machine.set_client_update_fields(expected.clone());
assert_eq!(machine.take_client_update_fields(), expected);
}
#[test]
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
+14 -1
View File
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
}
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
/// `EntityGetter.getEntityCollisions` gave the moving entity.
pub fn resolve_in_border_with(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
entities: &[Aabb],
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
entities,
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
@@ -0,0 +1,243 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
/// A layer's texture, RGBA8.
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Draw {
pub image: u16,
pub blend: Blend,
pub brightness: f32,
/// Camera-relative world from the skybox's space.
pub rotation: Mat4,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
&self,
world: i32,
conditions: &Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
+170 -31
View File
@@ -14,6 +14,8 @@ pub struct EffectInstance {
pub amplifier: i32,
pub ambient: bool,
pub visible: bool,
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
pub show_icon: bool,
pub hidden: Option<Box<EffectInstance>>,
}
impl EffectInstance {
@@ -24,9 +26,18 @@ impl EffectInstance {
amplifier: amplifier.clamp(0, 255),
ambient: false,
visible: true,
show_icon: true,
hidden: None,
}
}
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
/// the icon shows exactly when the particles do.
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
self.ambient = ambient;
self.visible = visible;
self.show_icon = visible;
self
}
fn infinite(&self) -> bool {
self.duration == -1
}
@@ -71,6 +82,10 @@ impl EffectInstance {
self.visible = take.visible;
changed = true;
}
if take.show_icon != self.show_icon {
self.show_icon = take.show_icon;
changed = true;
}
changed
}
/// `MobEffectInstance.tickClient`: a client counts its effects down
@@ -98,6 +113,7 @@ impl EffectInstance {
self.amplifier = h.amplifier;
self.ambient = h.ambient;
self.visible = h.visible;
self.show_icon = h.show_icon;
self.hidden = h.hidden;
}
}
@@ -116,8 +132,9 @@ pub enum EffectAction {
}
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
let every = |base: i32| {
let interval = base >> amp;
let interval = base.wrapping_shr(amp as u32);
interval <= 0 || tick % interval == 0
};
match id {
@@ -126,10 +143,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
4i32.wrapping_shl(amp as u32).max(0) as f32,
)),
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
"minecraft:magic",
(6 << amp.min(30)) as f32,
6i32.wrapping_shl(amp as u32) as f32,
)),
_ => None,
}
@@ -139,11 +158,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -168,6 +187,57 @@ pub enum OmenTick {
/// It added this effect, then returned false.
Add(EffectInstance),
}
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
#[derive(Clone, Debug, PartialEq)]
pub struct SavedEffect {
pub id: String,
pub duration: i32,
pub amplifier: i32,
pub ambient: bool,
/// `show_particles`.
pub particles: bool,
/// `show_icon`.
pub icon: bool,
/// `hidden_effect`: the weaker, longer instance of the same effect that
/// comes back when this one ends.
pub hidden: Option<Box<SavedEffect>>,
}
impl SavedEffect {
/// Ambient off, particles and icon shown, nothing hidden: the codec's
/// defaults.
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
Self {
id: id.into(),
duration,
amplifier,
ambient: false,
particles: true,
icon: true,
hidden: None,
}
}
fn of(e: &EffectInstance) -> Self {
Self {
id: potions::namespaced(e.id).to_owned(),
duration: e.duration,
amplifier: e.amplifier,
ambient: e.ambient,
particles: e.visible,
icon: e.show_icon,
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
}
}
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
/// with it.
fn instance(&self, id: &'static str) -> EffectInstance {
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
e.ambient = self.ambient;
e.visible = self.particles;
e.show_icon = self.icon;
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
e
}
}
#[derive(Clone, Debug, Default)]
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
@@ -200,39 +270,25 @@ impl PlayerEffects {
pub fn clear(&mut self) {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
/// The effects of a saved player (`Player.active_effects`), each with
/// the instances it hides.
pub fn from_saved(saved: &[SavedEffect]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
for s in saved {
if let Some(def) = potions::effect(&s.id) {
effects.add(s.instance(def.id));
}
}
effects
}
/// `Player.active_effects` for the save.
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
self.list
.iter()
.map(|e| {
(
potions::namespaced(e.id).to_owned(),
e.duration,
e.amplifier,
e.ambient,
e.visible,
)
})
.collect()
pub fn saved(&self) -> Vec<SavedEffect> {
self.list.iter().map(SavedEffect::of).collect()
}
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
/// particles visible and the icon shown (always, for these effects).
/// particles visible and the icon shown.
pub fn flags(e: &EffectInstance) -> i8 {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
@@ -494,7 +550,75 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
#[cfg(test)]
mod tests {
use super::*;
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
/// at least 0 (#11386).
#[test]
fn heal_and_harm_shift_like_java() {
let magic = "minecraft:magic";
assert_eq!(
instant("instant_health", 32, 1.0),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
instant("instant_damage", 33, 1.0),
Some(EffectAction::Hurt(magic, 12.0))
);
assert_eq!(
instant("instant_health", 1, 0.5),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
periodic("instant_health", 1, 32),
Some(EffectAction::Heal(4.0))
);
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
assert_eq!(
periodic("instant_health", 1, 29),
Some(EffectAction::Heal(0.0))
);
assert_eq!(
periodic("instant_damage", 1, 30),
Some(EffectAction::Hurt(magic, i32::MIN as f32))
);
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
assert_eq!(periodic("regeneration", 49, 32), None);
assert_eq!(
periodic("regeneration", 50, 32),
Some(EffectAction::Heal(1.0))
);
}
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
/// false)`) shows neither particles nor its icon; a renewal that shows the
/// icon turns it back on (`MobEffectInstance.update`).
#[test]
fn an_effect_without_particles_hides_its_icon() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
let darkness = fx.get("darkness").unwrap();
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
assert_eq!(PlayerEffects::flags(darkness), 0);
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
// Details keeps show_icon apart from show_particles.
let mut speed = EffectInstance::new("speed", 100, 0);
speed.show_icon = false;
let mut fx = PlayerEffects::default();
fx.add(speed);
let saved = fx.saved();
assert_eq!(
saved,
[SavedEffect {
icon: false,
..SavedEffect::new("minecraft:speed", 100, 0)
}]
);
let loaded = PlayerEffects::from_saved(&saved);
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
}
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
@@ -531,6 +655,21 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
#[test]
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
// /effect give poison 6 s level II over poison 30 s level I, saved
// and loaded: when level II runs out, level I resumes with what is
// left of its 30 s.
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("poison", 600, 0));
fx.add(EffectInstance::new("poison", 120, 1));
let mut loaded = PlayerEffects::from_saved(&fx.saved());
for _ in 0..120 {
loaded.tick();
}
let poison = loaded.get("poison").expect("level I resumed");
assert_eq!((poison.amplifier, poison.duration), (0, 480));
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
@@ -546,11 +685,11 @@ mod tests {
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
.list
.is_empty()
);
@@ -0,0 +1,210 @@
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
// Copyright (c) 2022 The Create Team
//
// 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.
use horizon_core::geometry::Direction;
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
#[derive(Clone, Copy, Debug)]
pub struct NativeFluidRenderOptions {
pub argb: Option<u32>,
pub render_bottom: bool,
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
pub uv_shrink_ratio: f32,
}
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub normal: [i8; 3],
}
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
}
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
pub fn fluid_box(
bounds: [f32; 6],
options: NativeFluidRenderOptions,
mut emit: impl FnMut(FluidVertex),
) -> bool {
if !options.uv_shrink_ratio.is_finite()
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|| bounds
.iter()
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
{
return false;
}
let tiles =
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
let quads = tiles[0] * tiles[1] * 2
+ tiles[1] * tiles[2] * 2
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
if quads > 65_536 {
return false;
}
let [x0, y0, z0, x1, y1, z1] = bounds;
for (side, face) in [
(Direction::Down, [x0, z0, x1, z1, y0]),
(Direction::Up, [x0, z0, x1, z1, y1]),
(Direction::North, [x0, y0, x1, y1, z0]),
(Direction::South, [x0, y0, x1, y1, z1]),
(Direction::West, [z0, y0, z1, y1, x0]),
(Direction::East, [z0, y0, z1, y1, x1]),
] {
if side != Direction::Down || options.render_bottom {
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
}
}
true
}
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
let [a0, b0, a1, b1, plane] = face;
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
let shrink = ratio * 0.25;
let lerp = |v| v + shrink * (0.5 - v);
let mut a = a0;
while a < a1 {
let end_a = (a.floor() + 1.0).min(a1);
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
(end_a.ceil() - end_a, end_a.ceil() - a)
} else {
(a - a.floor(), end_a - a.floor())
};
let (u0, u1) = (lerp(u0), lerp(u1));
let mut b = b0;
while b < b1 {
let end_b = (b.floor() + 1.0).min(b1);
let (v0, v1) = if side == Direction::Up {
(b - b.floor(), end_b - b.floor())
} else {
(end_b.ceil() - end_b, end_b.ceil() - b)
};
let (v0, v1) = (lerp(v0), lerp(v1));
let positions = match side {
Direction::West | Direction::East => {
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
[
[plane, end_b, near],
[plane, b, near],
[plane, b, far],
[plane, end_b, far],
]
}
Direction::North | Direction::South => {
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
[
[near, end_b, plane],
[near, b, plane],
[far, b, plane],
[far, end_b, plane],
]
}
Direction::Down | Direction::Up => {
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
[
[a, plane, near],
[a, plane, far],
[end_a, plane, far],
[end_a, plane, near],
]
}
};
let normal = match side {
Direction::Down => [0, -1, 0],
Direction::Up => [0, 1, 0],
Direction::North => [0, 0, -1],
Direction::South => [0, 0, 1],
Direction::West => [-1, 0, 0],
Direction::East => [1, 0, 0],
};
for (pos, uv) in positions
.into_iter()
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
{
out(FluidVertex { pos, uv, normal });
}
b = end_b;
}
a = end_a;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_tiling_is_outward_and_does_not_inset_geometry() {
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
let mut result = Vec::new();
assert!(fluid_box(
bounds,
NativeFluidRenderOptions {
argb: None,
render_bottom: false,
uv_shrink_ratio: 4.0 / 2048.0,
},
|v| result.push(v),
));
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
for quad in result.as_chunks::<4>().0 {
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
assert!(
(b - a)
.cross(c - a)
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
> 0.0
);
assert!(
quad.iter()
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
);
}
assert!(
result
.iter()
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
);
assert!(!fluid_box(
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
NativeFluidRenderOptions {
argb: None,
render_bottom: true,
uv_shrink_ratio: 0.0,
},
|_| panic!("invalid geometry must fail before emitting"),
));
}
#[test]
fn luminosity_changes_only_block_light() {
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
assert_eq!(fluid_light(0xe030, 3), 0xe030);
}
}
+36 -2
View File
@@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
}
}
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
/// the sky reaches the eye, halved in a biome without precipitation.
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
rain * sky * if biome_rains { 1.0 } else { 0.5 }
}
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
@@ -428,10 +434,14 @@ pub fn update_rain_fog(
biome_rains: bool,
delta_ticks: f32,
) {
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
let target = rain_fog_target(rain, sky_light, biome_rains);
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
}
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
/// at its level and easing would wait for ticks that do not come.
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
}
fn rgb(c: u32) -> [f32; 3] {
[
(c >> 16 & 255) as f32 / 255.0,
@@ -726,6 +736,30 @@ mod tests {
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
/// of the way, as before.
#[test]
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
assert_eq!(
rain_fog_target(1.0, 15, false),
0.5,
"a dry biome halves it"
);
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
let start = FogState {
rain_fog_multiplier: 0.1,
..FogState::default()
};
let mut settled = start;
settle_rain_fog(&mut settled, 0.5, 15, true);
assert_eq!(settled.rain_fog_multiplier, 0.5);
let mut eased = start;
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
assert_eq!(
eased.rain_fog_multiplier.to_bits(),
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
File diff suppressed because it is too large. Load diff
@@ -1040,7 +1040,14 @@ mod tests {
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let pipeline = &pipeline;
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
super::super::SolidPipeline {
plain: pipeline(layout, label, "", "", None, None, true),
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -0,0 +1,416 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
Blend::Add,
Blend::Subtract,
Blend::Multiply,
Blend::Dodge,
Blend::Burn,
Blend::Screen,
Blend::Replace,
Blend::Overlay,
Blend::Alpha,
];
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
const MAX_DRAWS: usize = 32;
/// Dynamic uniform offsets are 256-byte aligned on every backend.
const STRIDE: u64 = 256;
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Uniform {
clip_from_sky: [[f32; 4]; 4],
brightness: f32,
mode: u32,
pad: [u32; 2],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
position: [f32; 3],
uv: [f32; 2],
cell: [f32; 4],
}
pub(super) struct CustomSky {
pipelines: Vec<wgpu::RenderPipeline>,
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
/// One bind group per layer texture of the pack.
groups: Vec<wgpu::BindGroup>,
/// This frame's layers: bind group and pipeline indices.
frame: Vec<(u16, u8)>,
}
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
fn blend(mode: Blend) -> wgpu::BlendComponent {
use wgpu::BlendFactor as F;
let (src_factor, dst_factor, operation) = match mode {
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
// dst + dst * src: lightens.
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
// dst * (1 - src): darkens.
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
// dst * src + src * dst.
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
};
wgpu::BlendComponent {
src_factor,
dst_factor,
operation,
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
for face in Face::ALL {
let cell = face.cell();
let [u0, v0, u1, v1] = cell;
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
let corners = face.corners();
for i in [0, 1, 2, 0, 2, 3] {
out.push(Vertex {
position: corners[i],
uv: uv[i],
cell,
});
}
}
out
}
impl CustomSky {
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Custom sky bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: wgpu::BufferSize::new(
std::mem::size_of::<Uniform>() as u64
),
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Custom sky shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Custom sky layout"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
let pipelines = MODES
.iter()
.map(|&mode| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Custom sky layer"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_custom_sky"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
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_custom_sky"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
// under the layers keeps its own.
blend: Some(wgpu::BlendState {
color: blend(mode),
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Custom sky"),
min_filter: wgpu::FilterMode::Linear,
mag_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Custom sky layers"),
size: STRIDE * MAX_DRAWS as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Custom sky box"),
contents: bytemuck::cast_slice(&mesh()),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
pipelines,
layout,
sampler,
uniform,
vertices,
groups: Vec::new(),
frame: Vec::new(),
}
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
(image.width, image.height, &image.rgba[..])
} else {
log::warn!(
"custom sky texture of {}x{} too large",
image.width,
image.height
);
(1, 1, &[0u8; 4][..])
};
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Custom sky layer"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
rgba,
);
let view = texture.create_view(&Default::default());
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Custom sky layer"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.uniform,
offset: 0,
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
})
.collect();
}
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
pub(super) fn prepare(
&mut self,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
clip: Mat4,
draws: &[Draw],
) {
self.frame.clear();
for draw in draws.iter().take(MAX_DRAWS) {
if usize::from(draw.image) >= self.groups.len() {
continue;
}
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
let uniform = Uniform {
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
brightness: draw.brightness.clamp(0.0, 1.0),
mode: mode as u32,
pad: [0; 2],
};
let offset = STRIDE * self.frame.len() as u64;
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
self.frame.push((draw.image, mode as u8));
}
}
pub(super) fn is_drawing(&self) -> bool {
!self.frame.is_empty()
}
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
/// changed.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
pass.set_vertex_buffer(0, self.vertices.slice(..));
for (i, &(image, mode)) in self.frame.iter().enumerate() {
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
pass.set_bind_group(
0,
&self.groups[usize::from(image)],
&[(STRIDE * i as u64) as u32],
);
pass.draw(0..36, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
let k = a * b;
let src = match mode {
Blend::Multiply => 1.0 + (t - 1.0) * k,
Blend::Overlay => 0.5 + (t - 0.5) * k,
Blend::Replace | Blend::Alpha => t,
_ => t * k,
};
let c = blend(mode);
let f = |factor: wgpu::BlendFactor| match factor {
wgpu::BlendFactor::One => 1.0,
wgpu::BlendFactor::Zero => 0.0,
wgpu::BlendFactor::Src => src,
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
wgpu::BlendFactor::Dst => dst,
wgpu::BlendFactor::SrcAlpha => k,
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
other => panic!("{other:?}"),
};
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
match c.operation {
wgpu::BlendOperation::Add => s + d,
wgpu::BlendOperation::ReverseSubtract => d - s,
other => panic!("{other:?}"),
}
}
#[test]
fn every_mode_gives_its_documented_result() {
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
let (d, t) = (0.4, 0.8);
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
for mode in MODES {
if mode != Blend::Replace {
close(mix(mode, d, t, 1.0, 0.0), d);
}
}
// Half faded: add adds half.
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
}
#[test]
fn the_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_box_covers_every_face_with_its_cell() {
let mesh = mesh();
assert_eq!(mesh.len(), 36);
for v in &mesh {
let [u0, v0, u1, v1] = v.cell;
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
}
}
}
@@ -0,0 +1,61 @@
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
// brightness 0 leaves the sky as it was.
struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
@group(0) @binding(2) var sky_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
@location(1) @interpolate(flat) cell: vec4<f32>,
}
@vertex fn vs_custom_sky(
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) cell: vec4<f32>,
) -> Out {
var out: Out;
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
out.uv = uv;
out.cell = cell;
return out;
}
const ADD: u32 = 0u;
const SUBTRACT: u32 = 1u;
const MULTIPLY: u32 = 2u;
const DODGE: u32 = 3u;
const BURN: u32 = 4u;
const SCREEN: u32 = 5u;
const REPLACE: u32 = 6u;
const OVERLAY: u32 = 7u;
const ALPHA: u32 = 8u;
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
let t = textureSample(sky_texture, sky_sampler, uv);
let k = t.a * layer.brightness;
switch layer.mode {
// dst * src: white leaves the sky alone.
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
// 2 * dst * src: grey leaves it alone.
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
case REPLACE: { return vec4(t.rgb, 1.0); }
// mix(dst, src, alpha).
case ALPHA: { return vec4(t.rgb, k); }
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
default: { return vec4(t.rgb * k, 1.0); }
}
}
@@ -14,8 +14,11 @@ use super::Vertex;
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
const MATERIALS: usize = 12;
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
const MATERIALS: usize = 13;
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
pub const FLUID_OVERLAY: u32 = 41;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
@@ -60,12 +63,15 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
fn material(layer: u32, oit: bool) -> usize {
if layer & 0xffff == FLUID_OVERLAY {
return 10;
}
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer & 0xffff {
WORLD_TEXT_OVERLAY => 10,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
WORLD_TEXT_OVERLAY => 11,
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
12 => 3,
@@ -148,9 +154,9 @@ impl EntityBatch {
}
}
}
// RenderType.text sorts alpha-blended quads from far to near. Keep
// both world-canvas layers after existing materials (including eyes
// moved out of additive); front text uses polygon offset.
// Fluid and RenderType.text sort their own alpha-blended quads far to
// near. Fluid precedes both world-canvas layers, after existing materials
// (including eyes moved out of additive).
for runs in &self.runs[10..] {
let start = self.combined.len();
for &(first, count) in runs {
@@ -189,7 +195,7 @@ mod tests {
use super::*;
use std::{hint::black_box, time::Instant};
#[test]
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
let quad = |layer, x| {
[Vertex {
layer,
@@ -204,6 +210,8 @@ mod tests {
(WORLD_TEXT_OVERLAY, 4.0),
(WORLD_TEXT_OVERLAY, 5.0),
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
(FLUID_OVERLAY, 11.0),
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
@@ -215,12 +223,14 @@ mod tests {
let mut batch = EntityBatch::default();
assert_eq!(
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1]
);
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(
positions,
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
[
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
]
);
}
@@ -320,12 +330,12 @@ mod tests {
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
+10 -16
View File
@@ -17,6 +17,7 @@
//! copied after the test, which tells which ones it saw: each run of a page
//! reserves room for all of its draws, the copy packs the seen ones at its
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
@@ -86,12 +87,12 @@ pub struct Call {
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
pub solid: SolidPipeline,
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
pub solid_opaque: SolidPipeline,
pub solid_cutout: SolidPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -357,7 +358,8 @@ fn plan(
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -372,6 +374,7 @@ impl Flat {
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
@@ -432,32 +435,23 @@ impl Flat {
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
solid: solid(
&layout,
"Terrain solid (flat)",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
solid_opaque: solid(
&layout,
"Terrain solid opaque (flat)",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
solid_cutout: solid(
&layout,
"Terrain solid cutout (flat)",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
@@ -0,0 +1,69 @@
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
label: Some("Still fluid (nearest texels, linear mipmaps)"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
lod_min_clamp: 0.0,
lod_max_clamp: 32.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
};
pub(super) fn pipeline(
device: &wgpu::Device,
layout: Option<&wgpu::PipelineLayout>,
shader: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Still fluid"),
layout,
vertex: wgpu::VertexState {
module: shader,
entry_point: Some("vs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some("fs_native_fluid"),
compilation_options: wgpu::PipelineCompilationOptions {
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(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,
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
@@ -349,12 +351,13 @@ fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -378,8 +381,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -403,6 +407,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -562,7 +567,7 @@ impl NativeMaterial {
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -609,7 +614,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -622,8 +627,8 @@ mod tests {
&stage.source,
&tags,
&materials,
false,
false
(false, false),
Api::V0_1
))
)
});
@@ -647,6 +652,33 @@ mod tests {
);
}
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
#[test]
fn the_terrain_validates_with_a_0_2_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
return input.position + input.tint.rgb;\n}\n";
let files = [
(MANIFEST, manifest),
("material.wgsl", material),
("vertex.wgsl", vertex),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -661,14 +693,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -681,4 +713,116 @@ mod tests {
);
}
}
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
/// what 0.1's does (vanilla's) within an 8-bit step.
#[test]
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
// The entries' call of `material`, with a 16×16 grid of colours.
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let c = floor(p.xy) / 15.0;
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
";
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
let format = wgpu::TextureFormat::Rgba8Unorm;
let draw = |api: Api| {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
[material]\nfile = \"material.wgsl\"\n"
);
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let size = wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: Default::default(),
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
drop(pass);
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
queue.submit([encoder.finish()]);
screenshot
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
};
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
}
}
@@ -101,7 +101,15 @@ impl NativePack {
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let api = report.manifest.api;
let mut frame = NativeFrame::new(
device,
settings.as_ref(),
textures,
lights,
terrain.pbr,
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
@@ -222,8 +230,13 @@ impl NativePack {
)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
})
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
File diff suppressed because it is too large. Load diff
@@ -6,7 +6,7 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
fn shadow_color(api: Api) -> wgpu::TextureFormat {
match api {
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
Api::V0_2 => super::native_post::INTERMEDIATE,
}
}
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
@@ -218,7 +223,7 @@ pub struct ShadowMap {
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -250,7 +255,7 @@ impl ShadowMap {
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
shadow_color(api),
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
@@ -474,6 +479,7 @@ fn shader_source(
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
api: Api,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
@@ -504,11 +510,12 @@ fn shader_source(
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
),
super::native_post::line_helpers(api)
)
}
@@ -527,6 +534,7 @@ impl NativeShadow {
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
frame.api,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -613,7 +621,7 @@ impl NativeShadow {
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
format: shadow_color(frame.api),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
@@ -731,8 +739,9 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let lines = [Api::V0_1, Api::V0_2];
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -767,6 +776,7 @@ mod tests {
None,
&materials,
Some(&stage.source),
Api::V0_1,
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
}
";
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
)
}
@@ -99,7 +101,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -195,7 +197,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -252,7 +254,14 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -217,6 +217,41 @@ impl CenterDepth {
})
}
/// Once the GPU has finished the frame (`screenshot-sequence`), the depth it copied, as
/// the next frame reads it: an error unless exactly that one sample was read back, never
/// an older one.
pub fn take_completed(&mut self, device: &wgpu::Device) -> Result<f32, String> {
device
.poll(wgpu::PollType::Poll)
.map_err(|e| e.to_string())?;
let state = |s: &Slot| s.state.load(Ordering::Acquire);
let mut mapped = self.slots.iter().filter(|s| state(s) == MAPPED);
let (Some(slot), None) = (mapped.next(), mapped.next()) else {
return Err("the frame's centre depth wasn't read back alone".into());
};
if self
.slots
.iter()
.any(|s| !matches!(state(s), FREE | MAPPED))
{
return Err("the frame's centre depth wasn't read back alone".into());
}
let depth = slot
.buffer
.slice(..4)
.get_mapped_range()
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]))
.map_err(|e| e.to_string());
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
let depth = depth?;
if !depth.is_finite() {
return Err("the centre depth isn't finite".into());
}
self.latest = Some(depth.clamp(0.0, 1.0));
Ok(depth.clamp(0.0, 1.0))
}
/// The newest depth read back so far, `None` before the first arrives.
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
let _ = device.poll(wgpu::PollType::Poll);
@@ -265,6 +300,76 @@ mod tests {
});
}
/// Inline and on the submit thread: each finished frame's depth, then none left to
/// take; several outstanding samples are refused.
#[test]
#[ignore = "requires a host GPU"]
fn a_finished_frame_s_depth_is_taken_alone_never_an_older_one() {
let (device, raw) = test_device().expect("host GPU required");
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 3,
height: 3,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = depth.create_view(&Default::default());
for threaded in [false, true] {
let queue = super::super::submit::Queue::new(raw.clone(), threaded);
if threaded {
assert!(queue.threaded(), "deferred submit unavailable");
}
let mut center = CenterDepth::new(&device);
assert!(center.take_completed(&device).is_err());
for value in [0.125, 0.75, 0.25] {
let mut encoder = device.create_command_encoder(&Default::default());
clear(&mut encoder, &view, value);
center.copy(&device, &mut encoder, &depth);
let after = center.submitted().expect("one copied sample");
if threaded {
let job = super::super::submit::Job {
encoder,
surface: None,
after: Some(Box::new(after)),
};
assert!(queue.submit_later(job).is_ok());
queue.wait();
} else {
queue.submit([encoder.finish()]);
after();
}
device
.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: Some(std::time::Duration::from_secs(30)),
})
.unwrap();
assert_eq!(center.take_completed(&device).unwrap(), value);
assert_eq!(center.collect(&device), Some(value));
assert!(
center.take_completed(&device).is_err(),
"no stale latest fallback"
);
}
// Several outstanding copies are ambiguous, even after they all map.
let mut encoder = device.create_command_encoder(&Default::default());
for _ in 0..SLOTS {
center.copy(&device, &mut encoder, &depth);
}
queue.submit([encoder.finish()]);
center.submitted().unwrap()();
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert!(center.take_completed(&device).is_err());
}
}
/// Frames that go to the submit thread start their readback there: the centre depth
/// follows the frames instead of keeping the first ones' depth.
#[test]
@@ -454,9 +454,31 @@ impl PackEntities {
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
let mut without_fluids = Vec::new();
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
// Keep it in its own material even when a shader pack draws the
// model in the same block-entity run. Do not corrupt either UV space.
let whole: &[Vertex] = if whole
.chunks(4)
.any(|q| q[0].layer & 0xffff == super::FLUID_OVERLAY)
{
without_fluids.clear();
for quad in whole.chunks(4) {
if quad[0].layer & 0xffff == super::FLUID_OVERLAY {
if !span.hidden {
rest.extend_from_slice(quad);
}
} else {
without_fluids.extend_from_slice(quad);
}
}
&without_fluids
} else {
whole
};
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
@@ -1141,6 +1163,76 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
mod gpu_span_tests {
use super::*;
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
#[test]
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
fn native_fluid_stays_native_while_model_uses_pack() {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
let (device, queue) =
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let layout = super::super::pack_draw::DrawUniforms::new(&device);
let mut geometry = PackEntities::new(&device, &layout.layout);
let mut mixed = vec![
Vertex {
layer: 0,
..bytemuck::Zeroable::zeroed()
};
8
];
for v in &mut mixed[4..] {
v.layer = super::super::FLUID_OVERLAY;
v.uv = [0.25, 0.75];
v.glint_uv = [17.0, 0.0];
}
let mut rest = Vec::new();
for hidden in [false, true] {
geometry.prepare(
&device,
&queue,
&layout.layout,
&mixed,
&[EntitySpan {
start: 0,
id: 77,
stage: render_stage("BLOCK_ENTITIES"),
hidden,
..Default::default()
}],
[true; PROGRAMS],
([0.; 4], [1; 2]),
Casters {
blocks: true,
..Default::default()
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
geometry.scratch.len(),
4,
"the model still uses the shader pack"
);
assert_eq!(geometry.draws.len(), 1);
assert_eq!(
(geometry.draws[0].pack, geometry.draws[0].shadow),
(!hidden, true)
);
if hidden {
assert!(
rest.is_empty(),
"hidden native fluid must not leak into the visible pass"
);
} else {
assert_eq!(
bytemuck::cast_slice::<_, u8>(&rest),
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
"fluid UV/layer payload cannot be sampled from the entity atlas"
);
}
}
}
#[test]
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
let mut skin = SkinFrame {
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
}
impl PackImage {
pub(super) fn reset_history(&mut self) {
self.view = None;
self.copy = None;
self.before = None;
self.made_for = [0; 2];
}
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
self.format
.sample_type(None, Some(features))
@@ -316,6 +323,11 @@ impl StandIn {
}
}
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
*images = stand_ins(device, wanted);
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
}
impl Buffers {
pub(super) fn reset_history(&mut self) {
self.group = None;
self.buffers.clear();
self.made_for = [0; 2];
}
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
mod history_reset_tests;
#[cfg(test)]
mod world_target_tests;
@@ -399,6 +402,8 @@ struct Pass {
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec<u8>,
/// Shader-declared defaults must survive a reset of the mutable upload block.
initial_block: Vec<u8>,
/// (index into `pack_uniforms::NAMES`, declared type, offset).
uniforms: Vec<(Option<usize>, String, u32)>,
samplers: Vec<Binding>,
@@ -452,6 +457,16 @@ struct Pass {
}
impl Pass {
/// Back to the uniform values the pass was made with, and no bind groups.
fn reset_history(&mut self) {
self.block.clone_from(&self.initial_block);
self.groups.clear();
self.portal_groups.clear();
for compute in &mut self.compute {
compute.reset_history();
}
}
fn render(&self) -> &wgpu::RenderPipeline {
match &self.pipeline {
Pipeline::Render(p) => p,
@@ -1973,6 +1988,7 @@ fn build_pass(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -2108,6 +2124,7 @@ fn build_compute(
mapped_at_creation: false,
}),
block: interface.initial_values.clone(),
initial_block: interface.initial_values.clone(),
uniforms: interface
.uniforms
.iter()
@@ -3124,23 +3141,7 @@ impl PackPost {
.create_view(&Default::default()),
textures,
shadow: (
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default()),
super::pack_shadow::stand_in(device),
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack shadow"),
compare: Some(wgpu::CompareFunction::LessEqual),
@@ -3189,6 +3190,74 @@ impl PackPost {
})
}
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
pub fn take_center_depth(&mut self, device: &wgpu::Device) -> Result<(), String> {
self.center_depth
.as_mut()
.ok_or("the shader pack copied no centre depth")?
.take_completed(device)
.map(|_| ())
}
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
/// and the centre depth too. Compiled programs and the pack's textures stay.
pub fn reset_history(&mut self, device: &wgpu::Device) {
self.uniforms.reset_history();
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
self.custom.restart_at(0);
self.custom.forget_world();
self.center_depth = None;
self.targets = None;
self.main = 0;
self.mips_valid = 0;
self.rgb_unset = true;
self.final_target = None;
self.blit_group = None;
self.page_groups.clear();
for image in &mut self.images {
image.reset_history();
}
self.buffers.reset_history();
pack_images::reset_stand_ins(device, &mut self.stand_ins);
if let Some(shadow) = &mut self.shadow_targets {
shadow.reset_history(device, &self.uniforms.shadow);
}
self.shadow.0 = super::pack_shadow::stand_in(device);
self.shadow_cleared = false;
self.shadow_cache = Default::default();
self.setup_pending = true;
self.portal_texture = None;
self.portal_rect = [0.0; 4];
let sky = self
.sky
.iter_mut()
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
let entities = self
.entities
.iter_mut()
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
for pass in self
.passes
.iter_mut()
.chain(&mut self.terrain)
.chain(&mut self.terrain_cutout)
.chain(&mut self.water)
.chain(sky)
.chain(entities)
.chain(&mut self.clouds)
.chain(&mut self.lines)
.chain(self.weather.iter_mut().flatten())
.chain(self.hand.iter_mut().map(|h| &mut h.0))
.chain(&mut self.shadow_terrain)
.chain(&mut self.shadow_water)
.chain(&mut self.shadow_comp)
{
pass.reset_history();
}
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
/// current mapping. The buffers it both samples and stores to are copied first, and it
/// samples the copies.
@@ -0,0 +1,109 @@
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
use super::destination_tests::Fixture;
use std::io::{Cursor, Write};
#[test]
#[ignore = "requires a host GPU with compute and fragment storage"]
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
add(
"setup.csh",
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
);
add(
"begin.csh",
&format!(
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
),
);
for name in ["composite", "shadowcomp"] {
add(&format!("{name}.vsh"), vertex);
let target = if name == "composite" { 2 } else { 0 };
let (declarations, value) = if name == "composite" {
("", "vec4(0.125)")
} else {
(
"uniform sampler2D shadowcolor0;varying vec2 uv;",
"texture2D(shadowcolor0,uv)+vec4(0.125)",
)
};
add(
&format!("{name}.fsh"),
&format!(
"#version 120\n/* RENDERTARGETS:{target} */\n\
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
),
);
}
add("final.vsh", vertex);
add(
"final.fsh",
&format!(
"#version 430 compatibility\n{state}\
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
),
);
add(
"shaders.properties",
"shadow.enabled=true\nbufferObject.0=8\n\
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
blend.composite=ONE ONE ONE ONE\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let load = || horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default());
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
let check = |pixels: &[[u8; 4]], frame: u8| {
let counter = 32 * frame;
for (i, pixel) in pixels.iter().enumerate() {
let expected = if i % 16 < 8 {
[counter, counter, counter, 255]
} else {
[counter, 32, 96, 255]
};
assert!(
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
);
}
};
for frame in 1..=3 {
check(&gpu.render(&mut post, [16, 16]), frame);
}
// At the same size too: the retained colortex2 and shadowcolor0, the image and
// the storage buffer start over, and setup runs once again. The control is a pack never
// reset.
for _ in 0..2 {
post.reset_history(&gpu.device);
for frame in 1..=2 {
let pixels = gpu.render(&mut post, [16, 16]);
check(&pixels, frame);
if frame == 1 {
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
}
}
fresh.reset_history(&gpu.device);
}
}
@@ -76,7 +76,50 @@ fn target(
(texture, view)
}
/// The depth stand-in is initialized to no shadow on the next pack render.
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack shadow stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&Default::default())
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
Some([a, b]) => {
colors |= 1 << n;
if a.texture() != b.texture() {
doubled |= 1 << n;
}
a.texture().format()
}
None => wgpu::TextureFormat::Rgba8Unorm,
});
*self = Self::new(
device,
shadow,
formats,
colors,
doubled,
self.depth1.is_some(),
);
}
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
/// resolution is kept within the device's limit.
@@ -709,6 +709,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl FrameUniforms {
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
/// centre depth), keeping the pack's atlas size and shadow settings.
pub(super) fn reset_history(&mut self) {
*self = Self {
atlas: self.atlas,
shadow: std::mem::take(&mut self.shadow),
..Self::default()
};
}
/// This frame's values for a render size of `size` pixels.
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
let now = std::time::Instant::now();
@@ -2367,6 +2377,61 @@ pub(super) mod initial_history_tests {
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
}
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
/// at their targets, the clock from 0; the atlas size it was given stays.
#[test]
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
let hud = HudBatch::new([16, 16]);
let mut f = frame(&hud);
let mut uniforms = FrameUniforms {
atlas: [256, 128],
..Default::default()
};
let mut inputs = PackInputs {
rain: 1.0,
eye_light: [15, 15],
frame_timing: Some(FrameTiming {
counter: 800,
elapsed: 99.0,
delta: 1.0,
}),
..Default::default()
};
uniforms.center_depth_raw = Some(0.25);
uniforms.update(&f, &inputs, [16, 16]);
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
f.yaw = 77.0;
inputs.rain = 0.25;
inputs.eye_light = [3, 8];
inputs.frame_timing = None;
uniforms.reset_history();
uniforms.update(&f, &inputs, [16, 16]);
let float = |name| match uniforms.values[index(name).unwrap()] {
Value::F(v) => v,
_ => panic!("float uniform expected"),
};
let int = |name| match uniforms.values[index(name).unwrap()] {
Value::I(v) => v,
_ => panic!("integer uniform expected"),
};
assert_eq!(int("frameCounter")[0], 0);
assert_eq!(float("frameTime")[0], 0.0);
assert_eq!(int("atlasSize")[..2], [256, 128]);
assert_eq!(float("wetness")[0], 0.25);
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
assert_eq!(float("centerDepthSmooth")[0], 1.0);
for (previous, current) in [
("previousCameraPosition", "cameraPosition"),
("gbufferPreviousModelView", "gbufferModelView"),
("gbufferPreviousProjection", "gbufferProjection"),
] {
assert_eq!(
float(previous).map(f32::to_bits),
float(current).map(f32::to_bits)
);
}
}
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
#[test]
@@ -323,4 +323,27 @@ mod tests {
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
/// the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
}
+23 -17
View File
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
in.snow != 0u);
let out = weather(input);
return vec4(horizon_line_out(out.rgb), out.a);
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
)
}
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -318,19 +322,21 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
use horizon_shader_api::{Api, stages::StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
+7 -3
View File
@@ -1092,8 +1092,9 @@ impl Graphics {
// (horizon_threads: niced on Linux; on macOS and Windows the server
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
// silicon only the performance cores count (horizon_threads::default_workers).
let auto = horizon_threads::jobs::default_threads();
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -1476,7 +1477,10 @@ mod tests {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
if !cfg!(target_vendor = "apple") {
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
}
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
assert_eq!(manual.worker_threads(), 3);
+15 -8
View File
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
return vec4(nits / 80.0, 1.0);
}
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let hand = covered && f.a < 1.5 / 255.0;
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
// at white before their lift, as on a unorm frame, so light sources, the sun and
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(c, vec3(2.2));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
// gets no gain and no knee: the clamp to the peak below is all it needs here.
if shows && w.a >= 0.75 && w.a < 1.0 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
}
}
light = min(light, vec3(params.peak));
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
+5
View File
@@ -1354,6 +1354,11 @@ impl HudAtlas {
bordered.insert(image.0.clone());
}
}
if full {
let (width, height, rgba) =
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let (width, placements, glyph_region) =
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
@@ -485,11 +485,6 @@ pub struct HealthHud {
/// `Util.getMillis()` of the last health change.
pub last_health_time: i64,
pub health_blink_time: i64,
/// `Hud.leftHeight` and `rightHeight` once the health, armor, food, mount
/// and air layers have run; the selected item name and the action bar sit
/// above them.
pub left_height: i32,
pub right_height: i32,
/// `Hud.lastBubblePopSoundPlayed`.
pub last_bubble_pop_sound: i32,
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
@@ -655,9 +650,6 @@ impl HealthHud {
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
let row_height = (10 - (rows - 2)).max(3);
let mut y_air = y_base - 10;
self.left_height = 39 + (rows - 1) * row_height + 10 + if i.armor > 0 { 10 } else { 0 };
// The mount's heart rows (`extractVehicleHealth`); food adds its row below.
self.right_height = 39 + 10 * ((i.vehicle_hearts + 9) / 10);
let offset_index = if i.regeneration {
i.tick % ceil(max_health + 5.0)
} else {
@@ -752,13 +744,11 @@ impl HealthHud {
}
}
y_air -= 10;
self.right_height += 10;
}
// Hud.extractAirBubbles.
let max_air = i.max_air.max(1);
let air = i.air.clamp(0, max_air);
if i.under_water || air < max_air {
self.right_height += 10;
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
let y = y_air - (rows - 1) * 10;
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
@@ -115,8 +115,7 @@ impl From<&crate::effects::EffectInstance> for EffectIcon {
color: def.map_or(0, |d| d.color as i32),
ambient: effect.ambient,
duration: effect.duration,
// Horizon keeps no showIcon flag: every effect shows its icon.
show_icon: true,
show_icon: effect.show_icon,
}
}
}
@@ -323,13 +322,12 @@ impl Titles {
}
}
}
/// `Hud.extractOverlayMessage`; `bar_height` is
/// `max(leftHeight, rightHeight)`.
/// `Hud.extractOverlayMessage`: the action bar keeps its place however
/// tall the health, food, air and mount rows above the hotbar are.
pub fn overlay_message(
&self,
w: i32,
h: i32,
bar_height: i32,
partial: f32,
font: &Font,
background: u32,
@@ -345,12 +343,11 @@ impl Titles {
let alpha = ((t * 255.0 / 20.0) as i32).min(255);
if alpha > 0 {
let message = message.resolve_legacy();
// The bars' height plus the gap between the item name (59) and the
// overlay (68), at least the default level.
let y_shift = bar_height + (68 - 59);
// `Hud.extractOverlayMessage` translates to `guiHeight - 68`, a
// constant the bars above the hotbar never move.
out.push(GuiDraw::Push(vec![PoseOp::Translate(
(w / 2) as f32,
(h - y_shift.max(68)) as f32,
(h - 68) as f32,
)]));
let color = if self.overlay_animated {
hsv_to_argb(t / 50.0, 0.7, 0.6, alpha as u32)
@@ -468,13 +465,13 @@ impl ToolHighlight {
self.last = selected;
}
/// `Hud.extractSelectedItemName`; `can_hurt` is
/// `gameMode.canHurtPlayer()` (survival and adventure) and `bar_height`
/// is `max(leftHeight, rightHeight)`.
/// `gameMode.canHurtPlayer()` (survival and adventure). The name sits 59
/// pixels above the bottom edge whatever the bars above the hotbar do,
/// and 14 lower when the mode cannot hurt the player.
pub fn draw(
&self,
w: i32,
h: i32,
bar_height: i32,
can_hurt: bool,
font: &Font,
background: u32,
@@ -495,7 +492,7 @@ impl ToolHighlight {
}
let str_width = width(font, &name);
let x = (w - str_width) / 2;
let mut y = h - bar_height.max(59);
let mut y = h - 59;
if !can_hurt {
y += 14;
}
@@ -1295,3 +1292,92 @@ mod subtitle_tests {
assert!(!o.listening());
}
}
#[cfg(test)]
mod text_position_tests {
use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The missing-glyph metrics let these tests run without game assets.
fn font() -> Font {
Font::load(
Path::new("/nonexistent-horizon-hud-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
)
}
fn held() -> HeldName {
HeldName {
item: "minecraft:stone".into(),
hover_name: Text::literal("Stone"),
rarity_color: 0xFFFF_FFFF,
custom_name: false,
}
}
fn text_top(draws: &[GuiDraw]) -> Option<i32> {
draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
})
}
/// `Hud.extractSelectedItemName`: `guiHeight - 59`, then `+ 14` when the
/// game mode cannot hurt the player. The health, food, air and mount rows
/// above the hotbar never move it: with armor and a second row of hearts
/// they reach 69 pixels, and the name stays where vanilla draws it.
#[test]
fn the_selected_item_name_sits_fifty_nine_above_the_bottom_edge() {
let mut highlight = ToolHighlight {
last: Some(held()),
timer: 40,
};
let f = font();
let draws = highlight.draw(320, 240, true, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59));
// Creative and spectator add the fourteen pixels the experience bar
// and the food row would have taken.
let draws = highlight.draw(320, 240, false, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59 + 14));
// No timer, no name.
highlight.timer = 0;
assert!(highlight.draw(320, 240, true, &f, 0).is_empty());
}
/// `Hud.extractOverlayMessage`: the action bar and its text translate to
/// `guiHeight - 68` and `-4`, again wherever the bars above the hotbar
/// reach.
#[test]
fn the_action_bar_sits_sixty_eight_above_the_bottom_edge() {
let mut titles = Titles::default();
titles.set_overlay(Text::literal("Action bar"), false);
let f = font();
let draws = titles.overlay_message(320, 240, 0.0, &f, 0);
let translate = draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(_, y) => Some(*y),
_ => None,
}),
_ => None,
});
assert_eq!(translate, Some((240 - 68) as f32));
// The text is drawn four pixels below that origin, as vanilla's is.
let relative = draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
});
assert_eq!(relative, Some(-4));
}
}
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2015 mezz
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

+2
View File
@@ -36,12 +36,14 @@ pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub mod fast_hash;
pub mod fluid_render;
pub mod fog;
pub mod font;
pub mod fusion;
+37 -1
View File
@@ -274,6 +274,8 @@ enum Out {
date: NaiveDate,
lines: Vec<String>,
stderr: Option<String>,
/// The message's level, thread and text, for the [`set_line_hook`] callback.
record: (log::Level, String, String),
},
/// Answer once everything before is written.
Flush(mpsc::SyncSender<()>),
@@ -293,12 +295,14 @@ struct Writer {
impl Writer {
fn run(mut self, out: mpsc::Receiver<Out>) {
let stderr = io::stderr();
let mut hook_alive = true;
for msg in out {
match msg {
Out::Write {
date,
lines,
stderr: message,
record: (level, thread, text),
} => {
if let Some(message) = message {
let _ = writeln!(stderr.lock(), "{message}");
@@ -319,6 +323,12 @@ impl Writer {
text.push('\n');
let _ = latest.file.write_all(text.as_bytes());
}
// After the file has it. A callback that panics is not called again: the
// panic's own log line would reach it next.
if let Some(hook) = LINE_HOOK.get().filter(|_| hook_alive) {
let call = || hook(level.as_str(), &thread, &text, &lines.join("\n"));
hook_alive = std::panic::catch_unwind(call).is_ok();
}
}
Out::Flush(done) => {
if let Some(latest) = &mut self.file {
@@ -395,6 +405,18 @@ impl Floods {
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
/// What [`set_line_hook`] installed: level (`INFO`, `WARN`, `ERROR`), thread, message, and the
/// lines as `latest.log` got them.
type LineHook = fn(&str, &str, &str, &str);
static LINE_HOOK: std::sync::OnceLock<LineHook> = std::sync::OnceLock::new();
/// Calls `hook` on the log's writer thread for each message written to the log, once the file
/// has it, so the thread that logged never waits on it. Once per process; a second call keeps
/// the first.
pub fn set_line_hook(hook: LineHook) {
let _ = LINE_HOOK.set(hook);
}
fn ours(target: &str) -> bool {
target.starts_with("horizon")
}
@@ -490,6 +512,7 @@ impl Logger {
date: now.date_naive(),
lines,
stderr,
record: (level, thread.to_owned(), message.to_owned()),
};
if let Err(mpsc::SendError(Out::Write {
stderr: Some(message),
@@ -502,12 +525,25 @@ impl Logger {
}
}
/// The `latest.log` this run writes, once `init` opened it.
static LATEST_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// The `latest.log` this run writes, absolute; None before `init` and when it logs to the
/// terminal only.
pub fn latest_path() -> Option<PathBuf> {
LATEST_PATH.get().cloned()
}
/// Starts the log in `home/logs`. The main thread shows as `main_thread` in it. Messages are
/// still printed when the file cannot be opened.
pub fn init(home: &Path, main_thread: &'static str) {
let dir = logs_dir(home);
let file = match open_latest(&dir) {
Ok(Some(latest)) => Some(latest),
Ok(Some(latest)) => {
let path = dir.join("latest.log");
let _ = LATEST_PATH.set(std::path::absolute(&path).unwrap_or(path));
Some(latest)
}
Ok(None) => {
eprintln!(
"Horizon: another game is writing {}; this one logs to the terminal only",
+141 -39
View File
@@ -90,6 +90,11 @@ fn round_to_u16(v: f32) -> u16 {
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// A model quad's own UVs in `TerrainVertex` fixed point, the packing
/// `TerrainVertex::new` does, baked once per face (`BakedFace::uv_fixed`).
pub(crate) fn baked_uv(uv: [[f32; 2]; 4]) -> [[u16; 2]; 4] {
uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)))
}
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
/// which span `[0, 2048)`: merged faces repeat their sprite once per block.
pub const TILED_UV_STEPS: f32 = 32.0;
@@ -103,6 +108,23 @@ impl TerrainVertex {
color,
}
}
/// A vertex whose UV is already in `UV_STEPS` fixed point (a model face's
/// own UVs, baked in `BakedFace::uv_fixed`).
pub(crate) fn packed_uv(
pos: [f32; 3],
uv: [u16; 2],
layer: u16,
color: u32,
light: u16,
) -> Self {
Self {
pos,
uv,
layer,
light,
color,
}
}
/// A vertex of a merged distant-terrain face, its UV in blocks from the
/// face's corner. Drawn with an instance UV scale of `1 / TILED_UV_STEPS`.
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
@@ -122,6 +144,63 @@ impl TerrainVertex {
#[cfg(test)]
mod vertex_tests {
use super::TerrainVertex;
#[test]
fn baked_face_keeps_shape_bounds_and_fixed_point_uvs() {
use crate::models::catalog::BakedFace;
use horizon_assets::{
bake::ModelQuad,
model::{Direction, Material},
};
let face = BakedFace::new(
ModelQuad {
positions: [
[-0.25, 0.5, 0.125],
[-0.25, 0.5, 0.875],
[0.625, 0.5, 0.875],
[0.625, 0.5, 0.125],
],
uv: [
[-0.25, f32::NAN],
[1.0 / 65536.0, 3.0 / 65536.0],
[1.5, 2.0],
[f32::INFINITY, f32::NEG_INFINITY],
],
facing: Direction::Up,
cull: None,
material: Material {
sprite: "test:face".into(),
force_translucent: false,
},
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
},
0,
true,
false,
None,
);
// Literal expectations: axis-ordered extrema and complements, plus
// clamping and half-step rounding in 1/32768 UV units.
let bounds: [f32; 12] = [
0.5, 0.5, 0.125, 0.875, -0.25, 0.625, 0.5, 0.5, 0.875, 0.125, 1.25, 0.375,
];
for shape in face.shapes {
assert_eq!(shape.bounds.map(f32::to_bits), bounds.map(f32::to_bits));
assert!(shape.partial);
}
assert!(!face.shapes[0].cubic);
assert!(face.shapes[1].cubic);
assert_eq!(face.uv_fixed, [[0, 0], [1, 2], [49152, 65535], [65535, 0]]);
}
#[test]
fn model_uvs_pack_exactly() {
for i in 0..=32 {
@@ -957,6 +1036,12 @@ const P: usize = 16 + 2 * B as usize;
fn pi(x: i32, y: i32, z: i32) -> usize {
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
}
/// `pi`'s index offset of a step `n`: `pi(p + n) - pi(p)`, so the samples
/// around a cell are `base + step_index(n)` rather than another `pi`.
#[inline]
const fn step_index(n: [i32; 3]) -> isize {
(n[0] + P as i32 * n[2] + (P * P) as i32 * n[1]) as isize
}
/// Blocks and light of one section plus a `B`-block border.
#[derive(Default)]
struct Padded {
@@ -1079,6 +1164,17 @@ const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
(d, d.normal(), d.opposite() as u8)
}
/// `step_index` of each direction's normal, indexed like `DIRECTIONS`: the
/// padded index of a block's neighbour is its own plus one of these.
const OFFSETS: [isize; 6] = {
let mut out = [0isize; 6];
let mut i = 0;
while i < 6 {
out[i] = step_index(DIRECTIONS[i].1);
i += 1;
}
out
};
/// 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).
@@ -1540,8 +1636,12 @@ impl Mesher<'_> {
let mut models = Vec::new();
for y in 0..16 {
for z in 0..16 {
// The padded index of the row's x = 0 cell: `pi(x, y, z)` is
// `row + x` and a neighbour adds `OFFSETS[d]`.
let row = pi(0, y, z);
for x in 0..16 {
let id = pad.blocks[pi(x, y, z)];
let cell = row + x as usize;
let id = pad.blocks[cell];
if id == 0 {
continue;
}
@@ -1596,7 +1696,7 @@ impl Mesher<'_> {
&& self.leaf_culling.is_interior()
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
let neighbor = pad.blocks[(cell as isize + OFFSETS[d as usize]) as usize];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
@@ -1656,7 +1756,8 @@ impl Mesher<'_> {
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
let centre =
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
let other = pad.blocks[pi(x, y + dy, z)];
let other =
pad.blocks[(cell as isize + step_index([0, dy, 0])) as usize];
if on_edge && connected.stacked_pane_hides(id, other, centre) {
continue;
}
@@ -1666,6 +1767,7 @@ impl Mesher<'_> {
&mut tints,
&mut out,
[x, y, z],
cell,
origin,
face,
ao,
@@ -1706,6 +1808,7 @@ impl Mesher<'_> {
tints: &mut Tints,
out: &mut SectionMesh,
pos: [i32; 3],
cell: usize,
origin: [f32; 3],
face: &BakedFace,
ao: bool,
@@ -1730,11 +1833,14 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// The quad's shape (bounds, partial, cubic), baked per face for full and
// non-full blocks: a pure function of the quad and the block's `full`.
let shape = &face.shapes[usize::from(state.full)];
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
lighting::ambient_occlusion(pad, info, state, pos, shape, q.facing);
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
@@ -1744,7 +1850,7 @@ impl Mesher<'_> {
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
None => lighting::flat_shaped(pad, state, pos, q.facing, shape),
};
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
@@ -1768,8 +1874,7 @@ impl Mesher<'_> {
count += 1;
}
};
let neighbour =
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
let fused = face.fusion.and_then(|rule| {
fusion.pick(
rule,
@@ -1810,44 +1915,41 @@ impl Mesher<'_> {
&mut push,
)
};
let vertex = |tint: Option<u32>, i: usize, uv: [u16; 2]| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
TerrainVertex::packed_uv(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv,
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
)
};
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
std::array::from_fn::<_, 4, _>(|i| {
let g = gray[i] as u32;
let rgb = match tint {
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
color,
pack_coords(lighting::with_min(coords[i], q.light)),
),
uv[i],
)
})
let fixed = uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)));
std::array::from_fn::<_, 4, _>(|i| (vertex(tint, i, fixed[i]), uv[i]))
};
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
let tiled = !matches!(pick, Pick::Keep);
let corners = vertices(
tint,
if state.pane && tiled {
standard()
} else {
q.uv
},
);
let corners = if state.pane && tiled {
vertices(tint, standard())
} else {
// The face's own UVs, already packed: no per-vertex rounding.
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
};
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
// solid layer whatever their sprite.
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
@@ -19,6 +19,11 @@ use std::{convert::Infallible, sync::OnceLock};
/// `FluidRenderer.MAX_FLUID_HEIGHT`.
const MAX_HEIGHT: f32 = 0.888_888_9;
const EPSILON: f32 = 0.001;
/// `super::step_index` of the unit steps of the padded index: one block east
/// (+x), one south (+z), one up (+y).
const X_STEP: isize = 1;
const P_STEP: isize = super::P as isize;
const UP: isize = super::step_index([0, 1, 0]);
/// For the occlusion face toward `direction`: the height from the bottom over
/// which its slice flush with the `direction` side covers the whole width
@@ -224,14 +229,14 @@ pub(super) fn tesselate(
}
};
let info = &tables.info;
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
let me = at([x, y, z]);
// The cell's own padded index: every other cell sampled here is a constant
// step from it (`super::step_index`), so no `pi` per sample.
let cell = pi(x, y, z) as isize;
let at = |step: isize| &info[pad.blocks[(cell + step) as usize] as usize];
let me = at(0);
let kind = me.fluid.kind();
let same = |s: &Info| s.fluid.kind().same_family(kind);
let neighbor = |d: Direction| {
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
at([x + dx, y + dy, z + dz])
};
let neighbor = |d: Direction| at(super::OFFSETS[d as usize]);
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
let should_render = |d: Direction| !same(neighbor(d)) && !occluded_by_self(me, d);
let render_up = !same(up);
@@ -265,10 +270,10 @@ pub(super) fn tesselate(
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
let height = |step: isize| -> f32 {
let s = at(step);
if same(s) {
if same(at([p[0], p[1] + 1, p[2]])) {
if same(at(step + UP)) {
1.0
} else {
s.fluid.own_height()
@@ -279,15 +284,15 @@ pub(super) fn tesselate(
-1.0
}
};
let own = height([x, y, z]);
let own = height(0);
let (mut ne, mut nw, mut se, mut sw) = (1.0f32, 1.0f32, 1.0f32, 1.0f32);
if own < 1.0 {
let n = height([x, y, z - 1]);
let s = height([x, y, z + 1]);
let e = height([x + 1, y, z]);
let w = height([x - 1, y, z]);
let n = height(-P_STEP);
let s = height(P_STEP);
let e = height(X_STEP);
let w = height(-X_STEP);
// calculateAverageHeight(self, height2, height1, corner)
let average = |h2: f32, h1: f32, corner: [i32; 3]| -> f32 {
let average = |h2: f32, h1: f32, corner: isize| -> f32 {
if h1 >= 1.0 || h2 >= 1.0 {
return 1.0;
}
@@ -313,16 +318,17 @@ pub(super) fn tesselate(
add(h2);
sum[0] / sum[1]
};
ne = average(n, e, [x + 1, y, z - 1]);
nw = average(n, w, [x - 1, y, z - 1]);
se = average(s, e, [x + 1, y, z + 1]);
sw = average(s, w, [x - 1, y, z + 1]);
ne = average(n, e, X_STEP - P_STEP);
nw = average(n, w, -X_STEP - P_STEP);
se = average(s, e, X_STEP + P_STEP);
sw = average(s, w, -X_STEP + P_STEP);
}
let light = |p: [i32; 3]| {
let above = [p[0], p[1] + 1, p[2]];
let light = |step: isize| {
let i = (cell + step) as usize;
let above = (cell + step + UP) as usize;
pack_coords(lighting::max_coords(
lighting::coords(pad, at(p), p),
lighting::coords(pad, at(above), above),
lighting::coords_at(pad, lighting::state_at(pad, info, i), i),
lighting::coords_at(pad, lighting::state_at(pad, info, above), above),
))
};
let (still, flow, overlay) = if water {
@@ -393,7 +399,7 @@ pub(super) fn tesselate(
// FluidState.shouldRenderBackwardUpFace over the 3x3 cells above.
let backward = (-1..=1).any(|ox| {
(-1..=1).any(|oz| {
let s = at([x + ox, y + 1, z + oz]);
let s = at(UP + ox as isize * X_STEP + oz as isize * P_STEP);
!same(s) && !s.solid
})
});
@@ -412,7 +418,7 @@ pub(super) fn tesselate(
corners,
layer,
color(cardinal(D::Up)),
light([x, y, z]),
light(0),
backward,
Direction::Up as usize,
);
@@ -428,12 +434,12 @@ pub(super) fn tesselate(
],
still,
color(cardinal(D::Down)),
light([x, y - 1, z]),
light(-UP),
false,
Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
let side_light = light(0);
let up_shade = cardinal(D::Up);
for (i, d) in [
Direction::North,
@@ -3,6 +3,7 @@
//! and `ModelBlockRenderer`'s integer colour arithmetic (`ARGB.gray`,
//! `scaleRGB`, `multiply`).
use super::{Info, Padded, pi};
use crate::models::catalog::QuadShape;
use horizon_assets::model::Direction as D;
/// `LightCoordsUtil.FULL_BRIGHT`.
@@ -25,28 +26,37 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
pub(super) fn pack_flat(nether: bool) -> f32 {
cardinal(nether, D::Up)
}
/// The normals of `D`'s variants, in the order [`ADJACENCY`] is indexed by.
const NORMALS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
fn normal(face: D) -> [i32; 3] {
match face {
D::Down => [0, -1, 0],
D::Up => [0, 1, 0],
D::North => [0, 0, -1],
D::South => [0, 0, 1],
D::West => [-1, 0, 0],
D::East => [1, 0, 0],
}
NORMALS[face as usize]
}
pub(super) fn step(p: [i32; 3], face: D) -> [i32; 3] {
let n = normal(face);
[p[0] + n[0], p[1] + n[1], p[2] + n[2]]
}
fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
&info[pad.blocks[pi(p[0], p[1], p[2])] as usize]
/// The block state of the padded cell at index `i` (see `pi`, `step_index`).
#[inline]
pub(super) fn state_at<'a>(pad: &Padded, info: &'a [Info], i: usize) -> &'a Info {
&info[pad.blocks[i] as usize]
}
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
/// (block level << 4 | sky level << 20).
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
}
/// `coords` of the cell at index `i`, as seen through `state`.
#[inline]
pub(super) fn coords_at(pad: &Padded, state: &Info, i: usize) -> u32 {
packed_coords(state, pad.light[i])
}
/// `coords` of a stored light byte (sky level << 4 | block level).
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
if state.emissive {
@@ -238,64 +248,21 @@ const ADJACENCY: [Adjacency; 6] = [
adjacency(D::West),
adjacency(D::East),
];
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
/// the block face) and whether it is cubic (flush with the block boundary, or
/// flat on a full collision shape).
struct Shape {
bounds: [f32; 12],
partial: bool,
cubic: bool,
}
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> Shape {
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
let mut bounds = [0f32; 12];
for (index, value) in [
(WEST, min[0]),
(EAST, max[0]),
(DOWN, min[1]),
(UP, max[1]),
(NORTH, min[2]),
(SOUTH, max[2]),
] {
bounds[index as usize] = value;
bounds[index as usize + 6] = 1.0 - value;
}
let (lo, hi) = (1.0e-4f32, 0.9999f32);
let partial = |a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
let (partial, cubic) = match face {
D::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
D::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
D::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
D::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
D::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
D::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
};
Shape {
bounds,
partial,
cubic,
}
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> QuadShape {
QuadShape::new(positions, face, full)
}
/// `prepareQuadFlat` for a quad without a cull face: light from the
/// neighbour when the quad is flush with the face, else from the block itself.
pub(super) fn flat(
/// neighbour when the quad is flush with the face (cubic), else from the block
/// itself. `shape` is the quad's baked [`QuadShape`].
pub(super) fn flat_shaped(
pad: &Padded,
own: &Info,
pos: [i32; 3],
positions: &[[f32; 3]; 4],
face: D,
shape: &QuadShape,
) -> u32 {
coords(
pad,
own,
flat_position(pos, positions, face, None, own.full),
)
let at = if shape.cubic { step(pos, face) } else { pos };
coords(pad, own, at)
}
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
@@ -314,6 +281,51 @@ pub(super) fn flat_position(
None => pos,
}
}
/// A face's AO sample offsets from the sample base, in `pi` index space, so a
/// sample is `base + offset` rather than a `pi` per coordinate triple.
struct Samples {
/// The four corners (`adjacency`'s).
corners: [isize; 4],
/// Each corner stepped out of the face (the permeability sample).
out: [isize; 4],
/// Corner `a` stepped toward corner `b`.
diagonal: [[isize; 4]; 4],
/// `step(pos, face)` from the block's own cell.
step: isize,
}
const fn samples(face: D) -> Samples {
let mut corners = [0isize; 4];
let mut out = [0isize; 4];
let mut diagonal = [[0isize; 4]; 4];
let step = super::step_index(NORMALS[face as usize]);
let mut i = 0;
while i < 4 {
let c = super::step_index(NORMALS[ADJACENCY[face as usize].corners[i] as usize]);
corners[i] = c;
out[i] = c + step;
let mut j = 0;
while j < 4 {
diagonal[i][j] =
c + super::step_index(NORMALS[ADJACENCY[face as usize].corners[j] as usize]);
j += 1;
}
i += 1;
}
Samples {
corners,
out,
diagonal,
step,
}
}
const SAMPLES: [Samples; 6] = [
samples(D::Down),
samples(D::Up),
samples(D::North),
samples(D::South),
samples(D::West),
samples(D::East),
];
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
/// and smooth light coords, by vertex index.
pub(super) fn ambient_occlusion(
@@ -321,26 +333,34 @@ pub(super) fn ambient_occlusion(
info: &[Info],
own: &Info,
pos: [i32; 3],
positions: &[[f32; 3]; 4],
shape: &QuadShape,
face: D,
) -> ([f32; 4], [u32; 4]) {
let shape = shape(positions, face, own.full);
let base = if shape.cubic { step(pos, face) } else { pos };
let samples = &SAMPLES[face as usize];
// The block's own cell and the sample base (the neighbour when the quad is
// cubic): every corner and diagonal is a constant step from it.
let cell = pi(pos[0], pos[1], pos[2]) as isize;
let base = if shape.cubic {
cell + samples.step
} else {
cell
};
let adjacency = &ADJACENCY[face as usize];
let corner = |i: usize| step(base, adjacency.corners[i]);
let sample = |p: [i32; 3]| {
let s = state(pad, info, p);
(coords(pad, s, p), s.shade)
let corner = |i: usize| base + samples.corners[i];
let sample = |i: isize| {
let i = i as usize;
let s = state_at(pad, info, i);
(coords_at(pad, s, i), s.shade)
};
let (light0, shade0) = sample(corner(0));
let (light1, shade1) = sample(corner(1));
let (light2, shade2) = sample(corner(2));
let (light3, shade3) = sample(corner(3));
let permeable = |i: usize| state(pad, info, step(corner(i), face)).permeable;
let permeable = |i: usize| state_at(pad, info, (base + samples.out[i]) as usize).permeable;
let (p0, p1, p2, p3) = (permeable(0), permeable(1), permeable(2), permeable(3));
let diagonal = |a: usize, b: usize, open: bool| {
if open {
sample(step(corner(a), adjacency.corners[b]))
sample(base + samples.diagonal[a][b])
} else {
(light0, shade0)
}
@@ -349,15 +369,15 @@ pub(super) fn ambient_occlusion(
let (light03, shade03) = diagonal(0, 3, p3 || p0);
let (light12, shade12) = diagonal(1, 2, p2 || p1);
let (light13, shade13) = diagonal(1, 3, p3 || p1);
let next = step(pos, face);
let next_state = state(pad, info, next);
let next = (cell + samples.step) as usize;
let next_state = state_at(pad, info, next);
let light_center = if shape.cubic || !next_state.solid {
coords(pad, next_state, next)
coords_at(pad, next_state, next)
} else {
coords(pad, own, pos)
coords_at(pad, own, cell as usize)
};
let shade_center = if shape.cubic {
state(pad, info, base).shade
state_at(pad, info, base as usize).shade
} else {
own.shade
};
@@ -393,6 +413,57 @@ pub(super) fn ambient_occlusion(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ao_offsets_match_coordinate_samples_at_section_edges() {
// Explicit coordinate directions, without NORMALS, ADJACENCY or step_index.
let (down, up, north, south, west, east) = (
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
);
let faces = [
(D::Down, down, [west, east, north, south]),
(D::Up, up, [east, west, north, south]),
(D::North, north, [up, down, east, west]),
(D::South, south, [west, east, down, up]),
(D::West, west, [up, down, north, south]),
(D::East, east, [down, up, north, south]),
];
let add = |a: [i32; 3], b: [i32; 3]| std::array::from_fn(|i| a[i] + b[i]);
let index = |p: [i32; 3]| pi(p[0], p[1], p[2]);
for x in [0, 1, 14, 15] {
for y in [0, 1, 14, 15] {
for z in [0, 1, 14, 15] {
let pos = [x, y, z];
for (face, normal, corners) in faces {
let samples = &SAMPLES[face as usize];
let cell = index(pos) as isize;
assert_eq!((cell + samples.step) as usize, index(add(pos, normal)));
for cubic in [false, true] {
let base_pos = if cubic { add(pos, normal) } else { pos };
let base = if cubic { cell + samples.step } else { cell };
for (i, corner) in corners.into_iter().enumerate() {
let p = add(base_pos, corner);
assert_eq!((base + samples.corners[i]) as usize, index(p));
assert_eq!((base + samples.out[i]) as usize, index(add(p, normal)));
}
for (a, b) in [(0, 2), (0, 3), (1, 2), (1, 3)] {
assert_eq!(
(base + samples.diagonal[a][b]) as usize,
index(add(add(base_pos, corners[a]), corners[b]))
);
}
}
}
}
}
}
}
const fn packed(block: u32, sky: u32) -> u32 {
block << 4 | sky << 20
}
@@ -1,6 +1,44 @@
use super::*;
use crate::chunk::Light;
#[test]
fn padded_steps_match_coordinate_lookup_through_the_border() {
// Coordinate lookup is the old addressing path, independent of step_index
// and the direction table. Include every cell and the full two-cell halo.
let neighbors = [
(Direction::Down, [0, -1, 0]),
(Direction::Up, [0, 1, 0]),
(Direction::North, [0, 0, -1]),
(Direction::South, [0, 0, 1]),
(Direction::West, [-1, 0, 0]),
(Direction::East, [1, 0, 0]),
];
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
let cell = (pi(0, y, z) + x as usize) as isize;
assert_eq!(cell as usize, pi(x, y, z));
for (face, [dx, dy, dz]) in neighbors {
assert_eq!(
(cell + OFFSETS[face as usize]) as usize,
pi(x + dx, y + dy, z + dz)
);
}
for dy in -2..=2 {
for dz in -2..=2 {
for dx in -2..=2 {
assert_eq!(
(cell + step_index([dx, dy, dz])) as usize,
pi(x + dx, y + dy, z + dz)
);
}
}
}
}
}
}
}
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
let mut pad = Padded::default();
@@ -9,6 +9,7 @@ use horizon_assets::{
bind::{Properties, PropertySchema, PropertyValues, SelectedModels},
choice::CompiledChoices,
},
model::Direction,
};
use horizon_core::{
position::BlockPos,
@@ -26,6 +27,65 @@ use std::{
sync::OnceLock,
};
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
/// the block face) and whether it is cubic (flush with the block boundary, or
/// flat on a full collision shape). A function of the quad's positions and the
/// block's `isCollisionShapeFullBlock` only, so it is baked per face, once for
/// each.
#[derive(Clone, Copy)]
pub struct QuadShape {
/// The quad's bounds by face index (`mesher::lighting`'s `SizeInfo`
/// order: down, up, north, south, west, east), and `1.0 - the bound`.
pub bounds: [f32; 12],
pub partial: bool,
pub cubic: bool,
}
impl QuadShape {
pub fn new(positions: &[[f32; 3]; 4], face: Direction, full: bool) -> Self {
// `SizeInfo` indices into the face shape.
const DOWN: usize = 0;
const UP: usize = 1;
const NORTH: usize = 2;
const SOUTH: usize = 3;
const WEST: usize = 4;
const EAST: usize = 5;
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
for p in positions {
for i in 0..3 {
min[i] = min[i].min(p[i]);
max[i] = max[i].max(p[i]);
}
}
let mut bounds = [0f32; 12];
for (index, value) in [
(WEST, min[0]),
(EAST, max[0]),
(DOWN, min[1]),
(UP, max[1]),
(NORTH, min[2]),
(SOUTH, max[2]),
] {
bounds[index] = value;
bounds[index + 6] = 1.0 - value;
}
let (lo, hi) = (1.0e-4f32, 0.9999f32);
let partial =
|a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
let (partial, cubic) = match face {
Direction::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
Direction::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
Direction::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
Direction::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
Direction::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
Direction::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
};
QuadShape {
bounds,
partial,
cubic,
}
}
}
pub struct BakedFace {
pub quad: ModelQuad,
pub layer: u32,
@@ -37,6 +97,33 @@ pub struct BakedFace {
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
/// two bytes, within the padding every face already has.
pub fusion: Option<std::num::NonZeroU16>,
/// `prepareQuadShape` of the quad, indexed by the block's `full` flag: the
/// mesher would otherwise recompute it for every face of every block.
pub shapes: [QuadShape; 2],
/// The quad's own UVs in `TerrainVertex` fixed point, which the mesh
/// otherwise recomputes for every face of every block.
pub uv_fixed: [[u16; 2]; 4],
}
impl BakedFace {
pub fn new(
quad: ModelQuad,
layer: u32,
opaque: bool,
translucent: bool,
fusion: Option<std::num::NonZeroU16>,
) -> Self {
let shapes = [false, true].map(|full| QuadShape::new(&quad.positions, quad.facing, full));
let uv_fixed = crate::mesher::baked_uv(quad.uv);
Self {
quad,
layer,
opaque,
translucent,
fusion,
shapes,
uv_fixed,
}
}
}
pub struct BakedModel {
pub ambient_occlusion: bool,
@@ -102,13 +189,7 @@ fn bake_resolved(
sprites.region_opaque(layer, &quad.uv),
),
};
Ok(BakedFace {
translucent,
opaque,
quad,
layer,
fusion: None,
})
Ok(BakedFace::new(quad, layer, opaque, translucent, None))
})
.collect::<Result<_>>()?;
let cube = super::cube::cube_styles(&faces);
@@ -118,13 +118,7 @@ mod tests {
)
.unwrap()
.into_iter()
.map(|quad| BakedFace {
quad,
layer: 7,
opaque: true,
translucent: false,
fusion: None,
})
.map(|quad| BakedFace::new(quad, 7, true, false, None))
.collect();
let Some(styles) = cube_styles(&faces) else {
assert!(lock);
@@ -153,13 +147,7 @@ mod tests {
bake_model_transformed(&model, BlockModelTransform::new([0; 3], false))
.unwrap()
.into_iter()
.map(|quad| BakedFace {
quad,
layer: 0,
opaque: true,
translucent: false,
fusion: None,
})
.map(|quad| BakedFace::new(quad, 0, true, false, None))
.collect();
faces[0].opaque = false;
assert!(cube_styles(&faces).is_none());
+35 -3
View File
@@ -63,6 +63,11 @@ struct Globals {
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
override SPRITE_SPLIT: u32 = 2048u;
// Whether the solid shaders carry HDR output's lighting (a pipeline constant): the
// renderer builds its solid terrain without it for the frames HDR is off, where
// `hdr_light.z` is 0, since even a branch never taken costs Apple GPUs ~15% of a
// solid pixel's time (gfx.rs's `SolidPipeline`).
override HDR_LIGHTING: bool = true;
// A sprite sampled with the given gradients, from whichever array holds its layer.
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
@@ -620,7 +625,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -637,7 +642,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -665,7 +670,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// fully fogged mountain would stand out from the fog behind it.
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
var emissive = 1.0;
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
@@ -718,6 +723,33 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
return apply_fog(color, in.spherical, in.cylindrical);
}
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
// packing or entity-model diffuse shading. The absolute sprite layer occupies glint UV x.
struct NativeFluidOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) sprite: u32,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
}
@vertex fn vs_native_fluid(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(6) sprite: vec2<f32>) -> NativeFluidOut {
let block = max(f32(light.y), dynamic_block_light(position));
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
if globals.colored_count.x > 0u {
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
}
return NativeFluidOut(globals.view_proj * vec4(position, 1.0), uv, lit, u32(sprite.x),
length(position), max(length(position.xz), abs(position.y)));
}
@fragment fn fs_native_fluid(in: NativeFluidOut) -> @location(0) vec4<f32> {
// The source translucent shader has no alpha discard; transparent texels write depth too.
let color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
return apply_fog(color, in.spherical, in.cylindrical);
}
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
// the sky colour with sky fog (sky.fsh: fog 0..FogSkyEnd on the spherical
// distance, a step at FogSkyEnd on the cylindrical one), evaluated per pixel
+29 -1
View File
@@ -184,6 +184,14 @@ impl Weather {
self.rain_level.1 =
(self.rain_level.1 + if self.raining { 0.01 } else { -0.01 }).clamp(0.0, 1.0);
}
/// The levels at their targets at once (rain 1 while it rains, else 0, and thunder the
/// same), both halves, so the frame shows them at any partial tick: a frozen world under
/// `HORIZON_STILL`, which would otherwise have to run about a hundred ticks to fade them.
pub fn settle_levels(&mut self) {
let target = |on: bool| if on { 1.0 } else { 0.0 };
self.rain_level = (target(self.raining), target(self.raining));
self.thunder_level = (target(self.thundering), target(self.thundering));
}
/// Level.getRainLevel / getThunderLevel (thunder scales with rain).
pub fn levels(&self, partial: f32) -> (f32, f32) {
let rain = self.rain_level.0 + (self.rain_level.1 - self.rain_level.0) * partial;
@@ -562,11 +570,31 @@ impl Default for HealthRules {
#[cfg(test)]
mod tests {
use super::{DirtySections, World};
use super::{DirtySections, Weather, World};
use crate::chunk::{Chunk, MIN_Y, SECTIONS};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// `/weather thunder` then `/weather clear` in a frozen world: the levels sit at their
/// targets at every partial tick, where a tick of fading would have moved them 0.01.
#[test]
fn settled_weather_shows_its_targets_at_any_partial_tick() {
let mut weather = Weather {
raining: true,
thundering: true,
rain_level: (0.25, 0.26),
..Weather::default()
};
weather.settle_levels();
for partial in [0.0, 0.37, 1.0] {
assert_eq!(weather.levels(partial), (1.0, 1.0), "{partial}");
}
weather.raining = false;
weather.thundering = false;
weather.settle_levels();
assert_eq!(weather.levels(0.5), (0.0, 0.0));
}
/// Every block write marks the same section set the 27-cell loop did, but
/// only the distinct keys: one in the interior, a neighbour section on a
/// section face, a neighbour column on a chunk border and the diagonal at
+1 -1
View File
@@ -4,7 +4,7 @@ default-run = "horizon-client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
rust-version.workspace = true
description = "Horizon native development client"
autobins = false
autoexamples = false
@@ -892,5 +892,9 @@
"Editing: %s": "Editing: %s",
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"horizon.pad.notice.connected.press": "Press to use it"
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
"horizon.recipe_transfer.unsupported": "Server requires a known recipe"
}
@@ -2,7 +2,10 @@
//! 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)]
#[allow(
dead_code,
reason = "this fixture shares another one's source and uses part of it"
)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
@@ -1,6 +1,5 @@
//! Shared by the audio fixtures: a backend recording what an OpenAL source
//! would hold, and the per-step dump the Java oracles print.
#![allow(dead_code)]
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
use horizon_client::sound_events::Sound;
use std::collections::HashMap;
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
&m,
&c,
case["initial"].as_str().unwrap_or(""),
false,
history.len(),
w,
h,
@@ -8,7 +8,8 @@ use horizon_client::{
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES,
species::can_be_a_baby, sulfur_cube,
},
random_sequences::RandomSequences,
};
@@ -111,7 +112,7 @@ pub fn main() -> horizon_client::Result<()> {
for slot in drops::SLOTS {
drops::clear_slot(&mut m, slot);
}
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
if get("baby", "0") == "1" && can_be_a_baby(m.species()) {
m.extra.age = -24000;
}
if get("fire", "0") == "1" {
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
@@ -1200,6 +1200,7 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
others_villagers: None,
others_solid: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
+32
View File
@@ -146,6 +146,16 @@ fn frame(v: &Value) -> Frame {
}
}
impl Tree {
/// `PlayerAdvancements.checkForAutomaticTriggers`: the advancements that
/// have no criteria at all, so nothing can ever grant them a criterion and
/// vanilla hands them over (and grants their rewards) as the player's
/// progress loads.
pub fn automatic(&self) -> Vec<usize> {
(0..self.list.len())
.filter(|&i| self.list[i].criteria.is_empty())
.collect()
}
/// [`Tree::load`], shared while anyone holds it: a server's advancement
/// packets (`ServerAdvancements`) and its game's criteria read the same
/// files, and each kept its own copy (7 MB). A world whose data packs bring
@@ -2677,6 +2687,28 @@ mod tests {
p.grant("minecraft:t", "b", "now");
assert!(p.done(&a));
}
/// `AdvancementRequirements.test` answers false with no requirement groups, so an advancement
/// without criteria is never done by `isDone`; `PlayerAdvancements.checkForAutomaticTriggers`
/// still grants its rewards as progress loads, and `Tree::automatic` is the list it walks.
#[test]
fn an_advancement_without_criteria_is_not_done_but_granted_automatically() {
let empty = parse("minecraft:free", &serde_json::json!({"criteria": {}})).unwrap();
assert!(!Progress::default().done(&empty), "no groups: not done");
let waiting = parse(
"minecraft:waits",
&serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
)
.unwrap();
assert!(!Progress::default().done(&waiting));
let tree = tree_of(&[
(
"minecraft:waits",
serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
),
("minecraft:free", serde_json::json!({"criteria": {}})),
]);
assert_eq!(tree.automatic(), vec![1]);
}
#[test]
fn layout_spreads_children_like_tree_node_position() {
let mut tree = Tree::default();
@@ -587,6 +587,10 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
cmd!("screenshot-sequence", Scripting, "screenshot-sequence FRAMES STEP_MS PATH|cancel", "draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG",
"Needs HORIZON_STILL and a frozen world (docs/agent-control.md). The pack starts over, frame n's clock is n frames and (n - 1) × STEP_MS milliseconds, the PNG is written as screenshot PATH writes it, and the reply comes once it is. Relative paths are taken from horizon-ctl's directory. cancel stops a running sequence.",
["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence 64 16.666667 frame64.png", "horizon-ctl screenshot-sequence cancel"],
&[p("frames", Ty::Str, "frames to draw, 1 to 10000, or cancel"), opt("step_ms", Ty::Num, "milliseconds between frames on the pack's clock, more than 0 (16.666667 for 60 fps)"), opt("path", Ty::Rest, "the PNG to write")], mcp: false, local: false),
cmd!("render-capture", Scripting, "render-capture on|off|DIR", "keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example",
"on meshes every section and far tile again so the copies are complete (HORIZON_RENDER_CAPTURE=1 keeps them from the start). DIR gets the camera, frame inputs, settings and every resident near section and far tile mesh; entities, particles, weather, the hand and the HUD are left out. See docs/graphics.md.",
["horizon-ctl render-capture on", "horizon-ctl render-capture /tmp/scene"], &[], mcp: false, local: false),
@@ -361,6 +361,11 @@ fn absolute_paths(words: &mut [String]) {
*path = cwd.join(&*path).display().to_string();
}
}
Some("screenshot-sequence") => {
if let Some(path) = words.get_mut(3) {
*path = cwd.join(&*path).display().to_string();
}
}
// `repro-export [NAME] [PATH]`: the second positional is the zip.
Some("repro-export") => {
if let Some(path) = words.get_mut(2)
@@ -831,5 +836,9 @@ mod tests {
let mut words = vec!["screenshot".to_owned(), "a.png".to_owned()];
absolute_paths(&mut words);
assert!(Path::new(&words[1]).is_absolute());
let mut words = ["screenshot-sequence", "64", "50", "a.png"].map(str::to_owned);
absolute_paths(&mut words);
assert!(Path::new(&words[3]).is_absolute());
assert_eq!(words[1..3], ["64", "50"]);
}
}
@@ -57,6 +57,21 @@ pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// The game's native trample hook (`native_trample`), set once by the game: dimension, entity
/// id, entity type, the farmland's position, the fall distance and cancel.
pub static TRAMPLE: OnceLock<fn(&str, i32, &str, [i32; 3], f64, &mut bool)> = OnceLock::new();
/// Whether the mods let `entity` (of type `kind`) trample the farmland at `position`.
pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f64) -> bool {
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
!cancel
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
+20 -8
View File
@@ -42,6 +42,8 @@ pub struct Interaction {
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
/// `lowerFillLevel` posts BLOCK_CHANGE with the new state.
pub lower_fill_level: bool,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -60,6 +62,7 @@ fn pass() -> Interaction {
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
}
@@ -126,9 +129,20 @@ fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -
block: Some(block),
sound,
stat: Some(stat),
lower_fill_level: false,
}
}
fn changed_lowered(
cauldron: &BlockState,
sound: Option<&'static str>,
stat: &'static str,
) -> Interaction {
let mut result = changed(lowered(cauldron), sound, stat);
result.lower_fill_level = true;
result
}
const COLORS: [&str; 16] = [
"white",
"orange",
@@ -183,7 +197,7 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None, "clean_armor");
return changed_lowered(cauldron, None, "clean_armor");
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
@@ -208,6 +222,7 @@ pub fn interact(
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
} else {
empty_bucket(
@@ -231,6 +246,7 @@ pub fn interact(
block: None,
sound: None,
stat: None,
lower_fill_level: false,
}
} else {
empty_bucket(
@@ -266,11 +282,7 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
changed_lowered(cauldron, Some("minecraft:item.bottle.fill"), "use_cauldron")
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -298,7 +310,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None, "clean_banner")
changed_lowered(cauldron, None, "clean_banner")
}
("water", name)
if name
@@ -307,7 +319,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None, "clean_shulker_box")
changed_lowered(cauldron, None, "clean_shulker_box")
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+12 -11
View File
@@ -13,17 +13,18 @@ pub use horizon_client_render::{
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
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, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, 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,
crash, custom_sky, datapack, dynamic_light, effects, environment, extras, fast_hash,
fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder,
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_browser, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, 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;
+88
View File
@@ -289,6 +289,8 @@ impl ServerTickRateManager {
/// Ticks one sprint frame may run (rendering carries on between frames).
const SPRINT_PER_FRAME: i64 = 40;
const SAMPLES: usize = 100;
/// The windows of [`FixedStep::tps`], in seconds: one, five and fifteen minutes.
const TPS_WINDOWS: [f64; 3] = [60.0, 300.0, 900.0];
/// A step command's broadcast value and the ticks still owed to a new client.
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
@@ -316,6 +318,10 @@ pub struct FixedStep {
tick_times: [i64; SAMPLES],
aggregated: i64,
tick_count: i64,
/// The ticks a second over the last [`TPS_WINDOWS`], decayed once a second ([`Self::tps`]).
tps: [f64; 3],
/// When the second being counted started (at a recorded tick's end) and the ticks since.
tps_second: Option<(std::time::Instant, u32)>,
/// A server's client: its level's tick rate only slows the timer down
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
client: bool,
@@ -349,6 +355,8 @@ impl FixedStep {
tick_times: [0; SAMPLES],
aggregated: 0,
tick_count: 0,
tps: [20.0; 3],
tps_second: None,
client: false,
step_changes: 0,
step_requested: 0,
@@ -530,6 +538,41 @@ impl FixedStep {
let end = self.now_ns() + took;
self.manager.end_tick_work(end);
}
self.count_tick(std::time::Instant::now());
}
/// A recorded tick ended at `now`: once a second has passed since the second being
/// counted started, its ticks a second join each average of [`Self::tps`], weighted as a
/// load average is (`exp(-elapsed / window)` kept of the old value).
fn count_tick(&mut self, now: std::time::Instant) {
let Some((start, ticks)) = &mut self.tps_second else {
self.tps_second = Some((now, 0));
return;
};
*ticks += 1;
let elapsed = now.saturating_duration_since(*start).as_secs_f64();
if elapsed < 1.0 {
return;
}
let rate = f64::from(*ticks) / elapsed;
for (average, window) in self.tps.iter_mut().zip(TPS_WINDOWS) {
let kept = (-elapsed / window).exp();
*average = *average * kept + rate * (1.0 - kept);
}
self.tps_second = Some((now, 0));
}
/// The ticks a second the server kept over the last one, five and fifteen minutes (what
/// Bukkit's `Server.getTPS` reports; vanilla keeps no such average): measured between
/// recorded tick ends, so paused passes count where they are recorded. Starts at 20.
pub fn tps(&self) -> [f64; 3] {
self.tps
}
/// `getTickTimesNanos` as vanilla lays it out: the tick numbered `n` (from 1, as
/// `MinecraftServer.tickCount` after its increment) at index `n % 100`, so the
/// newest is at the recorded ticks' count `% 100`; 0 where no tick was recorded yet.
pub fn tick_times_nanos(&self) -> Vec<u64> {
(0..SAMPLES)
.map(|i| self.tick_times[(i + SAMPLES - 1) % SAMPLES].max(0) as u64)
.collect()
}
/// `getAverageTickTimeNanos`.
pub fn average_nanos(&self) -> u64 {
@@ -547,6 +590,51 @@ impl FixedStep {
mod tests {
use super::*;
/// Twenty ticks a second keep 20; ten a second pull the one-minute average down
/// fastest; a second not yet over changes nothing.
#[test]
fn tps_averages_follow_the_ticks_a_second() {
let mut clock = FixedStep::new(false);
let t0 = std::time::Instant::now();
let ms = std::time::Duration::from_millis;
for i in 0..=40 {
clock.count_tick(t0 + ms(50 * i));
}
assert_eq!(
clock.tps().map(|v| (v * 1000.0).round() / 1000.0),
[20.0; 3]
);
let slow = t0 + ms(2000);
for i in 1..=10 {
clock.count_tick(slow + ms(100 * i));
}
let [one, five, fifteen] = clock.tps();
assert!(
one < five && five < fifteen && fifteen < 20.0,
"{:?}",
clock.tps()
);
assert!((one - (20.0 - 10.0 * (1.0 - (-1.0f64 / 60.0).exp()))).abs() < 1e-9);
clock.count_tick(slow + ms(1050));
assert_eq!(clock.tps(), [one, five, fifteen]);
}
/// The tick times sit where vanilla's array has them: tick n at n % 100.
#[test]
fn tick_times_are_laid_out_as_vanillas() {
let mut clock = FixedStep::new(false);
assert_eq!(clock.tick_times_nanos(), vec![0; SAMPLES]);
for n in 1..=150u64 {
clock.record(std::time::Duration::from_nanos(n));
}
let times = clock.tick_times_nanos();
assert_eq!(times.len(), SAMPLES);
assert_eq!(times[50], 150, "the newest, tick 150");
assert_eq!(times[51], 51, "tick 51, overwritten by none yet");
assert_eq!(times[0], 100);
assert_eq!(times[1], 101);
}
#[test]
fn timer_carries_fractions_and_caps_nothing_itself() {
let mut t = Timer::new(20.0, 0);
+33 -3
View File
@@ -666,7 +666,7 @@ impl ItemEntity {
wanted
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still), and
// too (a boat, a shulker, a happy ghast held still; not a minecart), and
// so does the world border (`Entity.collectColliders`).
crate::collisions::EntityCollisions {
blocks: scene,
@@ -1313,6 +1313,35 @@ impl ExperienceOrb {
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
self.tick_among(
world,
facts,
collision,
player,
server,
next_float,
border,
fluids,
on_sound,
&[],
)
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -1374,10 +1403,11 @@ impl ExperienceOrb {
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
// Entity.collide: the world border's walls stop the orb too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop the orb too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
+114 -14
View File
@@ -105,6 +105,16 @@ impl Base<'_> {
}
tag
}
/// Preserve Entity data without flags owned by Mob's save writer.
/// Only the dedicated boat, item and armor-stand writers use this path.
fn non_mob_tag(&self, raw: Option<&Tag>) -> Tag {
let mut tag = self.tag(raw);
if let Tag::Compound(map) = &mut tag {
map.remove(&"NoAI".into());
map.remove(&"PersistenceRequired".into());
}
tag
}
}
/// `Entity.saveWithoutId`'s common fields for entities Horizon keeps as raw
@@ -128,8 +138,8 @@ pub fn fill_common_defaults(tag: &mut Tag) {
/// The mobs that save `Age` and `AgeLocked`.
fn saves_age(s: crate::mobs::Species) -> bool {
use crate::mobs::{Species, drops::can_be_baby, finalize::ageable};
s == Species::Tadpole || (ageable(s) && can_be_baby(s))
use crate::mobs::{Species, finalize::ageable, species::can_be_a_baby};
s == Species::Tadpole || (ageable(s) && can_be_a_baby(s))
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
@@ -633,15 +643,11 @@ pub fn mob_to_nbt(
if species != crate::mobs::Species::Tadpole {
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
}
} else if matches!(
species,
crate::mobs::Species::Parrot
| crate::mobs::Species::Slime
| crate::mobs::Species::MagmaCube
| crate::mobs::Species::ZombieNautilus
) {
} else if crate::mobs::finalize::ageable(species) {
// AgeableMob.addAdditionalSaveData writes nothing when canBeABaby is
// false: drop the ignored values a raw input compound carried.
// false — the frog, the parrot, the zombie nautilus, the camel husk,
// the slime and the magma cube: drop the ignored values a raw input
// compound carried rather than writing them back out.
for key in ["Age", "ForcedAge", "AgeLocked"] {
map.remove(&key.into());
}
@@ -2356,7 +2362,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
on_ground: e.on_ground,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
let mut tag = base.non_mob_tag(raw);
for (key, value) in [
("Item", item_to_nbt(&e.stack)),
("Health", Tag::Short(e.health as i16)),
@@ -3428,7 +3434,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
on_ground: a.on_ground,
fall_distance: a.enchantment_fall_distance,
};
let mut tag = base.tag(raw);
let mut tag = base.non_mob_tag(raw);
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
.iter()
.zip(&a.equipment)
@@ -3582,7 +3588,7 @@ pub fn boat_to_nbt(
on_ground: false,
fall_distance: 0.0,
}
.tag(raw);
.non_mob_tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
}
@@ -4038,11 +4044,16 @@ mod tests {
}
#[test]
fn cubes_omit_supplied_age_and_pigs_keep_modeled_age() {
fn an_ageable_mob_that_cannot_be_a_baby_saves_no_age() {
use crate::mobs::Species;
// AgeableMob.addAdditionalSaveData writes Age only when canBeABaby is
// true, so every ageable class that overrides it to false writes none
// of it: a frog and a camel husk keep no raw `Age` either.
for (species, id) in [
(Species::Slime, "minecraft:slime"),
(Species::MagmaCube, "minecraft:magma_cube"),
(Species::Frog, "minecraft:frog"),
(Species::CamelHusk, "minecraft:camel_husk"),
(Species::Pig, "minecraft:pig"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
@@ -4934,6 +4945,95 @@ mod tests {
}
}
#[test]
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
let uuid = [104, 0, 0, 1];
let mut kind = crate::mobs::sensor_scan_tests::kind();
let def = Arc::make_mut(&mut kind);
def.id = "minecraft:pig".into();
def.species = crate::mobs::Species::Pig;
let types = [("minecraft:pig".into(), kind)].into();
for input in [None, Some(0), Some(1)] {
for id in [
"minecraft:oak_boat",
"minecraft:oak_chest_boat",
"minecraft:armor_stand",
"minecraft:item",
"minecraft:pig",
] {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[4.5, 200.0, 4.5])),
("Motion", doubles(&[0.0, 0.0, 0.0])),
("Silent", Tag::Byte(1)),
("NoGravity", Tag::Byte(1)),
("Invulnerable", Tag::Byte(1)),
("PortalCooldown", Tag::Int(17)),
("Tags", Tag::List(vec![string("audit")])),
("Unrelated", string("keep")),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:stone", 1)),
),
]);
if let Some(value) = input {
put(&mut raw, "NoAI", Tag::Byte(value));
put(&mut raw, "PersistenceRequired", Tag::Byte(value));
}
let stand = armor_stand_from_nbt(&raw, 1).unwrap();
let item = item_entity_from_nbt(&raw, 1, 0.0).unwrap();
let boat = boat_from_nbt(&raw).unwrap();
for source in [None, Some(&raw)] {
let saved = match id {
"minecraft:armor_stand" => armor_stand_to_nbt(&stand, uuid, source),
"minecraft:item" => item_entity_to_nbt(&item, uuid, source),
"minecraft:pig" => {
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
// The writer must use live state, even opposite stale raw values.
mob.no_ai = input != Some(1);
mob.persistent = input != Some(1);
mob_to_nbt(&mob, uuid, source, None, |_| None)
}
_ => boat_to_nbt(
id,
boat.0,
boat.1,
[boat.2, 0.0],
uuid,
source,
(id == "minecraft:oak_chest_boat").then_some(&[]),
),
};
if id == "minecraft:pig" {
let live = input != Some(1);
assert_eq!(saved.get("NoAI"), live.then_some(&Tag::Byte(1)));
assert_eq!(
saved.get("PersistenceRequired"),
Some(&Tag::Byte(i8::from(live)))
);
} else {
assert!(saved.get("NoAI").is_none(), "{id} {input:?}");
assert!(saved.get("PersistenceRequired").is_none(), "{id} {input:?}");
}
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
assert_eq!(saved.get("Pos"), raw.get("Pos"));
if source.is_some() {
for key in [
"Silent",
"NoGravity",
"Invulnerable",
"PortalCooldown",
"Tags",
"Unrelated",
] {
assert_eq!(saved.get(key), raw.get(key), "{id} {key}");
}
}
}
}
}
}
#[test]
fn animal_integer_loads_save_modeled_values_without_raw_source() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
+61
View File
@@ -46,6 +46,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
(logging::game_home(), "client", "Render thread")
};
// Before the log opens, so a mod's injection hears its first lines.
logging::set_line_hook(crate::native_client_hooks::native_log_line);
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
@@ -98,6 +100,35 @@ pub fn main_with(mods: crate::mod_api::Mods) {
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if horizon_world::worldgen::native_generate::install(
horizon_world::worldgen::native_generate::NativeGenerateHooks {
chunk_generate: |dimension, chunk, seed, flat, chunk_out, generate| {
crate::mod_api::dispatch_chunk_generate(
crate::mod_api::mods(),
dimension,
chunk,
seed,
flat,
chunk_out,
generate,
);
},
lod_column: |dimension, column, seed, flat, column_out| {
crate::mod_api::dispatch_lod_column(
crate::mod_api::mods(),
dimension,
column,
seed,
flat,
column_out,
);
},
},
)
.is_err()
{
log::warn!("Native generation hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
@@ -168,6 +199,23 @@ pub fn main_with(mods: crate::mod_api::Mods) {
register_goals: |mob| {
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
},
player_pre_login: |player, name, address, defer| {
crate::mod_api::dispatch_player_pre_login(
crate::mod_api::mods(),
player,
name,
address,
defer,
);
},
player_profile: |player, name, properties| {
crate::mod_api::dispatch_player_profile(
crate::mod_api::mods(),
player,
name,
properties,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
@@ -180,6 +228,19 @@ pub fn main_with(mods: crate::mod_api::Mods) {
accept,
);
},
log_line: |level, thread, message, line| {
crate::mod_api::dispatch_log_line(crate::mod_api::mods(), level, thread, message, line);
},
handshake: |address, host, intent, cancel, reason| {
crate::mod_api::dispatch_handshake(
crate::mod_api::mods(),
address,
host,
intent,
reason,
cancel,
);
},
})
.is_err()
{
+63 -3
View File
@@ -78,9 +78,6 @@ impl FurnaceCooking for Furnace {
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
@@ -250,6 +247,69 @@ mod tests {
(items, recipes, providers)
}
#[test]
fn loaded_totals_survive_until_actual_completion() {
let (items, recipes, providers) = data("loaded-total");
assert_eq!(
recipes
.cook(
FurnaceKind::Furnace.recipe_type(),
&ItemStack::new("test:ore", 3)
)
.unwrap()
.time,
200
);
// Fresh Java's five-case Game fixture independently admits the loaded
// zero (including a missing saved field), five and normal totals.
for (total, timer, completion) in [(0, 0, 1), (5, 0, 5), (200, 0, 200), (5, 1, 4)] {
let mut f = Furnace {
kind: FurnaceKind::Furnace,
items: [Some(ItemStack::new("test:ore", 3)), None, None],
lit_remaining: 20,
lit_total: 20,
cooking_timer: timer,
cooking_total: total,
speed: 1.0,
recipes_used: HashMap::new(),
};
for elapsed in 1..=6 {
let mut drops = Vec::new();
assert!(!f.idle());
assert_eq!(f.tick(&recipes, &items, &providers, &mut drops), None);
assert!(drops.is_empty());
assert_eq!((f.lit_remaining, f.lit_total), (20 - elapsed, 20));
assert!(f.items[1].is_none());
let complete = elapsed >= completion;
assert_eq!(
f.items[0].as_ref().unwrap().count,
if complete { 2 } else { 3 },
"loaded {total}/{timer}, tick {elapsed}"
);
assert_eq!(
f.items[2].as_ref().map(|s| (&*s.item, s.count)),
if complete {
Some(("test:ingot", 1))
} else {
None
}
);
assert_eq!(f.cooking_total, if complete { 200 } else { total });
assert_eq!(
f.cooking_timer,
if complete {
elapsed - completion
} else {
timer + elapsed
}
);
}
// A changed input still goes through setItem's independent reset.
f.set_input(&recipes, Some(ItemStack::new("test:ingot", 1)));
assert_eq!((f.cooking_timer, f.cooking_total), (0, 200));
}
}
#[test]
fn cooking_requires_output_components_and_capacity() {
let (items, recipes, providers) = data("output-components");
+307 -61
View File
@@ -104,6 +104,7 @@ mod creative_hotbar;
mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod custom_sky;
mod debug_keys;
mod debug_lines;
mod debug_options;
@@ -223,8 +224,10 @@ mod native_biome_colors;
pub mod native_block_entity_draw;
mod native_block_sprites;
mod native_bots;
mod native_chunk_snapshot;
mod native_chunk_tickets;
mod native_claimed_items;
mod native_client_entities;
mod native_client_particles;
mod native_client_particles_fixture;
pub mod native_client_services;
@@ -232,13 +235,16 @@ mod native_client_world;
mod native_components;
mod native_configs;
mod native_criteria;
mod native_damage;
mod native_data_maps;
mod native_diagnostics;
mod native_entities;
mod native_entity_data;
mod native_entity_motion;
mod native_entity_refs;
mod native_explosions;
mod native_falling;
mod native_fluid_render;
mod native_hooks;
mod native_host_bridge;
mod native_inventories;
@@ -256,8 +262,15 @@ mod native_mobs;
mod native_motion;
mod native_mounts;
mod native_net;
mod native_npcs;
mod native_packets;
mod native_panel;
mod native_panel_recipes;
mod native_permissions;
mod native_player_fx;
mod native_player_reads;
mod native_player_state;
mod native_profiles;
mod native_projectiles;
mod native_quick_move;
mod native_recipes;
@@ -266,12 +279,17 @@ mod native_scene;
mod native_server;
mod native_shaders;
mod native_structures;
pub mod native_text_field;
mod native_ticks;
mod native_travel;
mod native_view_distance;
mod native_visuals;
mod native_world_border;
mod native_world_columns;
pub mod native_world_draw;
mod native_world_edit;
mod native_world_read;
mod native_world_seed;
mod natural_spawning;
mod nautili;
mod net_harness;
@@ -327,6 +345,7 @@ mod saving;
mod scoreboard;
mod screen_info;
mod screens;
mod screenshot_sequence;
mod sculk;
mod sculk_fixture;
mod server_links;
@@ -459,6 +478,7 @@ use horizon_world::{
pub use machines::native_fluids::NativeFluidTank;
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_chunk_snapshot::NativeChunkSnapshot;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
pub use native_components::NativeComponentError;
@@ -467,8 +487,10 @@ pub use native_criteria::NativeCriterion;
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
pub use native_inventories::NativeInventoryKind;
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_player_state::NativeEffect;
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_structures::{NativeStructureBlock, NativeStructureTemplate};
@@ -1112,6 +1134,24 @@ impl EntityObstructionWorld for BuildingObstruction {
.collect())
}
}
/// Whether a block's successful use is `InteractionResult.SUCCESS_SERVER`,
/// which a client does not predict: `useWithoutItem` of a bed, a light
/// block, a charged respawn anchor or a sign. Every other block use the
/// level answers with SUCCESS is `SUCCESS` (`SwingSource.PREDICTED`).
fn server_only_block_use(
block: &horizon_items::blocks::StateFacts,
branch: horizon_world::level::UseBranch,
) -> bool {
branch == horizon_world::level::UseBranch::WithoutItem
&& [
"AbstractBedBlock",
"LightBlock",
"RespawnAnchorBlock",
"SignBlock",
]
.iter()
.any(|class| block.has_class(class))
}
/// An entity's box (`EntityDimensions.makeBoundingBox`): `width` wide and
/// `height` tall, standing on `feet`.
fn point_box(feet: [f64; 3], width: f64, height: f64) -> Aabb {
@@ -1252,6 +1292,11 @@ impl LevelAccess for GameLevel<'_> {
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
}
}
fn redstone_change(&self, pos: BlockPos, old: i32, new: &mut i32) {
if self.native_authoritative {
self.native_block_redstone(self.dimension, pos, old, new);
}
}
fn allow_spread(
&self,
kind: horizon_world::level::Spread,
@@ -1574,6 +1619,9 @@ impl LevelAccess for GameLevel<'_> {
/// the count a recount of the players with that container open finds, and
/// the count it replaces. Only the block entities that keep a counter and
/// a scheduled recheck answer; anything else is `None`.
fn container_open(&self, pos: BlockPos) -> bool {
self.open_containers.contains(&(pos.x, pos.y, pos.z))
}
fn recheck_container_openers(&mut self, pos: BlockPos) -> Option<(i32, i32)> {
use horizon_world::level::classes::Class;
let data = self.behaviours.data(self.world.block(pos.x, pos.y, pos.z));
@@ -2240,6 +2288,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
}
impl PackResources {
/// Everything a pack overlay at `root` provides, over `assets` and `render`
@@ -2315,6 +2365,7 @@ impl PackResources {
names: Arc::new(Names::load(root)),
clouds: Arc::new(load_clouds(root)),
forcefield: Arc::new(load_forcefield(root)),
custom_sky: Arc::new(horizon_client::custom_sky::CustomSky::load(root)),
}
}
}
@@ -2874,6 +2925,9 @@ struct Game<'a> {
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
/// Swings of uses whose client predicts them (`SwingSource.PREDICTED`):
/// a server does not send these back to the player who swung.
predicted_swings: u32,
/// The first-person equip heights and the items the hands show (with
/// the slot each came from).
heights: horizon_client::first_person::HandHeights,
@@ -2904,6 +2958,8 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// OptiFine custom sky layers' eased brightness (`custom_sky.rs`).
custom_sky: custom_sky::CustomSkyView,
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
rain_sound_time: i32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
@@ -2966,6 +3022,8 @@ struct Game<'a> {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stands: horizon_client::join_seq::JoinList<horizon_client::entities::ArmorStand>,
/// Worn armor as of the last `collectEquipmentChanges` look (frost walker).
location_armor: [Option<ItemStack>; 4],
/// `Cushion` decoration entities (game/cushions.rs).
cushions: Vec<horizon_client::entities::Cushion>,
/// Entities of types Horizon does not simulate.
@@ -3001,6 +3059,9 @@ struct Game<'a> {
carts: horizon_client::join_seq::JoinList<minecarts::Minecart>,
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
/// What the mods asked for server players out of reach (`native_teleport_player`...),
/// done after the players' packets of the tick (native_travel.rs).
native_moves: Vec<(u128, native_travel::NativeMove)>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
@@ -3110,6 +3171,8 @@ struct Game<'a> {
perf: horizon_client::telemetry::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
screenshot_sequence: Option<screenshot_sequence::Sequence>,
/// `render-capture DIR`: replied once the renderer wrote the scene.
pending_render_capture: Option<std::sync::mpsc::Sender<String>>,
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
@@ -3135,6 +3198,10 @@ struct Game<'a> {
native_configs: native_configs::NativeConfigs,
/// Native mods' messages waiting (`game/native_net.rs`).
native_net: native_net::NativeNet,
/// What native mods send one player, waiting for the hub (`game/native_player_fx.rs`).
native_player_fx: native_player_fx::NativePlayerFx,
/// Native mods' kicks waiting for the tick's end (`game/native_packets.rs`).
native_packets: native_packets::NativePackets,
/// The mod whose own screen is open (`Screen::Mod`).
native_mod_screen: Option<String>,
/// Meshed sections waiting for an upload budget (Horizon settings).
@@ -3281,6 +3348,9 @@ struct Game<'a> {
/// The last `use_hand` that ended the click did so with `FAIL`, which
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
use_failed: bool,
/// A mod canceled a use the acting guest's client predicted (a throw, a
/// placement): their inventory goes out whole when the turn ends.
guest_inventory_resend: bool,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
@@ -3333,6 +3403,9 @@ struct Game<'a> {
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
lan_acting_uuid: Option<u128>,
/// The player and the stack on their cursor as they clicked a native
/// menu, while `native_menu_clicked` hears it (`native_menu_cursor`).
native_click_cursor: Option<(u128, Option<horizon_client::items::ItemStack>)>,
/// Where the mods refused to prime TNT: the sound and game event the level
/// queued right after the prime are dropped (`prime_tnt_event`).
tnt_refused: Option<BlockPos>,
@@ -3341,6 +3414,9 @@ struct Game<'a> {
/// Their entity id, which the hub (often taken out of `lan` while they
/// act) would otherwise have to be asked for.
lan_acting_entity: Option<u32>,
/// The player `native_player_login` hears and their permission level, while it runs
/// (`native_player_permission`): the hub is out of reach there.
native_login_permission: Option<(u128, u8)>,
/// Command feedback to forward to a guest.
chat_capture: Option<Vec<horizon_client::text::Text>>,
/// Packets for the guest acting now, sent once its action returns.
@@ -3348,6 +3424,9 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// Players whose command tree the mods asked to send again
/// (`native_resend_commands`), sent in the next server tick.
native_resend_trees: Vec<u128>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
@@ -4374,6 +4453,15 @@ impl Game<'_> {
self.award_stat("dropped", &thrown.item, thrown.count as i32);
self.award_custom_stat("drop", 1);
let player = self.native_acting_id();
let mut cancel = false;
self.native_item_dropped_v2(player, &thrown, &mut cancel);
if cancel {
// The stack stays the player's: what doesn't fit is thrown after all.
if let Some(rest) = self.give(thrown) {
self.throw_from_eyes(rest, eye, true);
}
return;
}
self.native_item_dropped(player, &thrown.item, thrown.count);
self.throw_from_eyes(thrown, eye, true);
}
@@ -4650,7 +4738,7 @@ impl Game<'_> {
&& b.max_position().z > min[2];
// ItemEntity.playerTouch: no pickup delay, and the target (if any) is this player.
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
if self.native_pickup_refused(&item.stack) {
if self.native_pickup_refused(&item.stack, item.thrower, item.position) {
item.pickup_delay = 10;
kept.push(item);
continue;
@@ -5603,9 +5691,10 @@ impl Game<'_> {
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
// LevelEventHandler 2011 and 2012: a bee growing a crop, a
// turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
id: 2011 | 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
@@ -6162,11 +6251,12 @@ impl Game<'_> {
/// One falling block's `tick`: places or drops its block in its own tick,
/// so a block falling onto one that just landed lands on top of it. `i` is
/// its place in the list; the caller has checked it is ticking.
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
let entity_drops = self.ui.rules.bool("entity_drops");
let mut f = self.falling.remove(i);
let original = f.clone();
match self.fall_step(&mut f, entity_drops) {
match self.fall_step(&mut f, entity_drops, entities) {
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
Ok(FallStep::Gone) => {}
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
@@ -6179,7 +6269,12 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(
&mut self,
f: &mut FallingBlock,
entity_drops: bool,
entities: &[Aabb],
) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
@@ -6213,10 +6308,11 @@ impl Game<'_> {
false,
);
// On an error the caller keeps the block as it was before this tick.
// Entity.collide: the world border's walls stop it too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop it too.
let movement = collisions::EntityCollisions {
blocks: scene,
entities: &[],
entities,
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
}
.resolve_with_context(
@@ -6729,6 +6825,7 @@ impl Game<'_> {
}
if !claimed {
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
}
if claimed {
self.swing_arm();
@@ -6827,6 +6924,8 @@ impl Game<'_> {
let mut canceled = false;
horizon_client::spans::time("use_mods", || {
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
let hand = self.hand;
self.native_right_click_block_v2(hit.pos, hit.face, hand, &mut canceled);
});
if canceled {
// Nothing was used, so nothing consumed the action.
@@ -7012,7 +7111,13 @@ impl Game<'_> {
}
}
if result.should_swing() {
self.swing_arm();
// SUCCESS swings on the client too; the beds, light,
// respawn anchor and sign's own uses are SUCCESS_SERVER.
if server_only_block_use(self.res.facts().get(cake), branch) {
self.swing_arm();
} else {
self.swing_arm_predicted();
}
}
return Ok(true);
}
@@ -7252,6 +7357,13 @@ impl Game<'_> {
item: stack.item.to_string(),
can_use_game_master_blocks: self.can_use_game_master_blocks(),
};
// The mods, once the position and state are known: a canceled
// placement is a failed one, and the client's prediction is undone.
if self.block_place_canceled(&stack, &ctx) {
self.use_failed = true;
self.guest_inventory_resend = true;
return Ok(true);
}
let outcome = self.with_level(|b, level, ticks| b.place_block(level, ticks, &ctx));
let PlaceOutcome::Placed { pos, state } = outcome else {
self.process_level_events()?;
@@ -7290,7 +7402,8 @@ impl Game<'_> {
_ => {}
}
}
self.swing_arm();
// BlockItem.place's SUCCESS: the client swings with its own prediction.
self.swing_arm_predicted();
// BlockItem.place's sound is among the level events;
// SolidBucketItem.getPlaceSound replaces it with the bucket's own.
let solid_bucket = &*stack.item == "minecraft:powder_snow_bucket";
@@ -7317,6 +7430,30 @@ impl Game<'_> {
}
Ok(true)
}
/// Asks the mods whether the acting guest may place `stack` where and as
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
/// one canceled it. A game's own player is not asked, nor a placement
/// vanilla refuses.
fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let Some((pos, state)) =
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
else {
return false;
};
let mut cancel = false;
self.native_block_place(
uuid,
&name,
stack,
[pos.x, pos.y, pos.z],
state,
&mut cancel,
);
cancel
}
/// Asks the mods whether the acting guest may empty `stack` at `pos`
/// (`native_bucket_empty`): true when one cancelled it. A game's own
/// player is not asked.
@@ -7423,6 +7560,7 @@ impl Game<'_> {
);
instance.ambient = eff.ambient;
instance.visible = eff.show_particles;
instance.show_icon = eff.show_icon;
self.add_player_effect(instance);
}
}
@@ -7550,12 +7688,18 @@ impl Game<'_> {
.instrument(&stack)
.map(|i| i.use_ticks().max(0) as u32)
{
// InstrumentItem.getUseDuration: held up that long, nothing more.
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
// InstrumentItem.getUseDuration: held up that long. When it runs
// out, LivingEntity.stopUsingItem ends the use and causes the
// interaction vibration; letting go of the button already does.
if ticks + 1 < duration {
self.using = Some((slot, ticks + 1));
} else {
self.stop_using_item(&stack);
}
return;
}
if &*stack.item == "minecraft:brush" {
self.tick_brush(slot, ticks);
self.tick_brush(&stack, slot, ticks);
return;
}
if info.bundle {
@@ -8062,6 +8206,11 @@ impl Game<'_> {
self.world.weather.tick_timers(&mut self.world.random);
}
self.world.weather.tick_levels();
} else if self.clock.still() {
// Under `HORIZON_STILL` a frozen world's rain and thunder sit at their levels, as a
// shader pack's smoothed values do, so `/weather rain` shows full rain without
// stepping the world (and its mobs) through the fade.
self.world.weather.settle_levels();
}
// ClientLevel.tick: the sky flash fades in a frozen level too.
self.tick_weather_effects();
@@ -9059,9 +9208,6 @@ impl Game<'_> {
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let show_hud = !self.ui.hide_gui;
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
// the action bar sit above it.
let mut bar_height = 39;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
@@ -9160,8 +9306,6 @@ impl Game<'_> {
} else {
self.draw_mount_hud(&mut batch, wi, hi)
};
// Without the survival bars only the mount's hearts raise it.
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
// The one bar above the hotbar: the mount's jump bar, the locator
// bar (with its background) or the experience bar, with the level
// number that a survival player always has.
@@ -9254,10 +9398,6 @@ impl Game<'_> {
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
hud_layout::paint(batch, &g.gui.atlas, &draws);
});
bar_height = self
.health_hud
.left_height
.max(self.health_hud.right_height);
// Hud.playAirBubblePoppedSound: Player.playSound (PLAYERS), heard
// by this client only.
if let Some((volume, pitch)) = self.health_hud.bubble_pop_sound.take() {
@@ -9273,7 +9413,7 @@ impl Game<'_> {
}
if !spectator {
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
g.draw_selected_name(batch, bar_height, !g.creative);
g.draw_selected_name(batch, !g.creative);
});
}
}
@@ -9284,7 +9424,7 @@ impl Game<'_> {
});
if show_hud {
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
g.draw_overlay_message(batch, bar_height);
g.draw_overlay_message(batch);
});
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
g.draw_title(batch);
@@ -10438,18 +10578,27 @@ impl Game<'_> {
if let Some((s, mob)) = session.as_mut() {
self.merchant_sync_in(s, *mob);
}
// The cartography table reads and makes the level's maps; a
// Crafting and cartography read and derive the level's maps; a
// server's (the client menu's ContainerLevelAccess.NULL) doesn't.
let level_maps = (kind == Kind::Cartography && self.remote.is_none()).then(|| {
let held: Vec<ItemStack> = before
let level_maps = (matches!(kind, Kind::Cartography | Kind::Crafting | Kind::Inventory)
&& self.remote.is_none())
.then(|| {
before
.iter()
.flatten()
.chain(self.inventory.iter().flatten())
.chain(self.carried.iter())
.cloned()
.collect();
self.level_maps(&held)
});
.collect::<Vec<_>>()
})
.filter(|held| {
kind == Kind::Cartography
|| held
.iter()
.filter_map(maps::map_id)
.any(|id| self.map(id).is_some())
})
.map(|held| self.level_maps(&held));
let next_map = if merchant_open { self.peek_map_id() } else { 0 };
let tag = |t: &str| self.structure_tag(t);
let maps = trading::GameMaps {
@@ -10559,6 +10708,9 @@ impl Game<'_> {
let predicting = self.remote.is_some();
if !predicting {
self.register_exploration_maps(made);
for (item, count) in std::mem::take(&mut menu.crafted_counts) {
self.award_stat("crafted", &item, count);
}
}
if let Some(s) = menu.merchant.take() {
self.merchant_sync_out(s);
@@ -12287,8 +12439,12 @@ impl Game<'_> {
if let Some(hit) = self.tick_mob {
return hit;
}
self.mob_hit_within(self.entity_reach)
}
/// [`Game::mob_hit`] out to `reach` instead of the entity interaction range
/// (`ProjectileUtil.getHitResultOnViewVector` with another range).
pub(super) fn mob_hit_within(&self, reach: f64) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
@@ -13804,6 +13960,9 @@ impl Game<'_> {
&& let Ok(new) = registry.resolve(&block)
{
self.level_set(pos, new)?;
if result.lower_fill_level {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
if let Some(sound) = result.sound {
self.audio.play(
@@ -14360,12 +14519,19 @@ impl Game<'_> {
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
/// `tryMergeToExisting` compares from the level random, then moves with
/// its own random.
fn spawn_orbs(&mut self, at: [f64; 3], mut value: u32) {
fn spawn_orbs(&mut self, at: [f64; 3], value: u32) {
self.spawn_orbs_toward(at, [0.0; 3], value);
}
/// `ExperienceOrb.awardWithDirection`: `award` whose fresh orbs start
/// `roughly` that way (`new ExperienceOrb(level, pos, roughly, value)`).
fn spawn_orbs_toward(&mut self, at: [f64; 3], roughly: [f64; 3], mut value: u32) {
while value > 0 {
let v = ExperienceOrb::split(value);
value -= v;
let merge_id = self.world.random.next_int_bound(40).unwrap_or(0);
self.award_orb(at, v, merge_id);
if !self.merge_new_orb(at, v, merge_id) {
self.add_orb_toward(at, roughly, v);
}
}
}
/// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a
@@ -14373,6 +14539,12 @@ impl Game<'_> {
/// the new one as one more in its count (and restarts its age); otherwise
/// a fresh orb spawns.
fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) {
if !self.merge_new_orb(at, value, merge_id) {
self.add_orb(at, value);
}
}
/// `tryMergeToExisting`: true when an orb took the new one.
fn merge_new_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) -> bool {
let centre = Vec3::new(at[0], at[1], at[2]);
let half = Vec3::new(0.5, 0.5, 0.5);
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
@@ -14394,8 +14566,9 @@ impl Game<'_> {
if let Some(i) = target {
self.orbs[i].count += 1;
self.orbs[i].age = 0;
true
} else {
self.add_orb(at, value);
false
}
}
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
@@ -14469,10 +14642,33 @@ impl Game<'_> {
/// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from
/// the orb's own random.
fn add_orb(&mut self, at: [f64; 3], value: u32) {
let at = self.unstuck_orb(at);
self.add_orb_toward(at, [0.0; 3], value);
}
/// `new ExperienceOrb(level, pos, roughly, value)`: a random motion that
/// points against `roughly` is turned round, and the orb starts half its
/// size (0.25) along `roughly`, unstuck if it collides there.
fn add_orb_toward(&mut self, at: [f64; 3], roughly: [f64; 3], value: u32) {
let len_sqr = roughly.iter().map(|v| v * v).sum::<f64>();
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let len = len_sqr.sqrt();
let unit = if len < 1.0e-5 {
[0.0; 3]
} else {
roughly.map(|v| v / len)
};
let at = self.unstuck_orb([0, 1, 2].map(|a| at[a] + unit[a] * 0.25));
let mut r = dispenser::entity_random();
let _yaw = r.next_float();
let p = Vec3::new(at[0], at[1], at[2]);
let mut velocity = Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
);
let dot = roughly[0] * velocity.x + roughly[1] * velocity.y + roughly[2] * velocity.z;
if len_sqr > 0.0 && dot < 0.0 {
velocity = Vec3::new(-velocity.x, -velocity.y, -velocity.z);
}
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
@@ -14481,11 +14677,7 @@ impl Game<'_> {
id: self.next_entity,
position: p,
previous: p,
velocity: Vec3::new(
(r.next_double() * 0.2 - 0.1) * 2.0,
r.next_double() * 0.2 * 2.0,
(r.next_double() * 0.2 - 0.1) * 2.0,
),
velocity,
value,
age: 0,
on_ground: false,
@@ -15157,7 +15349,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -15198,7 +15390,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -16327,6 +16519,17 @@ impl Game<'_> {
// (`take_parent_vehicle`); here it stays in the chunk saves.
let mut host = host;
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
// Read-only fixture input, correlated with the real save receipt.
// Keep this identical on the untouched/fixed selector builds.
if std::env::var_os("HORIZON_DEV_ADVANCEMENT_SELECTOR_AUDIT").is_some() {
eprintln!(
"advancement selector model raw: {}",
serde_json::json!({"conn":g.conn,"name":g.name,
"uuid":horizon_protocol::crypto::uuid_string(g.uuid),
"game_time":self.world.game_time,
"progress":g.advancements.to_json(&self.adv.tree, horizon_world::DATA_VERSION)})
);
}
self.guest_attachments_into(g);
self.player_autosaves.enqueue(g, &self.world_root);
}
@@ -16358,6 +16561,10 @@ impl Game<'_> {
/// saved icon is on the screen, grab the rendered frame once a second until
/// the save has one. The Select World list shows it as the world's preview.
fn tick_world_icon(&mut self) {
// Its grab would take a sequence's frame.
if self.screenshot_sequence.is_some() {
return;
}
let Some(icon) = self.world_icon.as_ref() else {
return;
};
@@ -16405,6 +16612,12 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
if self.screenshot_sequence.is_some() {
return self.render_sequence_frame();
}
self.render_frame()
}
fn render_frame(&mut self) -> Result<bool> {
let prepare = spans::guard("prepare");
let mut pr = spans::laps();
// The window's GPU holds the atlas: its copy in memory is kept
@@ -16892,6 +17105,7 @@ impl Game<'_> {
} else {
(Vec::new(), None)
};
self.custom_sky_frame([camera.x, camera.y, camera.z], &sky, (rain, thunder), title);
pr.lap("pr_celestial");
let (mut hand, hand_items) = if title || self.scoping() {
Default::default()
@@ -16937,12 +17151,16 @@ impl Game<'_> {
self.gather_dynamic_lights(camera, alpha);
}
pr.lap("pr_lights");
let pack_inputs = if title {
let mut pack_inputs = if title {
Default::default()
} else {
self.shader_pack_inputs(camera, alpha as f32, &sky)
};
pr.lap("pr_shader_inputs");
pack_inputs.frame_timing = self
.screenshot_sequence
.as_ref()
.map(screenshot_sequence::Sequence::timing);
// Map textures and font glyphs changed while building this frame's
// geometry (game/maps.rs, world text) upload with this batch; draining
// map_patches any earlier leaves them a frame behind.
@@ -17567,6 +17785,7 @@ impl Game<'_> {
_ if self.screen == Screen::Dead => {
self.death_key(key)?;
}
_ if self.screen == Screen::Stats => self.stats_key(raw),
_ => {}
}
Ok(())
@@ -18209,13 +18428,7 @@ impl ApplicationHandler for Game<'_> {
return;
}
};
if fullscreen && window.is_maximized() {
window.set_maximized(false);
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor());
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
}
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
match self.new_renderer(window.clone()) {
@@ -18627,6 +18840,19 @@ impl Game<'_> {
}
}
}
// Title boxes keep their text on the key path. A field hook takes
// it through the same per-character editor as IME and control input.
if !canceled
&& control::title_owns_text(&self.screen, self.title.is_some())
&& self.types_text()
&& let Some(text) = key_event.text.as_deref()
&& let Some(result) = self.type_text_field_input(text)
{
if let Err(error) = result {
log::warn!("Horizon: text input: {error}");
}
key_event.text = None;
}
if canceled {
// charTyped is a separate callback: the characters still
// reach the text box, without the key's own effects. Options'
@@ -19820,6 +20046,7 @@ impl Game<'_> {
}
/// The loop ended: close the pads' output, save the world, write the perf report.
pub(super) fn shut_down(&mut self) {
self.end_screenshot_sequence(Err("cancelled: the game is closing".into()));
self.pad_close_output();
if let Err(e) = self.save_for_exit() {
log::warn!("Horizon: failed to save the world on exit: {e}");
@@ -20221,6 +20448,7 @@ impl<'a> Game<'a> {
level_up_sound: Default::default(),
swing: Default::default(),
swings_started: 0,
predicted_swings: 0,
heights: Default::default(),
sway: Default::default(),
visible_hands: [(0, None), (0, None)],
@@ -20238,6 +20466,7 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
custom_sky: Default::default(),
rain_sound_time: 0,
lightning_seen: [0; 2],
sleeping: None,
@@ -20273,6 +20502,7 @@ impl<'a> Game<'a> {
map_frame_model: prepared.map_frame_model,
glow_map_frame_model: prepared.glow_map_frame_model,
armor_stands: Default::default(),
location_armor: Default::default(),
cushions: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
@@ -20292,6 +20522,7 @@ impl<'a> Game<'a> {
entity_models: prepared.entity_models,
carts: Default::default(),
watched_vehicles: Vec::new(),
native_moves: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
maps: maps::Maps::default(),
@@ -20361,6 +20592,7 @@ impl<'a> Game<'a> {
frame_stats: Default::default(),
perf: Default::default(),
pending_screenshot: None,
screenshot_sequence: None,
pending_render_capture: None,
pending_bench: None,
flight: None,
@@ -20390,6 +20622,8 @@ impl<'a> Game<'a> {
native_lifecycle: Default::default(),
native_configs: Default::default(),
native_net: Default::default(),
native_player_fx: Default::default(),
native_packets: Default::default(),
native_mod_screen: None,
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
@@ -20447,6 +20681,7 @@ impl<'a> Game<'a> {
block_reach: 4.5,
guest_use: None,
use_failed: false,
guest_inventory_resend: false,
lan_events: Vec::new(),
lan_songs: Vec::new(),
lan_cushion_breaks: Vec::new(),
@@ -20468,14 +20703,17 @@ impl<'a> Game<'a> {
lan_break_pos: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
native_resend_trees: Vec::new(),
guest_entity_events: Vec::new(),
guest_equipment_breaks: Vec::new(),
guest_player_sounds: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
native_click_cursor: None,
tnt_refused: None,
lan_acting_permission: None,
lan_acting_entity: None,
native_login_permission: None,
chat_capture: None,
admin_feedback: Vec::new(),
console_broadcast: None,
@@ -21893,7 +22131,7 @@ fn build_renderer(
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
shader_pack: shader_packs::PickedPack,
filtering: (i32, i32),
) -> Result<Renderer> {
let assets = &pack.render;
@@ -21932,7 +22170,9 @@ fn build_renderer(
api,
translucent_batching,
wide_frame,
shader_pack,
shader_pack.draws_flat,
shader_pack.linear,
shader_pack.hdr_float,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -21945,6 +22185,7 @@ fn build_renderer(
pack.item_renderer
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
.hud
@@ -22004,6 +22245,8 @@ fn loading_renderer(
// An 8-bit frame, which HDR output takes as well.
false,
false,
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -22424,13 +22667,7 @@ impl Loading {
return;
}
};
if fullscreen && window.is_maximized() {
window.set_maximized(false);
let monitor = window
.current_monitor()
.or_else(|| window.primary_monitor());
window.set_fullscreen(Some(winit::window::Fullscreen::Borderless(monitor)));
}
horizon_client::window::settle_created_fullscreen(&window, fullscreen);
drop(window_step);
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
@@ -22621,7 +22858,7 @@ impl Loading {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -22905,11 +23142,17 @@ pub fn run_with() -> Result<()> {
{
return Ok(());
}
// The packets the server sends its players reach the mods that ask (native_packets.rs).
let _ = horizon_client::lan::conn::SERVER_PACKET.set(Game::native_server_packet);
let _ = horizon_client::lan::conn::ENTITY_DATA_TO.set(Game::native_entity_data_to);
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
let _ = horizon_client::lan::hub::PLAYER_MOVE.set(Game::native_player_move);
let _ = horizon_client::lan::hub::PLAYER_TELEPORT.set(Game::native_player_teleport);
let _ = horizon_client::block_contact::TRAMPLE.set(Game::native_trample);
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
@@ -22937,7 +23180,7 @@ pub fn run_with() -> Result<()> {
{
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
horizon_client::spans::enable(options.spikes.is_some());
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
if options.execution_role.is_client()
@@ -24211,3 +24454,6 @@ fn write_level_for_role(
level.write_without_player(dir)
}
}
#[cfg(test)]
mod vibration_tests;
+60 -41
View File
@@ -392,6 +392,15 @@ impl Game<'_> {
let tree = self.adv.tree.clone();
self.adv.progress = progress;
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
// `PlayerAdvancements.checkForAutomaticTriggers`: an advancement with no
// criteria is never done (`AdvancementRequirements.test` is false), but
// its rewards are granted as the progress loads. Vanilla's `award(holder, "")` finds no `""`
// criterion to grant and so returns without a toast or an announcement;
// the reward grant is the whole effect, and it happens again on the next
// load because there is no criterion to record it.
for index in tree.automatic() {
self.grant_rewards(index);
}
}
pub(super) fn save_advancements(&self) {
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
@@ -920,47 +929,7 @@ impl Game<'_> {
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_done(player, &name, &a.id);
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
if let Some(function) = &a.rewards.function {
self.run_advancement_reward_function(function);
}
let unlocked = self.grant_rewards(index);
if let Some(d) = &a.display {
// A network player's client shows its own toast.
if d.show_toast
@@ -1012,6 +981,56 @@ impl Game<'_> {
}
self.recipe_unlocked(&unlocked);
}
/// `AdvancementRewards.grant`: the experience, recipes, loot and function of
/// a finished advancement, and the recipes it unlocked for the
/// `recipe_unlocked` trigger, which the caller runs after its toast and
/// announcement. Returns those recipes.
fn grant_rewards(&mut self, index: usize) -> Vec<String> {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
if let Some(function) = &a.rewards.function {
self.run_advancement_reward_function(function);
}
unlocked
}
/// `InventoryChangeTrigger`, checked when the inventory changes.
pub(super) fn check_inventory_trigger(&mut self) {
use std::hash::{Hash, Hasher};
+3 -1
View File
@@ -1647,7 +1647,9 @@ impl Game<'_> {
}
if text == "\\b" {
title.backspace();
} else {
} else if let Some(result) = self.type_text_field_input(text) {
result?;
} else if let Some(title) = self.title.as_mut() {
title.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
+58 -1
View File
@@ -151,6 +151,13 @@ impl Game<'_> {
.block_entities
.put(key, BlockEntity::Beehive(hive));
if stored {
let state = self.world.block(at[0], at[1], at[2]);
self.post_block_game_event(
"minecraft:block_change",
at,
Some(Who::Mob(mob)),
Some(state),
);
let kind = self.mobs[i].kind.id.clone();
let block = self
.res
@@ -268,7 +275,9 @@ impl Game<'_> {
} else if let Some(rest) = self.give(bottle)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
// `player.drop(stack, false, PREDICTED)`: thrown from below
// the eyes along the view, with no thrower.
self.throw_from_eyes(rest, eye, false);
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
@@ -368,3 +377,51 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod honey_overflow_tests {
use super::*;
/// `BeehiveBlock.useItemOn`: a honey bottle that no longer fits is
/// `player.drop(stack, false, PREDICTED)`: from 0.3 below the eyes,
/// thrown along the view, with no thrower (#11377).
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_honey_bottle_that_does_not_fit_is_thrown_ahead() {
let mut game = super::super::native_machines::tests::server_game();
let registry = &game.pack.assets.registry;
let hive = registry.default_state("minecraft:beehive").unwrap();
let mut full = registry.state(hive).unwrap().clone();
full.properties.insert("honey_level".into(), "5".into());
let full = registry.id(&full).unwrap();
game.world.set(8, 64, 10, full).unwrap();
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 64.0, 8.5))
.unwrap(),
);
// Facing south (+z), towards the hive.
game.yaw = 0.0;
game.pitch = 0.0;
for slot in game.inventory.iter_mut() {
*slot = Some(ItemStack::new("minecraft:stone", 64));
}
game.selected = 0;
let bottles = ItemStack::new("minecraft:glass_bottle", 2);
game.inventory[0] = Some(bottles.clone());
game.dropped.clear();
assert!(game.use_beehive([8, 64, 10], Some(&bottles)).unwrap());
let eye = game.eye_position().unwrap();
let honey: Vec<_> = game
.dropped
.iter()
.filter(|e| &*e.stack.item == "minecraft:honey_bottle")
.collect();
assert_eq!(honey.len(), 1);
let e = honey[0];
assert!((e.position.y - (eye[1] - f64::from(0.3f32))).abs() < 1e-9);
assert!(e.velocity.z > 0.2, "thrown ahead: {:?}", e.velocity);
assert_eq!(e.thrower, None);
assert_eq!(e.pickup_delay, 40);
}
}
@@ -506,6 +506,69 @@ impl Game<'_> {
mod tests {
use super::*;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn command_view_uses_live_and_pending_boat_leashes_not_raw_tags() {
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
let (id, uuid, actual_holder, unknown) =
(1, [326, 1, 0, 1], [326, 2, 0, 1], [111, 222, 333, 444]);
game.boats
.push(Boat::new(id, "oak".into(), Vec3::new(6.5, 100.0, 4.5), 0.0));
game.persist.uuids.insert(id, uuid);
game.level.player_uuid = Some([326, 3, 0, 1]);
let read = |game: &mut Game<'_>| game.entity_nbt(Target::Boat(id)).unwrap();
assert!(!game.persist.raw.contains_key(&id));
assert_eq!(
read(&mut game).get("UUID"),
Some(&Tag::IntArray(uuid.to_vec()))
);
assert!(read(&mut game).get("leash").is_none());
game.boats[0].leash = Some(Holder::Player);
game.boats[0].leash_player = Some(actual_holder);
let expected = Tag::compound([("UUID", Tag::IntArray(actual_holder.to_vec()))]);
assert_eq!(read(&mut game).get("leash"), Some(&expected));
assert_eq!(read(&mut game).get("leash"), Some(&expected));
let delayed = Tag::compound([("UUID", Tag::IntArray(unknown.to_vec()))]);
game.persist
.raw
.insert(id, Tag::compound([("leash", delayed.clone())]));
assert_eq!(read(&mut game).get("leash"), Some(&expected));
game.boats[0].leash = None;
game.boats[0].leash_player = None;
game.boats[0].leash_pending = Some(Pending::Uuid(unknown));
assert_eq!(read(&mut game).get("leash"), Some(&delayed));
game.boats[0].leash_pending = None;
assert!(read(&mut game).get("leash").is_none());
}
#[test]
fn a_boat_is_centred_half_its_height_up() {
let b = Boat::new(1, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 0.0);
@@ -26,6 +26,9 @@ pub(super) struct BossState {
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
/// The bars server mods show single players (`native_boss_bar`): by
/// player UUID, each bar's id and bar, in the order they were added.
pub(super) native: HashMap<u128, Vec<(u128, horizon_client::mod_api::NativeBossBar)>>,
}
impl BossState {
@@ -551,6 +554,20 @@ impl Game<'_> {
scope: Scope::Global,
});
}
// Server mods' bars for this player alone (`native_boss_bar`).
for (id, bar) in self.bosses.native.get(&viewer.uuid).into_iter().flatten() {
out.push(Offer {
id: *id,
name: horizon_client::lan::data::component_nbt(&bar.name.component()),
progress: bar.progress,
color: bar.color,
overlay: bar.overlay,
darken: bar.darken,
music: bar.music,
fog: bar.fog,
scope: Scope::Global,
});
}
if viewer.here
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
{
+35 -5
View File
@@ -40,19 +40,49 @@ pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
pub(super) fn tick_brush(
&mut self,
stack: &horizon_client::items::ItemStack,
slot: usize,
ticks: u32,
) {
// No block under the crosshair: vanilla calls releaseUsingItem, which
// stops the use and causes the interaction vibration.
let Some(hit) = self.pick() else {
self.using = None;
self.stop_using_item(stack);
return;
};
// `BrushItem.onUseTick` aims with `getHitResultOnViewVector` out to the
// block interaction range, for mobs too: a mob between the player and
// the block is nearer, so the hit is not a block and the use releases
// the same way. A guest acts on its client's targeted block, whose view
// we cannot trace here, so only the local aim runs the entity test.
if self.forced_hit.is_none()
&& let Some((eye, _)) = self.aim()
&& let Some((_, mob, _)) = self.mob_hit_within(self.block_reach)
{
let block = (
hit.point[0] - eye[0],
hit.point[1] - eye[1],
hit.point[2] - eye[2],
);
if mob <= (block.0 * block.0 + block.1 * block.1 + block.2 * block.2).sqrt() {
self.stop_using_item(stack);
return;
}
}
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
// The use ends after USE_DURATION; stopUsingItem causes the vibration
// (vanilla's tickUsingItem), the held button starts it again.
if elapsed < USE_DURATION {
self.using = Some((slot, elapsed));
} else {
self.stop_using_item(stack);
}
}
/// One stroke on the block aimed at: dust (this window only), the brush
@@ -252,6 +252,7 @@ mod tests {
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[20.5, 64.0, 0.5],
@@ -257,6 +257,76 @@ impl MapData {
#[cfg(test)]
mod tests {
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn crafting_map_takes_use_real_saved_maps() {
use crate::game::{Audio, Options, Settings, load_resources};
use horizon_client::menu::{Env, Input, Kind, Menu};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&crate::game::Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
for mode in [Input::Swap, Input::Pickup, Input::QuickMove] {
let source = game.new_map_id(MapData::fresh(4.5, 4.5, 0, "minecraft:overworld"));
let mut input = ItemStack::new("minecraft:filled_map", 1);
input.data_mut().map_id = Some(source);
let maps = game.level_maps([&input]);
let env = Env {
maps: Some(&maps),
..game.menu_env()
};
let mut menu = Menu::new(Kind::Crafting);
for i in [1, 2, 3, 4, 6, 7, 8, 9] {
menu.set_slot(&env, i, Some(ItemStack::new("minecraft:paper", 1)));
}
menu.set_slot(&env, 5, Some(input));
let preview = menu.slot(0).expect("real scaling recipe admitted");
assert_eq!(preview.data().and_then(|d| d.map_id), Some(source));
assert_eq!(preview.data().and_then(|d| d.map_post_processing), Some(1));
menu.click(&env, 0, if mode == Input::Swap { 2 } else { 0 }, mode)
.unwrap();
let output = match mode {
Input::Swap => menu.player_item(2),
Input::Pickup => menu.carried.as_ref(),
_ => menu.player_item(8),
}
.expect("crafted map remains present");
assert_eq!(output.count, 1);
assert_eq!(output.data().and_then(|d| d.map_id), Some(source + 1));
assert_eq!(output.data().and_then(|d| d.map_post_processing), None);
assert!((1..10).all(|i| menu.slot(i).is_none()));
game.register_made_maps(maps);
let original = game.map(source).unwrap();
assert_eq!((original.scale, original.center), (0, [0, 0]));
let derived = game.map(source + 1).unwrap();
assert_eq!((derived.scale, derived.center), (1, [64, 64]));
}
}
use super::*;
use crate::game::maps::{Decoration, Key};
+71 -11
View File
@@ -720,10 +720,13 @@ impl Game<'_> {
.as_ref()
.filter(|d| !command || d.command)
.map(|d| d.text.clone());
self.open_chat(draft.as_deref().unwrap_or(prefix));
self.open_chat(draft.as_deref().unwrap_or(prefix), draft.is_some());
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
}
pub(super) fn open_chat(&mut self, initial: &str) {
/// Open the chat box on `initial`; `is_draft` when that text is the saved
/// draft (`ChatComponent.createScreen`), which draws grey italic until it
/// is edited.
pub(super) fn open_chat(&mut self, initial: &str, is_draft: bool) {
if self.player.is_none() {
return;
}
@@ -743,6 +746,7 @@ impl Game<'_> {
&m,
&c,
initial,
is_draft,
self.ui.chat.recent.len(),
b.width as i32,
b.height as i32,
@@ -757,6 +761,36 @@ impl Game<'_> {
self.right = false;
self.capture(false);
}
/// The open chat box's text and its cursor, counted in characters; `None`
/// while the chat screen isn't open. API @API:chat_input@.
#[allow(dead_code)]
pub(super) fn native_chat_input(&self) -> Option<(String, usize)> {
let screen = self.ui.chat_screen.as_ref()?;
Some((screen.input.value.clone(), screen.input.cursor))
}
/// Replaces the open chat box's text and puts the cursor at `cursor`
/// (characters, clamped to the text), as an edit: completions refresh as
/// they do for typing. What typing can't put in the box isn't put there
/// either (`chat_box_text`), and the 256-character limit holds. An error
/// while the chat screen isn't open. API @API:chat_input@.
#[allow(dead_code)]
pub(super) fn native_set_chat_input(
&mut self,
text: &str,
cursor: usize,
) -> Result<(), String> {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
let Some(screen) = self.ui.chat_screen.as_mut() else {
return Err("the chat isn't open".into());
};
screen.suggest_command(&m, &c, &chat_box_text(text));
screen.input.move_cursor_to(&m, cursor, false);
Ok(())
}
pub(super) fn close_chat(&mut self, reason: ChatExit) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
@@ -836,11 +870,13 @@ impl Game<'_> {
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat(ChatExit::Interrupted);
if !text.is_empty() {
self.open_chat(&text);
// InBedChatScreen.onPlayerWokeUp: a new ChatScreen with
// the typed text, not a draft.
self.open_chat(&text, false);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.open_chat("", false);
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
@@ -1627,23 +1663,27 @@ impl Game<'_> {
pub(super) fn toggle_hide_gui(&mut self) {
self.ui.hide_gui = !self.ui.hide_gui;
}
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
pub(super) fn take_screenshot(&mut self) {
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
/// file it will write, `None` without a window to draw.
pub(super) fn take_screenshot(&mut self) -> Option<std::path::PathBuf> {
// Its grab would take a sequence's frame.
if self.screenshot_sequence.is_some() {
return None;
}
let dir = horizon_client::logging::player_dir().join("screenshots");
if std::fs::create_dir_all(&dir).is_err() {
return;
return None;
}
let name = screenshot_name(&dir);
let Some(renderer) = self.renderer.as_mut() else {
return;
};
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
self.ui.screenshot_message = Some(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
/// `key.screenshot` mapping (F2 by default) takes the picture before a
@@ -1699,6 +1739,14 @@ impl Game<'_> {
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
/// What a mod may write into the chat box: the characters `ChatScreen`'s
/// typing lets in (`StringUtil.isAllowedChatCharacter`: no control
/// characters, no DEL, no section sign).
fn chat_box_text(text: &str) -> String {
text.chars()
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
@@ -1730,6 +1778,17 @@ const LINK_COPY: usize = 1;
mod tests {
use super::*;
/// A mod's text reaches the chat box as typing would leave it: emoji and
/// accents stay, control characters, DEL and the section sign go.
#[test]
fn a_mods_chat_text_keeps_what_typing_allows() {
assert_eq!(
super::chat_box_text("hi :smile: → 😀 é"),
"hi :smile: → 😀 é"
);
assert_eq!(super::chat_box_text("a\nb\tc\u{7f}d§6e"), "abcd6e");
}
/// `ChatScreen.shouldDiscardDraft`.
#[test]
fn draft_discard_follows_the_exit_reason() {
@@ -2057,6 +2116,7 @@ mod tests {
&m,
&c,
"",
false,
0,
width as i32,
height as i32,
@@ -599,6 +599,47 @@ impl Game<'_> {
}
// Java produced the fixture file before this world was opened; map_state loads it.
"crafter_max_map_fixture" => {}
"boat_interact" => {
let uuid = u128::from_str_radix(
&op["uuid"]
.as_str()
.ok_or("boat click: missing UUID")?
.replace('-', ""),
16,
)?;
self.in_owner_level(|g| -> Result<()> {
let id = g
.boat_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
.ok_or("boat click: missing live boat")?;
let owner = g.serving.as_ref().ok_or("boat click: no server")?.owner;
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.map(|p| p.conn)
})
.ok_or("boat click: owner not connected")?;
let mut host = g.lan.take().ok_or("boat click: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::Interact(
horizon_protocol::packets::play::Interact {
id: id as i32,
hand: 0,
location: [0.0; 3],
secondary: false,
},
),
);
g.lan = Some(host);
Ok(())
})?;
}
"use_item" => {
let owner = self
.serving
@@ -691,6 +732,100 @@ impl Game<'_> {
Ok(())
})?;
}
"craft_menu_click" | "craft_menu_state" | "craft_menu_close" => {
let owner = self.serving.as_ref().ok_or("no server")?.owner;
let state = self.in_owner_level(|g| -> Result<Value> {
let conn = g.lan.as_ref().and_then(|h| h.hub.guests.iter()
.find(|p| p.uuid == owner && p.placed).map(|p| p.conn))
.ok_or("craft fixture: no connected owner")?;
let mut host = g.lan.take().ok_or("no hub")?;
let result = (|| -> Result<Value> {
let (window, revision, _, _) = host.play.get(&conn)
.and_then(|p| p.crafting.table.as_ref())
.ok_or("craft fixture: crafting menu not open")?;
let (window, revision) = (*window, *revision);
if op["op"] == "craft_menu_click" {
// Revision last sent by the real table-content sync, never a fixed 0.
eprintln!("craft fixture click: window={window}, revision={revision}");
g.lan_guest_packet(&mut host, conn,
horizon_protocol::packets::play::Serverbound::ContainerClick(
horizon_protocol::packets::play::ContainerClick {
container: window, state: revision,
slot: op["slot"].as_i64().ok_or("click slot")? as i16,
button: op["button"].as_i64().unwrap_or(0) as i8,
input: op["input"].as_i64().unwrap_or(0) as i32,
changed: Vec::new(), carried: None,
}));
return Ok(Value::Null);
}
if op["op"] == "craft_menu_close" {
g.lan_guest_packet(&mut host, conn,
horizon_protocol::packets::play::Serverbound::ContainerClose(
horizon_protocol::packets::play::ContainerClose { container: window }));
return Ok(Value::Null);
}
let (_, _, menu, _) = host.play.get(&conn)
.and_then(|p| p.crafting.table.as_ref()).unwrap();
if menu.kind() != horizon_client::menu::Kind::Crafting {
return Err("craft fixture: wrong menu kind".into());
}
let guest = host.hub.guest(conn).ok_or("no guest")?;
let stack = |s: Option<&horizon_client::items::ItemStack>| {
s.map(|s| json!([&*s.item, s.count,
s.data().and_then(|d| d.map_id),
s.data().and_then(|d| d.map_post_processing)]))
};
// Table's ten slots plus the owner's live inventory in menu order.
let slots: Vec<_> = (0..10).map(|i| stack(menu.slot(i)))
.chain((9..36).chain(0..9).map(|i| stack(guest.inventory[i].as_ref())))
.collect();
Ok(json!({"window": window, "kind": "minecraft:crafting",
"slots": slots, "carried": stack(guest.carried.as_ref()).unwrap_or_else(|| json!([]))}))
})();
g.lan = Some(host);
result
})?;
if op["op"] == "craft_menu_state" {
let record = json!({"cmd": format!("#craft_menu {}", op["label"].as_str().unwrap_or("")),
"state": state, "messages": [], "results": []});
out.push_str(&record.to_string());
out.push('\n');
}
}
"map_files" => {
let state = self.harness_owner(|g| {
use horizon_world::nbt::Tag;
g.save_maps();
let directory = g.world_root.join("data/minecraft/maps");
let maps: Vec<_> = op["ids"].as_array().ok_or("map IDs")?.iter().map(|id| {
let path = directory.join(format!("{}.dat", id.as_i64().unwrap()));
horizon_client::save::read_dat(&path).map(|root| {
let data = root.get("data").unwrap_or(&root);
let number = |key| match data.get(key) {
Some(Tag::Int(v)) => *v, Some(Tag::Byte(v)) => i32::from(*v), _ => 0,
};
let colors = match data.get("colors") {
Some(Tag::ByteArray(bytes)) => Some(bytes.len()), _ => None,
};
json!({"scale": number("scale"), "locked": number("locked") != 0,
"center": [number("xCenter"), number("zCenter")],
"dimension": horizon_client::save::text(data.get("dimension")),
"byte_array_length": colors})
})
}).collect();
let last = horizon_client::save::read_dat(&directory.join("last_id.dat"))
.and_then(|tag| match tag.get("data").and_then(|d| d.get("map")) {
Some(Tag::Int(v)) => Some(*v), _ => None,
});
Ok(json!({"maps": maps, "last_id": last}))
})?;
out.push_str(
&json!({"cmd": "#map_files", "state": state,
"messages": [], "results": []})
.to_string(),
);
out.push('\n');
}
"map_state" => {
let id = op["id"].as_i64().ok_or("command harness: map id")? as i32;
let value = self.harness_owner(|g| {
@@ -735,6 +870,118 @@ impl Game<'_> {
out.push_str(&record.to_string());
out.push('\n');
}
"merchant_saved_stats" => {
let (state, raw) = self.harness_owner(|g| {
g.load_stats();
if !g.stats.loaded { return Err("merchant stats not loaded".into()); }
g.save_stats();
let uuid = g.level.player_uuid.ok_or("merchant stats UUID")?;
let path = super::stats::player_json(&g.world_root, "stats", uuid);
let raw = std::fs::read_to_string(path)?;
let saved: Value = serde_json::from_str(&raw)?;
let value = |ty: &str, key: &str| saved["stats"][ty][key].as_i64().unwrap_or(0);
Ok((json!({"uuid":uuid,"books":value("minecraft:crafted","minecraft:bookshelf"),
"glass":value("minecraft:crafted","minecraft:glass"),
"trades":value("minecraft:custom","minecraft:traded_with_villager")}), raw))
})?;
let label = op["label"].as_str().unwrap_or("");
std::fs::write(
output.with_file_name(format!("native-merchant-stats-{label}.json")),
raw,
)?;
out.push_str(
&json!({"cmd":format!("#merchant_saved_stats {label}"),
"messages":[],"results":[],"state":state})
.to_string(),
);
out.push('\n');
}
"merchant_action" | "merchant_state" => {
let observe = op["op"] == "merchant_state";
let state = self.in_owner_level(|g| -> Result<Value> {
let mut host = g.lan.take().ok_or("merchant oracle: no hub")?;
let result = (|| -> Result<Value> {
let owner = g
.serving
.as_ref()
.ok_or("merchant oracle: no server")?
.owner;
let conn = host
.hub
.guests
.iter()
.find(|p| p.uuid == owner && p.placed)
.ok_or("merchant oracle: owner not connected")?
.conn;
if observe {
return Ok(g
.harness_merchant(&host, conn)
.ok_or("merchant oracle: no live menu")?
.2);
}
use horizon_protocol::packets::play::{self, Serverbound as S};
let packet = match op["action"].as_str().unwrap_or("") {
"open" => {
let uuid = u128::from_str_radix(
op["uuid"]
.as_str()
.ok_or("merchant oracle: UUID")?
.replace('-', "")
.as_str(),
16,
)?;
let Some(super::commands::Target::Mob(id)) =
g.target_by_uuid(uuid)
else {
return Err("merchant oracle: missing live mob".into());
};
S::Interact(play::Interact {
id: id as i32,
hand: 0,
location: [0.0; 3],
secondary: false,
})
}
"select" => S::SelectTrade(play::SelectTrade {
item: op["index"].as_i64().unwrap_or(0) as i32,
}),
"click" => {
let (container, state, _) = g
.harness_merchant(&host, conn)
.ok_or("merchant oracle: no menu")?;
S::ContainerClick(play::ContainerClick {
container,
state,
slot: op["slot"].as_i64().ok_or("merchant oracle: slot")?
as i16,
button: op["button"].as_i64().unwrap_or(0) as i8,
input: op["input"].as_i64().unwrap_or(0) as i32,
changed: Vec::new(),
carried: None,
})
}
"close" => S::ContainerClose(play::ContainerClose {
container: g
.harness_merchant(&host, conn)
.ok_or("merchant oracle: no menu")?
.0,
}),
_ => return Err("merchant oracle: unknown action".into()),
};
eprintln!("merchant action receipt: action={} owner={owner:032x} conn={conn} menu={:?} clock={}",
op["action"], g.harness_merchant(&host, conn).map(|(w, r, _)| (w, r)), g.world.game_time);
g.lan_guest_packet(&mut host, conn, packet);
Ok(Value::Null)
})();
g.lan = Some(host);
result
})?;
if observe {
out.push_str(&json!({"cmd":format!("#merchant_state {}",op["label"].as_str().unwrap_or("")),
"messages":[],"results":[],"state":state}).to_string());
out.push('\n');
}
}
"menu_state" => {
let (_, own_slots, _) = self.harness_owner_menu()?;
let record = json!({"cmd": format!("#menu_state {}", op["label"].as_str().unwrap_or("")),
Loaded 100 of 682 files, more files were not shown because too many files have changed in this diff. Show more