mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
take_screenshot returns main's Option<PathBuf>; a running sequence still refuses F2 (None). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
278ffc3b96
758 files changed
+57934
-4688
No files matched your search
@@ -234,14 +234,15 @@ jobs:
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
|
||||
# `$output = & launcher --version` read no output from it (run 42217014588).
|
||||
shell: bash
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
python ../tools/check_imports.py "$launcher"
|
||||
out=$("$launcher" --version)
|
||||
echo "$out"
|
||||
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
|
||||
- name: Build a native-mod game with the launcher's managed toolchain
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
|
||||
@@ -34,7 +34,9 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
|
||||
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
|
||||
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
|
||||
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
|
||||
|
||||
## Crates
|
||||
|
||||
Generated
+199
-3
@@ -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"
|
||||
@@ -1105,8 +1130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,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"
|
||||
@@ -1264,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"
|
||||
@@ -1660,10 +1706,15 @@ dependencies = [
|
||||
name = "horizon-mod-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gif",
|
||||
"mysql",
|
||||
"quinn",
|
||||
"redis",
|
||||
"rusqlite",
|
||||
"rusty-opus",
|
||||
"snap",
|
||||
"tungstenite",
|
||||
"yaml-rust2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2268,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"
|
||||
@@ -2323,6 +2385,12 @@ version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.14.0"
|
||||
@@ -2450,6 +2518,7 @@ dependencies = [
|
||||
"btoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
@@ -2484,7 +2553,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-ident",
|
||||
@@ -2498,7 +2567,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -3354,6 +3423,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3401,6 +3526,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -3539,12 +3673,32 @@ 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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3619,6 +3773,7 @@ version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3883,6 +4038,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snap"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -4198,13 +4359,23 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4274,6 +4445,9 @@ name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
@@ -4412,6 +4586,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"
|
||||
@@ -4675,6 +4855,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"
|
||||
@@ -4728,7 +4914,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"wgpu-core-deps-apple",
|
||||
@@ -5471,6 +5657,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"
|
||||
|
||||
+40
-12
@@ -266,7 +266,7 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
|
||||
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786) · [#9045](https://github.com/horizon-rust/horizon/issues/9045) · [#6233](https://github.com/horizon-rust/horizon/issues/6233) · [#6485](https://github.com/horizon-rust/horizon/issues/6485) · [#3734](https://github.com/horizon-rust/horizon/issues/3734) · [#3125](https://github.com/horizon-rust/horizon/issues/3125)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -298,6 +298,8 @@ Far terrain drawn from the world you really built and explored, caves and builds
|
||||
|
||||
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
|
||||
|
||||
More: [#4678](https://github.com/horizon-rust/horizon/issues/4678)
|
||||
|
||||
</details>
|
||||
|
||||
#### A batch of targeted optimisations
|
||||
@@ -308,7 +310,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
|
||||
|
||||
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
|
||||
|
||||
More: [docs/profiling.md](docs/profiling.md)
|
||||
More: [docs/profiling.md](docs/profiling.md) · [#7752](https://github.com/horizon-rust/horizon/issues/7752) · [#8251](https://github.com/horizon-rust/horizon/issues/8251) · [#10229](https://github.com/horizon-rust/horizon/issues/10229)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -322,6 +324,8 @@ Recognise mods built for versions before 26.1 in the mod browser, so they can be
|
||||
|
||||
A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
|
||||
|
||||
More: [#10262](https://github.com/horizon-rust/horizon/issues/10262)
|
||||
|
||||
</details>
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
@@ -406,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.
|
||||
@@ -536,6 +530,8 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
|
||||
|
||||
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod.
|
||||
|
||||
More: [#6302](https://github.com/horizon-rust/horizon/issues/6302) · [#5313](https://github.com/horizon-rust/horizon/issues/5313) · [#5556](https://github.com/horizon-rust/horizon/issues/5556)
|
||||
|
||||
</details>
|
||||
|
||||
#### Proximity voice chat as a native mod
|
||||
@@ -558,6 +554,8 @@ Every past and future Minecraft version prepared for Horizon automatically. When
|
||||
|
||||
The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
|
||||
|
||||
More: [#4758](https://github.com/horizon-rust/horizon/issues/4758)
|
||||
|
||||
</details>
|
||||
|
||||
#### Controllers: tested on real pads, and in the launcher
|
||||
@@ -568,6 +566,8 @@ Make controller support solid: tested with real PlayStation and Xbox pads and a
|
||||
|
||||
The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
|
||||
|
||||
More: [#10298](https://github.com/horizon-rust/horizon/issues/10298) · [#8678](https://github.com/horizon-rust/horizon/issues/8678)
|
||||
|
||||
</details>
|
||||
|
||||
#### The full debug screen
|
||||
@@ -668,6 +668,16 @@ A shared resource store keyed by the preparer's digest, no prepare intermediates
|
||||
|
||||
</details>
|
||||
|
||||
#### A launcher screen readers can read
|
||||
|
||||
Screen readers read the launcher's buttons, lists and settings, and every part of it works from the keyboard.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#4537](https://github.com/horizon-rust/horizon/issues/4537): the launcher is GPUI (not Electron), so it exposes nothing to UI Automation, NSAccessibility or AT-SPI yet. Needs an accessibility tree from GPUI (an AccessKit bridge) and a role and label on each widget.
|
||||
|
||||
</details>
|
||||
|
||||
#### A visible beta label
|
||||
|
||||
A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
|
||||
@@ -690,6 +700,18 @@ Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 buil
|
||||
|
||||
</details>
|
||||
|
||||
#### The launcher builds Horizon on other systems
|
||||
|
||||
On a system Horizon has no download for, such as FreeBSD, the launcher offers to build the game on your computer, as it already does for native mods.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#4354](https://github.com/horizon-rust/horizon/issues/4354): when no release asset matches the platform, the launcher fetches the tagged source and runs the same pinned-toolchain `cargo build --locked` its native-mod builds use, with a warning about the time it takes. The build is unsigned, so the launcher marks the version as built locally.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Windows ARM64
|
||||
|
||||
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
|
||||
@@ -710,6 +732,8 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
More: [#9134](https://github.com/horizon-rust/horizon/issues/9134) · [#8069](https://github.com/horizon-rust/horizon/issues/8069) · [#4153](https://github.com/horizon-rust/horizon/issues/4153)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
@@ -752,6 +776,8 @@ Record, replay and edit videos without leaving the game: record in a keypress, r
|
||||
|
||||
Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
|
||||
|
||||
More: [#10025](https://github.com/horizon-rust/horizon/issues/10025)
|
||||
|
||||
</details>
|
||||
|
||||
#### Game menus in the launcher's style
|
||||
@@ -842,6 +868,8 @@ Every download Horizon makes is checked against the project's signature, includi
|
||||
|
||||
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
|
||||
|
||||
More: [#10354](https://github.com/horizon-rust/horizon/issues/10354)
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
@@ -108,6 +108,16 @@ impl<T> CompiledChoices<T> {
|
||||
multipart: self.multipart.iter().map(|c| c.map(&mut f)).collect(),
|
||||
}
|
||||
}
|
||||
/// A variant that needs no random selection and produces exactly one model.
|
||||
pub fn single_variant(&self, selected: &SelectedModels) -> Option<&T> {
|
||||
let SelectedModels::Variant(index) = selected else {
|
||||
return None;
|
||||
};
|
||||
match self.variants.get(*index)? {
|
||||
Choice::Single(value) => Some(value),
|
||||
Choice::Weighted { .. } => None,
|
||||
}
|
||||
}
|
||||
/// The variant `particleMaterial` comes from: the first entry of a weighted
|
||||
/// list (`WeightedVariants`) and, for a multipart root, of its first
|
||||
/// selector whether or not that selector applies (`MultiPartModel`).
|
||||
|
||||
+136
-12
@@ -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).
|
||||
@@ -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(),
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! NBT path parser output and byte cursor for the pinned Java oracle.
|
||||
use horizon_client_core::nbt_path::NbtPath;
|
||||
use std::io::{BufRead, Write};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let input = std::io::BufReader::new(std::fs::File::open(args.next().unwrap())?);
|
||||
let mut output = std::io::BufWriter::new(std::fs::File::create(args.next().unwrap())?);
|
||||
for line in input.lines() {
|
||||
let text: String = serde_json::from_str(&line?)?;
|
||||
match NbtPath::parse_prefix(&text) {
|
||||
Ok((path, cursor)) => {
|
||||
write!(output, "OK {cursor} ")?;
|
||||
for value in path.as_str().as_bytes() {
|
||||
write!(output, "{value:02x}")?;
|
||||
}
|
||||
writeln!(output)?;
|
||||
}
|
||||
Err(error) => writeln!(output, "ERR {} {}", error.cursor, error.key)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -111,7 +111,15 @@ impl Parser<'_> {
|
||||
map.insert(key, self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
// SnbtGrammar: an entry may be followed by a comma before the brace.
|
||||
Some(b',') => {
|
||||
self.i += 1;
|
||||
self.ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
}
|
||||
}
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Object(map));
|
||||
@@ -140,7 +148,15 @@ impl Parser<'_> {
|
||||
list.push(self.value()?);
|
||||
self.ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
// SnbtGrammar: a list or array may end with a comma.
|
||||
Some(b',') => {
|
||||
self.i += 1;
|
||||
self.ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
}
|
||||
}
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Value::Array(list));
|
||||
@@ -497,6 +513,22 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// SnbtGrammar: a compound or list may end with one comma; two in a row
|
||||
/// stay an error.
|
||||
#[test]
|
||||
fn a_component_takes_one_trailing_comma() {
|
||||
let parse = |s: &str| parse_snbt(s).map(|(v, _)| v);
|
||||
assert_eq!(
|
||||
parse("{text:\"a\",color:\"red\",}"),
|
||||
parse("{text:\"a\",color:\"red\"}")
|
||||
);
|
||||
assert_eq!(
|
||||
parse("[\"a\", {text:\"b\",} ,]"),
|
||||
parse("[\"a\",{text:\"b\"}]")
|
||||
);
|
||||
assert!(parse("{text:\"a\",,}").is_err());
|
||||
assert!(parse("[\"a\",,]").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_color_follows_text_color_parse_color() {
|
||||
|
||||
@@ -726,11 +726,16 @@ fn parse_node(text: &str, i: usize, first: bool) -> Result<(Node, usize), PathEr
|
||||
Some(b']') => Ok((Node::AllElements, j + 1)),
|
||||
_ => {
|
||||
let start = j;
|
||||
if b.get(j) == Some(&b'-') {
|
||||
// StringReader.readInt scans all allowed number characters
|
||||
// before parsing, including malformed signs and decimal points.
|
||||
while b
|
||||
.get(j)
|
||||
.is_some_and(|c| c.is_ascii_digit() || matches!(c, b'-' | b'.'))
|
||||
{
|
||||
j += 1;
|
||||
}
|
||||
while b.get(j).is_some_and(u8::is_ascii_digit) {
|
||||
j += 1;
|
||||
if start == j {
|
||||
return Err(PathError::new("parsing.int.expected", vec![], start));
|
||||
}
|
||||
let n: i32 = text[start..j].parse().map_err(|_| {
|
||||
PathError::new(
|
||||
@@ -792,6 +797,13 @@ fn read_quoted(text: &str, i: usize) -> Result<(String, usize), PathError> {
|
||||
let mut escaped = false;
|
||||
for (o, c) in text[i + 1..].char_indices() {
|
||||
if escaped {
|
||||
if c != quote && c != '\\' {
|
||||
return Err(PathError::new(
|
||||
"parsing.quote.escape",
|
||||
vec![c.to_string()],
|
||||
i + 1 + o,
|
||||
));
|
||||
}
|
||||
out.push(c);
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
@@ -840,6 +852,40 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn quoted_escapes_and_index_tokens_match_brigadier() {
|
||||
let cases = [
|
||||
(r#""a\q""#, "parsing.quote.escape", vec!["q"], 3),
|
||||
(r#"'a\x'"#, "parsing.quote.escape", vec!["x"], 3),
|
||||
("[1.0]", "parsing.int.invalid", vec!["1.0"], 1),
|
||||
("[.]", "parsing.int.invalid", vec!["."], 1),
|
||||
("[--1]", "parsing.int.invalid", vec!["--1"], 1),
|
||||
("[2147483648]", "parsing.int.invalid", vec!["2147483648"], 1),
|
||||
("[+1]", "parsing.int.expected", vec![], 1),
|
||||
(r#""é\q""#, "parsing.quote.escape", vec!["q"], 4),
|
||||
(r#""é"[1.0]"#, "parsing.int.invalid", vec!["1.0"], 5),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (text, key, args, cursor) in cases {
|
||||
match NbtPath::parse_prefix(text) {
|
||||
Err(error) if error.key == key && error.args == args && error.cursor == cursor => {}
|
||||
other => failures.push(format!("{text:?}: {other:?}")),
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_quoted_escapes_still_select_the_exact_field() {
|
||||
let root = Tag::compound([("a\"b", Tag::Int(7)), ("a\\b", Tag::Int(8))]);
|
||||
for (path, value) in [(r#""a\"b""#, 7), (r#""a\\b""#, 8)] {
|
||||
assert_eq!(
|
||||
NbtPath::parse(path).unwrap().get(&root).unwrap(),
|
||||
vec![Tag::Int(value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
|
||||
for text in [
|
||||
|
||||
@@ -395,16 +395,14 @@ impl FullscreenState {
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => window.set_fullscreen(Some(fullscreen_request(
|
||||
Some(true) => enter_fullscreen(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None if reapply || mode_change => enter_fullscreen(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -583,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.
|
||||
@@ -592,13 +618,13 @@ pub fn fullscreen_request(
|
||||
preferred: Option<&VideoMode>,
|
||||
) -> winit::window::Fullscreen {
|
||||
use winit::window::Fullscreen;
|
||||
if !exclusive {
|
||||
return Fullscreen::Borderless(None);
|
||||
}
|
||||
let Some(monitor) = window
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
else {
|
||||
.or_else(|| window.primary_monitor());
|
||||
if !exclusive {
|
||||
return Fullscreen::Borderless(monitor);
|
||||
}
|
||||
let Some(monitor) = monitor else {
|
||||
return Fullscreen::Borderless(None);
|
||||
};
|
||||
let wanted = preferred.and_then(|p| {
|
||||
@@ -608,7 +634,7 @@ pub fn fullscreen_request(
|
||||
});
|
||||
match wanted.or_else(|| monitor_current_mode(&monitor)) {
|
||||
Some(mode) => Fullscreen::Exclusive(mode),
|
||||
None => Fullscreen::Borderless(None),
|
||||
None => Fullscreen::Borderless(Some(monitor)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
|
||||
@@ -224,6 +224,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
settings.translucent_batching,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
|
||||
@@ -126,6 +126,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
assert!(
|
||||
@@ -368,6 +369,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
|
||||
@@ -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::{
|
||||
@@ -856,27 +890,50 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffer size to request for `open_device`. On Linux the ALSA backend's
|
||||
/// `BufferSize::Default` means a ~100 ms buffer, so sound effects come out
|
||||
/// about 100 ms late: request a short fixed buffer (1024 frames) by default.
|
||||
/// An environment override `HORIZON_AUDIO_BUFFER` can specify `default` or a
|
||||
/// custom frame count (e.g. 2048) when the default short buffer underruns
|
||||
/// (e.g. PipeWire min-quantum >= 1024).
|
||||
#[cfg(feature = "audio")]
|
||||
fn target_buffer_size(
|
||||
range: (u32, u32),
|
||||
var: impl Fn(&str) -> Option<std::ffi::OsString>,
|
||||
) -> Option<cpal::BufferSize> {
|
||||
if let Some(val) = var("HORIZON_AUDIO_BUFFER").and_then(|v| v.into_string().ok()) {
|
||||
if val == "default" {
|
||||
return Some(cpal::BufferSize::Default);
|
||||
}
|
||||
if let Ok(frames) = val.parse::<u32>() {
|
||||
return Some(cpal::BufferSize::Fixed(frames.clamp(range.0, range.1)));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(cpal::BufferSize::Fixed(1024u32.clamp(range.0, range.1)))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
|
||||
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
|
||||
/// come out about 100 ms late: request a short fixed buffer there. Other
|
||||
/// platforms keep cpal's default behaviour.
|
||||
#[cfg(feature = "audio")]
|
||||
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let range = match config.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
|
||||
cpal::SupportedBufferSize::Unknown => (256, 4096),
|
||||
};
|
||||
let target = 1024u32.clamp(range.0, range.1);
|
||||
let mut sc = config.config();
|
||||
sc.buffer_size = cpal::BufferSize::Fixed(target);
|
||||
sc
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
config.config()
|
||||
let mut sc = config.config();
|
||||
let range = match config.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
|
||||
cpal::SupportedBufferSize::Unknown => (256, 4096),
|
||||
};
|
||||
if let Some(bs) = target_buffer_size(range, |name| std::env::var_os(name)) {
|
||||
sc.buffer_size = bs;
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1028,6 +1085,8 @@ impl Audio {
|
||||
devices_seen: devices,
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -1188,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
|
||||
@@ -1209,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,
|
||||
@@ -1223,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 {
|
||||
@@ -1805,6 +1892,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[cfg(feature = "audio")]
|
||||
fn buffer_size_override_and_defaults() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |name: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| std::ffi::OsString::from(*v))
|
||||
}
|
||||
};
|
||||
let range = (256, 4096);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "default")])),
|
||||
Some(cpal::BufferSize::Default)
|
||||
);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "2048")])),
|
||||
Some(cpal::BufferSize::Fixed(2048))
|
||||
);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "100")])),
|
||||
Some(cpal::BufferSize::Fixed(256))
|
||||
);
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[("HORIZON_AUDIO_BUFFER", "10000")])),
|
||||
Some(cpal::BufferSize::Fixed(4096))
|
||||
);
|
||||
#[cfg(target_os = "linux")]
|
||||
assert_eq!(
|
||||
target_buffer_size(range, env(&[])),
|
||||
Some(cpal::BufferSize::Fixed(1024))
|
||||
);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert_eq!(target_buffer_size(range, env(&[])), None);
|
||||
}
|
||||
#[test]
|
||||
fn linear_attenuation_reaches_zero_at_the_max_distance() {
|
||||
assert_eq!(distance_gain(None, 100.0), 1.0);
|
||||
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3407,7 +3407,9 @@ impl BeamPart {
|
||||
(at(b, self.end), [s.u0, v1]),
|
||||
] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
// The beacon beam render types keep the default back-face culling: seen
|
||||
// from inside a beam, its walls must not show.
|
||||
normal: CULL_BACK,
|
||||
pos: p.to_array(),
|
||||
uv,
|
||||
layer: 6,
|
||||
@@ -4277,6 +4279,23 @@ mod tests {
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
/// face only the side looking at the camera draws: the portal shows one
|
||||
/// surface from above and one from below, never both at once.
|
||||
/// `RenderPipelines.BEACON_BEAM_OPAQUE` / `_TRANSLUCENT` keep the default
|
||||
/// back-face culling, so a camera inside a beam sees no wall of it.
|
||||
#[test]
|
||||
fn beacon_beam_faces_cull_their_backs() {
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
beacon_beam(Vec3::ZERO, &[(0xFFFFFF, 4)], 0.0, 1.0, rect, &mut out);
|
||||
assert_eq!(out.len(), 32, "solid and glow, four quads each");
|
||||
assert!(out.iter().all(|v| v.normal == CULL_BACK));
|
||||
}
|
||||
#[test]
|
||||
fn the_end_portal_cube_is_one_sided() {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Every block state's facts and looks by its id, for a client mod drawing blocks or a map off the main thread
|
||||
//! (`native_block_states`; docs/native-mods.md): what the game knows of each state, what its model draws on each side
|
||||
//! and the texture behind each of those sprites. A copy, so the game's own tables can change shape under it.
|
||||
use crate::{
|
||||
block_sprites::{BlockSprites, Lookup, SpriteRef},
|
||||
streaming::WorkerContext,
|
||||
};
|
||||
use horizon_items::blocks::{BlockFacts, MAP_COLORS, RenderShape};
|
||||
use horizon_world::{
|
||||
block_collision::BlockCollisionRegistry, fluid::FluidState, registry::BlockStateId,
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// What one block state is, as a renderer or a map reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct BlockStateFacts {
|
||||
/// Its block, without properties (`minecraft:grass_block`).
|
||||
pub name: Arc<str>,
|
||||
/// `BlockState.isAir`: air, cave air or void air.
|
||||
pub air: bool,
|
||||
/// No model draws it (render shape `INVISIBLE`): air, barriers, light, and water and lava, drawn as fluids.
|
||||
pub invisible: bool,
|
||||
/// `BlockState.getFluidState`: the fluid in it, a waterlogged block's and kelp's included; empty for none.
|
||||
pub fluid: FluidState,
|
||||
/// Its `waterlogged` property is true.
|
||||
pub waterlogged: bool,
|
||||
/// The light it gives, 0 to 15.
|
||||
pub emission: u8,
|
||||
/// `BlockState.getShape`, the outline the cursor picks, as block-local boxes
|
||||
/// `[min_x, min_y, min_z, max_x, max_y, max_z]`: `[0, 0, 0, 1, 1, 1]` for a whole block, none for air. Without
|
||||
/// the random offset of flowers and grass.
|
||||
pub outline: Arc<[[f64; 6]]>,
|
||||
/// `MapColor.col` (`0xRRGGBB`), its colour on a vanilla map; none for `MapColor.NONE`, which maps leave out.
|
||||
pub map_color: Option<u32>,
|
||||
/// It can collide at all: false for a state whose collision shape is always empty (air, flowers, water).
|
||||
pub collides: bool,
|
||||
}
|
||||
|
||||
// A mod builds its tables from these on a worker thread.
|
||||
const _: () = {
|
||||
const fn shareable<T: Send + Sync + 'static>() {}
|
||||
shareable::<BlockStates>()
|
||||
};
|
||||
|
||||
/// Every block state by its id: its facts, and what its model draws with the resources loaded when it was made.
|
||||
#[derive(Clone)]
|
||||
pub struct BlockStates {
|
||||
states: Arc<[BlockStateFacts]>,
|
||||
context: Arc<WorkerContext>,
|
||||
/// Texture names by sprite layer, across every array.
|
||||
textures: Arc<HashMap<u32, String>>,
|
||||
split: u32,
|
||||
}
|
||||
|
||||
impl BlockStates {
|
||||
/// The states of `context`'s registry, with their `facts` and `collision`, and the sprites of `context`'s
|
||||
/// resources at `split` layers to an array (`Renderer::sprite_split`).
|
||||
pub fn new(
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
context: Arc<WorkerContext>,
|
||||
split: u32,
|
||||
) -> Self {
|
||||
let registry = context.assets.registry;
|
||||
let states = facts
|
||||
.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| {
|
||||
let id = BlockStateId(i as u32);
|
||||
BlockStateFacts {
|
||||
name: f.name.clone(),
|
||||
air: facts.is_air(id),
|
||||
invisible: f.render_shape == RenderShape::Invisible,
|
||||
fluid: registry
|
||||
.state(id)
|
||||
.and_then(|state| FluidState::from_block(state).ok())
|
||||
.unwrap_or(FluidState::EMPTY),
|
||||
waterlogged: f.waterlogged,
|
||||
emission: f.emission,
|
||||
outline: f.outline.clone(),
|
||||
map_color: MAP_COLORS
|
||||
.get(usize::from(f.map_color))
|
||||
.copied()
|
||||
.filter(|_| f.map_color != 0),
|
||||
collides: collision.may_collide(id),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Vanilla's names go without their namespace, as `native_draw_cube` takes them.
|
||||
let textures = context
|
||||
.render
|
||||
.sprite_layers
|
||||
.iter()
|
||||
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
|
||||
.collect();
|
||||
Self {
|
||||
states,
|
||||
context,
|
||||
textures: Arc::new(textures),
|
||||
split: split.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// How many states there are: their ids run from 0 to one less.
|
||||
pub fn len(&self) -> usize {
|
||||
self.states.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.states.is_empty()
|
||||
}
|
||||
|
||||
/// `state`'s facts; none for an id past the last state.
|
||||
pub fn get(&self, state: BlockStateId) -> Option<&BlockStateFacts> {
|
||||
self.states.get(state.0 as usize)
|
||||
}
|
||||
|
||||
/// Every state with its id, in id order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (BlockStateId, &BlockStateFacts)> {
|
||||
self.states
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, facts)| (BlockStateId(i as u32), facts))
|
||||
}
|
||||
|
||||
/// What `state` draws on each side, as `native_block_sprites` says for a block by name; none for an id past the
|
||||
/// last state.
|
||||
pub fn sprites(&self, state: BlockStateId) -> Option<BlockSprites> {
|
||||
self.get(state)?;
|
||||
Some(Lookup::new(&self.context, self.split).state(state))
|
||||
}
|
||||
|
||||
/// The texture `sprite` shows, by the name `native_texture_sprites` takes (`block/grass_block_top`; a mod's with
|
||||
/// its namespace, `mymod:block/gear`).
|
||||
pub fn texture(&self, sprite: SpriteRef) -> Option<&str> {
|
||||
let layer = sprite
|
||||
.array
|
||||
.checked_mul(self.split)?
|
||||
.checked_add(sprite.layer)?;
|
||||
self.textures.get(&layer).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::Assets, biome::Biomes, block_sprites::parse_state, light::LightTables,
|
||||
mesher::MeshTables,
|
||||
};
|
||||
use horizon_assets::model::Direction;
|
||||
use horizon_world::fluid::FluidKind;
|
||||
use std::{path::PathBuf, sync::OnceLock};
|
||||
|
||||
/// Far fewer layers to an array than a GPU takes, so most sprites lie past array 0.
|
||||
const SMALL_SPLIT: u32 = 64;
|
||||
|
||||
/// The game's own states from real prepared resources, loaded once for every test here.
|
||||
fn states() -> &'static BlockStates {
|
||||
static STATES: OnceLock<BlockStates> = OnceLock::new();
|
||||
STATES.get_or_init(|| {
|
||||
let root = PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Arc::new(Assets::load(&root).unwrap());
|
||||
let render = Arc::new(assets.reload(&root).unwrap());
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let context = Arc::new(WorkerContext {
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
biomes: Arc::new(Biomes::load(&root).unwrap()),
|
||||
});
|
||||
BlockStates::new(&facts, &collision, context, SMALL_SPLIT)
|
||||
})
|
||||
}
|
||||
|
||||
fn facts(text: &str) -> &'static BlockStateFacts {
|
||||
let states = states();
|
||||
let id = parse_state(states.context.assets.registry, text).expect(text);
|
||||
states.get(id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_grass_block_is_a_whole_solid_grass_coloured_block() {
|
||||
let grass = facts("grass_block");
|
||||
assert_eq!(&*grass.name, "minecraft:grass_block");
|
||||
assert!(!grass.air && !grass.invisible && !grass.waterlogged);
|
||||
assert_eq!(grass.fluid.kind(), FluidKind::Empty);
|
||||
assert_eq!(grass.emission, 0);
|
||||
assert_eq!(&*grass.outline, [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]);
|
||||
assert_eq!(grass.map_color, Some(0x7F_B238), "MapColor.GRASS");
|
||||
assert!(grass.collides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn air_and_light_are_not_drawn() {
|
||||
let air = facts("air");
|
||||
assert!(air.air && air.invisible && !air.collides);
|
||||
assert!(air.outline.is_empty());
|
||||
assert_eq!(air.map_color, None);
|
||||
let light = facts("light[level=15]");
|
||||
assert!(!light.air && light.invisible);
|
||||
assert_eq!(light.emission, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_slabs_outline_is_its_half() {
|
||||
assert_eq!(
|
||||
&*facts("stone_slab[type=bottom]").outline,
|
||||
[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]
|
||||
);
|
||||
assert_eq!(
|
||||
&*facts("stone_slab[type=top]").outline,
|
||||
[[0.0, 0.5, 0.0, 1.0, 1.0, 1.0]]
|
||||
);
|
||||
let wet = facts("stone_slab[type=bottom,waterlogged=true]");
|
||||
assert!(wet.waterlogged && wet.collides);
|
||||
assert_eq!(wet.fluid.kind(), FluidKind::Water);
|
||||
assert!(wet.fluid.is_source());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_face_names_its_texture() {
|
||||
let states = states();
|
||||
let id = parse_state(states.context.assets.registry, "grass_block").unwrap();
|
||||
let grass = states.sprites(id).unwrap();
|
||||
let top = grass.sides[Direction::Up as usize][0].sprite;
|
||||
assert_eq!(states.texture(top), Some("block/grass_block_top"));
|
||||
let north = &grass.sides[Direction::North as usize];
|
||||
assert_eq!(
|
||||
states.texture(north[0].sprite),
|
||||
Some("block/grass_block_side")
|
||||
);
|
||||
assert_eq!(
|
||||
states.texture(north[1].sprite),
|
||||
Some("block/grass_block_side_overlay")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -1729,6 +1748,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 +1889,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);
|
||||
|
||||
@@ -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: ®istries,
|
||||
};
|
||||
// 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: ®istries,
|
||||
};
|
||||
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: ®istries,
|
||||
};
|
||||
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: ®istries,
|
||||
};
|
||||
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<_>>()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! The files of the resources the game loaded, resource packs' over vanilla's, for a client mod reading textures and
|
||||
//! other resources on any thread (`native_client_resources`; docs/native-mods.md).
|
||||
use horizon_core::prepared_files;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A texture's pixels: `width` by `height` RGBA, rows top first, as its PNG stores them (gamma-encoded, alpha not
|
||||
/// premultiplied). An animated texture is its frames one under another, as in its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TexturePixels {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
// A mod reads textures on a worker thread.
|
||||
const _: () = {
|
||||
const fn shareable<T: Send + Sync + 'static>() {}
|
||||
shareable::<ClientResources>()
|
||||
};
|
||||
|
||||
/// The resources the game loaded, as one tree: vanilla's, with the resource packs turned on (a higher pack over a
|
||||
/// lower one) and the content mods' files over it. A pack turned on or off makes a new tree: ask for it again then.
|
||||
/// It reads the files on disk at each call, not a copy: while a reload rebuilds them, a read can miss a file or get
|
||||
/// the new pack's before `native_block_sprites_version()` changes.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientResources {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl ClientResources {
|
||||
/// The tree at `root` (the game's `options.resources`).
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
|
||||
/// The bytes of the resource at `path` in the tree (`assets/minecraft/textures/entity/cow/cow_temperate.png`).
|
||||
/// None when there is no such file, or for a path that isn't plain names under `assets/`.
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let plain = |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\');
|
||||
if !path.starts_with("assets/") || !path.split('/').all(plain) {
|
||||
return None;
|
||||
}
|
||||
prepared_files::read(&self.root.join(path)).ok()
|
||||
}
|
||||
|
||||
/// A texture's pixels by name (`block/stone`, `entity/cow/cow_temperate`, a mod's `mymod:item/gear`): the PNG
|
||||
/// under `assets/<namespace>/textures/`, decoded. None when there is no such PNG or it doesn't decode.
|
||||
pub fn texture(&self, name: &str) -> Option<TexturePixels> {
|
||||
let path = crate::atlas_sources::texture_path(&self.root, name, "textures");
|
||||
let (width, height, rgba) =
|
||||
crate::hud::decode_bytes(&prepared_files::read(&path).ok()?).ok()?;
|
||||
Some(TexturePixels {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::packs::{build_overlay, discover};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
const COW: &str = "assets/minecraft/textures/entity/cow/cow_temperate.png";
|
||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||
const BLUE: [u8; 4] = [0, 0, 255, 255];
|
||||
|
||||
fn one_pixel_png(rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&rgba)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn write(path: &Path, bytes: &[u8]) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resource_pack_overrides_a_texture() {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("horizon-client-resources-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
write(&vanilla.join(COW), &one_pixel_png(RED));
|
||||
let pack = base.join("packs/Blue cows");
|
||||
write(
|
||||
&pack.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"blue cows","pack_format":97}}"#,
|
||||
);
|
||||
write(&pack.join(COW), &one_pixel_png(BLUE));
|
||||
let overlay = base.join("overlay");
|
||||
build_overlay(&vanilla, &discover(&vanilla, &base.join("packs")), &overlay).unwrap();
|
||||
|
||||
let (plain, packed) = (ClientResources::new(vanilla), ClientResources::new(overlay));
|
||||
assert_eq!(plain.read(COW), Some(one_pixel_png(RED)));
|
||||
assert_eq!(packed.read(COW), Some(one_pixel_png(BLUE)));
|
||||
assert_eq!(
|
||||
packed.texture("entity/cow/cow_temperate"),
|
||||
Some(TexturePixels {
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba: BLUE.to_vec(),
|
||||
})
|
||||
);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plain_paths_under_assets_are_read() {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"horizon-client-resources-paths-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let root = base.join("root");
|
||||
write(&root.join(COW), &one_pixel_png(RED));
|
||||
write(&root.join("horizon-resources.json"), b"{}");
|
||||
write(&base.join("secret.txt"), b"secret");
|
||||
|
||||
let resources = ClientResources::new(root);
|
||||
assert!(resources.read(COW).is_some());
|
||||
for refused in [
|
||||
"horizon-resources.json",
|
||||
"assets/../horizon-resources.json",
|
||||
"assets/../../secret.txt",
|
||||
"assets//minecraft/textures/entity/cow/cow_temperate.png",
|
||||
"/assets/minecraft/textures/entity/cow/cow_temperate.png",
|
||||
] {
|
||||
assert_eq!(resources.read(refused), None, "{refused}");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use horizon_telemetry::system::machine_arch;
|
||||
|
||||
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// The build's architecture, and the machine's after "on" when it emulates the build.
|
||||
fn arch_label(build: &str, machine: &str) -> String {
|
||||
if build == machine {
|
||||
build.to_owned()
|
||||
} else {
|
||||
format!("{build} on {machine}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
let arch = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
let version = if cfg!(target_os = "linux") {
|
||||
let name = read("/etc/os-release").and_then(|t| {
|
||||
t.lines()
|
||||
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_emulated_build_names_both_architectures() {
|
||||
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
|
||||
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
|
||||
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
|
||||
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
|
||||
// On the machine running the tests: native builds name one architecture.
|
||||
let label = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
assert!(label.starts_with(std::env::consts::ARCH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -98,14 +98,42 @@ pub struct Source {
|
||||
/// The [`MAX`] sources nearest `camera` within reach, as the shaders read them: camera-relative
|
||||
/// position and level. Overwrites `out`.
|
||||
pub fn select(sources: &[Source], camera: DVec3, out: &mut Vec<[f32; 4]>) {
|
||||
select_iter(sources.iter().copied(), camera, out);
|
||||
}
|
||||
|
||||
/// Streaming selection with the same stable distance order as [`select`]. Only the best
|
||||
/// [`MAX`] sources are kept, and each source's distance is evaluated once.
|
||||
pub fn select_iter(
|
||||
sources: impl IntoIterator<Item = Source>,
|
||||
camera: DVec3,
|
||||
out: &mut Vec<[f32; 4]>,
|
||||
) {
|
||||
out.clear();
|
||||
let key = |s: &Source| s.at.distance_squared(camera);
|
||||
let mut near: Vec<&Source> = sources
|
||||
.iter()
|
||||
.filter(|s| s.level > 0 && key(s) <= REACH * REACH)
|
||||
.collect();
|
||||
near.sort_by(|a, b| key(a).total_cmp(&key(b)));
|
||||
out.extend(near.into_iter().take(MAX).map(|s| {
|
||||
let mut near = [(
|
||||
0.0_f64,
|
||||
Source {
|
||||
at: DVec3::ZERO,
|
||||
level: 0,
|
||||
},
|
||||
); MAX];
|
||||
let mut len = 0;
|
||||
for source in sources {
|
||||
let distance = source.at.distance_squared(camera);
|
||||
// The original <= test also rejects NaN and infinite distances.
|
||||
if source.level == 0 || !(0.0..=REACH * REACH).contains(&distance) {
|
||||
continue;
|
||||
}
|
||||
let at = near[..len].partition_point(|(d, _)| d.total_cmp(&distance).is_le());
|
||||
if at == MAX {
|
||||
continue;
|
||||
}
|
||||
// Insert after equal distances: the first MAX sources in input order win ties.
|
||||
let end = len.min(MAX - 1);
|
||||
near.copy_within(at..end, at + 1);
|
||||
near[at] = (distance, source);
|
||||
len = (len + 1).min(MAX);
|
||||
}
|
||||
out.extend(near[..len].iter().map(|(_, s)| {
|
||||
let p = (s.at - camera).as_vec3();
|
||||
[p.x, p.y, p.z, f32::from(s.level.min(15))]
|
||||
}));
|
||||
@@ -179,6 +207,104 @@ mod tests {
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_selection_matches_stable_full_sort() {
|
||||
let camera = DVec3::new(30_000_000.0, -64.0, -30_000_000.0);
|
||||
let mut seed = 0x8469_d28b_314a_723fu64;
|
||||
let mut sources = Vec::new();
|
||||
for i in 0..2048 {
|
||||
let offset = std::array::from_fn::<_, 3, _>(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed % 241) as f64 - 120.0
|
||||
});
|
||||
sources.push(Source {
|
||||
at: camera + DVec3::from(offset),
|
||||
level: (i % 22) as u8,
|
||||
});
|
||||
}
|
||||
sources.extend([
|
||||
Source {
|
||||
at: camera + DVec3::X * 96.0,
|
||||
level: 14,
|
||||
},
|
||||
Source {
|
||||
at: camera - DVec3::X * 96.0,
|
||||
level: 15,
|
||||
},
|
||||
Source {
|
||||
at: camera + DVec3::X * 96.0001,
|
||||
level: 15,
|
||||
},
|
||||
Source {
|
||||
at: DVec3::splat(f64::NAN),
|
||||
level: 15,
|
||||
},
|
||||
Source {
|
||||
at: DVec3::splat(f64::INFINITY),
|
||||
level: 15,
|
||||
},
|
||||
]);
|
||||
let reference = |input: &[Source], camera: DVec3| {
|
||||
let mut selected: Vec<_> = input
|
||||
.iter()
|
||||
.filter(|s| s.level > 0 && s.at.distance_squared(camera) <= 96.0 * 96.0)
|
||||
.collect();
|
||||
selected.sort_by(|a, b| {
|
||||
a.at.distance_squared(camera)
|
||||
.total_cmp(&b.at.distance_squared(camera))
|
||||
});
|
||||
selected
|
||||
.into_iter()
|
||||
.take(16)
|
||||
.map(|s| {
|
||||
let p = (s.at - camera).as_vec3();
|
||||
[p.x, p.y, p.z, f32::from(s.level.min(15))]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut out = vec![[999.0; 4]; 20];
|
||||
for input in [sources.clone(), sources.into_iter().rev().collect()] {
|
||||
for n in [0, 1, 15, 16, 17, 40, input.len()] {
|
||||
select_iter(input[..n].iter().copied(), camera, &mut out);
|
||||
assert_eq!(out, reference(&input[..n], camera));
|
||||
}
|
||||
select_iter(input.iter().copied(), DVec3::splat(f64::NAN), &mut out);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_distances_keep_first_sources_and_reach_is_inclusive() {
|
||||
// Every source ties: neither a different position nor a different light level
|
||||
// may win the sixteenth slot just because it was inserted later.
|
||||
let sources: Vec<_> = (0..40)
|
||||
.map(|i| Source {
|
||||
at: if i % 2 == 0 {
|
||||
DVec3::X * 96.0
|
||||
} else {
|
||||
-DVec3::X * 96.0
|
||||
},
|
||||
level: (i % 15 + 1) as u8,
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
select_iter(sources, DVec3::ZERO, &mut out);
|
||||
assert_eq!(out.len(), 16);
|
||||
for (i, light) in out.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*light,
|
||||
[
|
||||
if i % 2 == 0 { 96.0 } else { -96.0 },
|
||||
0.0,
|
||||
0.0,
|
||||
(i % 15 + 1) as f32
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_falls_off_a_level_per_block() {
|
||||
let torch = [[0.0, 0.0, 0.0, 14.0]];
|
||||
|
||||
@@ -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,9 @@ pub enum OmenTick {
|
||||
/// It added this effect, then returned false.
|
||||
Add(EffectInstance),
|
||||
}
|
||||
/// A saved player's effect: id, duration, amplifier, ambient, particles
|
||||
/// visible, icon shown (`MobEffectInstance.Details`).
|
||||
pub type SavedEffect = (String, i32, i32, bool, bool, bool);
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
@@ -201,21 +223,22 @@ impl PlayerEffects {
|
||||
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 {
|
||||
/// duration, amplifier, ambient, particles visible, icon shown).
|
||||
pub fn from_saved(saved: &[SavedEffect]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
for (id, duration, amplifier, ambient, particles, icon) in saved {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
e.show_icon = *icon;
|
||||
effects.add(e);
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `Player.active_effects` for the save.
|
||||
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
|
||||
pub fn saved(&self) -> Vec<SavedEffect> {
|
||||
self.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
@@ -225,14 +248,15 @@ impl PlayerEffects {
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
e.visible,
|
||||
e.show_icon,
|
||||
)
|
||||
})
|
||||
.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 +518,72 @@ 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,
|
||||
[("minecraft:speed".to_owned(), 100, 0, false, true, false)]
|
||||
);
|
||||
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();
|
||||
@@ -546,11 +635,11 @@ mod tests {
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true, true)]);
|
||||
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(&[("gone:glow".into(), 600, 1, false, true, true)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -413,6 +413,13 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// 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`
|
||||
/// `shouldRenderSky` false while a boss bar makes world fog (the Ender Dragon's).
|
||||
pub fn sky_pass_skipped(fog_type: FogType, effect_blocks_sky: bool, boss_world_fog: bool) -> bool {
|
||||
matches!(fog_type, FogType::Lava | FogType::PowderSnow) || effect_blocks_sky || boss_world_fog
|
||||
}
|
||||
/// `AtmosphericFogEnvironment.updateRainFogState`.
|
||||
pub fn update_rain_fog(
|
||||
state: &mut FogState,
|
||||
@@ -480,6 +487,20 @@ mod tests {
|
||||
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
|
||||
}
|
||||
#[test]
|
||||
fn the_dragon_fights_world_fog_leaves_out_the_sky() {
|
||||
use FogType::*;
|
||||
// GameRenderer.renderLevel: no sky pass under a createWorldFog boss bar, in clear air
|
||||
// as in water.
|
||||
assert!(sky_pass_skipped(Atmospheric, false, true));
|
||||
assert!(sky_pass_skipped(Water, false, true));
|
||||
// LevelRenderer.addSkyPass's own conditions.
|
||||
assert!(sky_pass_skipped(Lava, false, false));
|
||||
assert!(sky_pass_skipped(PowderSnow, false, false));
|
||||
assert!(sky_pass_skipped(Atmospheric, true, false));
|
||||
assert!(!sky_pass_skipped(Atmospheric, false, false));
|
||||
assert!(!sky_pass_skipped(Water, false, false));
|
||||
}
|
||||
#[test]
|
||||
fn boss_world_fog_closes_in() {
|
||||
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
|
||||
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;
|
||||
|
||||
@@ -530,6 +530,7 @@ impl Scene {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,11 +7,209 @@
|
||||
//! (`NativeFrame`).
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
|
||||
use horizon_shader_api::{
|
||||
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
|
||||
};
|
||||
|
||||
use super::Frame;
|
||||
|
||||
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
|
||||
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
|
||||
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
|
||||
pub fn line_helpers(api: Api) -> &'static str {
|
||||
match api {
|
||||
Api::V0_1 => {
|
||||
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
|
||||
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
|
||||
"
|
||||
}
|
||||
Api::V0_2 => {
|
||||
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
|
||||
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
|
||||
}
|
||||
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
|
||||
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
|
||||
}
|
||||
"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
|
||||
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
|
||||
#[derive(Clone)]
|
||||
pub struct Decoder(wgpu::RenderPipeline);
|
||||
|
||||
impl Decoder {
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let source = line_helpers(Api::V0_2).to_owned()
|
||||
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
|
||||
@vertex fn horizon_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 horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
|
||||
}
|
||||
";
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader API 0.2 decode"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let entries = (None, "horizon_vs", "horizon_fs");
|
||||
Self(fullscreen_pipeline(
|
||||
device,
|
||||
"Shader API 0.2 decode",
|
||||
&module,
|
||||
entries,
|
||||
INTERMEDIATE,
|
||||
&[],
|
||||
))
|
||||
}
|
||||
|
||||
/// Draws `from` decoded onto `to`.
|
||||
pub fn run(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
from: &wgpu::TextureView,
|
||||
to: &wgpu::TextureView,
|
||||
) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader API 0.2 decode"),
|
||||
layout: &self.0.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(from),
|
||||
}],
|
||||
});
|
||||
fullscreen_pass(
|
||||
encoder,
|
||||
"Shader API 0.2 decode",
|
||||
to,
|
||||
&self.0,
|
||||
(0, &group),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-screen pipeline `label` of `module`'s entries `(vertex, fragment)`, unblended.
|
||||
fn fullscreen_pipeline(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
module: &wgpu::ShaderModule,
|
||||
(layout, vertex, fragment): (Option<&wgpu::PipelineLayout>, &str, &str),
|
||||
format: wgpu::TextureFormat,
|
||||
constants: &[(&str, f64)],
|
||||
) -> wgpu::RenderPipeline {
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One full-screen triangle of `pipeline` onto `view`, cleared first.
|
||||
fn fullscreen_pass(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
label: &str,
|
||||
view: &wgpu::TextureView,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
(index, group): (u32, &wgpu::BindGroup),
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(index, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
|
||||
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
|
||||
fn tonemap_entries() -> String {
|
||||
StageKind::Tonemap.entries(Api::V0_2).replace(
|
||||
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
|
||||
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
|
||||
)
|
||||
}
|
||||
|
||||
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
|
||||
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
|
||||
const DEFAULT_TONEMAP: &str =
|
||||
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
|
||||
|
||||
/// A colour target of `INTERMEDIATE` and `size`, drawn and read.
|
||||
fn intermediate(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERMEDIATE,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// A 0.2 pack's display (docs/api/shaders-reference-0.2.md): the world decoded into
|
||||
/// `scene_color` (the first pass's `previous`), the chain ending in `picture`, and the tonemap
|
||||
/// stage drawing that onto the screen.
|
||||
struct Display {
|
||||
decoder: Decoder,
|
||||
/// The pack's `[tonemap]` label, `None` for Horizon's.
|
||||
label: Option<String>,
|
||||
tonemap: wgpu::RenderPipeline,
|
||||
/// `scene_color` at the world's size; `picture` at the window's (none without a chain:
|
||||
/// the tonemap reads `scene_color`).
|
||||
scene: Option<([u32; 2], wgpu::TextureView)>,
|
||||
picture: Option<([u32; 2], wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
|
||||
@@ -98,6 +296,8 @@ pub struct NativePost {
|
||||
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
|
||||
/// The screen's colour format, the world target's too.
|
||||
format: wgpu::TextureFormat,
|
||||
/// Under 0.2: the decode, the picture and the tonemap.
|
||||
display: Option<Display>,
|
||||
}
|
||||
|
||||
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
|
||||
@@ -145,6 +345,11 @@ pub struct NativeFrame {
|
||||
frames: u32,
|
||||
/// `frame.wetness`, and when it was last written.
|
||||
wetness: (f32, Option<std::time::Instant>),
|
||||
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
|
||||
/// decoded (`linear_mirror`).
|
||||
pub api: Api,
|
||||
pub decoder: Option<Decoder>,
|
||||
mirror: Option<wgpu::TextureView>,
|
||||
}
|
||||
|
||||
/// A depth texture of `size` the world's depth is copied into.
|
||||
@@ -179,7 +384,10 @@ fn copy_target(
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
// RENDER_ATTACHMENT: 0.2 draws `opaque_color` decoded rather than copying it.
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
@@ -225,6 +433,7 @@ impl NativeFrame {
|
||||
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
|
||||
lights: super::block_light::LightTable,
|
||||
pbr: Option<&[wgpu::Texture; 2]>,
|
||||
api: Api,
|
||||
) -> Self {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Native shader pack frame"),
|
||||
@@ -290,7 +499,7 @@ impl NativeFrame {
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
|
||||
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow, api);
|
||||
let block_light = super::block_light::BlockLight::new(device, lights);
|
||||
let array_view = |texture: &wgpu::Texture| {
|
||||
texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
@@ -347,6 +556,9 @@ impl NativeFrame {
|
||||
shadow_map,
|
||||
shadow_camera: shadow.map(super::native_shadow::camera),
|
||||
reflects: false,
|
||||
api,
|
||||
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
|
||||
mirror: None,
|
||||
last: None,
|
||||
eye: [f32::NAN; 4],
|
||||
history_reset: true,
|
||||
@@ -460,6 +672,7 @@ impl NativeFrame {
|
||||
) {
|
||||
let size = [depth.width(), depth.height()];
|
||||
let (color_size, format) = match color {
|
||||
Some(c) if self.decoder.is_some() => ([c.width(), c.height()], INTERMEDIATE),
|
||||
Some(c) => ([c.width(), c.height()], c.format()),
|
||||
None => ([1, 1], self.opaque_color.format()),
|
||||
};
|
||||
@@ -478,6 +691,46 @@ impl NativeFrame {
|
||||
self.regroup(device);
|
||||
}
|
||||
|
||||
/// Fills `opaque_color` from the world's colour `color`: a copy, under 0.2 decoded.
|
||||
pub fn copy_opaque(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
) {
|
||||
match &self.decoder {
|
||||
Some(d) => {
|
||||
let to = self.opaque_color.create_view(&Default::default());
|
||||
d.run(device, encoder, color, &to);
|
||||
}
|
||||
None => encoder.copy_texture_to_texture(
|
||||
color.texture().as_image_copy(),
|
||||
self.opaque_color.as_image_copy(),
|
||||
color.texture().size(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The water mirror `view` as the material reads it: under 0.2 decoded into a copy.
|
||||
pub fn linear_mirror(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
view: wgpu::TextureView,
|
||||
) -> wgpu::TextureView {
|
||||
let Some(decoder) = &self.decoder else {
|
||||
return view;
|
||||
};
|
||||
let size = [view.texture().width(), view.texture().height()];
|
||||
let fits = |m: &wgpu::TextureView| [m.texture().width(), m.texture().height()] == size;
|
||||
if !self.mirror.as_ref().is_some_and(fits) {
|
||||
self.mirror = Some(intermediate(device, "Water mirror, linear", size));
|
||||
}
|
||||
let mirror = self.mirror.clone().unwrap();
|
||||
decoder.run(device, encoder, &view, &mirror);
|
||||
mirror
|
||||
}
|
||||
|
||||
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
|
||||
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if [self.surface.width(), self.surface.height()] != size {
|
||||
@@ -543,6 +796,10 @@ impl NativeFrame {
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
|
||||
let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref());
|
||||
inputs.wetness = self.wetness.0;
|
||||
if self.api == Api::V0_2 {
|
||||
inputs.sky_color = decoded(inputs.sky_color);
|
||||
inputs.fog_color = decoded(inputs.fog_color);
|
||||
}
|
||||
if !self.reflects {
|
||||
inputs.has_reflection = 0;
|
||||
}
|
||||
@@ -693,7 +950,8 @@ fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
|
||||
impl NativePost {
|
||||
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
|
||||
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
|
||||
/// `constants`, drawing onto a screen of `format`; under 0.2 the last onto `picture`, and
|
||||
/// `tonemap` (else Horizon's) onto the screen. Errors name the pass wgpu refused.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
stages: Vec<CheckedStage>,
|
||||
@@ -701,6 +959,7 @@ impl NativePost {
|
||||
constants: &[(String, f64)],
|
||||
format: wgpu::TextureFormat,
|
||||
frame: &NativeFrame,
|
||||
tonemap: Option<&CheckedStage>,
|
||||
) -> Result<Self, String> {
|
||||
let targets = manifest.post_targets();
|
||||
let outputs: Vec<Option<usize>> = manifest
|
||||
@@ -773,7 +1032,7 @@ impl NativePost {
|
||||
entry_point: Some("horizon_fs_post"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: if plan[i].writes == Writes::Screen {
|
||||
format: if plan[i].writes == Writes::Screen && frame.decoder.is_none() {
|
||||
format
|
||||
} else {
|
||||
INTERMEDIATE
|
||||
@@ -790,6 +1049,41 @@ impl NativePost {
|
||||
}
|
||||
passes.push((stage.label, pipeline));
|
||||
}
|
||||
let display = match &frame.decoder {
|
||||
Some(decoder) => {
|
||||
let source = tonemap.map_or_else(
|
||||
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|
||||
|s| s.source.clone(),
|
||||
) + &tonemap_entries();
|
||||
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
|
||||
let module = naga::front::wgsl::parse_str(&source)
|
||||
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
|
||||
let present = present_constants(&module, constants);
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Tonemap"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let entries = (
|
||||
Some(&pipeline_layout),
|
||||
"horizon_vs_tonemap",
|
||||
"horizon_fs_tonemap",
|
||||
);
|
||||
let pipeline =
|
||||
fullscreen_pipeline(device, "Tonemap", &module, entries, format, &present);
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
return Err(format!("{file}: {e}"));
|
||||
}
|
||||
Some(Display {
|
||||
decoder: decoder.clone(),
|
||||
label: tonemap.map(|s| s.label.clone()),
|
||||
tonemap: pipeline,
|
||||
scene: None,
|
||||
picture: None,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let sampler = |label, filter| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
@@ -820,12 +1114,18 @@ impl NativePost {
|
||||
named: None,
|
||||
plan,
|
||||
format,
|
||||
display,
|
||||
})
|
||||
}
|
||||
|
||||
/// The passes' labels, in order.
|
||||
/// The passes' labels, in order, then the pack's tonemap's.
|
||||
pub fn labels(&self) -> Vec<&str> {
|
||||
self.passes.iter().map(|(l, _)| l.as_str()).collect()
|
||||
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
|
||||
self.passes
|
||||
.iter()
|
||||
.map(|(l, _)| l.as_str())
|
||||
.chain(tonemap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Forgets the pack's own targets (`[[post]] output`): made again, blank, on the next frame,
|
||||
@@ -840,8 +1140,26 @@ impl NativePost {
|
||||
device: &wgpu::Device,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
size: [u32; 2],
|
||||
window: [u32; 2],
|
||||
history_reset: bool,
|
||||
) {
|
||||
if let Some(d) = &mut self.display {
|
||||
if d.scene.as_ref().is_none_or(|s| s.0 != size) {
|
||||
d.scene = Some((
|
||||
size,
|
||||
intermediate(device, "Shader API 0.2 scene_color", size),
|
||||
));
|
||||
self.groups = None;
|
||||
}
|
||||
let chain = !self.passes.is_empty();
|
||||
if chain && d.picture.as_ref().is_none_or(|p| p.0 != window) {
|
||||
d.picture = Some((
|
||||
window,
|
||||
intermediate(device, "Shader API 0.2 picture", window),
|
||||
));
|
||||
self.groups = None;
|
||||
}
|
||||
}
|
||||
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
|
||||
let views = self
|
||||
.scales
|
||||
@@ -929,85 +1247,98 @@ impl NativePost {
|
||||
}
|
||||
.create_view(&Default::default());
|
||||
let blank = self.history_blank.create_view(&Default::default());
|
||||
let groups = (0..self.passes.len())
|
||||
// Under 0.2 the passes read the world decoded.
|
||||
let scene = self
|
||||
.display
|
||||
.as_ref()
|
||||
.and_then(|d| d.scene.as_ref())
|
||||
.map_or(world.0, |s| &s.1);
|
||||
let group = |previous: &wgpu::TextureView, writes: Option<Writes>| {
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(scene),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&history),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(world.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
},
|
||||
];
|
||||
entries.extend(pack_texture_group_entries(&self.textures));
|
||||
// The pack's targets, a blank one in place of the one this pass writes.
|
||||
if let Some((_, views)) = &self.named {
|
||||
entries.extend(views.iter().enumerate().map(|(j, view)| {
|
||||
let view = if writes == Some(Writes::Target(j)) {
|
||||
&blank
|
||||
} else {
|
||||
view
|
||||
};
|
||||
wgpu::BindGroupEntry {
|
||||
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}
|
||||
}));
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
let mut groups: Vec<_> = (0..self.passes.len())
|
||||
.map(|i| {
|
||||
// The chain's picture so far: the world's before its first pass.
|
||||
let previous = match (&self.targets, self.plan[i].reads) {
|
||||
(Some((_, t)), Some(slot)) => &t[slot],
|
||||
_ => world.0,
|
||||
_ => scene,
|
||||
};
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.frame.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(world.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(previous),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&self.nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: wgpu::BindingResource::TextureView(&history),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(world.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
},
|
||||
];
|
||||
entries.extend(pack_texture_group_entries(&self.textures));
|
||||
// The pack's targets, a blank one in place of the one this pass writes.
|
||||
if let Some((_, views)) = &self.named {
|
||||
entries.extend(views.iter().enumerate().map(|(j, view)| {
|
||||
let view = if self.plan[i].writes == Writes::Target(j) {
|
||||
&blank
|
||||
} else {
|
||||
view
|
||||
};
|
||||
wgpu::BindGroupEntry {
|
||||
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}
|
||||
}));
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
layout: &self.layout,
|
||||
entries: &entries,
|
||||
})
|
||||
group(previous, Some(self.plan[i].writes))
|
||||
})
|
||||
.collect();
|
||||
// The tonemap's, last: the chain's picture, else the decoded world.
|
||||
if let Some(d) = &self.display {
|
||||
groups.push(group(d.picture.as_ref().map_or(scene, |p| &p.1), None));
|
||||
}
|
||||
self.groups = Some((
|
||||
(world.0.clone(), world.1.clone(), world.2.clone()),
|
||||
blank_history,
|
||||
@@ -1027,18 +1358,34 @@ impl NativePost {
|
||||
history_reset: bool,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.prepare(device, world, size, history_reset);
|
||||
let window = [screen.texture().width(), screen.texture().height()];
|
||||
self.prepare(device, world, size, window, history_reset);
|
||||
if let Some(t) = timing.as_deref_mut() {
|
||||
t.mark(device, encoder, (world.0, self.format));
|
||||
}
|
||||
let Some((_, _, groups)) = &self.groups else {
|
||||
return;
|
||||
};
|
||||
if let Some(Display {
|
||||
decoder,
|
||||
scene: Some((_, scene)),
|
||||
..
|
||||
}) = &self.display
|
||||
{
|
||||
decoder.run(device, encoder, world.0, scene);
|
||||
}
|
||||
let last = match &self.display {
|
||||
Some(Display {
|
||||
picture: Some((_, picture)),
|
||||
..
|
||||
}) => picture,
|
||||
_ => screen,
|
||||
};
|
||||
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
|
||||
let view = match (self.plan[i].writes, &self.targets, &self.named) {
|
||||
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
|
||||
(Writes::Target(j), _, Some((_, n))) => &n[j],
|
||||
_ => screen,
|
||||
_ => last,
|
||||
};
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
@@ -1059,9 +1406,13 @@ impl NativePost {
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
if let (Some(d), Some(group)) = (&self.display, groups.last()) {
|
||||
let label = d.label.as_deref().unwrap_or("Tonemap");
|
||||
fullscreen_pass(encoder, label, screen, &d.tonemap, (GROUP, group), timing);
|
||||
}
|
||||
// The chain's last picture becomes next frame's `history`, once every pass has read
|
||||
// this frame's.
|
||||
let before_last = &self.plan[..self.plan.len() - 1];
|
||||
let before_last = &self.plan[..self.plan.len().saturating_sub(1)];
|
||||
if let Some(Writes::Chain(slot)) = before_last
|
||||
.iter()
|
||||
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
|
||||
@@ -1096,6 +1447,11 @@ fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::T
|
||||
})
|
||||
}
|
||||
|
||||
/// Vanilla's gamma colour `c` in linear light (0.2's decode; negatives and NaN made 0).
|
||||
fn decoded(c: [f32; 3]) -> [f32; 3] {
|
||||
c.map(|v| if v > 0.0 { v.powf(2.2) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
|
||||
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
|
||||
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
|
||||
@@ -1358,7 +1714,14 @@ mod tests {
|
||||
};
|
||||
let hud = crate::hud::HudBatch::new([4, 4]);
|
||||
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
|
||||
let mut native = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let mut native = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_1,
|
||||
);
|
||||
frame.pack.still_ticks = Some(47_811);
|
||||
frame.pack.eye_light = [0, 15];
|
||||
frame.pack.rain = 1.0;
|
||||
@@ -1563,7 +1926,14 @@ file = \"c.wgsl\"
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_1,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
@@ -1576,6 +1946,7 @@ file = \"c.wgsl\"
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
|
||||
@@ -1683,7 +2054,14 @@ file = \"b.wgsl\"
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_1,
|
||||
);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
@@ -1691,6 +2069,7 @@ file = \"b.wgsl\"
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
@@ -1795,7 +2174,14 @@ file = \"b.wgsl\"
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_1,
|
||||
);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
@@ -1803,6 +2189,7 @@ file = \"b.wgsl\"
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
@@ -2074,4 +2461,126 @@ file = \"b.wgsl\"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shader API 0.2: the world decoded, through a chain that passes it on or none, and
|
||||
/// Horizon's tonemap or the pack's identity one, comes back encoded as it went in (within
|
||||
/// an 8-bit step), at alpha 1.
|
||||
#[test]
|
||||
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
|
||||
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let post = "[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n";
|
||||
let curve = "[tonemap]\nfile = \"t.wgsl\"\n";
|
||||
// A pack needs a stage: the first has only a sky, and no chain or tonemap.
|
||||
let sky = "[sky]\nfile = \"s.wgsl\"\n";
|
||||
for extra in [
|
||||
sky.into(),
|
||||
post.into(),
|
||||
curve.into(),
|
||||
format!("{post}{curve}"),
|
||||
] {
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n{extra}"
|
||||
);
|
||||
let pass = "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
|
||||
let identity = "fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
|
||||
let mut files = vec![(MANIFEST, manifest.as_str())];
|
||||
if extra.contains("[[post]]") {
|
||||
files.extend([("a.wgsl", pass), ("b.wgsl", pass)]);
|
||||
}
|
||||
if extra.contains("[tonemap]") {
|
||||
files.push(("t.wgsl", identity));
|
||||
}
|
||||
if extra.contains("[sky]") {
|
||||
files.push((
|
||||
"s.wgsl",
|
||||
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
|
||||
));
|
||||
}
|
||||
let files = files
|
||||
.into_iter()
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.collect();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let (passes, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
.partition(|s| s.kind == StageKind::Post);
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_2,
|
||||
);
|
||||
let inputs = FrameInputs {
|
||||
headroom: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
passes,
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
tonemap,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.labels().last() == Some(&"t.wgsl"), tonemap.is_some());
|
||||
let usage =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
.create_view(&Default::default());
|
||||
let screen = texture(
|
||||
&device,
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let clear = wgpu::Color {
|
||||
r: 0.8,
|
||||
g: 0.4,
|
||||
b: 0.2,
|
||||
a: 0.5,
|
||||
};
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(clear),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
let view = screen.create_view(&Default::default());
|
||||
let world = (&color, &depth, &frame.surface_view);
|
||||
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &screen, [4, 4]);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
|
||||
let want = [204, 102, 51, 255];
|
||||
assert!(
|
||||
pixel
|
||||
.iter()
|
||||
.zip(want)
|
||||
.all(|(&got, want)| got.abs_diff(want) <= 1),
|
||||
"{extra}: {pixel:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -32,13 +32,28 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
|
||||
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
|
||||
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
|
||||
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
|
||||
vec4 hz_tangent; vec2 hz_mid;\n"
|
||||
.to_owned();
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
// `at_velocity`: the vertex's offset from last frame in view space, this frame's view
|
||||
// position less last frame's. Last frame's vertices stay at binding 2, relative to last
|
||||
// frame's camera, and binding 3 gives each vertex its index there (none: no velocity).
|
||||
declarations.push_str(&format!(
|
||||
"layout(set = 1, binding = 2) readonly buffer HzEntityPrevious {{ uint hz_entity_previous[]; }};\n\
|
||||
layout(set = 1, binding = 3) readonly buffer HzEntityPreviousIndex {{ uint hz_previous_index[]; }};\n\
|
||||
uint hz_vi;\n\
|
||||
vec3 hz_velocity(mat4 view, mat4 previous_view) {{\n \
|
||||
uint p = hz_previous_index[hz_vi];\n \
|
||||
if (p == 0xffffffffu) return vec3(0.0);\n \
|
||||
uint w = p * {words}u;\n \
|
||||
vec3 then = uintBitsToFloat(uvec3(hz_entity_previous[w], hz_entity_previous[w + 1u], hz_entity_previous[w + 2u]));\n \
|
||||
return (view * vec4(hz_v_pos, 1.0)).xyz - (previous_view * vec4(then, 1.0)).xyz;\n\
|
||||
}}\n"
|
||||
));
|
||||
let corner = |k: usize, field: &str| {
|
||||
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
|
||||
match field {
|
||||
@@ -47,7 +62,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}
|
||||
};
|
||||
let body = format!(
|
||||
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
|
||||
" hz_vi = uint(gl_VertexIndex);\n \
|
||||
uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
|
||||
vec3 hz_e1 = {p1} - {p0};\n \
|
||||
vec3 hz_e2 = {p2} - {p0};\n \
|
||||
vec2 hz_t0 = {t0};\n \
|
||||
@@ -87,6 +103,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
(
|
||||
"at_velocity",
|
||||
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
|
||||
),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
@@ -229,7 +249,15 @@ impl Casters {
|
||||
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
|
||||
pub(super) struct PackEntities {
|
||||
vertices: wgpu::Buffer,
|
||||
/// Set 1: the vertices again, for the quads' corners.
|
||||
/// Last frame's `vertices` (the two swap each frame), for `at_velocity`.
|
||||
previous: wgpu::Buffer,
|
||||
/// For each of `vertices`, its index in `previous` (`NO_PREVIOUS`: none).
|
||||
previous_index: wgpu::Buffer,
|
||||
/// This frame's entities (`Frame::entity_motion`) and last frame's.
|
||||
motion: Motion,
|
||||
previous_motion: Motion,
|
||||
index_scratch: Vec<u32>,
|
||||
/// Set 1: the vertices again, for the quads' corners; last frame's and the indices into them.
|
||||
pub table: wgpu::BindGroupLayout,
|
||||
pub table_group: wgpu::BindGroup,
|
||||
slots: wgpu::Buffer,
|
||||
@@ -248,23 +276,32 @@ impl PackEntities {
|
||||
let stride = SLOT.max(u64::from(
|
||||
device.limits().min_uniform_buffer_offset_alignment,
|
||||
));
|
||||
let entry = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
entries: &[entry(0), entry(2), entry(3)],
|
||||
});
|
||||
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
|
||||
let vertices = Self::vertex_buffer(device, 4096);
|
||||
let previous = Self::vertex_buffer(device, 4096);
|
||||
let previous_index = Self::index_buffer(device, 4096);
|
||||
let table_group = Self::table_group(device, &table, &vertices, &previous, &previous_index);
|
||||
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
|
||||
Self {
|
||||
vertices,
|
||||
previous,
|
||||
previous_index,
|
||||
motion: Motion::default(),
|
||||
previous_motion: Motion::default(),
|
||||
index_scratch: Vec::new(),
|
||||
table,
|
||||
table_group,
|
||||
slots,
|
||||
@@ -278,28 +315,51 @@ impl PackEntities {
|
||||
}
|
||||
}
|
||||
|
||||
fn vertex_buffer(
|
||||
device: &wgpu::Device,
|
||||
table: &wgpu::BindGroupLayout,
|
||||
vertices: u64,
|
||||
) -> (wgpu::Buffer, wgpu::BindGroup) {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
fn vertex_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
size: vertices * std::mem::size_of::<Vertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX
|
||||
| wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
})
|
||||
}
|
||||
|
||||
fn index_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack entity previous indices"),
|
||||
size: vertices * 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn table_group(
|
||||
device: &wgpu::Device,
|
||||
table: &wgpu::BindGroupLayout,
|
||||
vertices: &wgpu::Buffer,
|
||||
previous: &wgpu::Buffer,
|
||||
index: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack entity vertices"),
|
||||
layout: table,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
(buffer, group)
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: vertices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: previous.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: index.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn slot_buffer(
|
||||
@@ -359,11 +419,17 @@ impl PackEntities {
|
||||
shadow: Casters,
|
||||
rest: &mut Vec<Vertex>,
|
||||
skin: Option<&super::entity_skin::SkinFrame>,
|
||||
motion: (&[(u32, u64)], &[u32]),
|
||||
) {
|
||||
self.draws.clear();
|
||||
self.scratch.clear();
|
||||
self.uniforms.clear();
|
||||
rest.clear();
|
||||
// Last frame's vertices stay for `at_velocity`; this frame's go to the other buffer.
|
||||
std::mem::swap(&mut self.vertices, &mut self.previous);
|
||||
std::mem::swap(&mut self.motion, &mut self.previous_motion);
|
||||
self.motion.clear();
|
||||
let mut copied = Vec::new();
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
if let Some(skin) = skin {
|
||||
for (span, range, overlay) in skin_runs(skin) {
|
||||
@@ -389,6 +455,7 @@ impl PackEntities {
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// 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.
|
||||
@@ -444,6 +511,10 @@ impl PackEntities {
|
||||
Part::Beam => beam_segments(run, &mut self.scratch),
|
||||
_ => self.scratch.extend_from_slice(run),
|
||||
}
|
||||
// A run copied whole: its vertices keep their order, for `at_velocity`.
|
||||
if !split {
|
||||
copied.push((run_start, posed + start, run.len() as u32));
|
||||
}
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
}
|
||||
@@ -468,6 +539,14 @@ impl PackEntities {
|
||||
if self.draws.is_empty() && posed == 0 {
|
||||
return;
|
||||
}
|
||||
let total = posed + self.scratch.len() as u32;
|
||||
let posed_marks = if skin.is_some() { motion.1 } else { &[] };
|
||||
self.motion
|
||||
.collect(motion.0, posed_marks, entities.len() as u32, posed, &copied);
|
||||
self.index_scratch.clear();
|
||||
self.index_scratch.resize(total as usize, NO_PREVIOUS);
|
||||
self.motion
|
||||
.link(&self.previous_motion, &mut self.index_scratch);
|
||||
let vertex_size = std::mem::size_of::<Vertex>() as u64;
|
||||
let (need_vertices, need_slots) = (
|
||||
u64::from(posed) + self.scratch.len() as u64,
|
||||
@@ -476,9 +555,23 @@ impl PackEntities {
|
||||
let have_vertices = self.vertices.size() / vertex_size;
|
||||
let have_slots = self.slots.size() / self.stride;
|
||||
if need_vertices > have_vertices {
|
||||
(self.vertices, self.table_group) =
|
||||
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
|
||||
self.vertices = Self::vertex_buffer(device, need_vertices.next_power_of_two());
|
||||
}
|
||||
if need_vertices * 4 > self.previous_index.size() {
|
||||
self.previous_index = Self::index_buffer(device, need_vertices.next_power_of_two());
|
||||
}
|
||||
queue.write_buffer(
|
||||
&self.previous_index,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.index_scratch),
|
||||
);
|
||||
self.table_group = Self::table_group(
|
||||
device,
|
||||
&self.table,
|
||||
&self.vertices,
|
||||
&self.previous,
|
||||
&self.previous_index,
|
||||
);
|
||||
if need_slots > have_slots {
|
||||
(self.slots, self.group) =
|
||||
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
|
||||
@@ -670,6 +763,87 @@ fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
|
||||
}
|
||||
|
||||
/// The kind of program that draws `span`'s run, when a pack may.
|
||||
/// A vertex with no copy last frame: `at_velocity` reads zero.
|
||||
const NO_PREVIOUS: u32 = u32::MAX;
|
||||
|
||||
/// Where each entity's vertices are in a frame's pack buffer, to match them with last frame's
|
||||
/// (`at_velocity`): the indices of each one's GPU-posed vertices, then of its CPU ones.
|
||||
#[derive(Default)]
|
||||
struct Motion {
|
||||
/// Entity id (`Frame::entity_motion`) → (first index in `indices`, posed count, CPU count).
|
||||
entities: std::collections::HashMap<u64, (u32, u32, u32)>,
|
||||
indices: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
fn clear(&mut self) {
|
||||
self.entities.clear();
|
||||
self.indices.clear();
|
||||
}
|
||||
|
||||
/// Records each marked entity (`marks`: first CPU vertex and id, each run up to the next;
|
||||
/// `posed_marks`: the posed count at each mark, empty without posing). A CPU vertex is in
|
||||
/// the pack buffer where `copied` (first vertex in `Frame::entities`, first index in the
|
||||
/// buffer, count, in order) has it, else `NO_PREVIOUS`.
|
||||
fn collect(
|
||||
&mut self,
|
||||
marks: &[(u32, u64)],
|
||||
posed_marks: &[u32],
|
||||
cpu_total: u32,
|
||||
posed_total: u32,
|
||||
copied: &[(u32, u32, u32)],
|
||||
) {
|
||||
let at = |e: u32| {
|
||||
let i = copied.partition_point(|c| c.0 <= e);
|
||||
match i.checked_sub(1).map(|i| copied[i]) {
|
||||
Some((first, index, n)) if e < first + n => index + (e - first),
|
||||
_ => NO_PREVIOUS,
|
||||
}
|
||||
};
|
||||
for (i, &(cpu, id)) in marks.iter().enumerate() {
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let cpu_end = marks.get(i + 1).map_or(cpu_total, |m| m.0).max(cpu);
|
||||
let posed = posed_marks.get(i).copied().unwrap_or(0).min(posed_total);
|
||||
let posed_end = posed_marks
|
||||
.get(i + 1)
|
||||
.copied()
|
||||
.unwrap_or(posed_total)
|
||||
.clamp(posed, posed_total);
|
||||
let first = self.indices.len() as u32;
|
||||
self.indices.extend(posed..posed_end);
|
||||
self.indices.extend((cpu..cpu_end).map(at));
|
||||
self.entities
|
||||
.insert(id, (first, posed_end - posed, cpu_end - cpu));
|
||||
}
|
||||
}
|
||||
|
||||
/// For each entity drawn last frame with the same posed and CPU counts, each vertex's index
|
||||
/// last frame, written into `out` (indexed by this frame's buffer).
|
||||
fn link(&self, previous: &Motion, out: &mut [u32]) {
|
||||
for (id, &(first, posed, cpu)) in &self.entities {
|
||||
let Some(&(then, p, c)) = previous.entities.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if (p, c) != (posed, cpu) {
|
||||
continue;
|
||||
}
|
||||
let n = (posed + cpu) as usize;
|
||||
let now = &self.indices[first as usize..][..n];
|
||||
let before = &previous.indices[then as usize..][..n];
|
||||
for (&now, &before) in now.iter().zip(before) {
|
||||
if now != NO_PREVIOUS
|
||||
&& before != NO_PREVIOUS
|
||||
&& let Some(slot) = out.get_mut(now as usize)
|
||||
{
|
||||
*slot = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn program_of(span: &EntitySpan) -> Option<usize> {
|
||||
Some(if span.stage == render_stage("ENTITIES") {
|
||||
if span.translucent {
|
||||
@@ -1079,6 +1253,7 @@ mod gpu_span_tests {
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(
|
||||
(geometry.draws.iter())
|
||||
@@ -1160,6 +1335,7 @@ mod gpu_span_tests {
|
||||
shadows,
|
||||
&mut rest,
|
||||
Some(&skin),
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
|
||||
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
|
||||
@@ -1203,6 +1379,7 @@ mod gpu_span_tests {
|
||||
shadows,
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(geometry.draws.is_empty());
|
||||
assert_eq!(
|
||||
@@ -1230,8 +1407,125 @@ mod gpu_span_tests {
|
||||
Casters::default(),
|
||||
&mut rest,
|
||||
Some(&skin),
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(geometry.draws.is_empty());
|
||||
assert!(geometry.skin_target().size() >= 5000 * 44);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_tests {
|
||||
use super::{Motion, NO_PREVIOUS};
|
||||
|
||||
/// Two mobs (ids 7 and 9) and a run of unmarked geometry (id 0); `copied` places CPU
|
||||
/// vertices `first..first + n` at buffer index `at`.
|
||||
fn frame(order: [(u64, u32); 2], copied: &[(u32, u32, u32)]) -> Motion {
|
||||
let mut marks = Vec::new();
|
||||
let mut cpu = 0;
|
||||
for (id, n) in order {
|
||||
marks.push((cpu, id));
|
||||
cpu += n;
|
||||
}
|
||||
marks.push((cpu, 0));
|
||||
let mut m = Motion::default();
|
||||
m.collect(&marks, &[], cpu + 2, 0, copied);
|
||||
m
|
||||
}
|
||||
|
||||
/// The same mob's vertices find themselves however the frame's order and the buffer's
|
||||
/// layout moved between frames; geometry of no entity, and vertices not in the buffer,
|
||||
/// find nothing.
|
||||
#[test]
|
||||
fn each_vertex_finds_its_copy_from_last_frame() {
|
||||
// Last frame: mob 7 (4 vertices) then mob 9 (2), copied whole from buffer index 10.
|
||||
let before = frame([(7, 4), (9, 2)], &[(0, 10, 8)]);
|
||||
// This frame: mob 9 first, then mob 7, at buffer index 0; 9's last vertex not drawn.
|
||||
let now = frame([(9, 2), (7, 4)], &[(0, 0, 1), (2, 1, 6)]);
|
||||
let mut out = vec![NO_PREVIOUS; 8];
|
||||
now.link(&before, &mut out);
|
||||
// 9's first vertex (buffer 0) was 10 + 4; 7's four (buffer 1..5) were 10..14.
|
||||
assert_eq!(
|
||||
out,
|
||||
[14, 10, 11, 12, 13, NO_PREVIOUS, NO_PREVIOUS, NO_PREVIOUS]
|
||||
);
|
||||
}
|
||||
|
||||
/// A mob whose vertex count changed (armor put on, an item picked up) gets no velocity
|
||||
/// this frame rather than the wrong vertices'.
|
||||
#[test]
|
||||
fn a_changed_model_gets_none() {
|
||||
let before = frame([(7, 4), (9, 2)], &[(0, 0, 8)]);
|
||||
let now = frame([(7, 6), (9, 2)], &[(0, 0, 10)]);
|
||||
let mut out = vec![NO_PREVIOUS; 10];
|
||||
now.link(&before, &mut out);
|
||||
assert!(out[..6].iter().all(|&i| i == NO_PREVIOUS));
|
||||
assert_eq!(out[6..8], [4, 5]);
|
||||
}
|
||||
|
||||
/// GPU-posed vertices come first in the buffer: each mob's posed part is matched by its
|
||||
/// own posed count, then its CPU part.
|
||||
#[test]
|
||||
fn posed_and_cpu_parts_match_separately() {
|
||||
let marks = [(0, 7), (2, 9), (2, 0)];
|
||||
let mut before = Motion::default();
|
||||
// Mob 7: 3 posed (0..3) and 2 CPU; mob 9: 1 posed (3), no CPU.
|
||||
before.collect(&marks, &[0, 3, 4], 2, 4, &[(0, 4, 2)]);
|
||||
let mut now = Motion::default();
|
||||
// Mob 9 posed first this frame (0), then mob 7 (1..4).
|
||||
now.collect(&[(0, 9), (0, 7), (2, 0)], &[0, 1, 4], 2, 4, &[(0, 4, 2)]);
|
||||
let mut out = vec![NO_PREVIOUS; 6];
|
||||
now.link(&before, &mut out);
|
||||
assert_eq!(out, [3, 0, 1, 2, 4, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_translate_tests {
|
||||
/// A pack that reads `at_velocity` translates with the entity inputs (which read the
|
||||
/// previous view and last frame's vertices) and with the hand's (which read zero).
|
||||
#[test]
|
||||
fn a_pack_reading_at_velocity_translates_for_entities_and_the_hand() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-at-velocity-{}", std::process::id()));
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.vsh"),
|
||||
"#version 120\nattribute vec3 at_velocity;\nvarying vec3 v;\n\
|
||||
void main() {\n gl_Position = ftransform();\n v = at_velocity;\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.fsh"),
|
||||
"#version 120\nvarying vec3 v;\nvoid main() {\n gl_FragData[0] = vec4(v, 1.0);\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&dir).unwrap();
|
||||
let m = horizon_shaders::Macros::standard();
|
||||
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
|
||||
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
|
||||
for (who, inputs) in [
|
||||
("entities", super::inputs()),
|
||||
("hand", super::super::pack_hand::inputs()),
|
||||
] {
|
||||
let p = horizon_shaders::translate_gbuffers(
|
||||
&vsh,
|
||||
&fsh,
|
||||
horizon_shaders::Target::Buffers,
|
||||
&inputs,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
|
||||
let previous = p
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.any(|u| u.name == "gbufferPreviousModelView");
|
||||
assert_eq!(
|
||||
previous,
|
||||
who == "entities",
|
||||
"{who}: previous view in the block"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ const SLOT: u64 = 32;
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
|
||||
// has no previous vertices).
|
||||
for (name, value) in &mut inputs.attributes {
|
||||
if name == "at_velocity" {
|
||||
*value = "vec3(0.0)".to_owned();
|
||||
}
|
||||
}
|
||||
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
|
||||
inputs.item_id_declared = true;
|
||||
inputs.declarations.push_str(
|
||||
@@ -54,8 +61,12 @@ pub struct PackHand {
|
||||
slots: wgpu::Buffer,
|
||||
pub slot_group: wgpu::BindGroup,
|
||||
stride: u64,
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot. The opaque
|
||||
/// quads come first in the buffer, then the translucent ones (`translucent_runs`), which a
|
||||
/// pack's `gbuffers_hand_water` draws after deferred.
|
||||
pub runs: Vec<(u32, u32, u32)>,
|
||||
pub translucent_runs: Vec<(u32, u32, u32)>,
|
||||
order: Vec<Vertex>,
|
||||
}
|
||||
|
||||
impl PackHand {
|
||||
@@ -120,6 +131,8 @@ impl PackHand {
|
||||
slot_group,
|
||||
stride,
|
||||
runs: Vec::new(),
|
||||
translucent_runs: Vec::new(),
|
||||
order: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,17 +179,25 @@ impl PackHand {
|
||||
) {
|
||||
self.quads = (hand.len() / 4) as u32;
|
||||
self.runs.clear();
|
||||
self.translucent_runs.clear();
|
||||
if self.quads == 0 {
|
||||
return;
|
||||
}
|
||||
let split = split(hand, items);
|
||||
self.order = split.order;
|
||||
let mut contents = Vec::new();
|
||||
for (start, quads, id) in runs(self.quads, items) {
|
||||
let slot = (self.runs.len() as u64).min(SLOTS - 1);
|
||||
if slot as usize == self.runs.len() {
|
||||
contents.extend_from_slice(&draw_slot(id));
|
||||
for (i, &(first, quads, id, translucent)) in split.runs.iter().enumerate() {
|
||||
let slot = (i as u64).min(SLOTS - 1);
|
||||
if slot == i as u64 {
|
||||
contents.extend_from_slice(&draw_slot(id, translucent));
|
||||
contents.resize(((slot + 1) * self.stride) as usize, 0);
|
||||
}
|
||||
self.runs.push((start, quads, (slot * self.stride) as u32));
|
||||
let run = (first, quads, (slot * self.stride) as u32);
|
||||
if translucent {
|
||||
self.translucent_runs.push(run);
|
||||
} else {
|
||||
self.runs.push(run);
|
||||
}
|
||||
}
|
||||
queue.write_buffer(&self.slots, 0, &contents);
|
||||
if self.quads > self.capacity {
|
||||
@@ -185,7 +206,7 @@ impl PackHand {
|
||||
Self::vertex_buffer(device, &self.layout, &self.uniform, self.capacity);
|
||||
self.indices = super::pack_entities::quad_indices(device, self.capacity);
|
||||
}
|
||||
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(hand));
|
||||
queue.write_buffer(&self.vertices, 0, bytemuck::cast_slice(&self.order));
|
||||
let matrices = [projection, projection.inverse()].map(|m| m.to_cols_array());
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::cast_slice(&matrices));
|
||||
}
|
||||
@@ -198,11 +219,62 @@ impl PackHand {
|
||||
/// The hand's runs a frame keeps slots for.
|
||||
const SLOTS: u64 = 8;
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
|
||||
/// entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
|
||||
/// The hand's quads reordered, opaque first and translucent after (each in their order), and
|
||||
/// their runs: first quad, quads, `currentRenderedItemId`, translucent.
|
||||
struct Split {
|
||||
order: Vec<Vertex>,
|
||||
runs: Vec<(u32, u32, i32, bool)>,
|
||||
}
|
||||
|
||||
fn split(hand: &[Vertex], items: &[(u32, i32)]) -> Split {
|
||||
let quads = (hand.len() / 4) as u32;
|
||||
let mut ids = vec![0; quads as usize];
|
||||
for (start, n, id) in runs(quads, items) {
|
||||
ids[start as usize..(start + n) as usize].fill(id);
|
||||
}
|
||||
let kind = |q: usize| translucent_quad(&hand[q * 4..q * 4 + 4]);
|
||||
let opaque: Vec<usize> = (0..quads as usize).filter(|&q| !kind(q)).collect();
|
||||
let clear: Vec<usize> = (0..quads as usize).filter(|&q| kind(q)).collect();
|
||||
let mut out = Split {
|
||||
order: Vec::with_capacity(hand.len()),
|
||||
runs: Vec::new(),
|
||||
};
|
||||
for &q in opaque.iter().chain(&clear) {
|
||||
out.order.extend_from_slice(&hand[q * 4..q * 4 + 4]);
|
||||
}
|
||||
for (translucent, list, base) in [(false, &opaque, 0), (true, &clear, opaque.len())] {
|
||||
let mut i = 0;
|
||||
while i < list.len() {
|
||||
let id = ids[list[i]];
|
||||
let mut n = 1;
|
||||
while i + n < list.len() && ids[list[i + n]] == id {
|
||||
n += 1;
|
||||
}
|
||||
out.runs
|
||||
.push(((base + i) as u32, n as u32, id, translucent));
|
||||
i += n;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A held item's quad drawn from a translucent sheet (`item_render`: its overlay layer).
|
||||
fn translucent_quad(quad: &[Vertex]) -> bool {
|
||||
matches!(
|
||||
quad[0].layer & 0xffff,
|
||||
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)
|
||||
}
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid or translucent hand's, `blockEntityId` -1 as
|
||||
/// outside block entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
|
||||
let stage = horizon_shaders::render_stage(if translucent {
|
||||
"HAND_TRANSLUCENT"
|
||||
} else {
|
||||
"HAND_SOLID"
|
||||
}) as i32;
|
||||
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&id.to_le_bytes());
|
||||
@@ -233,6 +305,42 @@ fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The arm, then an item whose middle quad is translucent, then a second item: the opaque
|
||||
/// quads keep their order first, the translucent one comes after them, and each run keeps
|
||||
/// its item's id.
|
||||
#[test]
|
||||
fn translucent_quads_go_after_the_opaque_ones() {
|
||||
let quad = |layer: u32, tag: f32| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [tag, 0.0, 0.0],
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}; 4]
|
||||
};
|
||||
let glass = super::super::TRANSLUCENT_ITEM_OVERLAY;
|
||||
let hand: Vec<Vertex> = [
|
||||
quad(0, 0.),
|
||||
quad(0, 1.),
|
||||
quad(glass, 2.),
|
||||
quad(0, 3.),
|
||||
quad(0, 4.),
|
||||
]
|
||||
.concat();
|
||||
// The arm is quad 0; item 7 quads 1..4; item 9 quad 4.
|
||||
let split = split(&hand, &[(4, 7), (16, 9)]);
|
||||
let order: Vec<f32> = split.order.chunks(4).map(|q| q[0].pos[0]).collect();
|
||||
assert_eq!(order, [0., 1., 3., 4., 2.]);
|
||||
assert_eq!(
|
||||
split.runs,
|
||||
[
|
||||
(0, 1, 0, false),
|
||||
(1, 2, 7, false),
|
||||
(3, 1, 9, false),
|
||||
(4, 1, 7, true)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
|
||||
#[test]
|
||||
fn runs_split_the_hand_by_item() {
|
||||
|
||||
@@ -37,11 +37,12 @@ impl PackImage {
|
||||
}
|
||||
}
|
||||
|
||||
/// The images `image.*` declares.
|
||||
pub(super) fn images(defs: Vec<CustomImage>) -> Vec<PackImage> {
|
||||
/// The images `image.*` declares; `packed_float`: this device stores into `Rg11b10Ufloat`
|
||||
/// (`format`).
|
||||
pub(super) fn images(defs: Vec<CustomImage>, packed_float: bool) -> Vec<PackImage> {
|
||||
defs.into_iter()
|
||||
.map(|def| PackImage {
|
||||
format: format(def.format),
|
||||
format: format(def.format, packed_float),
|
||||
dimension: if def.is_1d() {
|
||||
wgpu::TextureViewDimension::D1
|
||||
} else if def.is_3d() {
|
||||
@@ -70,7 +71,10 @@ fn texture_dimension(view: wgpu::TextureViewDimension) -> wgpu::TextureDimension
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's internal format: a storage format of the same channels.
|
||||
pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
/// `R11F_G11F_B10F` is itself where the device stores into it (`packed_float`): no alpha, so it
|
||||
/// reads 1 there, as Iris 1.11.7 read it with a probe pack of ours (#10490); elsewhere an
|
||||
/// `Rgba16Float` stand-in, which keeps an alpha.
|
||||
pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
|
||||
use wgpu::TextureFormat as T;
|
||||
let pick = |r, rg, rgba| match f.channels {
|
||||
1 => r,
|
||||
@@ -85,6 +89,7 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
|
||||
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
|
||||
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
|
||||
(Kind::Float, 11) if packed_float => T::Rg11b10Ufloat,
|
||||
(Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
|
||||
(Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
|
||||
(Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
|
||||
@@ -94,8 +99,12 @@ pub(super) fn format(f: Format) -> wgpu::TextureFormat {
|
||||
|
||||
/// Whether this device can make `def`: its format's features, and a size in pixels within the
|
||||
/// device's limits.
|
||||
pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
|
||||
let format = format(def.format);
|
||||
pub(super) fn makeable(
|
||||
device: &wgpu::Device,
|
||||
def: &CustomImage,
|
||||
packed_float: bool,
|
||||
) -> Result<(), String> {
|
||||
let format = format(def.format, packed_float);
|
||||
let missing = format.required_features() - device.features();
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
|
||||
@@ -37,33 +37,54 @@ mod history_reset_tests;
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod celestial_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mixed_size_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sky_disc_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod undrawn_terrain_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod r11f_image_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod weather_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod float_into_integer_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod custom_type_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sky_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_length_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod translucent_color_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod noise_resolution_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -375,6 +396,9 @@ struct Pass {
|
||||
name: String,
|
||||
group: Group,
|
||||
pipeline: Pipeline,
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
@@ -608,6 +632,8 @@ pub struct PackPost {
|
||||
/// What draws the first-person hand (`gbuffers_hand` or its fallback), after the opaque
|
||||
/// world, and its vertices.
|
||||
hand: Option<(Pass, super::pack_hand::PackHand)>,
|
||||
/// `gbuffers_hand_water` (or its fallback): the hand's translucent quads, after deferred.
|
||||
hand_water: Option<Pass>,
|
||||
/// The pre-translucent copy: `depthtex1`, or `depthtex2` without a shader hand.
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
@@ -873,10 +899,11 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
|
||||
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
/// `size`×`size` RGB white noise (6.4), from a fixed seed so every run looks the same.
|
||||
fn noise(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> wgpu::TextureView {
|
||||
let size = size.min(device.limits().max_texture_dimension_2d);
|
||||
let mut state = 0x2545_f491_4f6c_dd1du64;
|
||||
let texels: Vec<u8> = (0..256 * 256 * 4)
|
||||
let texels: Vec<u8> = (0..size as usize * size as usize * 4)
|
||||
.map(|i| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
@@ -884,7 +911,7 @@ fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
|
||||
})
|
||||
.collect();
|
||||
texture(device, queue, "Shader pack noisetex", 256, &texels)
|
||||
texture(device, queue, "Shader pack noisetex", size, &texels)
|
||||
}
|
||||
|
||||
fn texture(
|
||||
@@ -1540,13 +1567,20 @@ fn bindings(
|
||||
}
|
||||
(None, None) => stand_in(&i.name, notes),
|
||||
};
|
||||
if actual != format {
|
||||
// An `R11F_G11F_B10F` image or buffer this device makes `RGBA16F` (a float stand-in
|
||||
// that neither clamps nor drops NaN) takes `layout(r11f_g11f_b10f)` as `rgba16f`
|
||||
// (`storage_formats_of`): Iris 1.11.7 stored into and read back such an image with a
|
||||
// probe pack of ours (#10490), where Horizon refused the program.
|
||||
let stand_in = format == wgpu::TextureFormat::Rg11b10Ufloat
|
||||
&& actual == wgpu::TextureFormat::Rgba16Float;
|
||||
if actual != format && !stand_in {
|
||||
notes.push(format!(
|
||||
"{name}: `{}` is {} but its texture is {actual:?}",
|
||||
i.name, i.format
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let format = actual;
|
||||
let access = match i.access.as_str() {
|
||||
"writeonly" => wgpu::StorageTextureAccess::WriteOnly,
|
||||
"readonly" => wgpu::StorageTextureAccess::ReadOnly,
|
||||
@@ -1577,6 +1611,51 @@ fn bindings(
|
||||
Some((entries, samplers, slots, reads, shadow_reads, images))
|
||||
}
|
||||
|
||||
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
|
||||
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
|
||||
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
|
||||
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
|
||||
for handle in globals {
|
||||
let var = &module.global_variables[handle];
|
||||
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
|
||||
continue;
|
||||
};
|
||||
let naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage { format, access },
|
||||
} = module.types[var.ty].inner
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bound = entries.iter().find_map(|e| match e.ty {
|
||||
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
|
||||
Some(format)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if format == naga::StorageFormat::Rg11b10Ufloat
|
||||
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
|
||||
{
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: module.types[var.ty].name.clone(),
|
||||
inner: naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage {
|
||||
format: naga::StorageFormat::Rgba16Float,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
module.global_variables[handle].ty = ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's format qualifier.
|
||||
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
|
||||
use wgpu::TextureFormat as T;
|
||||
@@ -1692,10 +1771,15 @@ fn build_pass(
|
||||
features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
|
||||
)?;
|
||||
let final_pass = p.group == Group::Final;
|
||||
// Outputs into an integer buffer, and its integer kind (`packed::bitcast_outputs`).
|
||||
let mut int_outputs = Vec::new();
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
// Outputs into a buffer of a small packed format, and the bits it keeps.
|
||||
let mut packed_outputs = Vec::new();
|
||||
// Each output's own `blend.<program>.<buffer>`, and whether its buffer blends at all: what
|
||||
// the sky's part pipelines keep (`PostProgram::vanilla_sky_blends`).
|
||||
let mut own_blends: Vec<(Option<wgpu::BlendState>, bool)> = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
@@ -1707,6 +1791,15 @@ fn build_pass(
|
||||
}
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
let format = formats[buffer as usize];
|
||||
match format.sample_type(None, Some(features)) {
|
||||
Some(wgpu::TextureSampleType::Uint) => {
|
||||
int_outputs.push((location, naga::ScalarKind::Uint));
|
||||
}
|
||||
Some(wgpu::TextureSampleType::Sint) => {
|
||||
int_outputs.push((location, naga::ScalarKind::Sint));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut state = target(format);
|
||||
if !p.group.shadow()
|
||||
&& pack.rgb & 1 << buffer != 0
|
||||
@@ -1714,19 +1807,21 @@ fn build_pass(
|
||||
{
|
||||
t.write_mask = wgpu::ColorWrites::COLOR;
|
||||
}
|
||||
if let (Some(t), Some(b)) = (
|
||||
&mut state,
|
||||
p.buffer_blends
|
||||
.iter()
|
||||
.find(|(n, _)| *n == buffer)
|
||||
.map_or(p.blend, |(_, b)| *b),
|
||||
) && format
|
||||
let own = p.buffer_blends.iter().find(|(n, _)| *n == buffer);
|
||||
let blendable = format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE);
|
||||
if let (Some(t), Some(b)) = (&mut state, own.map_or(p.blend, |(_, b)| *b))
|
||||
&& blendable
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
if own_blends.len() <= l {
|
||||
own_blends.resize(l + 1, (None, false));
|
||||
}
|
||||
let own = own.and_then(|(_, b)| *b).filter(|_| blendable);
|
||||
own_blends[l] = (own.map(blend_state), blendable);
|
||||
if !p.group.shadow()
|
||||
&& let Some(bits) = pack.packed[buffer as usize]
|
||||
{
|
||||
@@ -1786,6 +1881,7 @@ fn build_pass(
|
||||
});
|
||||
let module = |mut m: naga::Module| {
|
||||
share_samplers(&mut m, &slots);
|
||||
storage_formats_of(&mut m, &entries);
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&p.name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
@@ -1793,55 +1889,76 @@ fn build_pass(
|
||||
};
|
||||
let mut fragment = program.fragment;
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
packed::bitcast_outputs(&mut fragment, &int_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
},
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline = descriptor(&targets);
|
||||
// The program's own blend replaced by vanilla's for the part; a buffer's own
|
||||
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
|
||||
// buffer only, with a probe pack of ours).
|
||||
let sky_parts = p.vanilla_sky_blends.then(|| {
|
||||
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
|
||||
let targets: Vec<_> = (targets.iter().enumerate())
|
||||
.map(|(l, t)| {
|
||||
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
blend: own.or(part.filter(|_| blendable)),
|
||||
..t
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
descriptor(&targets)
|
||||
})
|
||||
});
|
||||
(layout, pipeline)
|
||||
(layout, pipeline, sky_parts)
|
||||
});
|
||||
let (layout, pipeline) = match created {
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("{}: skipped: {e}", p.name));
|
||||
@@ -1880,6 +1997,7 @@ fn build_pass(
|
||||
name: p.name,
|
||||
group: p.group,
|
||||
pipeline: Pipeline::Render(pipeline),
|
||||
sky_parts,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
@@ -1973,6 +2091,7 @@ fn build_compute(
|
||||
});
|
||||
let mut module = c.module;
|
||||
share_samplers(&mut module, &slots);
|
||||
storage_formats_of(&mut module, &entries);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
@@ -2019,6 +2138,7 @@ fn build_compute(
|
||||
render: c.work_groups_render,
|
||||
local,
|
||||
},
|
||||
sky_parts: None,
|
||||
layout,
|
||||
samplers,
|
||||
writes: Vec::new(),
|
||||
@@ -2103,6 +2223,7 @@ impl PackPost {
|
||||
}
|
||||
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
|
||||
let shadow_colors = (1u16 << post.shadow_colors) - 1;
|
||||
let mut custom = post.custom;
|
||||
@@ -2145,9 +2266,14 @@ impl PackPost {
|
||||
// An image this device can't make or store into: the programs using it are skipped.
|
||||
let mut image_defs = std::mem::take(&mut post.images);
|
||||
let mut left_out = Vec::new();
|
||||
let packed_float = gpu.storable(adapter, wgpu::TextureFormat::Rg11b10Ufloat, features);
|
||||
image_defs.retain(|def| {
|
||||
let made = pack_images::makeable(device, def).and_then(|()| {
|
||||
if gpu.storable(adapter, pack_images::format(def.format), features) {
|
||||
let made = pack_images::makeable(device, def, packed_float).and_then(|()| {
|
||||
if gpu.storable(
|
||||
adapter,
|
||||
pack_images::format(def.format, packed_float),
|
||||
features,
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("this GPU can't store into its format".into())
|
||||
@@ -2169,7 +2295,7 @@ impl PackPost {
|
||||
));
|
||||
left_out.push(def);
|
||||
}
|
||||
let images = pack_images::images(image_defs);
|
||||
let images = pack_images::images(image_defs, packed_float);
|
||||
let mut objects = std::mem::take(&mut post.buffer_objects);
|
||||
if gpu.storage_stages().is_empty() {
|
||||
objects.clear();
|
||||
@@ -2270,7 +2396,16 @@ impl PackPost {
|
||||
} else {
|
||||
written.next()
|
||||
};
|
||||
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
|
||||
// Sizes of one kind that differ never meet. A size relative to the render size and
|
||||
// one in pixels can: the pass then draws while they resolve to one size
|
||||
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
|
||||
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
|
||||
let apart = |s: horizon_shaders::BufferSize| {
|
||||
first.is_some_and(|f| {
|
||||
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
|
||||
})
|
||||
};
|
||||
if !in_shadow && group != Group::Final && written.any(apart) {
|
||||
notes.push(format!(
|
||||
"{}: writes buffers of different sizes (size.buffer); skipped",
|
||||
pass.name
|
||||
@@ -2482,6 +2617,20 @@ impl PackPost {
|
||||
};
|
||||
build(p, Some(&g)).map(|pass| (pass, geometry))
|
||||
});
|
||||
// Translucent held items, drawn from the hand's own buffer.
|
||||
let hand_water = post.hand_water.and_then(|p| {
|
||||
let (_, geometry) = hand.as_ref()?;
|
||||
let g = Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: Some(&geometry.layout),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&g))
|
||||
});
|
||||
let weather = post
|
||||
.weather
|
||||
.zip(weather_geometry.as_ref())
|
||||
@@ -2602,6 +2751,9 @@ impl PackPost {
|
||||
if let Some((h, _)) = &hand {
|
||||
notes.push(format!("the hand draws with {}", h.name));
|
||||
}
|
||||
if let Some(h) = &hand_water {
|
||||
notes.push(format!("translucent held items draw with {}", h.name));
|
||||
}
|
||||
if let Some([w, _]) = &weather {
|
||||
notes.push(format!("rain and snow draw with {}", w.name));
|
||||
}
|
||||
@@ -2655,6 +2807,7 @@ impl PackPost {
|
||||
.chain(&lines)
|
||||
.chain(weather.iter().flatten())
|
||||
.chain(hand.iter().map(|h| &h.0))
|
||||
.chain(&hand_water)
|
||||
};
|
||||
let shadow_targets = shadow_formats.map(|f| {
|
||||
let colors = every_pass().fold(0u16, |m, p| {
|
||||
@@ -2708,6 +2861,7 @@ impl PackPost {
|
||||
.chain(&water)
|
||||
.chain(weather.iter().take(1).flatten())
|
||||
.chain(hand.iter().map(|h| &h.0))
|
||||
.chain(&hand_water)
|
||||
.find(touched)
|
||||
.map(|_| true)
|
||||
.or_else(|| passes.iter().find(touched).map(|_| true));
|
||||
@@ -2887,6 +3041,7 @@ impl PackPost {
|
||||
weather_draw: None,
|
||||
weather_generation: 0,
|
||||
hand,
|
||||
hand_water,
|
||||
depth1,
|
||||
depth2,
|
||||
shadow_targets,
|
||||
@@ -2927,7 +3082,7 @@ impl PackPost {
|
||||
targets: None,
|
||||
noise: pack_textures::noise(&textures)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| noise(device, queue)),
|
||||
.unwrap_or_else(|| noise(device, queue, noise_size)),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
|
||||
dummy_normal: texture(
|
||||
@@ -3569,6 +3724,16 @@ impl PackPost {
|
||||
open.as_mut().expect("open").1 = i;
|
||||
}
|
||||
let rp = &mut open.as_mut().expect("open").0;
|
||||
// Each part with vanilla's blend for it: one program, three pipelines.
|
||||
let part_pipeline =
|
||||
pass.sky_parts
|
||||
.as_ref()
|
||||
.and_then(|[plain, stars]| match part.stage {
|
||||
"SKY" | "VOID" => Some(plain),
|
||||
"STARS" => Some(stars),
|
||||
_ => None,
|
||||
});
|
||||
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
|
||||
if pass.draw {
|
||||
self.draws.bind(rp, part.stage);
|
||||
}
|
||||
@@ -3769,6 +3934,7 @@ impl PackPost {
|
||||
portal: [f32; 4],
|
||||
rest: &mut Vec<super::Vertex>,
|
||||
skin: Option<&super::entity_skin::SkinFrame>,
|
||||
motion: (&[(u32, u64)], &[u32]),
|
||||
) {
|
||||
self.portal_rect = portal;
|
||||
let shadow = self.shadow_casters();
|
||||
@@ -3789,6 +3955,7 @@ impl PackPost {
|
||||
shadow,
|
||||
rest,
|
||||
skin,
|
||||
motion,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3907,8 +4074,14 @@ impl PackPost {
|
||||
rp.set_bind_group(1, &geometry.group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// The arm and each held item: their own `currentRenderedItemId`.
|
||||
for &(first, quads, offset) in &geometry.runs {
|
||||
// The arm and each held item: their own `currentRenderedItemId`. Translucent
|
||||
// quads wait for `gbuffers_hand_water` when the pack has one.
|
||||
let late = self.hand_water.is_some();
|
||||
let runs = geometry
|
||||
.runs
|
||||
.iter()
|
||||
.chain(geometry.translucent_runs.iter().filter(|_| !late));
|
||||
for &(first, quads, offset) in runs {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
|
||||
}
|
||||
@@ -4035,9 +4208,53 @@ impl PackPost {
|
||||
terrain,
|
||||
);
|
||||
self.draw_entities(device, queue, encoder, size, true);
|
||||
self.draw_hand_water(device, queue, encoder, size);
|
||||
self.draw_weather(device, queue, encoder, size);
|
||||
}
|
||||
|
||||
/// The hand's translucent quads (uploaded by `draw_hand` this frame), with
|
||||
/// `gbuffers_hand_water`.
|
||||
fn draw_hand_water(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
let Some(mut pass) = self.hand_water.take() else {
|
||||
return;
|
||||
};
|
||||
let hand = self.hand.take();
|
||||
if let Some((_, geometry)) = hand
|
||||
.as_ref()
|
||||
.filter(|(_, g)| g.quads > 0 && !g.translucent_runs.is_empty())
|
||||
{
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
let mut rp = self.begin(encoder, &pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
rp.set_bind_group(1, &geometry.group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for &(first, quads, offset) in &geometry.translucent_runs {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
|
||||
}
|
||||
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.hand = hand;
|
||||
self.hand_water = Some(pass);
|
||||
}
|
||||
|
||||
/// The rain, then the snow, `set_weather` gave for this frame, with `gbuffers_weather`.
|
||||
fn draw_weather(
|
||||
&mut self,
|
||||
@@ -4152,6 +4369,18 @@ impl PackPost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the buffers `pass` writes have different sizes at the current render size: then
|
||||
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
|
||||
/// a pass writing buffers sized relative to the render size and in pixels can.
|
||||
fn sizes_differ(&self, pass: &Pass) -> bool {
|
||||
let Some(targets) = &self.targets else {
|
||||
return false;
|
||||
};
|
||||
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
|
||||
let first = sizes.next();
|
||||
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
|
||||
}
|
||||
|
||||
/// Whether the pack draws terrain itself, or has nothing draw it.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
|
||||
@@ -4213,6 +4442,7 @@ impl PackPost {
|
||||
.chain(&mut self.lines)
|
||||
.chain(self.weather.iter_mut().flatten())
|
||||
.chain(self.hand.iter_mut().map(|h| &mut h.0))
|
||||
.chain(&mut self.hand_water)
|
||||
.chain(&mut self.shadow_terrain)
|
||||
.chain(&mut self.shadow_water)
|
||||
.chain(&mut self.shadow_comp);
|
||||
@@ -4392,6 +4622,14 @@ impl PackPost {
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
|
||||
log::info!(
|
||||
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
|
||||
pass.name,
|
||||
size[0],
|
||||
size[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
|
||||
@@ -4938,6 +5176,10 @@ impl PackPost {
|
||||
terrain_drawn = true;
|
||||
self.draw_opaque(device, queue, encoder, size, frame, terrain);
|
||||
}
|
||||
// As if the pack didn't have it: no compute stages, flips or stage changes.
|
||||
if self.sizes_differ(pass) {
|
||||
continue;
|
||||
}
|
||||
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
|
||||
water_drawn = true;
|
||||
self.draw_translucent(device, queue, encoder, size, terrain);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The sun and moon blend as vanilla's `RenderPipelines.CELESTIAL` (`BlendFunction.OVERLAY`):
|
||||
//! their colour is added to the sky's, and their own alpha replaces the buffer's. Iris 1.11.7
|
||||
//! left the sun's alpha in a buffer the sky had written first, with a probe pack of ours
|
||||
//! (#10490). Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// The sky writes alpha 1 into colortex1, then the sun (default blend) writes alpha 0.5 there;
|
||||
/// final shows colortex1's alpha in red.
|
||||
fn files() -> Vec<(String, String)> {
|
||||
let program = |alpha: &str| {
|
||||
format!(
|
||||
"#version 120\n/* DRAWBUFFERS:1 */\n\
|
||||
void main() {{ gl_FragData[0] = vec4(0.0, 0.0, 0.0, {alpha}); }}\n"
|
||||
)
|
||||
};
|
||||
let vertex = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
vec![
|
||||
("gbuffers_skybasic.vsh".into(), vertex.into()),
|
||||
("gbuffers_skybasic.fsh".into(), program("1.0")),
|
||||
("gbuffers_skytextured.vsh".into(), vertex.into()),
|
||||
("gbuffers_skytextured.fsh".into(), program("0.5")),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor = vec4(texture2D(colortex1, uv).a, 0.0, 0.0, 1.0); }\n"
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A quad across the whole view, `z` blocks in front of the camera.
|
||||
fn quad(z: f32) -> [crate::mesher::Vertex; 4] {
|
||||
[
|
||||
[-400.0, -400.0],
|
||||
[400.0, -400.0],
|
||||
[400.0, 400.0],
|
||||
[-400.0, 400.0],
|
||||
]
|
||||
.map(|[x, y]| crate::mesher::Vertex {
|
||||
pos: [x, y, z],
|
||||
uv: [0.0; 2],
|
||||
layer: 0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sun_leaves_its_own_alpha_over_the_sky() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
// The world drew nothing: the far plane (reverse-Z) everywhere.
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let sun = gpu.texture(T::Rgba8Unorm);
|
||||
let mut post = gpu.load(
|
||||
files(),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: Some(sun),
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
sky: Some(super::super::pack_sky::inputs()),
|
||||
celestial: Some(super::super::pack_sky::celestial_inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.sky.is_some(), "{:?}", post.notes);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
frame.skybox = true;
|
||||
let sun = quad(100.0);
|
||||
frame.celestial = &sun;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
// The top four rows see the sky disc (sky_depth_tests); the sun covers the whole view.
|
||||
for (i, p) in pixels[..4 * SIZE[0] as usize].iter().enumerate() {
|
||||
assert!(
|
||||
p[0].abs_diff(128) <= 1,
|
||||
"pixel {i}: colortex1 alpha {} / 255, expected the sun's 0.5",
|
||||
p[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -613,6 +613,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -451,6 +451,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! A program writing a float `vec4` into an integer buffer draws: its other buffers get what it
|
||||
//! wrote, and the integer buffer the floats' bits, as Iris 1.11.7 on NVIDIA 615.78.08 did with
|
||||
//! a probe pack of ours (#10490). Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn a_float_output_into_an_integer_buffer_writes_its_bits() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let files = vec![
|
||||
("composite.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"composite.fsh".into(),
|
||||
"#version 130\n/*\nconst int colortex3Format = RGBA32UI;\n*/\n/* DRAWBUFFERS:03 */\n\
|
||||
void main() {\n\
|
||||
\x20 gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\n\
|
||||
\x20 gl_FragData[1] = vec4(1.5, 2.0, 7.0, 0.25);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 130\nuniform sampler2D colortex0;\nuniform usampler2D colortex3;\n\
|
||||
varying vec2 uv;\n\
|
||||
void main() {\n\
|
||||
\x20 uvec4 q = texelFetch(colortex3, ivec2(gl_FragCoord.xy), 0);\n\
|
||||
\x20 bvec4 bits = equal(q, uvec4(0x3FC00000u, 0x40000000u, 0x40E00000u, 0x3E800000u));\n\
|
||||
\x20 gl_FragColor = uv.y > 0.5 ? texture2D(colortex0, uv)\n\
|
||||
\x20 : vec4(float(bits.x && bits.y), float(bits.z), float(bits.w), 1.0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
let width = SIZE[0] as usize;
|
||||
let (top, bottom) = (pixels[0], pixels[(SIZE[1] as usize - 1) * width]);
|
||||
assert!(
|
||||
top[0].abs_diff(64) <= 1 && top[1].abs_diff(128) <= 1 && top[2].abs_diff(191) <= 1,
|
||||
"colortex0 {top:?}, notes {:?}",
|
||||
post.notes
|
||||
);
|
||||
assert_eq!(bottom, [255, 255, 255, 255], "colortex3's bits");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
|
||||
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
|
||||
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
|
||||
//! original and cut down.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
|
||||
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
|
||||
/// blue.
|
||||
fn pack(colortex3: &str) -> Post {
|
||||
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();
|
||||
};
|
||||
add(
|
||||
"shaders.properties",
|
||||
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
|
||||
);
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
|
||||
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
|
||||
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
/// Every pixel's colour, with how many pixels differ from it by more than one step.
|
||||
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
|
||||
let first = pixels[0];
|
||||
let off = pixels
|
||||
.iter()
|
||||
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
|
||||
.count();
|
||||
(first, off)
|
||||
}
|
||||
|
||||
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
|
||||
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
|
||||
/// the whole pack in that case; Horizon skips only the program).
|
||||
#[test]
|
||||
fn a_program_writing_buffers_that_come_to_one_size_draws() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack("4 4"));
|
||||
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
|
||||
assert_eq!(off, 0, "{colour:?}");
|
||||
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
|
||||
assert!(got.abs_diff(want) <= 1, "{colour:?}");
|
||||
}
|
||||
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
|
||||
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
|
||||
}
|
||||
|
||||
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
|
||||
#[test]
|
||||
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let post = pack("0.5 0.5");
|
||||
let post = gpu
|
||||
.try_build(post)
|
||||
.unwrap_or_else(|_| panic!("the pack runs"));
|
||||
assert!(
|
||||
post.notes
|
||||
.iter()
|
||||
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! `noiseTextureResolution` sizes the `noisetex` Horizon makes, as Iris 1.11.7 did with probe packs
|
||||
//! of ours (#10490): a declaration in a fragment shader counts, one in a vertex shader doesn't.
|
||||
//! Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// The width of `noisetex` that `final` sees, with these lines added to `final.vsh` and `final.fsh`.
|
||||
fn noise_width(vertex_extra: &str, fragment_extra: &str) -> u8 {
|
||||
let Some(gpu) = Gpu::new() else { return 0 };
|
||||
let vertex = VERTEX.replacen('\n', &format!("\n{vertex_extra}"), 1);
|
||||
let files = vec![
|
||||
("final.vsh".into(), vertex),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 130\n{fragment_extra}uniform sampler2D noisetex;\nvarying vec2 uv;\n\
|
||||
void main() {{ gl_FragColor = vec4(float(textureSize(noisetex, 0).x) / 255.0, 0.0, 0.0, 1.0); }}\n"
|
||||
),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
pixels[0][0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fragment_shader_sets_the_noise_resolution() {
|
||||
if Gpu::new().is_none() {
|
||||
return;
|
||||
}
|
||||
let declared = "#define NOISE 64\nconst int noiseTextureResolution = NOISE;\n";
|
||||
assert_eq!(noise_width("", declared), 64);
|
||||
// Not from a vertex shader: the default 256, read as 255 (1.0).
|
||||
assert_eq!(
|
||||
noise_width("const int noiseTextureResolution = 64;\n", ""),
|
||||
255
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,124 @@ pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8;
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
/// A program's `vec4` float output into an integer buffer (`RGBA32UI`...) writes the float's
|
||||
/// bits there, and the program draws its other buffers. Horizon refused such a program, since
|
||||
/// wgpu has no float output into an integer target. Iris 1.11.7 drew it, with a probe pack of
|
||||
/// ours (#10490): its other buffers kept every bit written, NaN payloads included, and the
|
||||
/// integer buffer read the floats' bits back (1.5 as 0x3FC00000). GL leaves the integer
|
||||
/// buffer's value undefined, so those bits are what this driver (NVIDIA 615.78.08) wrote.
|
||||
pub(super) fn bitcast_outputs(module: &mut naga::Module, outputs: &[(u32, naga::ScalarKind)]) {
|
||||
let Some(ep) =
|
||||
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = &ep.function.result else {
|
||||
return;
|
||||
};
|
||||
let naga::TypeInner::Struct { members, span } = module.types[result.ty].inner.clone() else {
|
||||
return;
|
||||
};
|
||||
let mut new_members = members.clone();
|
||||
// (member, integer kind) of each output that changes.
|
||||
let mut changed = Vec::new();
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let Some(naga::Binding::Location { location, .. }) = m.binding else {
|
||||
continue;
|
||||
};
|
||||
let Some(&(_, kind)) = outputs.iter().find(|o| o.0 == location) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(
|
||||
module.types[m.ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar::F32
|
||||
}
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: None,
|
||||
inner: naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar { kind, width: 4 },
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
new_members[i].ty = ty;
|
||||
if let Some(naga::Binding::Location {
|
||||
interpolation,
|
||||
sampling,
|
||||
..
|
||||
}) = &mut new_members[i].binding
|
||||
{
|
||||
*interpolation = Some(naga::Interpolation::Flat);
|
||||
*sampling = None;
|
||||
}
|
||||
changed.push((i, kind));
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let old_name = module.types[result.ty].name.clone();
|
||||
let struct_ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: old_name.map(|n| n + "_bits"),
|
||||
inner: naga::TypeInner::Struct {
|
||||
members: new_members,
|
||||
span,
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
let f = &mut ep.function;
|
||||
if let Some(r) = &mut f.result {
|
||||
r.ty = struct_ty;
|
||||
}
|
||||
let body = std::mem::take(&mut f.body);
|
||||
let mut out = naga::Block::with_capacity(body.len() + 1);
|
||||
for (statement, sp) in body.span_into_iter() {
|
||||
let Statement::Return { value: Some(value) } = statement else {
|
||||
out.push(statement, sp);
|
||||
continue;
|
||||
};
|
||||
let Expression::Compose { components, .. } = &f.expressions[value] else {
|
||||
out.push(statement, sp);
|
||||
continue;
|
||||
};
|
||||
let mut components = components.clone();
|
||||
let mut first = None;
|
||||
for &(member, kind) in &changed {
|
||||
let e = f.expressions.append(
|
||||
Expression::As {
|
||||
expr: components[member],
|
||||
kind,
|
||||
convert: None,
|
||||
},
|
||||
sp,
|
||||
);
|
||||
first.get_or_insert(e);
|
||||
components[member] = e;
|
||||
}
|
||||
let value = f.expressions.append(
|
||||
Expression::Compose {
|
||||
ty: struct_ty,
|
||||
components,
|
||||
},
|
||||
sp,
|
||||
);
|
||||
out.push(
|
||||
Statement::Emit(naga::Range::new_from_bounds(first.unwrap_or(value), value)),
|
||||
sp,
|
||||
);
|
||||
out.push(Statement::Return { value: Some(value) }, sp);
|
||||
}
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! An `R11F_G11F_B10F` image that a compute program declares `layout(r11f_g11f_b10f)` is stored
|
||||
//! into and read back, as Iris 1.11.7 did with a probe pack of ours (#10490): its program is no
|
||||
//! longer refused when the image is the `RGBA16F` stand-in. Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn an_r11f_image_is_stored_into_and_read_back() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let files = vec![
|
||||
(
|
||||
"shaders.properties".into(),
|
||||
"image.imgA = texA RGBA R11F_G11F_B10F FLOAT false false 4 4\n".into(),
|
||||
),
|
||||
(
|
||||
"composite.csh".into(),
|
||||
"#version 430\nlayout(local_size_x = 1) in;\nconst ivec3 workGroups = ivec3(1, 1, 1);\n\
|
||||
layout(r11f_g11f_b10f) writeonly uniform image2D imgA;\n\
|
||||
void main() { imageStore(imgA, ivec2(1, 1), vec4(0.25, 1.5, 3.0, 1.0)); }\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 130\nuniform sampler2D texA;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(texA, ivec2(1, 1), 0).rgb / 4.0, 1.0); }\n"
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
// (0.25, 1.5, 3.0) / 4 = (0.0625, 0.375, 0.75).
|
||||
let p = pixels[0];
|
||||
assert!(
|
||||
p[0].abs_diff(16) <= 1 && p[1].abs_diff(96) <= 1 && p[2].abs_diff(191) <= 1,
|
||||
"{p:?}, notes {:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! `.length()` of a runtime-sized array in a named buffer block reads the buffer's own length, as
|
||||
//! Iris 1.11.7 did with a probe pack of ours (#10490): `bufferObject.0 = 32` holds 8 floats. The
|
||||
//! program was refused before. Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn a_runtime_arrays_length_is_the_buffers() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let files = vec![
|
||||
("shaders.properties".into(), "bufferObject.0 = 32\n".into()),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 430 compatibility\nin vec2 uv;\n\
|
||||
layout(std430, binding = 0) buffer Values { float v[]; } values;\n\
|
||||
void main() { gl_FragColor = vec4(float(values.v.length()) / 32.0, 0.0, 0.0, 1.0); }\n"
|
||||
.into(),
|
||||
),
|
||||
];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs::default(),
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {});
|
||||
// 32 bytes of floats: 8 entries, 8 / 32 = 0.25.
|
||||
let p = pixels[0];
|
||||
assert!(p[0].abs_diff(64) <= 1, "{p:?}, notes {:?}", post.notes);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The sky disc draws unblended, as vanilla draws it (`RenderPipelines.SKY`): what
|
||||
//! `gbuffers_skybasic` writes there stays, alpha included. Iris 1.11.7 left exactly that with
|
||||
//! probe packs of ours (#10490). Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// `gbuffers_skybasic` writes (0.2, 0.4, 0.6, 0.25) into colortex1, with `blend` as the
|
||||
/// properties line; final shows colortex1's colour on the top half and its alpha on the bottom
|
||||
/// half, each seeing the top of the view.
|
||||
fn files(blend: &str) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("shaders.properties".into(), blend.into()),
|
||||
(
|
||||
"gbuffers_skybasic.vsh".into(),
|
||||
"#version 120\nvoid main() { gl_Position = ftransform(); }\n".into(),
|
||||
),
|
||||
(
|
||||
"gbuffers_skybasic.fsh".into(),
|
||||
"#version 120\n/* DRAWBUFFERS:1 */\n\
|
||||
void main() { gl_FragData[0] = vec4(0.2, 0.4, 0.6, 0.25); }\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 120\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
|
||||
void main() {\n\
|
||||
\x20 vec4 c = texture2D(colortex1, vec2(uv.x, uv.y > 0.5 ? uv.y : uv.y + 0.5));\n\
|
||||
\x20 gl_FragColor = uv.y > 0.5 ? vec4(c.rgb, 1.0) : vec4(c.a, c.a, c.a, 1.0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The pack's picture of a frame whose world drew nothing.
|
||||
fn picture(gpu: &Gpu, blend: &str) -> Vec<[u8; 4]> {
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
// The far plane everywhere (reverse-Z).
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files(blend),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
sky: Some(super::super::pack_sky::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.sky.is_some(), "{:?}", post.notes);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
frame.skybox = true;
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// The top four rows see the sky disc (sky_depth_tests); final repeats them in rows 8 to 11.
|
||||
#[test]
|
||||
fn the_sky_disc_keeps_what_the_program_wrote() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let width = SIZE[0] as usize;
|
||||
let pixels = picture(&gpu, "");
|
||||
for (i, p) in pixels[..4 * width].iter().enumerate() {
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
assert!(
|
||||
near(p[0], 51) && near(p[1], 102) && near(p[2], 153),
|
||||
"pixel {i}: colour {p:?}, expected (0.2, 0.4, 0.6)"
|
||||
);
|
||||
}
|
||||
for (i, p) in pixels[8 * width..12 * width].iter().enumerate() {
|
||||
assert!(p[0].abs_diff(64) <= 1, "pixel {i}: alpha {} / 255", p[0]);
|
||||
}
|
||||
// A pack's own `blend.gbuffers_skybasic` still blends the disc: alpha over the cleared 1.
|
||||
let blended = picture(
|
||||
&gpu,
|
||||
"blend.gbuffers_skybasic = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
|
||||
);
|
||||
assert!(blended[0][0].abs_diff(51) > 8, "{:?}", blended[0]);
|
||||
// So does a `blend.gbuffers_skybasic.colortex1` of its own, into that buffer.
|
||||
let own = picture(
|
||||
&gpu,
|
||||
"blend.gbuffers_skybasic.colortex1 = SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ZERO\n",
|
||||
);
|
||||
assert!(own[0][0].abs_diff(51) > 8, "{:?}", own[0]);
|
||||
}
|
||||
@@ -420,6 +420,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
|
||||
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
|
||||
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
|
||||
//! write them), then read back when mapped. The next frame after that is timed again.
|
||||
//! write them), then read back when mapped. The next frame after that is timed again, once `gap`
|
||||
//! has passed since the last.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const REPORT: usize = 30;
|
||||
/// Passes timed per frame at most (two timestamps each).
|
||||
@@ -43,6 +45,10 @@ pub struct PassTiming {
|
||||
/// `mark`'s pipeline, for the target format it was made for.
|
||||
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
|
||||
reports: usize,
|
||||
/// Least time between timed frames.
|
||||
pub gap: Duration,
|
||||
/// When the last timed frame began.
|
||||
timed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PassTiming {
|
||||
@@ -81,6 +87,8 @@ impl PassTiming {
|
||||
log: Self::logging(),
|
||||
marker: None,
|
||||
reports: 0,
|
||||
gap: Duration::ZERO,
|
||||
timed_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,9 +131,12 @@ impl PassTiming {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(self.stage, Stage::Idle) {
|
||||
if matches!(self.stage, Stage::Idle)
|
||||
&& self.timed_at.is_none_or(|at| at.elapsed() >= self.gap)
|
||||
{
|
||||
self.names.clear();
|
||||
self.stage = Stage::Recording;
|
||||
self.timed_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2496,6 +2496,7 @@ pub(super) mod initial_history_tests {
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
|
||||
@@ -323,4 +323,26 @@ 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 under `HORIZON_HDR_FRAME=float`
|
||||
/// (`Renderer::frame_format`): what the tonemap 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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -771,7 +771,7 @@ pub struct Graphics {
|
||||
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
|
||||
/// (`smooth_gradients_offered`).
|
||||
pub hdr_smooth_gradients: bool,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
/// The native `horizon:<manifest-id>` or classic filename picked in `shaderpacks/`; empty: none. Its key is
|
||||
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
|
||||
/// ignored.
|
||||
pub shader_pack: String,
|
||||
|
||||
@@ -639,12 +639,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
/// BlueMap download radii, in chunks.
|
||||
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
|
||||
/// for a short ring of far terrain past a small render distance (a phone or an
|
||||
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
|
||||
/// up to the whole world.
|
||||
const LOD_STEPS: [u32; 18] = [
|
||||
const LOD_STEPS: [u32; 24] = [
|
||||
0,
|
||||
16,
|
||||
24,
|
||||
32,
|
||||
48,
|
||||
64,
|
||||
96,
|
||||
128,
|
||||
192,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
@@ -2692,12 +2700,21 @@ mod tests {
|
||||
item.label(&s).plain(),
|
||||
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
|
||||
);
|
||||
item.set_slider(&mut s, 5.0 / 17.0);
|
||||
item.set_slider(&mut s, 11.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 1024);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
|
||||
// The fine stops: a ring of far terrain just past a small render
|
||||
// distance, which the doubling steps skipped (#9694).
|
||||
item.set_slider(&mut s, 1.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 16);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
|
||||
item.set_slider(&mut s, 2.0 / 23.0);
|
||||
assert_eq!(s.horizon.lod_distance, 24);
|
||||
// A distance between stops (from the file) sits on the next one.
|
||||
s.horizon.lod_distance = 3000;
|
||||
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
|
||||
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
|
||||
s.horizon.lod_distance = 12;
|
||||
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_sprites;
|
||||
pub mod block_states;
|
||||
pub mod bluemap;
|
||||
pub mod bluemap_detect;
|
||||
pub mod border_render;
|
||||
@@ -27,6 +28,7 @@ pub mod cit;
|
||||
pub mod cit_armor;
|
||||
pub mod cit_assets;
|
||||
pub mod cit_glint;
|
||||
pub mod client_resources;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
@@ -34,6 +36,7 @@ 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;
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct LightTables {
|
||||
/// what `light_chunk` stores per cell, so a cell costs 2 bytes and the
|
||||
/// flood fill never reads the (large) `cells` table.
|
||||
info: Vec<u32>,
|
||||
/// `info`'s low 16 bits.
|
||||
packed: Vec<u16>,
|
||||
/// `blocked(type_a, type_b, direction)` as a bit per direction.
|
||||
block_masks: Vec<u8>,
|
||||
}
|
||||
@@ -73,6 +75,7 @@ impl LightTables {
|
||||
faces: 2,
|
||||
types: 1,
|
||||
info: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
block_masks: Vec::new(),
|
||||
};
|
||||
tables.rebuild_cache();
|
||||
@@ -101,6 +104,9 @@ impl LightTables {
|
||||
types <= 4096,
|
||||
"light tables: {types} cell configurations exceed the 12-bit type field"
|
||||
);
|
||||
// The region `light_chunk` floods over is filled with 0, and every air
|
||||
// cell of a section writes `info` of state 0 over it (`states_of`).
|
||||
debug_assert_eq!(info[0], 0, "state 0 (air) must hold no light info");
|
||||
let mut block_masks = vec![0u8; types * types];
|
||||
for a in 0..types {
|
||||
for b in 0..types {
|
||||
@@ -121,6 +127,7 @@ impl LightTables {
|
||||
}
|
||||
}
|
||||
self.types = types;
|
||||
self.packed = info.iter().map(|&i| i as u16).collect();
|
||||
self.info = info;
|
||||
self.block_masks = block_masks;
|
||||
}
|
||||
@@ -212,6 +219,7 @@ impl LightTables {
|
||||
faces,
|
||||
types: 0,
|
||||
info: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
block_masks: Vec::new(),
|
||||
};
|
||||
tables.rebuild_cache();
|
||||
@@ -276,7 +284,8 @@ struct LightScratch {
|
||||
sky_queue: Vec<u32>,
|
||||
present: Vec<bool>,
|
||||
lowest: Vec<i32>,
|
||||
ids: Box<[u32; 4096]>,
|
||||
/// What `states_of` keeps between sections.
|
||||
section: SectionScratch,
|
||||
}
|
||||
|
||||
impl LightScratch {
|
||||
@@ -289,7 +298,7 @@ impl LightScratch {
|
||||
sky_queue: Vec::new(),
|
||||
present: vec![false; W * W],
|
||||
lowest: vec![0; W * W],
|
||||
ids: Box::new([0u32; 4096]),
|
||||
section: SectionScratch::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,10 +316,132 @@ impl LightScratch {
|
||||
}
|
||||
}
|
||||
|
||||
/// One section's values mapped to the packed light info, and the palette's
|
||||
/// info (`states_of`, kept between sections).
|
||||
struct SectionScratch {
|
||||
stage: Box<[u16; 4096]>,
|
||||
stage32: Box<[u32; 4096]>,
|
||||
info: Vec<u32>,
|
||||
packed: Vec<u16>,
|
||||
}
|
||||
impl SectionScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stage: Box::new([0u16; 4096]),
|
||||
stage32: Box::new([0u32; 4096]),
|
||||
info: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
|
||||
}
|
||||
|
||||
/// Where a section sits in the 48-wide region.
|
||||
#[derive(Clone, Copy)]
|
||||
struct At {
|
||||
x: usize,
|
||||
y: usize,
|
||||
z: usize,
|
||||
}
|
||||
impl At {
|
||||
/// The region index of the section's row `row` (`ly << 4 | lz`), the
|
||||
/// start of a run of 16 cells along x.
|
||||
#[inline]
|
||||
fn row(self, row: usize) -> usize {
|
||||
(self.y + (row >> 4)) * (W * W) + (self.z + (row & 15)) * W + self.x
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy one section's block states into the region's `states` as the packed
|
||||
/// light info `light_chunk` floods over, and seed `queue` (and `block`) with
|
||||
/// the cells that emit light. The values are mapped to that info in one pass
|
||||
/// over the section's packed storage into `stage` (index order), then copied a
|
||||
/// row of 16 along x at a time: the region's rows are 48 apart, so the two
|
||||
/// cannot be one.
|
||||
fn states_of(
|
||||
section: &Section,
|
||||
tables: &LightTables,
|
||||
at: At,
|
||||
states: &mut [u16],
|
||||
block: &mut [u8],
|
||||
queue: &mut Vec<u32>,
|
||||
scratch: &mut SectionScratch,
|
||||
) {
|
||||
if section.blocks.bits() == 0 {
|
||||
// A section of one state (the caller has skipped the air ones): a row
|
||||
// of the region is that state's info.
|
||||
let info = tables.info[section.blocks.get(0) as usize];
|
||||
for ly in 0..16 {
|
||||
let mut p = at.row(ly << 4);
|
||||
for _ in 0..16 {
|
||||
states[p..p + 16].fill(info as u16);
|
||||
if info > 0xFFFF {
|
||||
for x in 0..16 {
|
||||
block[p + x] = (info >> 16) as u8;
|
||||
queue.push((p + x) as u32);
|
||||
}
|
||||
}
|
||||
p += W;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The info of the ids the section stores: a list palette's ids are mapped
|
||||
// once, a global palette's are state ids, which `tables.info` holds. Only
|
||||
// a section whose palette emits light (an info's high bits) needs the
|
||||
// emission of each cell.
|
||||
let (info, packed, emits) = match section.blocks.palette() {
|
||||
Some(list) => {
|
||||
scratch.info.clear();
|
||||
scratch
|
||||
.info
|
||||
.extend(list.iter().map(|&id| tables.info[id as usize]));
|
||||
scratch.packed.clear();
|
||||
scratch
|
||||
.packed
|
||||
.extend(scratch.info.iter().map(|&i| i as u16));
|
||||
let emits = scratch.info.iter().any(|&i| i > 0xFFFF);
|
||||
(&scratch.info[..], &scratch.packed[..], emits)
|
||||
}
|
||||
None => (&tables.info[..], &tables.packed[..], true),
|
||||
};
|
||||
if emits {
|
||||
section
|
||||
.blocks
|
||||
.unpack_ids_mapped(&mut scratch.stage32[..], info);
|
||||
for ly in 0..16 {
|
||||
let mut p = at.row(ly << 4);
|
||||
for lz in 0..16 {
|
||||
let i = (ly << 8) | (lz << 4);
|
||||
for x in 0..16 {
|
||||
let info = scratch.stage32[i + x];
|
||||
states[p + x] = info as u16;
|
||||
if info > 0xFFFF {
|
||||
block[p + x] = (info >> 16) as u8;
|
||||
queue.push((p + x) as u32);
|
||||
}
|
||||
}
|
||||
p += W;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
section
|
||||
.blocks
|
||||
.unpack_ids_mapped(&mut scratch.stage[..], packed);
|
||||
for ly in 0..16 {
|
||||
let mut p = at.row(ly << 4);
|
||||
let (rows, _) = scratch.stage[(ly << 8)..(ly << 8) + 256].as_chunks::<16>();
|
||||
for row in rows {
|
||||
states[p..p + 16].copy_from_slice(row);
|
||||
p += W;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(
|
||||
@@ -356,7 +487,7 @@ fn light_chunk_with_scratch(
|
||||
let block = &mut scratch.block[..total];
|
||||
let queue = &mut scratch.queue;
|
||||
let sky_queue = &mut scratch.sky_queue;
|
||||
let ids = &mut scratch.ids;
|
||||
let section_scratch = &mut scratch.section;
|
||||
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
let z_base = dz * 16;
|
||||
@@ -367,31 +498,12 @@ fn light_chunk_with_scratch(
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(ids);
|
||||
let y_base = si * 16;
|
||||
for ly in 0..16 {
|
||||
let y = y_base + ly;
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let z = z_base + lz;
|
||||
let p_row = y_offset + z * W + x_base;
|
||||
let i_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let id = ids[i_row + lx];
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let p = p_row + lx;
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = At {
|
||||
x: x_base,
|
||||
y: si * 16,
|
||||
z: z_base,
|
||||
};
|
||||
states_of(section, tables, at, states, block, queue, section_scratch);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2195,6 +2307,88 @@ mod tests {
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
/// 600 states of every dampening, the last 300 of them light sources (a
|
||||
/// section's palette must hold emitting values too, which take
|
||||
/// `states_of`'s other pass).
|
||||
fn info_tables() -> LightTables {
|
||||
let cells = (0..600u32)
|
||||
.map(|id| Cell {
|
||||
dampening: (id % 16) as u8,
|
||||
emission: if id >= 300 { 14 } else { 0 },
|
||||
shaped: false,
|
||||
faces: [EMPTY; 6],
|
||||
})
|
||||
.collect();
|
||||
LightTables::from_parts(cells, vec![false, true, true, true], 2)
|
||||
}
|
||||
|
||||
/// `states_of` writes what walking the section's `unpack`ed state ids
|
||||
/// writes, for every palette the collector can hold: one value, a list of
|
||||
/// up to 256 (four to eight bits wide) and the global palette beyond it,
|
||||
/// with and without a light source in the palette.
|
||||
#[test]
|
||||
fn section_states_match_the_unpacked_ids() {
|
||||
let tables = info_tables();
|
||||
let at = At { x: 0, y: 0, z: 0 };
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
// (distinct values, the bits the collector stores them in)
|
||||
let cases = [
|
||||
(1, 0),
|
||||
(3, 4),
|
||||
(20, 5),
|
||||
(40, 6),
|
||||
(80, 7),
|
||||
(200, 8),
|
||||
(300, 16),
|
||||
];
|
||||
for (distinct, bits) in cases {
|
||||
for emitting in [false, true] {
|
||||
let base = if emitting { 300 } else { 0 };
|
||||
let values: Vec<u32> = (0..4096)
|
||||
// A pattern that repeats each value the section holds.
|
||||
.map(|i| base + ((i * 7 + i / 16) % distinct) as u32)
|
||||
.collect();
|
||||
let section = Section {
|
||||
blocks: Section::from_blocks(values.as_slice().try_into().unwrap()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
section.blocks.bits(),
|
||||
bits,
|
||||
"{distinct} values should be stored in {bits} bits"
|
||||
);
|
||||
let mut states = vec![0u16; W * W * 16];
|
||||
let mut block = vec![0u8; W * W * 16];
|
||||
let mut queue = Vec::new();
|
||||
states_of(
|
||||
§ion,
|
||||
&tables,
|
||||
at,
|
||||
&mut states,
|
||||
&mut block,
|
||||
&mut queue,
|
||||
&mut SectionScratch::new(),
|
||||
);
|
||||
|
||||
section.unpack(&mut ids);
|
||||
let mut want_queue = Vec::new();
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let info = tables.info[id as usize];
|
||||
let p = at.row(i >> 4) + (i & 15);
|
||||
assert_eq!(
|
||||
states[p], info as u16,
|
||||
"{distinct} values, emitting {emitting}: state at {i}"
|
||||
);
|
||||
if info > 0xFFFF {
|
||||
assert_eq!(block[p], (info >> 16) as u8, "emission at {i}");
|
||||
want_queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
assert_eq!(queue, want_queue, "{distinct} values, emitting {emitting}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_chunk_matches_old_algorithm() {
|
||||
let t = tables();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -401,6 +401,7 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
}
|
||||
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
|
||||
/// x), from the padded rows of `MeshScratch::solid_rows`.
|
||||
#[cfg(test)]
|
||||
fn open_rows(solid: &[u32]) -> [u16; 256] {
|
||||
std::array::from_fn(|r| {
|
||||
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
|
||||
@@ -977,10 +978,11 @@ impl Padded {
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read in runs, from
|
||||
// sections found once per layer.
|
||||
own.fill(0);
|
||||
let center = region[1][1];
|
||||
if let Some(c) = center {
|
||||
c.sections[sy as usize].unpack(own);
|
||||
} else {
|
||||
own.fill(0);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
@@ -1065,6 +1067,18 @@ fn fill_run(
|
||||
},
|
||||
}
|
||||
}
|
||||
// Direction order is the same as Direction::ALL; all values are evaluated at compile time.
|
||||
const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
|
||||
direction_info(Direction::Down),
|
||||
direction_info(Direction::Up),
|
||||
direction_info(Direction::North),
|
||||
direction_info(Direction::South),
|
||||
direction_info(Direction::West),
|
||||
direction_info(Direction::East),
|
||||
];
|
||||
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
|
||||
(d, d.normal(), d.opposite() as u8)
|
||||
}
|
||||
/// 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).
|
||||
@@ -1080,21 +1094,63 @@ pub struct MeshScratch {
|
||||
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
|
||||
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
bits | u32::from(solid[id as usize]) << x
|
||||
})
|
||||
}));
|
||||
for row in pad.blocks.as_chunks::<P>().0 {
|
||||
let mut bits = 0;
|
||||
for (x, &id) in row.iter().enumerate() {
|
||||
bits |= u32::from(solid[id as usize]) << x;
|
||||
}
|
||||
rows.push(bits);
|
||||
}
|
||||
}
|
||||
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
|
||||
/// are all `isSolidRender`: none of its faces can show.
|
||||
#[inline]
|
||||
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
|
||||
let r = row(y, z);
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
|
||||
around >> (x + B) & 1 != 0
|
||||
/// Open and fully surrounded cells in each inner row.
|
||||
fn row_masks(rows: &[u32]) -> ([u16; 256], [u16; 256]) {
|
||||
let mut open = [0; 256];
|
||||
let mut hidden = [0; 256];
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let i = (y + B as usize) * P + z + B as usize;
|
||||
let r = rows[i];
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & rows[i - P] & rows[i + P] & rows[i - 1] & rows[i + 1];
|
||||
open[y * 16 + z] = !(r >> B) as u16;
|
||||
hidden[y * 16 + z] = (around >> B) as u16;
|
||||
}
|
||||
}
|
||||
(open, hidden)
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn row_masks_match_cell_definition() {
|
||||
let mut seed = 42u32;
|
||||
for _ in 0..100 {
|
||||
let rows: Vec<u32> = (0..P * P)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
seed & ((1 << P) - 1)
|
||||
})
|
||||
.collect();
|
||||
let (open, hidden) = row_masks(&rows);
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
let cell = |x: usize, y: usize, z: usize| (rows[y * P + z] >> x) & 1 != 0;
|
||||
for x in 0..16 {
|
||||
let (x, py, pz) = (x + B as usize, y + B as usize, z + B as usize);
|
||||
let occupied = cell(x, py, pz);
|
||||
let enclosed = occupied
|
||||
&& cell(x - 1, py, pz)
|
||||
&& cell(x + 1, py, pz)
|
||||
&& cell(x, py - 1, pz)
|
||||
&& cell(x, py + 1, pz)
|
||||
&& cell(x, py, pz - 1)
|
||||
&& cell(x, py, pz + 1);
|
||||
assert_eq!((open[y * 16 + z] >> (x - B as usize)) & 1 != 0, !occupied);
|
||||
assert_eq!((hidden[y * 16 + z] >> (x - B as usize)) & 1 != 0, enclosed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
@@ -1463,7 +1519,8 @@ impl Mesher<'_> {
|
||||
let pad = &scratch.padded;
|
||||
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
|
||||
let opaque = &scratch.solid_rows;
|
||||
out.visibility = visibility_rows(&open_rows(opaque));
|
||||
let (open, hidden) = row_masks(opaque);
|
||||
out.visibility = visibility_rows(&open);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1497,7 +1554,7 @@ impl Mesher<'_> {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if hidden(opaque, x, y, z) {
|
||||
if hidden[(y * 16 + z) as usize] >> x & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
@@ -1538,12 +1595,11 @@ impl Mesher<'_> {
|
||||
let interior_leaf = state.leaves
|
||||
&& self.leaf_culling.is_interior()
|
||||
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
// `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] >> d.opposite() as u8 & 1 != 0 {
|
||||
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
|
||||
continue;
|
||||
}
|
||||
let other = &info[neighbor as usize];
|
||||
|
||||
@@ -229,7 +229,7 @@ pub(super) fn tesselate(
|
||||
let kind = me.fluid.kind();
|
||||
let same = |s: &Info| s.fluid.kind().same_family(kind);
|
||||
let neighbor = |d: Direction| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
|
||||
at([x + dx, y + dy, z + dz])
|
||||
};
|
||||
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
|
||||
@@ -238,12 +238,11 @@ pub(super) fn tesselate(
|
||||
let render_down =
|
||||
should_render(Direction::Down) && !occluded_by(Direction::Down, MAX_HEIGHT, down);
|
||||
let sides = [
|
||||
Direction::North,
|
||||
Direction::South,
|
||||
Direction::West,
|
||||
Direction::East,
|
||||
]
|
||||
.map(should_render);
|
||||
should_render(Direction::North),
|
||||
should_render(Direction::South),
|
||||
should_render(Direction::West),
|
||||
should_render(Direction::East),
|
||||
];
|
||||
if !render_up && !render_down && !sides.iter().any(|&s| s) {
|
||||
return;
|
||||
}
|
||||
@@ -356,9 +355,9 @@ pub(super) fn tesselate(
|
||||
fluid_face(color, face)
|
||||
};
|
||||
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
|
||||
target.extend(v.map(vertex));
|
||||
target.extend([vertex(v[0]), vertex(v[1]), vertex(v[2]), vertex(v[3])]);
|
||||
if back {
|
||||
target.extend([v[0], v[3], v[2], v[1]].map(vertex));
|
||||
target.extend([vertex(v[0]), vertex(v[3]), vertex(v[2]), vertex(v[1])]);
|
||||
}
|
||||
};
|
||||
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
|
||||
|
||||
@@ -23,6 +23,7 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
pub struct BakedFace {
|
||||
@@ -51,8 +52,11 @@ type VariantKey = (String, [u8; 3], bool);
|
||||
pub struct StateModels {
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
/// All baked alternatives per definition, invalidated before mutable model access.
|
||||
bounds: OnceLock<Vec<Option<[[f32; 3]; 2]>>>,
|
||||
/// By state id: its definition in `choices` and the models it selects.
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
single_models: Vec<Option<u32>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
/// Standalone models by id: their index in `baked`.
|
||||
@@ -354,10 +358,19 @@ impl StateModels {
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
let choices = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
let choices: Vec<_> = choices.iter().map(|c| c.map(|v| index[&key(v)])).collect();
|
||||
let single_models = states
|
||||
.iter()
|
||||
.map(|state| {
|
||||
let (choice, selected) = state.as_ref()?;
|
||||
choices[*choice].single_variant(selected).copied()
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
choices,
|
||||
bounds: OnceLock::new(),
|
||||
states,
|
||||
single_models,
|
||||
baked,
|
||||
standalone: BTreeMap::new(),
|
||||
})
|
||||
@@ -408,6 +421,7 @@ impl StateModels {
|
||||
}
|
||||
/// Every baked model with the variant it was baked from, to change its faces.
|
||||
pub fn models_mut(&mut self) -> impl Iterator<Item = (&Variant, &mut BakedModel)> {
|
||||
self.bounds.take();
|
||||
self.baked
|
||||
.iter_mut()
|
||||
.map(|(variant, model)| (&*variant, model))
|
||||
@@ -432,6 +446,37 @@ impl StateModels {
|
||||
let model = self.choices[*choice].particle_variant(selected)?;
|
||||
self.baked[*model as usize].1.particle
|
||||
}
|
||||
/// Envelope of every weighted/multipart alternative in the state's definition.
|
||||
/// Includes transformed resource-pack faces outside the unit cube. Empty or
|
||||
/// non-finite geometry has no usable envelope and keeps the original draw path.
|
||||
pub fn render_bounds(&self, state: BlockStateId) -> Option<[[f32; 3]; 2]> {
|
||||
let (definition, _) = self.state(state)?;
|
||||
let bounds = self.bounds.get_or_init(|| {
|
||||
self.choices
|
||||
.iter()
|
||||
.map(|choices| {
|
||||
let mut lo = glam::Vec3::splat(f32::INFINITY);
|
||||
let mut hi = glam::Vec3::splat(f32::NEG_INFINITY);
|
||||
let mut valid = true;
|
||||
// `map` visits every choice, without consuming the render RNG.
|
||||
choices.map(|&index| {
|
||||
for face in &self.baked[index as usize].1.faces {
|
||||
for p in face.quad.positions {
|
||||
let p = glam::Vec3::from_array(p);
|
||||
valid &= p.is_finite();
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
}
|
||||
}
|
||||
});
|
||||
(valid && lo.is_finite() && hi.is_finite())
|
||||
.then_some([lo.to_array(), hi.to_array()])
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
bounds.get(*definition).copied().flatten()
|
||||
}
|
||||
|
||||
pub fn collect_seeded(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
@@ -452,6 +497,10 @@ impl StateModels {
|
||||
out: &mut Vec<&'a BakedModel>,
|
||||
) -> Result<()> {
|
||||
out.clear();
|
||||
if let Some(Some(model)) = self.single_models.get(state.0 as usize) {
|
||||
out.push(&self.baked[*model as usize].1);
|
||||
return Ok(());
|
||||
}
|
||||
self.each_seeded(state, position_seed(position), |(_, model)| out.push(model))
|
||||
}
|
||||
fn each_seeded<'a>(
|
||||
@@ -619,3 +668,7 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "catalog/bounds_tests.rs"]
|
||||
mod bounds_tests;
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
|
||||
fn catalog(definition: &str) -> StateModels {
|
||||
let definition = BlockStateDefinition::from_json(definition).unwrap();
|
||||
let choices = CompiledChoices::new(&definition)
|
||||
.unwrap()
|
||||
.map(|v| if v.model == "horizon:a" { 0 } else { 1 });
|
||||
let baked = [
|
||||
([-16, 0, 0], [16, 16, 16], [0, 0, 0]),
|
||||
([0, -8, -16], [32, 32, 16], [0, 90, 0]),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (from, to, turns))| {
|
||||
let faces: serde_json::Map<String, Value> =
|
||||
["up", "down", "north", "south", "east", "west"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
serde_json::json!({"texture":"#all"}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let json =
|
||||
serde_json::json!({"textures":{"all":"horizon:block/test"},"elements":[{"from":from,"to":to,"faces":faces}]}).to_string();
|
||||
let model = horizon_assets::model::CuboidModel::from_json(&json).unwrap();
|
||||
let name = format!("horizon:{}", if i == 0 { "a" } else { "b" });
|
||||
let library =
|
||||
horizon_assets::resolve::ModelLibrary::new(BTreeMap::from([(name.clone(), model)]))
|
||||
.unwrap();
|
||||
let baked = bake_resolved(
|
||||
Path::new("/nonexistent-horizon-bounds-fixture"),
|
||||
&mut Sprites::default(),
|
||||
&library.resolve(&name).unwrap(),
|
||||
turns,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
(
|
||||
Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: false,
|
||||
},
|
||||
baked,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
StateModels {
|
||||
choices: vec![choices],
|
||||
states: vec![Some((0, SelectedModels::Variant(0)))],
|
||||
baked,
|
||||
..StateModels::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelopes_cover_weighted_and_multipart_geometry_and_mutation_invalidates_them() {
|
||||
for (definition, selected) in [
|
||||
(
|
||||
r#"{"variants":{"": [{"model":"horizon:a","weight":1},{"model":"horizon:b","weight":3}]}}"#,
|
||||
SelectedModels::Variant(0),
|
||||
),
|
||||
(
|
||||
r#"{"multipart":[{"apply":{"model":"horizon:a"}},{"apply":{"model":"horizon:b"}}]}"#,
|
||||
SelectedModels::Multipart(vec![0, 1]),
|
||||
),
|
||||
] {
|
||||
let mut models = catalog(definition);
|
||||
models.states[0] = Some((0, selected));
|
||||
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
|
||||
assert!(
|
||||
lo[0] <= -1.0 && hi[1] >= 2.0,
|
||||
"pack geometry exceeds a unit cube"
|
||||
);
|
||||
let mut variants = BTreeSet::new();
|
||||
for i in 0..128 {
|
||||
let seed = position_seed(BlockPos::new(i * 13, i % 31, -i * 23));
|
||||
for (variant, model) in models.collect_seeded(BlockStateId(0), seed).unwrap() {
|
||||
variants.insert(variant.model.clone());
|
||||
for point in model.faces.iter().flat_map(|f| f.quad.positions) {
|
||||
assert!((0..3).all(|axis| point[axis] >= lo[axis] && point[axis] <= hi[axis]));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(variants.len(), 2);
|
||||
models.models_mut().next().unwrap().1.faces[0]
|
||||
.quad
|
||||
.positions[0] = [100.0, 2.0, -5.0];
|
||||
let [lo, hi] = models.render_bounds(BlockStateId(0)).unwrap();
|
||||
assert_eq!(hi[0], 100.0);
|
||||
assert_eq!(lo[2], -5.0);
|
||||
// A resource replacement owns a fresh catalog rather than the previous cache.
|
||||
let replacement = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
|
||||
assert_eq!(
|
||||
replacement.render_bounds(BlockStateId(0)),
|
||||
Some([[-1.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
|
||||
);
|
||||
models.models_mut().next().unwrap().1.faces[0]
|
||||
.quad
|
||||
.positions[0][0] = f32::NAN;
|
||||
assert!(models.render_bounds(BlockStateId(0)).is_none());
|
||||
}
|
||||
assert!(
|
||||
StateModels::default()
|
||||
.render_bounds(BlockStateId(0))
|
||||
.is_none()
|
||||
);
|
||||
let mut empty = catalog(r#"{"variants":{"":{"model":"horizon:a"}}}"#);
|
||||
for (_, model) in empty.models_mut() {
|
||||
model.faces.clear();
|
||||
}
|
||||
assert!(empty.render_bounds(BlockStateId(0)).is_none());
|
||||
}
|
||||
@@ -338,11 +338,21 @@ impl Settings {
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let horizon = path
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
.map(crate::graphics::Graphics::load)
|
||||
.unwrap_or_default();
|
||||
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
|
||||
if let Some(dir) = path.parent() {
|
||||
let (key, _) = crate::shader_pack_list::resolve(
|
||||
&crate::logging::shared_dir(dir).join(crate::shader_pack_list::DIR),
|
||||
&horizon.shader_pack,
|
||||
);
|
||||
if key != horizon.shader_pack {
|
||||
horizon.shader_pack = key;
|
||||
horizon.save(dir);
|
||||
}
|
||||
}
|
||||
let controller = path
|
||||
.parent()
|
||||
.map(crate::gamepad::PadSettings::load)
|
||||
|
||||
@@ -18,8 +18,10 @@ pub enum Kind {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The folder's or file's name in `shaderpacks`: what the pick saves.
|
||||
/// The folder or file name in `shaderpacks`.
|
||||
pub file: String,
|
||||
/// Native manifest identity (`horizon:<id>`), or a classic filename.
|
||||
pub key: String,
|
||||
/// The pack's own name for Horizon's packs, else `file`.
|
||||
pub title: String,
|
||||
pub kind: Kind,
|
||||
@@ -33,7 +35,7 @@ pub struct Entry {
|
||||
impl Entry {
|
||||
/// Saves the player's option values, for the pack in the folder `dir`.
|
||||
pub fn save_values(&self, dir: &Path) {
|
||||
let path = dir.join(&self.file);
|
||||
let path = values_pack(&dir.join(&self.file), &self.key);
|
||||
if let Err(e) = save_values(&path, &self.options, &self.values) {
|
||||
log::warn!("shader pack options of {}: {e}", self.file);
|
||||
}
|
||||
@@ -106,7 +108,22 @@ pub fn scan(dir: &Path) -> Vec<Entry> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
|
||||
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
let mut keys = std::collections::HashSet::new();
|
||||
let mut packs: Vec<Entry> = paths
|
||||
.iter()
|
||||
.filter_map(|p| entry(p))
|
||||
.filter(|e| keys.insert(e.key.clone()))
|
||||
.collect();
|
||||
for pack in &mut packs {
|
||||
if let Some(id) = pack.key.strip_prefix("horizon:") {
|
||||
pack.values = load_values(
|
||||
&native_values_pack(&dir.join(&pack.file), id),
|
||||
&pack.options,
|
||||
);
|
||||
}
|
||||
}
|
||||
packs.sort_by(|a, b| {
|
||||
(a.kind == Kind::Classic, a.title.to_lowercase())
|
||||
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
|
||||
@@ -122,26 +139,18 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
if horizon_shader_api::package::is_pack(path) {
|
||||
// Its manifest's name; a pack that can't be read is still listed, under its file
|
||||
// name: picking it says why in the chat.
|
||||
let manifest = if path.is_dir() {
|
||||
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
|
||||
.ok()
|
||||
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
|
||||
} else {
|
||||
horizon_shader_api::Pack::read(path)
|
||||
.ok()
|
||||
.map(|p| p.manifest)
|
||||
};
|
||||
let (title, options, pages) = manifest.map_or_else(
|
||||
|| (file.clone(), Vec::new(), Vec::new()),
|
||||
|m| (m.name, m.options, m.pages),
|
||||
let manifest = horizon_shader_api::Pack::read_manifest(path).ok();
|
||||
let (key, title, options, pages) = manifest.map_or_else(
|
||||
|| (file.clone(), file.clone(), Vec::new(), Vec::new()),
|
||||
|m| (format!("horizon:{}", m.id), m.name, m.options, m.pages),
|
||||
);
|
||||
let values = load_values(path, &options);
|
||||
return Some(Entry {
|
||||
key,
|
||||
file,
|
||||
title,
|
||||
kind: Kind::Horizon,
|
||||
options,
|
||||
values,
|
||||
values: Vec::new(),
|
||||
pages,
|
||||
});
|
||||
}
|
||||
@@ -151,6 +160,7 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
|
||||
(zip || path.join("shaders").is_dir()).then(|| Entry {
|
||||
title: file.clone(),
|
||||
key: file.clone(),
|
||||
file,
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
@@ -159,6 +169,104 @@ fn entry(path: &Path) -> Option<Entry> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a saved identity, migrating a legacy native filename when its manifest or the
|
||||
/// launcher's `<id>-<version>.hshader` name identifies it. Duplicate IDs use the first filename
|
||||
/// in lexical order, matching `scan`; versions are free-form strings, not ordered versions.
|
||||
/// An absent arbitrary old filename cannot be attributed to a manifest safely.
|
||||
pub fn resolve(dir: &Path, picked: &str) -> (String, PathBuf) {
|
||||
if picked.is_empty() {
|
||||
return (String::new(), dir.join(picked));
|
||||
}
|
||||
let direct = dir.join(picked);
|
||||
if direct.exists() && !horizon_shader_api::package::is_pack(&direct) {
|
||||
return (picked.to_owned(), direct);
|
||||
}
|
||||
let mut native: Vec<_> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let file = path.file_name()?.to_str()?.to_owned();
|
||||
if file.starts_with('.') || !horizon_shader_api::package::is_pack(&path) {
|
||||
return None;
|
||||
}
|
||||
Some((file, horizon_shader_api::Pack::read_manifest(&path).ok()?))
|
||||
})
|
||||
.collect();
|
||||
native.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let id = native
|
||||
.iter()
|
||||
.find(|(file, _)| file == picked)
|
||||
.map(|(_, m)| m.id.as_str())
|
||||
.or_else(|| picked.strip_prefix("horizon:"))
|
||||
.or_else(|| {
|
||||
// Never reinterpret an installed classic pack as a native identity.
|
||||
if dir.join(picked).exists() {
|
||||
return None;
|
||||
}
|
||||
let stem = picked.strip_suffix(".hshader")?;
|
||||
native
|
||||
.iter()
|
||||
.find(|(_, m)| {
|
||||
stem.strip_prefix(&m.id)
|
||||
.is_some_and(|v| v.starts_with('-') && v.len() > 1)
|
||||
})
|
||||
.map(|(_, m)| m.id.as_str())
|
||||
});
|
||||
if let Some((file, m)) = native.iter().find(|(_, m)| Some(m.id.as_str()) == id) {
|
||||
let key = format!("horizon:{}", m.id);
|
||||
if !picked.starts_with("horizon:") {
|
||||
migrate_values(&dir.join(picked), &key);
|
||||
}
|
||||
migrate_values(&dir.join(file), &key);
|
||||
return (key, dir.join(file));
|
||||
}
|
||||
(picked.to_owned(), dir.join(picked))
|
||||
}
|
||||
|
||||
fn values_pack(pack: &Path, key: &str) -> PathBuf {
|
||||
key.strip_prefix("horizon:").map_or_else(
|
||||
|| pack.to_path_buf(),
|
||||
|id| {
|
||||
pack.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(".horizon-options")
|
||||
.join(id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The native options path; imports a filename sidecar once, keeping the original intact.
|
||||
pub fn native_values_pack(pack: &Path, id: &str) -> PathBuf {
|
||||
let key = format!("horizon:{id}");
|
||||
migrate_values(pack, &key);
|
||||
values_pack(pack, &key)
|
||||
}
|
||||
|
||||
fn migrate_values(pack: &Path, key: &str) {
|
||||
let target = values_path(&values_pack(pack, key));
|
||||
if target.exists() {
|
||||
return;
|
||||
}
|
||||
if let Ok(text) = std::fs::read(values_path(pack)) {
|
||||
let result = std::fs::create_dir_all(target.parent().unwrap()).and_then(|()| {
|
||||
use std::io::Write;
|
||||
// Another reader may already have imported the values.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&target)?;
|
||||
file.write_all(&text)
|
||||
});
|
||||
if let Err(e) = result
|
||||
&& e.kind() != std::io::ErrorKind::AlreadyExists
|
||||
{
|
||||
log::warn!("shader pack options migration: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
|
||||
/// one `id=value` a line.
|
||||
pub fn values_path(pack: &Path) -> PathBuf {
|
||||
@@ -186,8 +294,7 @@ pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Saves `values` of `options`; values at their defaults are left out, and with none left
|
||||
/// the file goes.
|
||||
/// Saves non-default values. An empty file records a reset, so old sidecars stay retired.
|
||||
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
|
||||
let text: String = options
|
||||
.iter()
|
||||
@@ -196,14 +303,10 @@ fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::
|
||||
.map(|(o, v)| format!("{}={v}\n", o.id))
|
||||
.collect();
|
||||
let path = values_path(pack);
|
||||
if text.is_empty() {
|
||||
match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
} else {
|
||||
std::fs::write(path, text)
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, text)
|
||||
}
|
||||
|
||||
fn clamp(kind: &OptionKind, v: f64) -> f64 {
|
||||
@@ -315,6 +418,102 @@ pub fn show_value(kind: &OptionKind, v: f64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn install(dir: &Path, file: &str, version: &str) {
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"warm\"\nname = \"Warm\"\nversion = \"{version}\"\napi = \"0.1\"\n\
|
||||
[[post]]\nfile = \"p.wgsl\"\n[[options]]\nid = \"strength\"\nname = \"Strength\"\ntype = \"float\"\ndefault = 1.0\nmin = 0.0\nmax = 2.0\nstep = 0.5\n"
|
||||
);
|
||||
let files = [
|
||||
(horizon_shader_api::MANIFEST.into(), manifest.into_bytes()),
|
||||
("p.wgsl".into(), b"original test fixture".to_vec()),
|
||||
]
|
||||
.into();
|
||||
let pack = horizon_shader_api::Pack::from_files(files).unwrap();
|
||||
std::fs::write(dir.join(file), pack.package().unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_selection_and_values_survive_install_update_rename_and_reset() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-identity-update-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
install(&dir, "warm-1.hshader", "1");
|
||||
std::fs::write(dir.join("warm-1.hshader.txt"), "strength=1.5\n").unwrap();
|
||||
let options = dir.join("options.txt");
|
||||
std::fs::write(&options, "").unwrap();
|
||||
std::fs::write(
|
||||
dir.join(crate::graphics::FILE),
|
||||
"pickedShaderPack:warm-1.hshader\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Use the real settings loader, including persistence of the migrated selection.
|
||||
// Its shaderpacks folder sits beside options.txt.
|
||||
let packs = dir.join(DIR);
|
||||
std::fs::create_dir_all(&packs).unwrap();
|
||||
std::fs::rename(dir.join("warm-1.hshader"), packs.join("warm-1.hshader")).unwrap();
|
||||
std::fs::rename(
|
||||
dir.join("warm-1.hshader.txt"),
|
||||
packs.join("warm-1.hshader.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
let settings = crate::settings::Settings::load(&options);
|
||||
assert_eq!(settings.horizon.shader_pack, "horizon:warm");
|
||||
assert_eq!(
|
||||
crate::graphics::Graphics::load(&dir).shader_pack,
|
||||
"horizon:warm"
|
||||
);
|
||||
assert_eq!(scan(&packs)[0].values, [1.5]);
|
||||
install(&packs, "warm-2.hshader", "2");
|
||||
std::fs::remove_file(packs.join("warm-1.hshader")).unwrap();
|
||||
let (_, path) = resolve(&packs, &settings.horizon.shader_pack);
|
||||
assert_eq!(path, packs.join("warm-2.hshader"));
|
||||
std::fs::rename(path, packs.join("renamed.hshader")).unwrap();
|
||||
let (_, path) = resolve(&packs, "horizon:warm");
|
||||
assert_eq!(path, packs.join("renamed.hshader"));
|
||||
let manifest = horizon_shader_api::Pack::read_manifest(&path).unwrap();
|
||||
assert_eq!(
|
||||
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
|
||||
[1.5]
|
||||
);
|
||||
let mut entry = scan(&packs).remove(0);
|
||||
entry.values[0] = 0.5;
|
||||
entry.save_values(&packs);
|
||||
assert_eq!(
|
||||
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
|
||||
[0.5]
|
||||
);
|
||||
entry.reset_options();
|
||||
entry.save_values(&packs);
|
||||
assert_eq!(scan(&packs)[0].values, [1.0]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(packs.join("warm-1.hshader.txt")).unwrap(),
|
||||
"strength=1.5\n"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_a_removed_registry_filename_and_resolves_duplicates_deterministically() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-identity-duplicates-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
install(&dir, "z.hshader", "newest");
|
||||
install(&dir, "a.hshader", "oldest");
|
||||
std::fs::write(dir.join("warm-previous.hshader.txt"), "strength=2\n").unwrap();
|
||||
let (key, path) = resolve(&dir, "warm-previous.hshader");
|
||||
assert_eq!(key, "horizon:warm");
|
||||
assert_eq!(path, dir.join("a.hshader"));
|
||||
assert_eq!(resolve(&dir, "z.hshader"), (key.clone(), path));
|
||||
assert_eq!(scan(&dir).len(), 1);
|
||||
assert_eq!(scan(&dir)[0].values, [2.0]);
|
||||
// A classic folder named like a native ID keeps its filename identity.
|
||||
std::fs::create_dir_all(dir.join("warm/shaders")).unwrap();
|
||||
assert_eq!(resolve(&dir, "warm"), ("warm".into(), dir.join("warm")));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
|
||||
@@ -397,7 +596,7 @@ mod tests {
|
||||
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
|
||||
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
|
||||
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
|
||||
assert!(!values_path(&pack).exists());
|
||||
assert_eq!(std::fs::read_to_string(values_path(&pack)).unwrap(), "");
|
||||
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -417,6 +616,7 @@ mod tests {
|
||||
};
|
||||
let mut entry = Entry {
|
||||
file: "p".into(),
|
||||
key: "horizon:p".into(),
|
||||
title: "P".into(),
|
||||
kind: Kind::Horizon,
|
||||
options: vec![
|
||||
|
||||
@@ -64,7 +64,7 @@ fn picked(s: &Settings) -> Option<(usize, Kind)> {
|
||||
let i = s
|
||||
.shader_packs
|
||||
.iter()
|
||||
.position(|p| !s.horizon.shader_pack.is_empty() && p.file == s.horizon.shader_pack)?;
|
||||
.position(|p| !s.horizon.shader_pack.is_empty() && p.key == s.horizon.shader_pack)?;
|
||||
Some((i, s.shader_packs[i].kind))
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ impl ShaderUi {
|
||||
Item::Pack(i) => Some(
|
||||
s.shader_packs
|
||||
.get(usize::from(i))
|
||||
.is_some_and(|p| p.file == s.horizon.shader_pack),
|
||||
.is_some_and(|p| p.key == s.horizon.shader_pack),
|
||||
),
|
||||
Item::Option(i) => match picked(s)? {
|
||||
(p, Kind::Horizon) => {
|
||||
@@ -406,7 +406,7 @@ impl ShaderUi {
|
||||
Kind::Horizon => "A Horizon shader pack.",
|
||||
Kind::Classic => "An OptiFine or Iris shader pack.",
|
||||
});
|
||||
let what = lang::get(if pack.file == s.horizon.shader_pack {
|
||||
let what = lang::get(if pack.key == s.horizon.shader_pack {
|
||||
"In use: press to turn shaders off."
|
||||
} else {
|
||||
"Press to use it."
|
||||
@@ -588,8 +588,14 @@ impl ShaderUi {
|
||||
// The picked pack's own screens go under its name, apart from Horizon's pack list.
|
||||
if side.len() > 1 {
|
||||
let [x, y, w, _] = cols.page(1);
|
||||
let pack = &s.horizon.shader_pack;
|
||||
let pack = pack.strip_suffix(".zip").unwrap_or(pack);
|
||||
let pack = match picked(s) {
|
||||
Some((i, Kind::Horizon)) => s.shader_packs[i].title.as_str(),
|
||||
_ => s
|
||||
.horizon
|
||||
.shader_pack
|
||||
.strip_suffix(".zip")
|
||||
.unwrap_or(&s.horizon.shader_pack),
|
||||
};
|
||||
let caption = lang::tr("%s settings", &[&pack]);
|
||||
let rect = [x + 2.0, y + 6.0, w - 4.0, 8.0];
|
||||
crate::widgets::record(&caption, rect, false);
|
||||
@@ -854,10 +860,10 @@ impl ShaderUi {
|
||||
let Some(p) = s.shader_packs.get(usize::from(i)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
s.horizon.shader_pack = if s.horizon.shader_pack == p.file {
|
||||
s.horizon.shader_pack = if s.horizon.shader_pack == p.key {
|
||||
String::new()
|
||||
} else {
|
||||
p.file.clone()
|
||||
p.key.clone()
|
||||
};
|
||||
vec![Action::ButtonClick, Action::Picked]
|
||||
}
|
||||
@@ -1107,6 +1113,7 @@ mod tests {
|
||||
fn entry(file: &str, kind: Kind) -> Entry {
|
||||
Entry {
|
||||
file: file.into(),
|
||||
key: file.into(),
|
||||
title: file.into(),
|
||||
kind,
|
||||
options: Vec::new(),
|
||||
@@ -1174,14 +1181,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn picking_a_pack_and_picking_it_again_turns_shaders_off() {
|
||||
let mut s = settings();
|
||||
let mut ui = ShaderUi::default();
|
||||
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
|
||||
assert_eq!(s.horizon.shader_pack, "B.zip");
|
||||
ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(s.horizon.shader_pack, "");
|
||||
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
|
||||
for (kind, key) in [(Kind::Classic, "B.zip"), (Kind::Horizon, "horizon:warm")] {
|
||||
let mut s = settings();
|
||||
s.shader_packs[1].kind = kind;
|
||||
s.shader_packs[1].key = key.into();
|
||||
let mut ui = ShaderUi::default();
|
||||
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
|
||||
assert_eq!(s.horizon.shader_pack, key);
|
||||
assert_eq!(picked(&s), Some((1, kind)));
|
||||
assert_eq!(ShaderUi::switch(Item::Pack(1), &s), Some(true));
|
||||
ui.activate(&mut s, Item::Pack(1), false, None);
|
||||
assert_eq!(s.horizon.shader_pack, "");
|
||||
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1251,6 +1264,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_horizon_packs_slider_drag_is_saved_once_when_let_go() {
|
||||
let mut pack = entry("H", Kind::Horizon);
|
||||
pack.key = "horizon:slider_test".into();
|
||||
pack.options = vec![horizon_shader_api::PackOption {
|
||||
id: "strength".into(),
|
||||
name: "Strength".into(),
|
||||
@@ -1267,7 +1281,7 @@ mod tests {
|
||||
shader_packs: vec![pack],
|
||||
..Settings::default()
|
||||
};
|
||||
s.horizon.shader_pack = "H".into();
|
||||
s.horizon.shader_pack = "horizon:slider_test".into();
|
||||
let mut ui = ShaderUi::default();
|
||||
ui.open(Tab::Screen(0));
|
||||
let cols = Columns::of(SIZE.0, SIZE.1);
|
||||
|
||||
@@ -258,19 +258,10 @@ fn root_style(component: &Tag) -> Vec<(&'static str, Tag)> {
|
||||
.filter_map(|key| Some((*key, root?.get(key)?.clone())))
|
||||
.collect()
|
||||
}
|
||||
/// Plain text of a stored text component (string, `{text: ...}` or JSON string).
|
||||
/// `ComponentSerialization.CODEC`: modern NBT strings are literal text, not JSON.
|
||||
fn component_text(tag: &Tag) -> String {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
match serde_json::from_str::<serde_json::Value>(&s) {
|
||||
Ok(serde_json::Value::String(text)) => text,
|
||||
Ok(v) if v.get("text").is_some() => {
|
||||
v["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
_ => s,
|
||||
}
|
||||
}
|
||||
Tag::String(s) => s.to_string_lossy(),
|
||||
// Component.getString: translations and key binds resolved.
|
||||
Tag::Compound(_) | Tag::List(_) => {
|
||||
scoreboard::comp_text(&scoreboard::tag_to_json(tag)).plain()
|
||||
@@ -323,6 +314,35 @@ mod tests {
|
||||
decoded.decode(&tag);
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
#[test]
|
||||
fn modern_string_messages_keep_literal_quotes_and_json_text() {
|
||||
let lines = [
|
||||
"plain text",
|
||||
"\"quoted text\"",
|
||||
r#"{"text":"literal object","bold":true}"#,
|
||||
"",
|
||||
];
|
||||
let mut saved = styled_sign();
|
||||
let messages = Tag::List(lines.map(|line| Tag::String(line.into())).to_vec());
|
||||
if let Tag::Compound(front) = saved.get_mut("front_text").unwrap() {
|
||||
front.insert("messages".into(), messages.clone());
|
||||
}
|
||||
let mut data = SignData::default();
|
||||
data.decode(&saved);
|
||||
assert_eq!(data.front.lines, lines.map(str::to_owned));
|
||||
let encoded = Tag::compound(data.encode());
|
||||
assert_eq!(
|
||||
encoded.get("front_text").unwrap().get("messages"),
|
||||
Some(&messages)
|
||||
);
|
||||
let mut copied = SignData::default();
|
||||
copied.decode(&encoded);
|
||||
assert_eq!(copied.front.lines, data.front.lines);
|
||||
assert_eq!(
|
||||
front_messages(&Tag::compound(copied.encode())),
|
||||
front_messages(&encoded)
|
||||
);
|
||||
}
|
||||
/// A sign as a server sends it: a green "Online" and a plain line, the
|
||||
/// way `ComponentSerialization.CODEC` writes them to NBT.
|
||||
fn styled_sign() -> Tag {
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
sync::{Arc, OnceLock, RwLock},
|
||||
};
|
||||
|
||||
/// `SoundSource` indices (vanilla order).
|
||||
@@ -593,19 +593,25 @@ impl SoundInstance {
|
||||
}
|
||||
/// `AbstractSoundInstance.getVolume` (after `getOrResolve`).
|
||||
pub fn get_volume(&mut self) -> f32 {
|
||||
self.volume_with(self.volume)
|
||||
}
|
||||
fn volume_with(&mut self, volume: f32) -> f32 {
|
||||
let sample = match &self.sound {
|
||||
Some(s) => s.volume.sample(&mut self.random),
|
||||
None => 1.0,
|
||||
};
|
||||
self.volume * sample
|
||||
volume * sample
|
||||
}
|
||||
/// `AbstractSoundInstance.getPitch`.
|
||||
pub fn get_pitch(&mut self) -> f32 {
|
||||
self.pitch_with(self.pitch)
|
||||
}
|
||||
fn pitch_with(&mut self, pitch: f32) -> f32 {
|
||||
let sample = match &self.sound {
|
||||
Some(s) => s.pitch.sample(&mut self.random),
|
||||
None => 1.0,
|
||||
};
|
||||
self.pitch * sample
|
||||
pitch * sample
|
||||
}
|
||||
/// `AbstractTickableSoundInstance.stop`.
|
||||
fn stop(&mut self) {
|
||||
@@ -872,9 +878,36 @@ struct Handle {
|
||||
channel: Option<ChannelId>,
|
||||
streaming: bool,
|
||||
stopped: bool,
|
||||
/// Restore the unedited pitch when a later refresh no longer changes it.
|
||||
pitch_edited: bool,
|
||||
}
|
||||
/// One operation about to apply a sound's parameters on the game thread.
|
||||
/// `starting` is true for initial/delayed/loop-replay starts, false for live
|
||||
/// tickable or category-volume updates. The identifier is the sound event,
|
||||
/// not its selected file; source is the vanilla SoundSource index.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct SoundPlayback<'a> {
|
||||
/// Engine-local handle; not unique across split-screen engines.
|
||||
pub instance: InstanceId,
|
||||
pub identifier: &'a str,
|
||||
pub source: usize,
|
||||
pub position: [f64; 3],
|
||||
pub relative: bool,
|
||||
pub starting: bool,
|
||||
}
|
||||
pub type PlaybackHook = fn(&SoundPlayback<'_>, &mut f32, &mut f32, &mut bool);
|
||||
static PLAYBACK_HOOK: OnceLock<PlaybackHook> = OnceLock::new();
|
||||
|
||||
/// Install native playback dispatch before constructing client audio engines.
|
||||
/// No audio/device thread invokes this callback.
|
||||
pub fn install_playback_hook(hook: PlaybackHook) -> Result<(), PlaybackHook> {
|
||||
PLAYBACK_HOOK.set(hook)
|
||||
}
|
||||
|
||||
pub struct SoundEngine {
|
||||
pub registry: SoundRegistry,
|
||||
playback_hook: Option<PlaybackHook>,
|
||||
/// `loaded`: the library opened.
|
||||
pub loaded: bool,
|
||||
tick_count: i32,
|
||||
@@ -912,6 +945,7 @@ impl SoundEngine {
|
||||
pub fn new(registry: SoundRegistry) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
playback_hook: PLAYBACK_HOOK.get().copied(),
|
||||
loaded: true,
|
||||
tick_count: 0,
|
||||
instances: HashMap::new(),
|
||||
@@ -934,6 +968,42 @@ impl SoundEngine {
|
||||
notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Isolate a fixture's dispatch from process-wide native installation.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn set_test_playback_hook(&mut self, hook: PlaybackHook) {
|
||||
self.playback_hook = Some(hook);
|
||||
}
|
||||
fn playback_parameters(&self, id: InstanceId, starting: bool) -> Option<(f32, f32)> {
|
||||
let instance = self.instances.get(&id)?;
|
||||
let (mut volume, mut pitch) = (instance.volume, instance.pitch);
|
||||
if let Some(hook) = self.playback_hook {
|
||||
let sound = SoundPlayback {
|
||||
instance: id,
|
||||
identifier: &instance.identifier,
|
||||
source: instance.source,
|
||||
position: instance.position,
|
||||
relative: instance.relative,
|
||||
starting,
|
||||
};
|
||||
let mut cancel = false;
|
||||
hook(&sound, &mut volume, &mut pitch, &mut cancel);
|
||||
let volume_edited = volume.to_bits() != instance.volume.to_bits();
|
||||
let pitch_edited = pitch.to_bits() != instance.pitch.to_bits();
|
||||
if cancel
|
||||
|| (volume_edited && (!volume.is_finite() || volume < 0.0))
|
||||
|| (pitch_edited && (!pitch.is_finite() || pitch <= 0.0))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((volume, pitch))
|
||||
}
|
||||
fn cancel_playback(&mut self, id: InstanceId, backend: &mut impl Backend) {
|
||||
if let Some(instance) = self.instances.get_mut(&id) {
|
||||
instance.stop();
|
||||
}
|
||||
self.stop(id, backend);
|
||||
}
|
||||
/// Hand an instance to the engine. A retained instance stays until
|
||||
/// `release`; others go once the engine no longer refers to them.
|
||||
pub fn add(&mut self, instance: SoundInstance, retain: bool) -> InstanceId {
|
||||
@@ -1027,10 +1097,27 @@ impl SoundEngine {
|
||||
continue;
|
||||
};
|
||||
if source == instance.source || source == MASTER {
|
||||
let (v, s) = (instance.get_volume(), instance.source);
|
||||
let original_pitch = instance.pitch;
|
||||
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
|
||||
self.cancel_playback(id, backend);
|
||||
continue;
|
||||
};
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let (v, s) = (instance.volume_with(base_volume), instance.source);
|
||||
let pitch_edited = base_pitch.to_bits() != original_pitch.to_bits();
|
||||
// Restore a prior edit, but an always-no-op hook samples exactly as before.
|
||||
let restore_pitch = self.handles.get(&handle).is_some_and(|h| h.pitch_edited);
|
||||
let pitch = (pitch_edited || restore_pitch)
|
||||
.then(|| Self::calculate_pitch(instance.pitch_with(base_pitch)));
|
||||
let volume = self.calculate_volume(v, s);
|
||||
if let Some(h) = self.handles.get_mut(&handle) {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
if let Some(pitch) = pitch {
|
||||
backend.set_pitch(c, pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1207,8 +1294,11 @@ impl SoundEngine {
|
||||
if sound.sentinel != Sentinel::None {
|
||||
return PlayResult::NotStarted;
|
||||
}
|
||||
let Some((base_volume, base_pitch)) = self.playback_parameters(id, true) else {
|
||||
return PlayResult::NotStarted;
|
||||
};
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let instance_volume = instance.get_volume();
|
||||
let instance_volume = instance.volume_with(base_volume);
|
||||
let attenuation_distance = sound.attenuation_distance(instance_volume);
|
||||
if self.listening {
|
||||
self.notify(id, subtitle, attenuation_distance);
|
||||
@@ -1252,6 +1342,7 @@ impl SoundEngine {
|
||||
channel: Some(channel),
|
||||
streaming,
|
||||
stopped: false,
|
||||
pitch_edited: base_pitch.to_bits() != self.instances[&id].pitch.to_bits(),
|
||||
},
|
||||
);
|
||||
self.delete_time.insert(id, self.tick_count + 20);
|
||||
@@ -1264,7 +1355,7 @@ impl SoundEngine {
|
||||
self.instance_by_source.push((source, id));
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let pitch = Self::calculate_pitch(instance.get_pitch());
|
||||
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
|
||||
let (attenuation, relative, tickable) =
|
||||
(instance.attenuation, instance.relative, instance.tickable());
|
||||
backend.set_pitch(channel, pitch);
|
||||
@@ -1350,13 +1441,22 @@ impl SoundEngine {
|
||||
self.stop(id, backend);
|
||||
continue;
|
||||
}
|
||||
let (v, source) = (instance.get_volume(), instance.source);
|
||||
let pitch = Self::calculate_pitch(instance.get_pitch());
|
||||
let Some((base_volume, base_pitch)) = self.playback_parameters(id, false) else {
|
||||
self.cancel_playback(id, backend);
|
||||
continue;
|
||||
};
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
let pitch_edited = base_pitch.to_bits() != instance.pitch.to_bits();
|
||||
let (v, source) = (instance.volume_with(base_volume), instance.source);
|
||||
let pitch = Self::calculate_pitch(instance.pitch_with(base_pitch));
|
||||
let position = instance.position;
|
||||
let volume = self.calculate_volume(v, source);
|
||||
let Some(handle) = self.channel_of(id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(h) = self.handles.get_mut(&handle) {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
backend.set_pitch(c, pitch);
|
||||
@@ -1370,7 +1470,7 @@ impl SoundEngine {
|
||||
&& let (Some(sound), Some(subtitle)) =
|
||||
(instance.sound.clone(), instance.event.clone())
|
||||
{
|
||||
let distance = sound.attenuation_distance(instance.get_volume());
|
||||
let distance = sound.attenuation_distance(instance.volume_with(base_volume));
|
||||
self.notify(id, subtitle, distance);
|
||||
}
|
||||
}
|
||||
@@ -1995,3 +2095,7 @@ mod entity_bound_tests {
|
||||
assert_ne!(sound.position, replacement.state().position);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sound_engine/playback_tests.rs"]
|
||||
mod playback_tests;
|
||||
@@ -0,0 +1,188 @@
|
||||
use super::*;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
fn setup() -> (SoundEngine, test_backend::Null) {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"test",
|
||||
br#"{"beep":{"subtitle":"test.beep","sounds":[{"name":"beep","volume":0.5,"pitch":0.5}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.playback_hook = None;
|
||||
engine.listening = true;
|
||||
(engine, test_backend::Null::default())
|
||||
}
|
||||
fn instance() -> SoundInstance {
|
||||
SoundInstance::at(
|
||||
"test:beep",
|
||||
4,
|
||||
0.8,
|
||||
1.2,
|
||||
LegacyRandom::new(1),
|
||||
[2.0, 3.0, 4.0],
|
||||
)
|
||||
}
|
||||
fn adjust(sound: &SoundPlayback<'_>, volume: &mut f32, pitch: &mut f32, _: &mut bool) {
|
||||
assert_eq!(sound.identifier, "test:beep");
|
||||
assert_eq!(sound.source, 4);
|
||||
assert!(!sound.relative);
|
||||
*volume *= 0.5;
|
||||
*pitch = 2.0;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn played_parameters_reach_channels_without_changing_the_instance() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(adjust);
|
||||
let (id, result) = engine.play_new(instance(), true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.2);
|
||||
assert_eq!(backend.pitch[&channel], 1.0);
|
||||
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
|
||||
assert_eq!(engine.instances[&id].volume, 0.8);
|
||||
assert_eq!(engine.instances[&id].pitch, 1.2);
|
||||
assert_eq!(engine.notifications.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_and_manually_looped_plays_do_not_compound_edits() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(adjust);
|
||||
let mut sound = instance();
|
||||
sound.looping = true;
|
||||
sound.delay = 2;
|
||||
let id = engine.add(sound, true);
|
||||
engine.play_delayed(id, 2);
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert!(backend.volume.is_empty() && engine.notifications.is_empty());
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
let first = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&first], 0.2);
|
||||
backend.stopped.insert(first, true);
|
||||
for _ in 0..22 {
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
}
|
||||
let replay = engine.channel(id).unwrap();
|
||||
assert_ne!(first, replay);
|
||||
assert_eq!(backend.volume[&replay], 0.2);
|
||||
assert_eq!(engine.instances[&id].volume, 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tickable_and_category_updates_keep_transient_edits_and_can_cancel() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(adjust);
|
||||
let mut sound = instance();
|
||||
sound.ticking = Ticking::Native;
|
||||
let (id, _) = engine.play_new(sound, true, &mut backend);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
let sound = engine.instance_mut(id).unwrap();
|
||||
sound.volume = 0.4;
|
||||
sound.position = [8.0, 9.0, 10.0];
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.1);
|
||||
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
|
||||
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
|
||||
engine.update_category_volume(4, 0.5, &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.05);
|
||||
assert_eq!(
|
||||
backend.pitch[&channel], 0.6,
|
||||
"a later no-pitch edit restores the base pitch"
|
||||
);
|
||||
engine.notifications.clear();
|
||||
engine.set_test_playback_hook(|sound, _, _, cancel| {
|
||||
assert!(!sound.starting);
|
||||
*cancel = true;
|
||||
});
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert!(backend.stopped(channel));
|
||||
assert!(engine.notifications.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_and_invalid_edits_never_allocate_or_notify() {
|
||||
let edits: [PlaybackHook; 6] = [
|
||||
|_, _, _, cancel| *cancel = true,
|
||||
|_, volume, _, _| *volume = f32::NAN,
|
||||
|_, volume, _, _| *volume = f32::INFINITY,
|
||||
|_, volume, _, _| *volume = -1.0,
|
||||
|_, _, pitch, _| *pitch = 0.0,
|
||||
|_, _, pitch, _| *pitch = f32::NEG_INFINITY,
|
||||
];
|
||||
for hook in edits {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(hook);
|
||||
let (_, result) = engine.play_new(instance(), false, &mut backend);
|
||||
assert_eq!(result, PlayResult::NotStarted);
|
||||
assert!(backend.stopped.is_empty() && engine.notifications.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_dispatch_preserves_random_sampling_and_failed_pool_behavior() {
|
||||
let (mut plain, mut plain_backend) = setup();
|
||||
let (mut hooked, mut hooked_backend) = setup();
|
||||
hooked.set_test_playback_hook(|_, _, _, _| {});
|
||||
for (engine, backend) in [
|
||||
(&mut plain, &mut plain_backend),
|
||||
(&mut hooked, &mut hooked_backend),
|
||||
] {
|
||||
engine.limits = [0, 0];
|
||||
let id = engine.add(instance(), true);
|
||||
assert_eq!(engine.play(id, backend), PlayResult::NotStarted);
|
||||
engine.limits = [247, 8];
|
||||
assert_eq!(engine.play(id, backend), PlayResult::Started);
|
||||
}
|
||||
assert_eq!(plain_backend.volume, hooked_backend.volume);
|
||||
assert_eq!(plain_backend.pitch, hooked_backend.pitch);
|
||||
assert_eq!(plain.notifications, hooked.notifications);
|
||||
assert_eq!(
|
||||
plain.instances.get_mut(&1).unwrap().random.next_long(),
|
||||
hooked.instances.get_mut(&1).unwrap().random.next_long()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_bound_updates_are_intercepted_at_the_new_position() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
let entity = SoundEntity::new(42, [2.0, 3.0, 4.0], false);
|
||||
engine.set_test_playback_hook(|sound, volume, _, _| {
|
||||
if !sound.starting {
|
||||
assert_eq!(sound.position, [8.0, 9.0, 10.0]);
|
||||
}
|
||||
*volume *= 0.5;
|
||||
});
|
||||
let sound = SoundInstance::entity_bound(
|
||||
"test:beep",
|
||||
4,
|
||||
0.8,
|
||||
1.0,
|
||||
LegacyRandom::new(1),
|
||||
entity.clone(),
|
||||
);
|
||||
let (id, _) = engine.play_new(sound, true, &mut backend);
|
||||
entity.update(SoundEntityState {
|
||||
position: [8.0, 9.0, 10.0],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.position[&channel], [8.0, 9.0, 10.0]);
|
||||
assert_eq!(backend.volume[&channel], 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_only_edit_preserves_legacy_zero_pitch_clamping() {
|
||||
let (mut engine, mut backend) = setup();
|
||||
engine.set_test_playback_hook(|_, volume, _, _| *volume *= 0.5);
|
||||
let mut sound = instance();
|
||||
sound.pitch = 0.0;
|
||||
let (id, result) = engine.play_new(sound, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.2);
|
||||
assert_eq!(backend.pitch[&channel], 0.5);
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -112,6 +112,7 @@ pub fn main() -> Result<()> {
|
||||
raining: &no_rain,
|
||||
mob_griefing: true,
|
||||
tnt_explodes: true,
|
||||
owners: &(),
|
||||
};
|
||||
let targets: Vec<_> = target.iter().map(|t| (Who::Mob(2), t.0)).collect();
|
||||
let (_, hit) = arrows::tick(&mut arrow, &view, &targets);
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
&m,
|
||||
&c,
|
||||
case["initial"].as_str().unwrap_or(""),
|
||||
false,
|
||||
history.len(),
|
||||
w,
|
||||
h,
|
||||
|
||||
@@ -9,7 +9,7 @@ use horizon_client::{
|
||||
stations,
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{fmt::Write as _, fs, path::Path};
|
||||
use std::{fmt::Write as _, fs, path::Path, sync::Arc};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
@@ -24,7 +24,20 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for line in input.lines() {
|
||||
let f: Vec<&str> = line.split(' ').collect();
|
||||
let (seed, bookcases, item): (i32, i32, &str) = (f[0].parse()?, f[1].parse()?, f[2]);
|
||||
let stack = ItemStack::new(item, 1);
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if let Some(damage) = f.get(3) {
|
||||
stack.damage = damage.parse()?;
|
||||
}
|
||||
if let Some(levels) = f.get(4).filter(|levels| **levels != "-") {
|
||||
stack.data_mut().enchantments = levels
|
||||
.split(',')
|
||||
.map(|entry| {
|
||||
let (id, level) = entry.split_once('=')?;
|
||||
Some((Arc::from(id), level.parse().ok()?))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or("bad enchantments")?;
|
||||
}
|
||||
let offers = stations::enchant_offers(&e, &items, seed, bookcases, Some(&stack));
|
||||
for i in 0..3 {
|
||||
let cost = offers.costs[i];
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -607,7 +607,12 @@ fn player_damage(e: &MobEvent) -> f32 {
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::StingPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount,
|
||||
| MobEvent::DamagePlayer { amount, .. }
|
||||
| MobEvent::IronGolemHit {
|
||||
target: Who::Player,
|
||||
amount,
|
||||
..
|
||||
} => *amount,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -626,7 +631,12 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
|
||||
| MobEvent::CubeHurtPlayer { amount, .. }
|
||||
| MobEvent::StingPlayer { amount, .. }
|
||||
| MobEvent::AttackPlayerWithEffect { amount, .. }
|
||||
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
|
||||
| MobEvent::DamagePlayer { amount, .. }
|
||||
| MobEvent::IronGolemHit {
|
||||
target: Who::Player,
|
||||
amount,
|
||||
..
|
||||
} => *amount > 0.0,
|
||||
MobEvent::SonicBoom {
|
||||
target: Who::Player,
|
||||
..
|
||||
@@ -1391,14 +1401,28 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
// (LivingEntity.hurtServer), with the sounds they raise.
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if let MobEvent::AttackMob {
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
} = events[k]
|
||||
let (hit, toss) = match events[k] {
|
||||
MobEvent::AttackMob {
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
} => (Some((target, amount, source)), false),
|
||||
// IronGolem.doHurtTarget: thrown up only if the hit landed.
|
||||
MobEvent::IronGolemHit {
|
||||
target: Who::Mob(target),
|
||||
amount,
|
||||
source,
|
||||
} => (Some((target, amount, source)), true),
|
||||
_ => (None, false),
|
||||
};
|
||||
if let Some((target, amount, source)) = hit
|
||||
&& let Some(t) = mobs.iter_mut().find(|m| m.id == target)
|
||||
&& t.damage(amount, source)
|
||||
{
|
||||
if toss {
|
||||
t.velocity.y +=
|
||||
f64::from(0.4f32) * (1.0 - t.knockback_resistance()).max(0.0);
|
||||
}
|
||||
let raised = std::mem::take(&mut t.outbox);
|
||||
let n = raised.len();
|
||||
events.splice(k + 1..k + 1, raised);
|
||||
|
||||
@@ -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
|
||||
@@ -656,12 +666,19 @@ pub struct EntityView {
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
pub components: Vec<(String, String)>,
|
||||
/// `Entity.get(CUSTOM_NAME)`: the live Text component, preserving styles
|
||||
/// and siblings rather than treating its contents as a registry id.
|
||||
pub custom_name: Option<Value>,
|
||||
/// The entity as saved (`NbtPredicate.getEntityTagToCompare`), for
|
||||
/// `minecraft:nbt`; built only when a criterion may ask (`Tree::tests_nbt`).
|
||||
pub nbt: Option<horizon_world::nbt::Tag>,
|
||||
/// `Entity.getTeam().getName()` (a tamed pet answers its owner's team);
|
||||
/// `None` without a team or where the identity is not bound.
|
||||
pub team: Option<String>,
|
||||
/// Existing objective scores for the bound command source holder. `None`
|
||||
/// means this context did not bind a scoreboard holder; an empty map is a
|
||||
/// real holder without scores. Named predicates snapshot current values.
|
||||
pub scores: Option<BTreeMap<String, i32>>,
|
||||
}
|
||||
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
|
||||
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
|
||||
@@ -931,12 +948,20 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
/// `DataComponentExactPredicate.test` on an entity: every expected
|
||||
/// component equals what the entity reports (absent ones never match).
|
||||
fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
fn components(expected: &Value, entity: &EntityView) -> bool {
|
||||
expected.as_object().is_some_and(|o| {
|
||||
o.iter().all(|(id, want)| {
|
||||
let id = full(id);
|
||||
if id == "minecraft:custom_name" {
|
||||
return entity.custom_name.as_ref().is_some_and(|have| {
|
||||
crate::scoreboard::canonical_component(have)
|
||||
== crate::scoreboard::canonical_component(want)
|
||||
});
|
||||
}
|
||||
want.as_str().is_some_and(|want| {
|
||||
have.iter()
|
||||
entity
|
||||
.components
|
||||
.iter()
|
||||
.any(|(k, v)| full(k) == id && full(v) == full(want))
|
||||
})
|
||||
})
|
||||
@@ -1043,7 +1068,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
"minecraft:type_specific/fishing_hook" => p["in_open_water"]
|
||||
.as_bool()
|
||||
.is_none_or(|want| e.fishing_open_water == Some(want)),
|
||||
"minecraft:components" => components(p, &e.components),
|
||||
"minecraft:components" => components(p, e),
|
||||
// `TeamPredicate.matches`: a non-null effective team whose name
|
||||
// equals the string exactly.
|
||||
"minecraft:team" => {
|
||||
@@ -1343,6 +1368,30 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
match &*full(ty) {
|
||||
// EntityHasScoreCondition.test/hasScore: missing objective or
|
||||
// score fails; this bounded path supports constant integer ranges.
|
||||
"minecraft:entity_scores" if v["entity"].as_str() == Some("this") => {
|
||||
let scores = ctx.this.and_then(|e| e.scores.as_ref());
|
||||
v["scores"].as_object().is_some_and(|tests| {
|
||||
scores.is_some_and(|scores| {
|
||||
tests.iter().all(|(objective, range)| {
|
||||
// Do not treat unsupported number providers as
|
||||
// absent min/max bounds in the shared range helper.
|
||||
let numeric = range.as_i64().is_some()
|
||||
|| range.as_object().is_some_and(|bounds| {
|
||||
bounds.iter().all(|(key, value)| {
|
||||
matches!(key.as_str(), "min" | "max")
|
||||
&& value.as_i64().is_some()
|
||||
})
|
||||
});
|
||||
numeric
|
||||
&& scores
|
||||
.get(objective)
|
||||
.is_some_and(|score| range_i(range, i64::from(*score)))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
"minecraft:entity_properties" => ctx
|
||||
.this
|
||||
.is_some_and(|e| entity(&v["predicate"], e, w, ctx.origin)),
|
||||
@@ -2638,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();
|
||||
|
||||
@@ -331,6 +331,17 @@ pub struct ArrowScene<'a, 'r> {
|
||||
/// The `mob_griefing` and `tnt_explodes` game rules.
|
||||
pub mob_griefing: bool,
|
||||
pub tnt_explodes: bool,
|
||||
pub owners: &'a dyn OwnerLookup,
|
||||
}
|
||||
/// `EntityReference.getEntity` for an owner the arrow only knows by UUID.
|
||||
pub trait OwnerLookup {
|
||||
/// A loaded entity other than a player has this UUID.
|
||||
fn non_player_entity(&self, uuid: [i32; 4]) -> bool;
|
||||
}
|
||||
impl OwnerLookup for () {
|
||||
fn non_player_entity(&self, _: [i32; 4]) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
impl ArrowScene<'_, '_> {
|
||||
fn block(&self, p: BlockPos) -> BlockStateId {
|
||||
@@ -1065,9 +1076,17 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.st.pierce = 0;
|
||||
self.st.pierced.clear();
|
||||
}
|
||||
/// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`.
|
||||
/// `Projectile.mayInteract`: an arrow owned by anything but a player needs `mob_griefing`.
|
||||
fn may_interact(&self) -> bool {
|
||||
!matches!(self.p.owner, Some(Who::Mob(_))) || self.scene.mob_griefing
|
||||
let by_entity = match self.p.owner {
|
||||
Some(Who::Mob(_)) => true,
|
||||
Some(Who::Player) => false,
|
||||
None => self
|
||||
.p
|
||||
.owner_uuid
|
||||
.is_some_and(|u| self.scene.owners.non_player_entity(u)),
|
||||
};
|
||||
!by_entity || self.scene.mob_griefing
|
||||
}
|
||||
/// `onProjectileHit` of the blocks a burning projectile lights.
|
||||
fn block_reaction(&self, state: BlockStateId) -> Option<BlockReaction> {
|
||||
|
||||
@@ -47,6 +47,17 @@ fn shape_tag(shape: &VoxelShape) -> Tag {
|
||||
Tag::compound(fields)
|
||||
}
|
||||
|
||||
const TNT: u32 = 20;
|
||||
struct Rules<'o> {
|
||||
mob_griefing: bool,
|
||||
owners: &'o dyn OwnerLookup,
|
||||
server: bool,
|
||||
}
|
||||
const CLIENT: Rules<'static> = Rules {
|
||||
mob_griefing: true,
|
||||
owners: &(),
|
||||
server: false,
|
||||
};
|
||||
fn run(cells: &[([i32; 3], u32)], ticks: usize, shared_fire: bool) -> Vec<Projectile> {
|
||||
run_with(cells, ticks, shared_fire, |_| {})
|
||||
}
|
||||
@@ -55,6 +66,15 @@ fn run_with(
|
||||
ticks: usize,
|
||||
shared_fire: bool,
|
||||
setup: impl FnOnce(&mut Projectile),
|
||||
) -> Vec<Projectile> {
|
||||
run_in(cells, ticks, shared_fire, CLIENT, setup)
|
||||
}
|
||||
fn run_in(
|
||||
cells: &[([i32; 3], u32)],
|
||||
ticks: usize,
|
||||
shared_fire: bool,
|
||||
rules: Rules,
|
||||
setup: impl FnOnce(&mut Projectile),
|
||||
) -> Vec<Projectile> {
|
||||
let mut definitions = vec![("air", None), ("stone", None)];
|
||||
for level in 0..16 {
|
||||
@@ -63,6 +83,7 @@ fn run_with(
|
||||
for drag in [false, true] {
|
||||
definitions.push(("bubble_column", Some(("drag", drag.to_string()))));
|
||||
}
|
||||
definitions.push(("tnt", None));
|
||||
let states: Vec<_> = definitions
|
||||
.iter()
|
||||
.map(|(name, property)| {
|
||||
@@ -83,6 +104,7 @@ fn run_with(
|
||||
("minecraft:stone", Tag::Int(1)),
|
||||
("minecraft:water", Tag::Int(2)),
|
||||
("minecraft:bubble_column", Tag::Int(18)),
|
||||
("minecraft:tnt", Tag::Int(TNT as i32)),
|
||||
]),
|
||||
),
|
||||
]))
|
||||
@@ -99,7 +121,7 @@ fn run_with(
|
||||
serde_json::json!({"states":(0..states.len()).map(|id|serde_json::json!({"id":id})).collect::<Vec<_>>()} ).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let behaviours = serde_json::json!({"states":(0..states.len()).map(|id|if id==1{[63,0]}else{[0,0]}).collect::<Vec<_>>(),"blocks":(["air","stone","water","bubble_column"].iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::<Vec<_>>())});
|
||||
let behaviours = serde_json::json!({"states":(0..states.len()).map(|id|if id==1||id==20{[63,0]}else{[0,0]}).collect::<Vec<_>>(),"blocks":(["air","stone","water","bubble_column","tnt"].iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::<Vec<_>>())});
|
||||
let facts = BlockFacts::load_with_behaviour_json(&root, ®istry, &behaviours).unwrap();
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
let table = Tag::compound([
|
||||
@@ -114,7 +136,7 @@ fn run_with(
|
||||
),
|
||||
(
|
||||
"cached_shapes",
|
||||
Tag::IntArray((0..20).map(|id| i32::from(id == 1)).collect()),
|
||||
Tag::IntArray((0..21).map(|id| i32::from(id == 1 || id == TNT)).collect()),
|
||||
),
|
||||
]);
|
||||
let collision = BlockCollisionRegistry::new(Arc::new(
|
||||
@@ -140,8 +162,9 @@ fn run_with(
|
||||
collision: &collision,
|
||||
fluids: &fluids,
|
||||
raining: &|_| false,
|
||||
mob_griefing: true,
|
||||
mob_griefing: rules.mob_griefing,
|
||||
tnt_explodes: true,
|
||||
owners: rules.owners,
|
||||
};
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
@@ -159,7 +182,11 @@ fn run_with(
|
||||
let mut result = Vec::new();
|
||||
for _ in 0..ticks {
|
||||
p.arrow.as_mut().unwrap().events.clear();
|
||||
tick_client(&mut p, &scene);
|
||||
if rules.server {
|
||||
tick(&mut p, &scene, &[]);
|
||||
} else {
|
||||
tick_client(&mut p, &scene);
|
||||
}
|
||||
result.push(p.clone());
|
||||
}
|
||||
result
|
||||
@@ -189,6 +216,43 @@ fn first_water_tick_moves_before_water_inertia_and_keeps_synced_flames() {
|
||||
}
|
||||
}
|
||||
|
||||
struct ArmorStand;
|
||||
impl OwnerLookup for ArmorStand {
|
||||
fn non_player_entity(&self, uuid: [i32; 4]) -> bool {
|
||||
uuid == [1, 2, 3, 4]
|
||||
}
|
||||
}
|
||||
fn primes_tnt(owner_uuid: Option<[i32; 4]>, mob_griefing: bool) -> bool {
|
||||
let rules = Rules {
|
||||
mob_griefing,
|
||||
owners: &ArmorStand,
|
||||
server: true,
|
||||
};
|
||||
let steps = run_in(&[([1, 18, 0], TNT)], 2, false, rules, |p| {
|
||||
p.owner_uuid = owner_uuid;
|
||||
});
|
||||
steps
|
||||
.iter()
|
||||
.flat_map(|p| &p.arrow.as_ref().unwrap().events)
|
||||
.any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
ArrowEvent::Block {
|
||||
reaction: BlockReaction::PrimeTnt,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn an_arrow_owned_by_an_armor_stand_needs_mob_griefing_to_prime_tnt() {
|
||||
assert!(!primes_tnt(Some([1, 2, 3, 4]), false));
|
||||
assert!(primes_tnt(Some([1, 2, 3, 4]), true));
|
||||
// An owner that isn't loaded is no owner (getOwner is null).
|
||||
assert!(primes_tnt(Some([9, 9, 9, 9]), false));
|
||||
assert!(primes_tnt(None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by client-arrow differential harness"]
|
||||
fn client_arrow_parity_fixture() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -110,6 +110,25 @@ pub fn spawn_egg_entity_data<'a>(
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
|
||||
/// item places: `Entity.applyImplicitComponents` stores the item's
|
||||
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
|
||||
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
|
||||
/// the entity's saved tag `own` when its type is `kind`.
|
||||
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
|
||||
let mut tag = own.clone();
|
||||
if let (Tag::Compound(map), Some(custom)) = (
|
||||
&mut tag,
|
||||
stack.data().and_then(|d| d.other("minecraft:custom_data")),
|
||||
) {
|
||||
map.insert("data".into(), custom.clone());
|
||||
}
|
||||
match spawn_egg_entity_data(stack, kind, false) {
|
||||
Some(data) => crate::data_cmd::merged(&tag, data),
|
||||
None => tag,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
@@ -379,7 +398,13 @@ pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag {
|
||||
merged.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) {
|
||||
let merged = Tag::Compound(merged);
|
||||
// A native mod's live entity supplies its own canonical id and
|
||||
// schema. Keep the merged opaque payload for that type, while
|
||||
// retaining vanilla's load/save validation for minecraft types.
|
||||
let loaded =
|
||||
canonical(&merged).or_else(|| (!id.starts_with("minecraft:")).then_some(merged));
|
||||
if let Some(Tag::Compound(loaded)) = loaded {
|
||||
tag = loaded;
|
||||
}
|
||||
}
|
||||
@@ -481,6 +506,35 @@ mod tests {
|
||||
crate::item_data::canon_tag(&Tag::Compound(patch.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_entity_item_merges_opaque_data_without_replacing_its_identity() {
|
||||
let fresh = snbt(
|
||||
"{id:\"example:heater\",x:4,y:65,z:8,fuel:1,\
|
||||
nested:{retained:17,changed:0},components:{}}",
|
||||
);
|
||||
let Tag::Compound(patch) = snbt(
|
||||
"{\"minecraft:block_entity_data\":{id:\"example:heater\",\
|
||||
x:99,y:99,z:99,fuel:923,nested:{changed:5},opaque:[B;1,-2,3]}}",
|
||||
) else {
|
||||
unreachable!()
|
||||
};
|
||||
let placed = place(&fresh, &patch, false);
|
||||
assert_eq!(
|
||||
placed,
|
||||
snbt(
|
||||
"{id:\"example:heater\",x:4,y:65,z:8,fuel:923,\
|
||||
nested:{retained:17,changed:5},opaque:[B;1,-2,3],components:{}}"
|
||||
)
|
||||
);
|
||||
let Tag::Compound(other_type) =
|
||||
snbt("{\"minecraft:block_entity_data\":{id:\"example:other\",fuel:888}}")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(place(&fresh, &other_type, false), fresh);
|
||||
assert_eq!(place(&fresh, &pick(&placed), false), placed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filled_named_chest_gives_its_items_and_name_as_components() {
|
||||
let chest = saved(
|
||||
@@ -558,6 +612,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_entity_takes_the_items_custom_data_and_matching_entity_data() {
|
||||
let item = |components: &str| {
|
||||
let components = snbt(components);
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:armor_stand", 1)
|
||||
}
|
||||
};
|
||||
let own = snbt("{CustomName:\"Named\",ShowArms:0b}");
|
||||
let stack = item(
|
||||
"{\"minecraft:custom_data\":{k:1b},\"minecraft:entity_data\":{id:\"minecraft:armor_stand\",ShowArms:1b}}",
|
||||
);
|
||||
let tag = stack_configured_entity(&stack, "minecraft:armor_stand", &own);
|
||||
assert_eq!(tag.get("ShowArms"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(tag.get("CustomName"), Some(&Tag::String("Named".into())));
|
||||
assert_eq!(
|
||||
tag.get("data").and_then(|d| d.get("k")),
|
||||
Some(&Tag::Byte(1))
|
||||
);
|
||||
// Data of another type is not loaded; the custom data still is.
|
||||
let tag = stack_configured_entity(&stack, "minecraft:item_frame", &own);
|
||||
assert_eq!(tag.get("ShowArms"), Some(&Tag::Byte(0)));
|
||||
assert!(tag.get("data").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_data_needs_its_own_type_and_an_op_for_an_op_only_one() {
|
||||
let egg = |entity_data: &str| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,20 +10,20 @@ pub mod block_contact;
|
||||
pub mod powder_snow;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, block_sprites, bluemap, bluemap_detect, border_render,
|
||||
breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint,
|
||||
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,
|
||||
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, custom_sky, 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,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -122,6 +122,7 @@ pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub mod sound_playback;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod controller_notice;
|
||||
pub mod server_packs;
|
||||
|
||||
@@ -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,
|
||||
@@ -437,14 +445,15 @@ impl FixedStep {
|
||||
/// `TickRateManager.tick` for one tick, then whether the world runs
|
||||
/// (`runsNormally`). Also the sprint check that opens a sprint tick.
|
||||
pub fn world_runs(&mut self) -> bool {
|
||||
// MinecraftServer checks for a finished sprint before tickServer
|
||||
// asks the tick-rate manager whether the levels should run.
|
||||
if self.manager.is_sprinting() && self.manager.remaining_sprint_ticks() == 0 {
|
||||
self.sprint_report = Some(self.manager.finish_tick_sprint());
|
||||
}
|
||||
self.manager.base.tick();
|
||||
self.sprint_checked = false;
|
||||
if self.manager.is_sprinting() {
|
||||
let (_, report) = self.manager.check_should_sprint_this_tick(self.now_ns());
|
||||
self.sprint_checked = report.is_none();
|
||||
if report.is_some() {
|
||||
self.sprint_report = report;
|
||||
}
|
||||
self.sprint_checked = self.manager.check_should_sprint_this_tick(self.now_ns()).0;
|
||||
}
|
||||
self.manager.base.runs_normally()
|
||||
}
|
||||
@@ -529,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 {
|
||||
@@ -546,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);
|
||||
@@ -578,6 +667,25 @@ mod tests {
|
||||
assert!(!m.base.runs_normally());
|
||||
}
|
||||
#[test]
|
||||
fn a_finished_sprint_restores_freeze_before_the_next_world_tick() {
|
||||
for was_frozen in [false, true] {
|
||||
let mut clock = FixedStep::default();
|
||||
clock.set_frozen(was_frozen);
|
||||
assert_eq!(clock.world_runs(), !was_frozen);
|
||||
|
||||
assert!(!clock.start_sprint(1));
|
||||
assert!(clock.world_runs());
|
||||
assert!(!clock.frozen());
|
||||
assert!(clock.take_sprint_report().is_none());
|
||||
|
||||
assert_eq!(clock.world_runs(), !was_frozen);
|
||||
assert_eq!(clock.frozen(), was_frozen);
|
||||
assert!(clock.take_sprint_report().is_some());
|
||||
assert_eq!(clock.world_runs(), !was_frozen);
|
||||
assert!(clock.take_sprint_report().is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn the_camera_keeps_interpolating_in_a_frozen_level() {
|
||||
let mut clock = FixedStep::default();
|
||||
clock.set_frozen(true);
|
||||
|
||||
@@ -308,12 +308,13 @@ impl DebugEntries {
|
||||
pub fn is_using_profile(&self, profile: Profile) -> bool {
|
||||
self.profile == Some(profile)
|
||||
}
|
||||
/// `getStatus`: never for an entry the statuses leave out.
|
||||
/// `getStatus`: never for an entry the statuses leave out. The debug renderers ask every
|
||||
/// frame, so no identifier is built for the lookup.
|
||||
pub fn status(&self, path: &str) -> Status {
|
||||
self.statuses
|
||||
.get(&identifier(path))
|
||||
.copied()
|
||||
.unwrap_or(Status::Never)
|
||||
.iter()
|
||||
.find(|(id, _)| id.strip_prefix("minecraft:") == Some(path))
|
||||
.map_or(Status::Never, |(_, status)| *status)
|
||||
}
|
||||
/// `setStatus`: the statuses are custom from now on, and saved.
|
||||
pub fn set_status(&mut self, path: &str, status: Status) {
|
||||
@@ -343,11 +344,13 @@ impl DebugEntries {
|
||||
};
|
||||
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
|
||||
}
|
||||
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
|
||||
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw. Asked every frame, F3 closed
|
||||
/// or not, so it walks the few statuses kept rather than every entry.
|
||||
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.any(|e| self.enabled(e.path, overlay, reduced))
|
||||
self.statuses
|
||||
.keys()
|
||||
.filter_map(|id| id.strip_prefix("minecraft:"))
|
||||
.any(|path| self.enabled(path, overlay, reduced))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
//! them last, over any screen, at the debug GUI scale.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::HudVertex,
|
||||
hud_layout::{GuiDraw, PoseOp, fill},
|
||||
java_text,
|
||||
text::Text,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `extractLines`'s background: ARGB 0x90505050.
|
||||
const BACKGROUND: u32 = 0x9050_5050;
|
||||
@@ -15,6 +17,39 @@ const TEXT: u32 = 0xFFE0_E0E0;
|
||||
/// `Font.lineHeight`.
|
||||
const LINE_HEIGHT: i32 = 9;
|
||||
|
||||
/// How long the painted overlay is reused: 20 times a second, the tick rate most of its lines
|
||||
/// change at or below (fps once a second, memory twice).
|
||||
const REPAINT: Duration = Duration::from_millis(50);
|
||||
/// Least time between frames timed for the overlay's GPU lines. Their medians still span about
|
||||
/// 1.5 s of samples.
|
||||
pub const GPU_TIMING_GAP: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The overlay's painted vertices between rebuilds: copying them is far cheaper than formatting
|
||||
/// its lines and laying out their glyphs each frame.
|
||||
#[derive(Default)]
|
||||
pub struct PaintCache {
|
||||
vertices: Vec<HudVertex>,
|
||||
/// When they were painted, and for what layout (see [`PaintCache::fresh`]).
|
||||
painted: Option<(Instant, PaintLayout)>,
|
||||
}
|
||||
|
||||
/// What the painted vertices depend on besides the lines: the framebuffer size, the GUI scale's
|
||||
/// bits and `Font::epoch` (glyphs move in the atlas when it changes).
|
||||
pub type PaintLayout = ([u32; 2], u32, u64);
|
||||
|
||||
impl PaintCache {
|
||||
/// The vertices, while they are recent and painted for this `layout`.
|
||||
pub fn fresh(&self, now: Instant, layout: PaintLayout) -> Option<&[HudVertex]> {
|
||||
let (at, painted) = self.painted?;
|
||||
(painted == layout && now.duration_since(at) < REPAINT).then_some(&self.vertices)
|
||||
}
|
||||
pub fn store(&mut self, now: Instant, layout: PaintLayout, vertices: &[HudVertex]) {
|
||||
self.vertices.clear();
|
||||
self.vertices.extend_from_slice(vertices);
|
||||
self.painted = Some((now, layout));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the enabled entries reported (`DebugScreenDisplayer`).
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Displayer {
|
||||
@@ -422,8 +457,9 @@ pub fn light_line(sky: u8, block: u8) -> String {
|
||||
}
|
||||
|
||||
/// What the process holds, for `DebugEntryMemory` (Horizon has no JVM heap):
|
||||
/// anonymous resident memory for the heap's used part, all resident memory
|
||||
/// for its committed part, the machine's memory for its maximum.
|
||||
/// anonymous (on Windows, private) resident memory for the heap's used part,
|
||||
/// all resident memory for its committed part, the machine's memory for its
|
||||
/// maximum.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProcessMemory {
|
||||
pub used: u64,
|
||||
@@ -432,14 +468,20 @@ pub struct ProcessMemory {
|
||||
}
|
||||
|
||||
impl ProcessMemory {
|
||||
/// From `/proc/self/status` and `/proc/meminfo`; `None` elsewhere.
|
||||
/// From `/proc/self/status` and `/proc/meminfo` on Linux, the process's
|
||||
/// counters on Windows; `None` elsewhere.
|
||||
pub fn read() -> Option<Self> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return None;
|
||||
if cfg!(target_os = "linux") {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
return Self::parse(&status, &meminfo);
|
||||
}
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
Self::parse(&status, &meminfo)
|
||||
let [used, allocated, max] = horizon_threads::process_memory::usage()?;
|
||||
Some(Self {
|
||||
used,
|
||||
allocated,
|
||||
max: max.max(1),
|
||||
})
|
||||
}
|
||||
fn parse(status: &str, meminfo: &str) -> Option<Self> {
|
||||
let kib = |text: &str, key: &str| -> Option<u64> {
|
||||
@@ -510,6 +552,38 @@ mod tests {
|
||||
v.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_vertices_are_reused_until_stale_or_laid_out_again() {
|
||||
let vertex = HudVertex {
|
||||
pos: [1.0, 2.0],
|
||||
uv: [0.0; 2],
|
||||
color: 7,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0; 2],
|
||||
text: 1,
|
||||
};
|
||||
let layout = ([1920, 1080], 2.0f32.to_bits(), 5);
|
||||
let start = Instant::now();
|
||||
let mut cache = PaintCache::default();
|
||||
assert!(cache.fresh(start, layout).is_none());
|
||||
cache.store(start, layout, &[vertex]);
|
||||
let just_before = start + Duration::from_millis(49);
|
||||
assert_eq!(cache.fresh(just_before, layout).map(<[_]>::len), Some(1));
|
||||
// Stale after a twentieth of a second, so the stats keep moving.
|
||||
assert!(cache.fresh(start + REPAINT, layout).is_none());
|
||||
// Another window size, GUI scale or glyph layout repaints at once.
|
||||
for other in [
|
||||
([1280, 720], layout.1, layout.2),
|
||||
(layout.0, 3.0f32.to_bits(), layout.2),
|
||||
(layout.0, layout.1, 6),
|
||||
] {
|
||||
assert!(cache.fresh(just_before, other).is_none());
|
||||
}
|
||||
// A smaller repaint leaves nothing of the last one behind.
|
||||
cache.store(start, layout, &[]);
|
||||
assert_eq!(cache.fresh(start, layout).map(<[_]>::len), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_at_lines_match_vanilla() {
|
||||
let props = [
|
||||
@@ -611,6 +685,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_memory_lines_show_on_windows() {
|
||||
let m = ProcessMemory::read().expect("Windows reports the process's memory");
|
||||
assert!(
|
||||
0 < m.used && m.used <= m.allocated && m.allocated <= m.max,
|
||||
"{m:?}"
|
||||
);
|
||||
let lines = MemoryMeter::default()
|
||||
.lines()
|
||||
.expect("the meter reads the same counters");
|
||||
assert!(lines[0].starts_with("Mem: "), "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_follow_the_vanilla_split() {
|
||||
// The default profile's entries, sorted by identifier as
|
||||
|
||||
@@ -369,7 +369,8 @@ impl crate::entity_rules::EntityRules for ArmorStand {
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ItemStep {
|
||||
old_velocity: Vec3,
|
||||
moved: bool,
|
||||
movement: Option<Vec3>,
|
||||
clipped_vertical: bool,
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl ItemEntity {
|
||||
@@ -585,10 +586,35 @@ impl ItemEntity {
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
(water_height, lava_height): (f64, f64),
|
||||
heights: (f64, f64),
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
) -> ItemStep {
|
||||
let step = self.tick_move_before_block_effects(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
no_physics,
|
||||
heights,
|
||||
entity_boxes,
|
||||
border,
|
||||
);
|
||||
self.finish_move_block_effects(world, facts, no_physics, blocks, step);
|
||||
step
|
||||
}
|
||||
|
||||
/// Resolve the item's move before the owner handles block contact and
|
||||
/// resumes the normal block effects.
|
||||
pub fn tick_move_before_block_effects(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
(water_height, lava_height): (f64, f64),
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> ItemStep {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -640,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,
|
||||
@@ -686,22 +712,10 @@ impl ItemEntity {
|
||||
slime,
|
||||
);
|
||||
}
|
||||
// Entity.move's last step: soul sand and honey slow the item's
|
||||
// horizontal speed. A move through blocks (noPhysics) returns
|
||||
// before it. `blocks` is None only for the isolated unit tests.
|
||||
if !no_physics && let Some(blocks) = blocks {
|
||||
let factor =
|
||||
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
|
||||
self.velocity =
|
||||
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
self.apply_honey_slide(world, facts);
|
||||
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
|
||||
return ItemStep {
|
||||
old_velocity,
|
||||
moved: true,
|
||||
movement: Some(moved),
|
||||
clipped_vertical: !no_physics && moved.y != wanted.y,
|
||||
};
|
||||
}
|
||||
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
|
||||
@@ -710,7 +724,67 @@ impl ItemEntity {
|
||||
}
|
||||
ItemStep {
|
||||
old_velocity,
|
||||
moved: false,
|
||||
movement: None,
|
||||
clipped_vertical: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The block at `Entity.getOnPosLegacy` for this tick's clipped move.
|
||||
/// Idle ticks retain the entity's collision flags but have no contact.
|
||||
pub fn vertical_contact(
|
||||
&self,
|
||||
world: &World,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
blocks: &horizon_world::movement_properties::BoundMovementProperties,
|
||||
step: ItemStep,
|
||||
) -> Option<BlockPos> {
|
||||
if !step.clipped_vertical {
|
||||
return None;
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut ground = horizon_world::ground_support::GroundState::default();
|
||||
ground
|
||||
.check_support(self.on_ground, self.bounds(), step.movement, |bounds| {
|
||||
horizon_world::ground_support::find_supporting_block(
|
||||
&mut scene,
|
||||
horizon_world::collision_context::CollisionContext::empty(false),
|
||||
self.position,
|
||||
bounds,
|
||||
1_000_000,
|
||||
)
|
||||
})
|
||||
.ok()?;
|
||||
Some(crate::motion::legacy_block_support_at(
|
||||
world,
|
||||
blocks,
|
||||
self.position,
|
||||
ground.supporting_block,
|
||||
))
|
||||
}
|
||||
|
||||
/// Resume the normal block effects after the collision response.
|
||||
pub fn finish_move_block_effects(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
no_physics: bool,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
step: ItemStep,
|
||||
) {
|
||||
if step.movement.is_some() {
|
||||
// Entity.move applies the block's speed factor before block effects.
|
||||
if !no_physics && let Some(blocks) = blocks {
|
||||
let factor =
|
||||
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
|
||||
self.velocity =
|
||||
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
self.apply_honey_slide(world, facts);
|
||||
self.stuck = self.stuck_multiplier(world, facts).unwrap_or(Vec3::ZERO);
|
||||
}
|
||||
}
|
||||
/// The rest of `ItemEntity.tick`, after the bubble column's push: slime,
|
||||
@@ -724,7 +798,7 @@ impl ItemEntity {
|
||||
no_physics: bool,
|
||||
step: ItemStep,
|
||||
) -> bool {
|
||||
if step.moved {
|
||||
if step.movement.is_some() {
|
||||
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
|
||||
// vertical speed the bounce just left (before the air drag).
|
||||
if !no_physics {
|
||||
@@ -1239,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;
|
||||
@@ -1300,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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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(
|
||||
@@ -119,6 +150,15 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
sound_play: |sound, volume, pitch, cancel| {
|
||||
crate::mod_api::dispatch_sound_play(
|
||||
crate::mod_api::mods(),
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
living_heal: |entity, amount, cancel| {
|
||||
crate::mod_api::dispatch_living_heal(crate::mod_api::mods(), entity, amount, cancel);
|
||||
},
|
||||
@@ -159,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);
|
||||
},
|
||||
@@ -171,11 +228,31 @@ 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()
|
||||
{
|
||||
log::warn!("Native client hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if horizon_client::sound_engine::install_playback_hook(
|
||||
horizon_client::sound_playback::native_sound_play,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native sound hook already installed; keeping the first callback");
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run_with() {
|
||||
// A bad command line is the caller's mistake, not a crash: no report.
|
||||
|
||||
@@ -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");
|
||||
|
||||
+612
-130
File diff suppressed because it is too large.
Load diff
@@ -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() {
|
||||
@@ -661,6 +670,8 @@ impl Game<'_> {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `Entity.get`: the mob's live custom-name component and type variants,
|
||||
/// alongside the other state the entity predicate asks for.
|
||||
pub(super) fn mob_entity(&self, m: &horizon_client::mobs::Mob) -> EntityView {
|
||||
// mainhand, offhand, head, chest, legs, feet.
|
||||
let slot = |i: &Option<std::sync::Arc<str>>| {
|
||||
@@ -720,6 +731,7 @@ impl Game<'_> {
|
||||
.map(|(id, value)| (id.to_owned(), value))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
custom_name: m.custom_name_text.as_ref().map(Text::component),
|
||||
passengers,
|
||||
nbt,
|
||||
team: self.mob_team_of(m).map(|t| t.name.clone()),
|
||||
@@ -917,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
|
||||
@@ -1009,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};
|
||||
@@ -2391,6 +2413,91 @@ pub(super) fn text_of(v: &Value) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mob_name_components_read_live_text_without_saved_nbt() {
|
||||
use crate::game::{Audio, Options, 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,
|
||||
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();
|
||||
let kind = game.res.mob_types()["minecraft:pig"].clone();
|
||||
game.mobs.push(horizon_client::mobs::Mob::new(
|
||||
12500,
|
||||
kind,
|
||||
Vec3::new(4.5, 200.0, 4.5),
|
||||
0.0,
|
||||
));
|
||||
let matches = |game: &Game<'_>, key: &str, want: Value| {
|
||||
let view = game.entity_view_of(Target::Mob(12500));
|
||||
assert_eq!(view.kind, "minecraft:pig");
|
||||
assert!(view.nbt.is_none());
|
||||
let mut predicate = serde_json::Map::new();
|
||||
predicate.insert(
|
||||
key.into(),
|
||||
serde_json::json!({"minecraft:custom_name": want}),
|
||||
);
|
||||
adv::entity(
|
||||
&Value::Object(predicate),
|
||||
&view,
|
||||
&View(game),
|
||||
[4.5, 200.0, 4.5],
|
||||
)
|
||||
};
|
||||
for key in ["components", "minecraft:components"] {
|
||||
assert!(!matches(&game, key, serde_json::json!("Bob")));
|
||||
}
|
||||
for (name, unequal) in [
|
||||
(serde_json::json!("Bob"), serde_json::json!("minecraft:Bob")),
|
||||
(serde_json::json!("minecraft:Bob"), serde_json::json!("Bob")),
|
||||
(
|
||||
serde_json::json!({"text": "Bob", "color": "red", "bold": true}),
|
||||
serde_json::json!("Bob"),
|
||||
),
|
||||
(
|
||||
serde_json::json!({"text": "B", "extra": [{"text": "ob"}]}),
|
||||
serde_json::json!("Bob"),
|
||||
),
|
||||
] {
|
||||
let text = horizon_client::scoreboard::comp_text(&name);
|
||||
game.mobs[0].custom_name = Some(text.plain());
|
||||
game.mobs[0].custom_name_text = Some(text);
|
||||
for key in ["components", "minecraft:components"] {
|
||||
assert!(matches(&game, key, name.clone()), "{key}: {name}");
|
||||
assert!(!matches(&game, key, unequal.clone()), "{key}: {unequal}");
|
||||
}
|
||||
}
|
||||
game.mobs[0].custom_name = None;
|
||||
game.mobs[0].custom_name_text = None;
|
||||
for key in ["components", "minecraft:components"] {
|
||||
assert!(!matches(&game, key, serde_json::json!("Bob")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mob_predicate_view_reads_active_effect_instances() {
|
||||
|
||||
@@ -359,8 +359,26 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.zip(["helmet", "chestplate", "leggings", "boots"])
|
||||
}));
|
||||
// The smithing screen's preview stand wears the result slot's item.
|
||||
let preview = self
|
||||
.station
|
||||
.as_ref()
|
||||
.filter(|s| s.kind == super::stations::Kind::Smithing)
|
||||
.and_then(|s| s.result.as_ref())
|
||||
.map(|stack| {
|
||||
let equip = items.get(&stack.item).and_then(|i| i.equip_slot.as_deref());
|
||||
let layer = match preview_slot(equip) {
|
||||
2 => "boots",
|
||||
3 => "leggings",
|
||||
4 => "chestplate",
|
||||
5 => "helmet",
|
||||
_ => "",
|
||||
};
|
||||
(Some(stack), layer)
|
||||
});
|
||||
let stacks = stacks.map(|(s, l)| (s.as_ref(), l)).chain(preview);
|
||||
for (stack, layer) in stacks {
|
||||
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
|
||||
let Some(stack) = stack.filter(|s| s.extra.is_some()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(asset) = items
|
||||
@@ -504,6 +522,27 @@ impl Game<'_> {
|
||||
let drop = self.drop_offset_from(0.5, 1.975, (pos[0], pos[1], pos[2]), true);
|
||||
stand.position.y = f64::from(pos[1]) + drop;
|
||||
stand.previous = stand.position;
|
||||
// `EntityType.createDefaultStackConfig`: the item's `custom_data`, then its
|
||||
// `entity_data` merged over the stand; `ArmorStandItem.useOn` then snaps it
|
||||
// to the placement yaw with pitch 0.
|
||||
let own = horizon_client::entity_nbt::armor_stand_to_nbt(&stand, [0; 4], None);
|
||||
let mut raw = horizon_client::block_entity_item::stack_configured_entity(
|
||||
stack,
|
||||
"minecraft:armor_stand",
|
||||
&own,
|
||||
);
|
||||
if let Some(mut loaded) = horizon_client::entity_nbt::armor_stand_from_nbt(&raw, stand.id) {
|
||||
loaded.first_tick = stand.first_tick;
|
||||
loaded.yaw = stand.yaw;
|
||||
loaded.previous_yaw = stand.previous_yaw;
|
||||
loaded.pitch = 0.0;
|
||||
loaded.previous = loaded.position;
|
||||
stand = loaded;
|
||||
}
|
||||
if let horizon_world::nbt::Tag::Compound(map) = &mut raw {
|
||||
map.remove(&"UUID".into());
|
||||
}
|
||||
self.persist.raw.insert(stand.id, raw);
|
||||
let foot = stand.position;
|
||||
// EntityType.create's spawn yaw, `level.getRandom().nextFloat() * 360`, before
|
||||
// ArmorStandItem.useOn turns the stand to face the player.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,13 @@ impl Game<'_> {
|
||||
if let (Some(name), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.insert("CustomName".into(), Tag::String(name.as_str().into()));
|
||||
}
|
||||
block_entity_nbt::canonical(&tag)
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
// The mod owns its serializer; the vanilla canonicalizer only knows
|
||||
// vanilla entity types and must not discard this instance's data.
|
||||
Some(tag)
|
||||
} else {
|
||||
block_entity_nbt::canonical(&tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `CustomName` a block entity loads from `tag`
|
||||
@@ -125,11 +131,26 @@ impl Game<'_> {
|
||||
Some(t) if t == "sticky" => "minecraft:sticky_piston",
|
||||
_ => "minecraft:piston",
|
||||
});
|
||||
let item = clone_item(&facts.name, head.unwrap_or(&facts.item));
|
||||
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
|
||||
// (`new ItemStack(potted)`), an empty pot as the pot itself.
|
||||
let item = picked_item(self.res.behaviours(), state, item)?;
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
let mut custom = None;
|
||||
self.native_block_clone_item(&facts.name, pos, state, &mut custom);
|
||||
let mut stack = match custom {
|
||||
Some(stack) => {
|
||||
if stack.count == 0
|
||||
|| stack.item.as_ref() == "minecraft:air"
|
||||
|| self.items().get(&stack.item).is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
stack
|
||||
}
|
||||
None => {
|
||||
let item = clone_item(&facts.name, head.unwrap_or(&facts.item));
|
||||
// FlowerPotBlock.getCloneItemStack: a potted plant picks as
|
||||
// that plant (`new ItemStack(potted)`), an empty pot as itself.
|
||||
let item = picked_item(self.res.behaviours(), state, item)?;
|
||||
ItemStack::new(item, 1)
|
||||
}
|
||||
};
|
||||
let mut patch = Compound::new();
|
||||
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
|
||||
if include_data
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
//! The player's `Entity.boardingCooldown`: 60 ticks after leaving a vehicle in
|
||||
//! which a non-forced `startRiding` (`Entity.canRide`) is refused.
|
||||
//! Mobs keep theirs in `extra.boarding_cooldown`; a server's guests keep theirs
|
||||
//! in `Guest::boarding_cooldown`, swapped in for their turn like `riding`.
|
||||
use super::{Game, boat_passengers::BOARDING_COOLDOWN};
|
||||
|
||||
/// `Entity.baseTick`: the cooldown counts down while positive.
|
||||
pub(super) fn tick_down(cooldown: i32) -> i32 {
|
||||
(cooldown - 1).max(0)
|
||||
}
|
||||
|
||||
/// `Entity.removePassenger`: the passenger that left sets its cooldown to 60.
|
||||
/// `was` and `now` are its vehicle before and after.
|
||||
pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -> i32 {
|
||||
if was.is_some() && now.is_none() {
|
||||
BOARDING_COOLDOWN
|
||||
} else {
|
||||
cooldown
|
||||
}
|
||||
}
|
||||
//! A player's `Entity.boardingCooldown` is always 0: `Player.removeVehicle`
|
||||
//! zeroes the 60 ticks `Entity.removePassenger` just set (#11158), so
|
||||
//! `Entity.canRide` only asks that the player is not sneaking, which each
|
||||
//! vehicle's interaction checks. Mobs keep theirs in `extra.boarding_cooldown`
|
||||
//! (`boat_passengers::BOARDING_COOLDOWN`).
|
||||
use super::Game;
|
||||
|
||||
/// `Entity.getIndirectPassengersStream` for `vehicle`: the vehicle and every
|
||||
/// vehicle riding it, directly or through another rider. `mounts` holds
|
||||
@@ -63,48 +49,12 @@ impl Game<'_> {
|
||||
self.trigger_started_riding(vehicle);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for the player acting now (the host's, or the guest
|
||||
/// whose turn it is): the cooldown has elapsed, and the player did not
|
||||
/// leave a vehicle since the tick began (the tick that starts the
|
||||
/// cooldown has not run yet).
|
||||
pub(super) fn may_board(&self) -> bool {
|
||||
self.boarding_cooldown <= 0 && !(self.riding_at_tick.is_some() && self.riding.is_none())
|
||||
}
|
||||
/// The host's own cooldown, once per tick.
|
||||
pub(super) fn tick_boarding(&mut self) {
|
||||
self.boarding_cooldown = tick_down(self.boarding_cooldown);
|
||||
self.boarding_cooldown =
|
||||
after_leaving(self.boarding_cooldown, self.riding_at_tick, self.riding);
|
||||
self.riding_at_tick = self.riding;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn leaving_sets_sixty_and_boarding_waits_it_out() {
|
||||
let mut cd = after_leaving(0, Some(7), None);
|
||||
assert_eq!(cd, 60);
|
||||
for _ in 0..59 {
|
||||
cd = tick_down(cd);
|
||||
assert!(cd > 0, "still refused");
|
||||
}
|
||||
cd = tick_down(cd);
|
||||
assert_eq!(cd, 0, "allowed on the sixtieth tick");
|
||||
}
|
||||
#[test]
|
||||
fn staying_or_boarding_does_not_set_it() {
|
||||
assert_eq!(after_leaving(0, Some(7), Some(7)), 0);
|
||||
assert_eq!(after_leaving(0, None, Some(7)), 0);
|
||||
assert_eq!(after_leaving(0, None, None), 0);
|
||||
}
|
||||
#[test]
|
||||
fn never_negative() {
|
||||
assert_eq!(tick_down(0), 0);
|
||||
}
|
||||
#[test]
|
||||
fn started_riding_reaches_every_rider_of_the_vehicle() {
|
||||
// Entity.startRiding: START_RIDING_TRIGGER for every ServerPlayer in
|
||||
// entityToRide.getIndirectPassengersStream(). A mob between two
|
||||
|
||||
Loaded 100 of 758 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user