mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
373
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebcfa2263a | ||
|
|
7127fb8ed1 | ||
|
|
9d35a0c0ad | ||
|
|
b6aa962829 | ||
|
|
c1102d9227 | ||
|
|
057869bdad | ||
|
|
84ec8516c6 | ||
|
|
4500b9e72b | ||
|
|
2710eae7cf | ||
|
|
ac40181853 | ||
|
|
2a4a9a4616 | ||
|
|
bec6e40518 | ||
|
|
702adabb25 | ||
|
|
eb2d90fb10 | ||
|
|
5a83184c7e | ||
|
|
1082a55b0f | ||
|
|
aacb969efa | ||
|
|
143f965f6f | ||
|
|
318e263959 | ||
|
|
955cd0b289 | ||
|
|
29082ca5d1 | ||
|
|
3b6d6b542c | ||
|
|
7a2f824043 | ||
|
|
cca49a3f34 | ||
|
|
b03633436a | ||
|
|
f6a188d8cd | ||
|
|
93d50e0318 | ||
|
|
507e0ae6f4 | ||
|
|
1d0b591927 | ||
|
|
348e296ffa | ||
|
|
28f081a242 | ||
|
|
47672aefc6 | ||
|
|
d81408ef0f | ||
|
|
0ce9091c37 | ||
|
|
6c45dc0561 | ||
|
|
7775a14526 | ||
|
|
e3b3159901 | ||
|
|
77936bf4be | ||
|
|
f47ac2cbad | ||
|
|
7f0778e4da | ||
|
|
b0a8cbf6fb | ||
|
|
6bcbf52250 | ||
|
|
d2c44db65e | ||
|
|
2b1731508d | ||
|
|
026f014ff6 | ||
|
|
2aa5716a6a | ||
|
|
3e96961f1e | ||
|
|
ed38625015 | ||
|
|
31d0888da7 | ||
|
|
a5ea46ce3e | ||
|
|
5088011241 | ||
|
|
3be1aae4a1 | ||
|
|
7f3bf11140 | ||
|
|
afcfad914c | ||
|
|
6fefc220f9 | ||
|
|
fc8e14548a | ||
|
|
ae8bfba8c6 | ||
|
|
8703ad614c | ||
|
|
fce784f752 | ||
|
|
a5b752f7cc | ||
|
|
9834143df1 | ||
|
|
4774d07dd5 | ||
|
|
b9693b5716 | ||
|
|
1647c1b466 | ||
|
|
ac31802b58 | ||
|
|
c2adc89cc7 | ||
|
|
e82a08ca4d | ||
|
|
41944ed914 | ||
|
|
87784fb3df | ||
|
|
1b4a669936 | ||
|
|
230d414d62 | ||
|
|
0ad4f59af2 | ||
|
|
3e6e75d8f2 | ||
|
|
cef80f4fd5 | ||
|
|
52a7c28daf | ||
|
|
97e3a74179 | ||
|
|
2ba9bcf90d | ||
|
|
dd338cd642 | ||
|
|
17ee8c5f13 | ||
|
|
77db38ec8c | ||
|
|
6858867f2a | ||
|
|
00f6358e7a | ||
|
|
b33917f89b | ||
|
|
3fd9db1568 | ||
|
|
f31f4d179a | ||
|
|
88a372f808 | ||
|
|
1f7515f5ee | ||
|
|
84f77ee9a5 | ||
|
|
8ce49872e7 | ||
|
|
280ef828d6 | ||
|
|
3a6410b63b | ||
|
|
482cc7707c | ||
|
|
e409b37078 | ||
|
|
64c68064f6 | ||
|
|
80947472c1 | ||
|
|
cf93b66d14 | ||
|
|
486dd3f058 | ||
|
|
53d0ea1320 | ||
|
|
403ecdbadb | ||
|
|
5d91256713 | ||
|
|
5a24802d6a | ||
|
|
ac8a09ef0a | ||
|
|
0a639d598e | ||
|
|
13f70ad482 | ||
|
|
6590656a9a | ||
|
|
f2e7c1865f | ||
|
|
876c817f7d | ||
|
|
b465f24309 | ||
|
|
611dfc244f | ||
|
|
2e01586b5e | ||
|
|
d36a2c9e21 | ||
|
|
1ade310792 | ||
|
|
9d11d0c710 | ||
|
|
c90689fd74 | ||
|
|
22628d794c | ||
|
|
a94b615c99 | ||
|
|
63c8c5bce2 | ||
|
|
1d1f36972c | ||
|
|
e70f5347ba | ||
|
|
ad8a713d95 | ||
|
|
b25dbdab4e | ||
|
|
dc175fe838 | ||
|
|
d447274d79 | ||
|
|
c99811b0a7 | ||
|
|
883d53f647 | ||
|
|
dcc21109ac | ||
|
|
daf2f4debc | ||
|
|
c612f19826 | ||
|
|
8c820a5994 | ||
|
|
377650f3ee | ||
|
|
7fbe88924e | ||
|
|
42948e3f17 | ||
|
|
8761658a18 | ||
|
|
06a0f118e2 | ||
|
|
6218257d19 | ||
|
|
41d3a0cbb7 | ||
|
|
5393956e8a | ||
|
|
57a7b16d5a | ||
|
|
76c095290f | ||
|
|
7581d1fbe3 | ||
|
|
c25f5a1f49 | ||
|
|
7b21af9519 | ||
|
|
67fa3d1bb3 | ||
|
|
ed0780a03a | ||
|
|
cd86e8f45d | ||
|
|
eecfdf2f7b | ||
|
|
6d9ed21235 | ||
|
|
908903a58e | ||
|
|
686d897f59 | ||
|
|
791e721843 | ||
|
|
60742e9c49 | ||
|
|
0e0fa342b8 | ||
|
|
fa76ed4d94 | ||
|
|
34af7009f5 | ||
|
|
3365d72927 | ||
|
|
ee55bb1e75 | ||
|
|
1b221b01da | ||
|
|
3815013235 | ||
|
|
c960a329ce | ||
|
|
594552fec4 | ||
|
|
d3d24aca55 | ||
|
|
4f7fadfd9e | ||
|
|
11d40ff1b9 | ||
|
|
33c431e3ae | ||
|
|
c19d97699d | ||
|
|
904e1d5597 | ||
|
|
1fc4fa2529 | ||
|
|
964454e1f8 | ||
|
|
270d067afa | ||
|
|
962d7c6a22 | ||
|
|
304340fa9f | ||
|
|
830ee7c7f7 | ||
|
|
d8160a3ded | ||
|
|
fce1fb23d6 | ||
|
|
ef66a2db57 | ||
|
|
4198a4de7f | ||
|
|
d1288fc34b | ||
|
|
7750a91768 | ||
|
|
0580048c9d | ||
|
|
82f9fbf1ad | ||
|
|
4fa3bf699b | ||
|
|
624ac54cdc | ||
|
|
aba1b1eca4 | ||
|
|
bce9527c43 | ||
|
|
402534f462 | ||
|
|
fb9ce454ed | ||
|
|
f830672067 | ||
|
|
0df8f65eda | ||
|
|
c667277fcd | ||
|
|
b7dbeda41a | ||
|
|
c1de12ab86 | ||
|
|
1295602deb | ||
|
|
f416696038 | ||
|
|
fd2ec97a17 | ||
|
|
9b08e99433 | ||
|
|
a93e07da89 | ||
|
|
37dfe7d880 | ||
|
|
58ba651c7b | ||
|
|
950cd1058f | ||
|
|
93ff841913 | ||
|
|
6199a21cbe | ||
|
|
4cd44b1bfa | ||
|
|
3dc1eac9a1 | ||
|
|
1065005412 | ||
|
|
71e2f6b0f5 | ||
|
|
702055471b | ||
|
|
bf7e616ebf | ||
|
|
8887870ec3 | ||
|
|
21d7853cc9 | ||
|
|
b2ec69a247 | ||
|
|
23bb236cfc | ||
|
|
af12d6d443 | ||
|
|
b74f04855d | ||
|
|
010504d207 | ||
|
|
24876b536b | ||
|
|
5699b862d6 | ||
|
|
90a7a7d47b | ||
|
|
66b3059251 | ||
|
|
341896762f | ||
|
|
513b1fe43d | ||
|
|
b99b9e13f7 | ||
|
|
c2ec210fea | ||
|
|
9edbea0a6d | ||
|
|
c6ed7f75a0 | ||
|
|
5caff4ecb4 | ||
|
|
893eb8d636 | ||
|
|
8e83a2c898 | ||
|
|
0c8fb31992 | ||
|
|
d117673fe9 | ||
|
|
2efd4b8346 | ||
|
|
a8c67d77aa | ||
|
|
052443e2a5 | ||
|
|
38a9866a1f | ||
|
|
aa8f5fc12a | ||
|
|
329acc6c53 | ||
|
|
0bd927b7c1 | ||
|
|
3cc7e3c822 | ||
|
|
25ac7a9841 | ||
|
|
379850ac5c | ||
|
|
99b969686e | ||
|
|
fe7635e545 | ||
|
|
71b36cca26 | ||
|
|
ac4045d83f | ||
|
|
ca623aae34 | ||
|
|
8c7807e832 | ||
|
|
98e1e2d67f | ||
|
|
296d765d78 | ||
|
|
ca660e8d1f | ||
|
|
450b6c8548 | ||
|
|
4cf75b20d9 | ||
|
|
873480bddc | ||
|
|
0072925aef | ||
|
|
342a45b57a | ||
|
|
ca9437b79f | ||
|
|
0c10886ec0 | ||
|
|
d7c1e71d45 | ||
|
|
36323e05ae | ||
|
|
8abf5298fc | ||
|
|
7fbd43b6a9 | ||
|
|
e5acbdde15 | ||
|
|
b842df0899 | ||
|
|
7f91f765b2 | ||
|
|
33e0f27a8d | ||
|
|
ce01d5ffeb | ||
|
|
1ffc88650d | ||
|
|
d66f3e02ea | ||
|
|
ebb19e976e | ||
|
|
6ad344bec9 | ||
|
|
fe2ef8529c | ||
|
|
21da267e5b | ||
|
|
182345dc5e | ||
|
|
a59c7468d1 | ||
|
|
07acadab21 | ||
|
|
4855ac0767 | ||
|
|
aad1954499 | ||
|
|
e55c423833 | ||
|
|
db26ac1679 | ||
|
|
9bf6fb8323 | ||
|
|
4b1319096e | ||
|
|
6c3dd1247f | ||
|
|
f4ccc08720 | ||
|
|
91a7c31b12 | ||
|
|
14e39641c2 | ||
|
|
0a1a3c6ffe | ||
|
|
ff07b57b27 | ||
|
|
9553a05bb8 | ||
|
|
6847ec0b7b | ||
|
|
a58cb67f76 | ||
|
|
304276d8dc | ||
|
|
3dd74dcc64 | ||
|
|
3a20f23ee6 | ||
|
|
865e30ecb6 | ||
|
|
3e6a1990ce | ||
|
|
56b7dcbe62 | ||
|
|
29a0f2dc72 | ||
|
|
bb4f25c694 | ||
|
|
2a86196e83 | ||
|
|
ea6382f2a1 | ||
|
|
2f4294aba1 | ||
|
|
eacaa4db69 | ||
|
|
ca41bb4780 | ||
|
|
f1586af649 | ||
|
|
a7c6eff569 | ||
|
|
8e61d452e0 | ||
|
|
531a026a14 | ||
|
|
96d477b41b | ||
|
|
2e5d156644 | ||
|
|
157208790d | ||
|
|
16750b8c90 | ||
|
|
6564832337 | ||
|
|
413c2b1593 | ||
|
|
78f3b6fca9 | ||
|
|
7495f4c56b | ||
|
|
90a091b024 | ||
|
|
41413878d0 | ||
|
|
9b670c7cfc | ||
|
|
0da0001f38 | ||
|
|
a4a64c560d | ||
|
|
27200f64cd | ||
|
|
01fec61d2d | ||
|
|
ac6ff90c34 | ||
|
|
c87a60d013 | ||
|
|
960634a9ad | ||
|
|
a425fe4152 | ||
|
|
192898e730 | ||
|
|
14af676a89 | ||
|
|
3515e99feb | ||
|
|
da8c2cfc55 | ||
|
|
2f2eecf40d | ||
|
|
6e4fbfa79f | ||
|
|
9c23922432 | ||
|
|
2d2936b9df | ||
|
|
b1574b9721 | ||
|
|
f4b9bc1471 | ||
|
|
f221ae33ef | ||
|
|
dfa637e389 | ||
|
|
897f17114a | ||
|
|
8f75db7d5c | ||
|
|
6975556e3d | ||
|
|
a266ac1c43 | ||
|
|
349ec13a9b | ||
|
|
7c171a543c | ||
|
|
e1e307cbb5 | ||
|
|
64297b8e8b | ||
|
|
3ef1d5a7aa | ||
|
|
fadcfcd6d0 | ||
|
|
c679dff99e | ||
|
|
d0a71973e8 | ||
|
|
15a6831a33 | ||
|
|
ffd9b8f475 | ||
|
|
ceeb7841e2 | ||
|
|
9cf77dcdd7 | ||
|
|
7858858b94 | ||
|
|
276919beae | ||
|
|
e0b19571dd | ||
|
|
51a4b98992 | ||
|
|
0e97601d25 | ||
|
|
d48f37c2a4 | ||
|
|
a88fab01a5 | ||
|
|
44b980def2 | ||
|
|
12daf9d13f | ||
|
|
dd6a0b938d | ||
|
|
4077c5e01c | ||
|
|
6a51ac58a7 | ||
|
|
6d9e1191a2 | ||
|
|
fb828fda35 | ||
|
|
a1a052c35c | ||
|
|
37fed7a88e | ||
|
|
f3e8afcb03 | ||
|
|
66beeb539d | ||
|
|
2bc92b6348 | ||
|
|
36021e2559 | ||
|
|
82f601072b |
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
|
||||
|
||||
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"
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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: &[],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -116,8 +116,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 +127,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 +142,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,
|
||||
@@ -494,6 +497,44 @@ 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))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_counts_a_change_only_when_the_effect_changed() {
|
||||
|
||||
@@ -926,6 +926,10 @@ pub struct Frame<'a> {
|
||||
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
|
||||
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
|
||||
pub entity_motion: &'a [(u32, u64)],
|
||||
/// For each mark of `entity_motion`, the GPU-posed vertex count at it (`skin`'s
|
||||
/// `vertices`): where that entity's posed vertices start. A shader pack's `at_velocity`
|
||||
/// matches both parts of an entity with last frame's.
|
||||
pub entity_motion_posed: &'a [u32],
|
||||
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
|
||||
/// outlined through walls after the hand.
|
||||
pub glowing: &'a [Vertex],
|
||||
@@ -1030,6 +1034,25 @@ pub struct ScreenshotRequest {
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
/// lighting's code, even never run, slows the shader on Apple GPUs (~15% of the solid
|
||||
/// pass at Retina sizes).
|
||||
pub struct SolidPipeline {
|
||||
plain: wgpu::RenderPipeline,
|
||||
hdr: Option<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl SolidPipeline {
|
||||
/// The pipeline for a frame with HDR output on (`hdr`) or off.
|
||||
pub fn get(&self, hdr: bool) -> &wgpu::RenderPipeline {
|
||||
match &self.hdr {
|
||||
Some(lit) if hdr => lit,
|
||||
_ => &self.plain,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -1081,10 +1104,10 @@ pub struct Renderer {
|
||||
/// operation fails on a lost device (no error scope sees it).
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
pub config: wgpu::SurfaceConfiguration,
|
||||
solid: wgpu::RenderPipeline,
|
||||
solid: SolidPipeline,
|
||||
/// `solid` split in two passes for the world pass (terrain.wgsl's `opaque_quad`).
|
||||
solid_opaque: wgpu::RenderPipeline,
|
||||
solid_cutout: wgpu::RenderPipeline,
|
||||
solid_opaque: SolidPipeline,
|
||||
solid_cutout: SolidPipeline,
|
||||
translucent: wgpu::RenderPipeline,
|
||||
translucent_mark: wgpu::RenderPipeline,
|
||||
/// Flat section draws (`flat`), where multi-draw is emulated.
|
||||
@@ -1376,9 +1399,12 @@ pub struct Renderer {
|
||||
hdr_output: Option<hdr::Output>,
|
||||
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
|
||||
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
|
||||
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
|
||||
/// swapchain through `readback`.
|
||||
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
|
||||
/// with HDR off since reaches the swapchain through `readback`.
|
||||
frame_format: wgpu::TextureFormat,
|
||||
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
|
||||
/// the frame that exists (`set_native_shader_pack`).
|
||||
frame_kept_logged: bool,
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
@@ -1392,6 +1418,8 @@ pub struct Renderer {
|
||||
native: Option<native_pack::NativePack>,
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
pass_timing: bool,
|
||||
/// Least time between timed frames (`set_pass_timing_gap`).
|
||||
pass_timing_gap: std::time::Duration,
|
||||
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
|
||||
own_timing: Option<pack_timing::PassTiming>,
|
||||
/// The entity vertices Horizon draws itself while a shader pack draws the others.
|
||||
@@ -1852,9 +1880,10 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
}
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
|
||||
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
|
||||
/// `os` is `std::env::consts::OS`; `arch` the machine's (`system::machine_arch`), not the
|
||||
/// build's; `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends itself.
|
||||
/// Auto tries DirectX 12 first on Windows ARM64, the x64 build emulated there included,
|
||||
/// then Vulkan, based on the device losses seen there (#7513). Other
|
||||
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
|
||||
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
|
||||
/// crash in the driver can't be
|
||||
@@ -1977,7 +2006,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
|
||||
features.contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) && format.allowed_usages.contains(
|
||||
) && draws_frame(format)
|
||||
}
|
||||
|
||||
/// Whether a format with the adapter's `format` features takes every usage and flag the
|
||||
/// passes take of the frame.
|
||||
fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
|
||||
format.allowed_usages.contains(
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
@@ -1987,8 +2022,14 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
|
||||
)
|
||||
}
|
||||
|
||||
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
|
||||
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
|
||||
/// The frame of a renderer made with a shader API 0.2 pack picked under
|
||||
/// `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md): float, so values over 1
|
||||
/// survive from the world to the tonemap.
|
||||
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// What a renderer's frame is drawn in: `LINEAR_FRAME` for a 0.2 pack under the switch
|
||||
/// (`linear`: `(picked, the device draws it)`); else `WIDE_FRAME` when it is made with HDR on
|
||||
/// and Smooth Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
|
||||
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
|
||||
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
fn frame_format(
|
||||
@@ -1996,8 +2037,11 @@ fn frame_format(
|
||||
hdr_offered: bool,
|
||||
device_draws_it: bool,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear: (bool, bool),
|
||||
) -> wgpu::TextureFormat {
|
||||
if wide_frame && hdr_offered && device_draws_it {
|
||||
if linear.0 && linear.1 {
|
||||
LINEAR_FRAME
|
||||
} else if wide_frame && hdr_offered && device_draws_it {
|
||||
WIDE_FRAME
|
||||
} else {
|
||||
swapchain.remove_srgb_suffix()
|
||||
@@ -2018,7 +2062,8 @@ async fn open_device(
|
||||
for (order, allow_cpu) in device_search(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
// The machine's: the x64 build emulated on a Windows ARM64 PC has its GPU too (#7513).
|
||||
horizon_telemetry::system::machine_arch(),
|
||||
wgpu_backend,
|
||||
) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
@@ -2470,6 +2515,7 @@ impl Renderer {
|
||||
translucent_batching: bool,
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
linear_pack: bool,
|
||||
) -> Result<Self> {
|
||||
let Opened {
|
||||
instance,
|
||||
@@ -2603,7 +2649,22 @@ impl Renderer {
|
||||
adapter.get_texture_format_features(WIDE_FRAME),
|
||||
);
|
||||
crate::graphics::offer_smooth_gradients(wide_ok);
|
||||
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
|
||||
let linear = (
|
||||
linear_pack,
|
||||
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
|
||||
);
|
||||
if linear == (true, false) {
|
||||
log::warn!(
|
||||
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
|
||||
);
|
||||
}
|
||||
let frame_format = frame_format(
|
||||
wide_frame,
|
||||
hdr_output.is_some(),
|
||||
wide_ok,
|
||||
config.format,
|
||||
linear,
|
||||
);
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
@@ -2723,16 +2784,24 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &debug_line_attributes,
|
||||
})];
|
||||
let pipeline_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology: wgpu::PrimitiveTopology,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
// The solid terrain's fragment constants without HDR output's lighting, for the
|
||||
// frames HDR is off (`SolidPipeline`).
|
||||
let plain_constants = [
|
||||
sprite_constants[0],
|
||||
sprite_constants[1],
|
||||
("HDR_LIGHTING", 0.0),
|
||||
];
|
||||
let pipeline_with = |constants: &[(&str, f64)],
|
||||
layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology: wgpu::PrimitiveTopology,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
@@ -2763,7 +2832,7 @@ impl Renderer {
|
||||
module: &shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &sprite_constants,
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
@@ -2776,6 +2845,57 @@ impl Renderer {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology: wgpu::PrimitiveTopology,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
pipeline_with(
|
||||
&sprite_constants,
|
||||
layout,
|
||||
label,
|
||||
vertex,
|
||||
fragment,
|
||||
buffers,
|
||||
topology,
|
||||
cull,
|
||||
depth_write,
|
||||
depth_test,
|
||||
blend,
|
||||
)
|
||||
};
|
||||
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
|
||||
// output's lighting, and with it where the display offers HDR output.
|
||||
let solid_in = |layout: &wgpu::PipelineLayout,
|
||||
label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
|
||||
let build = |constants: &[(&str, f64)]| {
|
||||
pipeline_with(
|
||||
constants,
|
||||
layout,
|
||||
label,
|
||||
vertex,
|
||||
fragment,
|
||||
buffers,
|
||||
wgpu::PrimitiveTopology::TriangleList,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
};
|
||||
SolidPipeline {
|
||||
plain: build(&plain_constants),
|
||||
hdr: hdr_output.is_some().then(|| build(&sprite_constants)),
|
||||
}
|
||||
};
|
||||
let pipeline = |label: &str,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
@@ -2799,41 +2919,29 @@ impl Renderer {
|
||||
)
|
||||
};
|
||||
let tri = wgpu::PrimitiveTopology::TriangleList;
|
||||
let solid = pipeline(
|
||||
let solid = solid_in(
|
||||
&layout,
|
||||
"Terrain solid",
|
||||
"vs_terrain",
|
||||
"fs_solid",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
// `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the
|
||||
// world pass without a shader pack: no cutout first, through a shader that never
|
||||
// discards, then the rest.
|
||||
let solid_opaque = pipeline(
|
||||
let solid_opaque = solid_in(
|
||||
&layout,
|
||||
"Terrain solid opaque",
|
||||
"vs_terrain_opaque",
|
||||
"fs_opaque",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
let solid_cutout = pipeline(
|
||||
let solid_cutout = solid_in(
|
||||
&layout,
|
||||
"Terrain solid cutout",
|
||||
"vs_terrain_cutout",
|
||||
"fs_solid",
|
||||
&terrain_buffers,
|
||||
tri,
|
||||
Some(wgpu::Face::Back),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
// The water mirror's camera sees the world from the other side of the plane, which
|
||||
// flips the winding: its solid terrain culls the front.
|
||||
@@ -4354,6 +4462,7 @@ impl Renderer {
|
||||
layout, label, vertex, fragment, buffers, tri, cull, true, true, blend,
|
||||
)
|
||||
},
|
||||
|layout, label, vertex, fragment| solid_in(layout, label, vertex, fragment, &[]),
|
||||
)
|
||||
});
|
||||
// With flat draws' page groups the per-section solid draws read their vertices from
|
||||
@@ -4367,40 +4476,24 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
let origins = [None, terrain_buffers[1].clone()];
|
||||
let pulled = |label: &str, vertex: &str, fragment: &str, cull| {
|
||||
let solid = |label: &str, vertex: &str, fragment: &str| {
|
||||
solid_in(&pulled_layout, label, vertex, fragment, &origins)
|
||||
};
|
||||
(
|
||||
solid("Terrain solid", "vs_section", "fs_solid"),
|
||||
solid("Terrain solid opaque", "vs_section_opaque", "fs_opaque"),
|
||||
solid("Terrain solid cutout", "vs_section_cutout", "fs_solid"),
|
||||
pipeline_in(
|
||||
&pulled_layout,
|
||||
label,
|
||||
vertex,
|
||||
fragment,
|
||||
&origins,
|
||||
tri,
|
||||
Some(cull),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let back = wgpu::Face::Back;
|
||||
(
|
||||
pulled("Terrain solid", "vs_section", "fs_solid", back),
|
||||
pulled(
|
||||
"Terrain solid opaque",
|
||||
"vs_section_opaque",
|
||||
"fs_opaque",
|
||||
back,
|
||||
),
|
||||
pulled(
|
||||
"Terrain solid cutout",
|
||||
"vs_section_cutout",
|
||||
"fs_solid",
|
||||
back,
|
||||
),
|
||||
pulled(
|
||||
"Terrain solid (mirrored)",
|
||||
"vs_section",
|
||||
"fs_solid_mirror",
|
||||
wgpu::Face::Front,
|
||||
&origins,
|
||||
tri,
|
||||
Some(wgpu::Face::Front),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -4737,11 +4830,13 @@ impl Renderer {
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
frame_kept_logged: false,
|
||||
hdr_marker: None,
|
||||
pack_post: None,
|
||||
pack_loading: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pass_timing_gap: std::time::Duration::ZERO,
|
||||
own_timing: None,
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
@@ -6683,6 +6778,18 @@ impl Renderer {
|
||||
block_light::LightTable,
|
||||
)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let linear = pack
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
|
||||
if (linear || self.frame_format == LINEAR_FRAME)
|
||||
&& linear != (self.frame_format == LINEAR_FRAME)
|
||||
&& !std::mem::replace(&mut self.frame_kept_logged, true)
|
||||
{
|
||||
log::info!(
|
||||
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader API 0.2 pack picked or dropped since runs on it",
|
||||
self.frame_format
|
||||
);
|
||||
}
|
||||
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
@@ -6826,6 +6933,15 @@ impl Renderer {
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on || pack_timing::PassTiming::logging();
|
||||
}
|
||||
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
|
||||
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
|
||||
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
|
||||
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
gap
|
||||
};
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
@@ -7401,6 +7517,7 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
if let Some(t) = &mut self.own_timing {
|
||||
t.gap = self.pass_timing_gap;
|
||||
t.begin(&self.device, &self.queue);
|
||||
}
|
||||
if self.animated_textures {
|
||||
@@ -8531,6 +8648,7 @@ impl Renderer {
|
||||
frame.end_portal_sprites[1],
|
||||
&mut self.pack_entity_rest,
|
||||
skin.filter(|_| pack_skin),
|
||||
(frame.entity_motion, frame.entity_motion_posed),
|
||||
);
|
||||
entities = &self.pack_entity_rest;
|
||||
}
|
||||
@@ -9038,6 +9156,9 @@ impl Renderer {
|
||||
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
|
||||
.flatten();
|
||||
}
|
||||
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
t.gap = self.pass_timing_gap;
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
@@ -9441,6 +9562,8 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
if let Some(native) = &mut self.native {
|
||||
let reflection =
|
||||
reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view));
|
||||
native
|
||||
.frame
|
||||
.set_reflection(&self.device, reflection.as_ref());
|
||||
@@ -9474,7 +9597,9 @@ impl Renderer {
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)
|
||||
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
|
||||
let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid);
|
||||
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
|
||||
let hdr = self.hdr.is_some();
|
||||
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
|
||||
// Two solid passes only where a discard costs the GPU its hidden surface
|
||||
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
|
||||
// would double the solid draw calls and vertex work for nothing.
|
||||
@@ -9699,14 +9824,14 @@ impl Renderer {
|
||||
let passes: &[(&wgpu::RenderPipeline, Calls, Calls)] = match pack_flat {
|
||||
Some(p) => &[(&p.0, 0..flat.phase1_calls, 0..phase2_calls)],
|
||||
None if split_solid => &[
|
||||
(&flat.solid_opaque, 0..opaque, 0..phase2_split),
|
||||
(flat.solid_opaque.get(hdr), 0..opaque, 0..phase2_split),
|
||||
(
|
||||
&flat.solid_cutout,
|
||||
flat.solid_cutout.get(hdr),
|
||||
cutout..flat.phase1_calls,
|
||||
phase2_split..phase2_calls,
|
||||
),
|
||||
],
|
||||
None => &[(&flat.solid, 0..flat.phase1_calls, 0..phase2_calls)],
|
||||
None => &[(flat.solid.get(hdr), 0..flat.phase1_calls, 0..phase2_calls)],
|
||||
};
|
||||
let draw = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
for (pipeline, calls, _) in passes {
|
||||
@@ -9759,9 +9884,13 @@ impl Renderer {
|
||||
type Draws = std::ops::Range<u32>;
|
||||
let passes: &[(&wgpu::RenderPipeline, Draws, Draws)] = match material {
|
||||
None if split_solid => &[
|
||||
(&self.solid_opaque, 0..opaque, phase1_draws..phase2_cutout),
|
||||
(
|
||||
&self.solid_cutout,
|
||||
self.solid_opaque.get(hdr),
|
||||
0..opaque,
|
||||
phase1_draws..phase2_cutout,
|
||||
),
|
||||
(
|
||||
self.solid_cutout.get(hdr),
|
||||
cutout..phase1_draws,
|
||||
phase2_cutout..solid_draws,
|
||||
),
|
||||
@@ -10103,12 +10232,7 @@ impl Renderer {
|
||||
depth.size(),
|
||||
);
|
||||
if let Some((color, _)) = &scaled {
|
||||
let color = color.texture();
|
||||
encoder.copy_texture_to_texture(
|
||||
color.as_image_copy(),
|
||||
native.frame.opaque_color.as_image_copy(),
|
||||
color.size(),
|
||||
);
|
||||
native.frame.copy_opaque(&self.device, &mut encoder, color);
|
||||
}
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
@@ -10740,12 +10864,18 @@ impl Renderer {
|
||||
{
|
||||
self.readback.present(&self.device, &mut encoder, image);
|
||||
}
|
||||
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
|
||||
let lifts_world = !frame.title
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
|
||||
match (&self.hdr, &surface_frame) {
|
||||
(Some(hdr), Some(f)) => hdr.present(
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&f.texture,
|
||||
!frame.title,
|
||||
lifts_world,
|
||||
self.own_timing.as_mut(),
|
||||
),
|
||||
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
|
||||
@@ -10764,7 +10894,7 @@ impl Renderer {
|
||||
.filter(|(request, _)| request.size.is_none())
|
||||
.zip(self.hdr.as_ref())
|
||||
.and_then(|(_, hdr)| {
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
|
||||
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?;
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
@@ -12294,20 +12424,39 @@ mod tests {
|
||||
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
|
||||
use super::frame_format;
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
|
||||
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
|
||||
for (wide_frame, hdr_offered, draws) in [
|
||||
(false, true, true),
|
||||
(true, false, true),
|
||||
(true, true, false),
|
||||
(false, false, false),
|
||||
] {
|
||||
// No 0.2 pack (or a device without the float frame) leaves it as before.
|
||||
for linear in [(false, false), (false, true), (true, false)] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
|
||||
Bgra8Unorm
|
||||
frame_format(true, true, true, Bgra8Unorm, linear),
|
||||
Rgba16Unorm
|
||||
);
|
||||
for (wide_frame, hdr_offered, draws) in [
|
||||
(false, true, true),
|
||||
(true, false, true),
|
||||
(true, true, false),
|
||||
(false, false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear),
|
||||
Bgra8Unorm
|
||||
);
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear),
|
||||
Rgba8Unorm
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame
|
||||
/// wherever the device draws it, HDR on or off.
|
||||
#[test]
|
||||
fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() {
|
||||
use super::{LINEAR_FRAME, frame_format};
|
||||
use wgpu::TextureFormat::Bgra8UnormSrgb;
|
||||
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
|
||||
Rgba8Unorm
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, (true, true)),
|
||||
LINEAR_FRAME
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12707,6 +12856,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn plain_solid_shaders_translate_for_every_backend() {
|
||||
// `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING`
|
||||
// off on every backend: they must still translate (the other test translates them
|
||||
// with the constants' defaults).
|
||||
let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
let stage = naga::ShaderStage::Fragment;
|
||||
let constants = [("HDR_LIGHTING".to_owned(), 0.0)].into_iter().collect();
|
||||
for entry in ["fs_solid", "fs_opaque"] {
|
||||
let (module, info) = naga::back::pipeline_constants::process_overrides(
|
||||
&module,
|
||||
&info,
|
||||
Some((stage, entry)),
|
||||
&constants,
|
||||
)
|
||||
.unwrap();
|
||||
let msl = naga::back::msl::write_string(
|
||||
&module,
|
||||
&info,
|
||||
&naga::back::msl::Options {
|
||||
fake_missing_bindings: true,
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::msl::PipelineOptions {
|
||||
entry_point: Some((stage, entry.to_owned())),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
// Metal compiles the lighting out from this constant.
|
||||
assert!(
|
||||
msl.unwrap()
|
||||
.0
|
||||
.contains("constant bool HDR_LIGHTING = false;"),
|
||||
"{entry}"
|
||||
);
|
||||
let mut hlsl = String::new();
|
||||
naga::back::hlsl::Writer::new(
|
||||
&mut hlsl,
|
||||
&Default::default(),
|
||||
&naga::back::hlsl::PipelineOptions {
|
||||
entry_point: Some((stage, entry.to_owned())),
|
||||
},
|
||||
)
|
||||
.write(&module, &info, None)
|
||||
.unwrap_or_else(|e| panic!("{entry}: {e}"));
|
||||
let mut glsl = String::new();
|
||||
naga::back::glsl::Writer::new(
|
||||
&mut glsl,
|
||||
&module,
|
||||
&info,
|
||||
&naga::back::glsl::Options {
|
||||
version: naga::back::glsl::Version::Desktop(330),
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::glsl::PipelineOptions {
|
||||
shader_stage: stage,
|
||||
entry_point: entry.to_owned(),
|
||||
multiview: None,
|
||||
},
|
||||
Default::default(),
|
||||
)
|
||||
.and_then(|mut w| w.write())
|
||||
.unwrap_or_else(|e| panic!("{entry}: {e}"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn entity_shader_validates_with_its_overlay_constants() {
|
||||
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
|
||||
@@ -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: &[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -684,7 +941,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>,
|
||||
@@ -692,6 +950,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
|
||||
@@ -764,7 +1023,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
|
||||
@@ -781,6 +1040,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),
|
||||
@@ -811,12 +1105,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()
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
@@ -824,8 +1124,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
|
||||
@@ -913,85 +1231,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,
|
||||
@@ -1011,18 +1342,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,
|
||||
@@ -1043,9 +1390,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(_)))
|
||||
@@ -1080,6 +1431,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 {
|
||||
@@ -1342,7 +1698,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;
|
||||
@@ -1418,7 +1781,14 @@ mod tests {
|
||||
.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,
|
||||
@@ -1431,6 +1801,7 @@ mod tests {
|
||||
&[("gain".into(), 1.0)],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
|
||||
@@ -1538,7 +1909,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,
|
||||
@@ -1546,6 +1924,7 @@ file = \"b.wgsl\"
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
@@ -1650,7 +2029,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,
|
||||
@@ -1658,6 +2044,7 @@ file = \"b.wgsl\"
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
@@ -1929,4 +2316,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(
|
||||
|
||||
@@ -43,6 +43,9 @@ mod higher_shadowcolor_tests;
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mixed_size_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod sky_disc_blend_tests;
|
||||
|
||||
@@ -2376,7 +2379,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
|
||||
@@ -3853,6 +3865,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();
|
||||
@@ -3873,6 +3886,7 @@ impl PackPost {
|
||||
shadow,
|
||||
rest,
|
||||
skin,
|
||||
motion,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4286,6 +4300,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
|
||||
@@ -4527,6 +4553,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`
|
||||
@@ -5073,6 +5107,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);
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2431,6 +2431,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);
|
||||
|
||||
@@ -323,13 +323,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 +344,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 +466,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 +493,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 +1293,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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
&m,
|
||||
&c,
|
||||
case["initial"].as_str().unwrap_or(""),
|
||||
false,
|
||||
history.len(),
|
||||
w,
|
||||
h,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -146,6 +146,16 @@ fn frame(v: &Value) -> Frame {
|
||||
}
|
||||
}
|
||||
impl Tree {
|
||||
/// `PlayerAdvancements.checkForAutomaticTriggers`: the advancements that
|
||||
/// have no criteria at all, so nothing can ever grant them a criterion and
|
||||
/// vanilla hands them over (and grants their rewards) as the player's
|
||||
/// progress loads.
|
||||
pub fn automatic(&self) -> Vec<usize> {
|
||||
(0..self.list.len())
|
||||
.filter(|&i| self.list[i].criteria.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Tree::load`], shared while anyone holds it: a server's advancement
|
||||
/// packets (`ServerAdvancements`) and its game's criteria read the same
|
||||
/// files, and each kept its own copy (7 MB). A world whose data packs bring
|
||||
@@ -2677,6 +2687,28 @@ mod tests {
|
||||
p.grant("minecraft:t", "b", "now");
|
||||
assert!(p.done(&a));
|
||||
}
|
||||
/// `AdvancementRequirements.test` answers false with no requirement groups, so an advancement
|
||||
/// without criteria is never done by `isDone`; `PlayerAdvancements.checkForAutomaticTriggers`
|
||||
/// still grants its rewards as progress loads, and `Tree::automatic` is the list it walks.
|
||||
#[test]
|
||||
fn an_advancement_without_criteria_is_not_done_but_granted_automatically() {
|
||||
let empty = parse("minecraft:free", &serde_json::json!({"criteria": {}})).unwrap();
|
||||
assert!(!Progress::default().done(&empty), "no groups: not done");
|
||||
let waiting = parse(
|
||||
"minecraft:waits",
|
||||
&serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!Progress::default().done(&waiting));
|
||||
let tree = tree_of(&[
|
||||
(
|
||||
"minecraft:waits",
|
||||
serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
|
||||
),
|
||||
("minecraft:free", serde_json::json!({"criteria": {}})),
|
||||
]);
|
||||
assert_eq!(tree.automatic(), vec![1]);
|
||||
}
|
||||
#[test]
|
||||
fn layout_spreads_children_like_tree_node_position() {
|
||||
let mut tree = Tree::default();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, 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;
|
||||
|
||||
@@ -289,6 +289,8 @@ impl ServerTickRateManager {
|
||||
/// Ticks one sprint frame may run (rendering carries on between frames).
|
||||
const SPRINT_PER_FRAME: i64 = 40;
|
||||
const SAMPLES: usize = 100;
|
||||
/// The windows of [`FixedStep::tps`], in seconds: one, five and fifteen minutes.
|
||||
const TPS_WINDOWS: [f64; 3] = [60.0, 300.0, 900.0];
|
||||
|
||||
/// A step command's broadcast value and the ticks still owed to a new client.
|
||||
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
|
||||
@@ -316,6 +318,10 @@ pub struct FixedStep {
|
||||
tick_times: [i64; SAMPLES],
|
||||
aggregated: i64,
|
||||
tick_count: i64,
|
||||
/// The ticks a second over the last [`TPS_WINDOWS`], decayed once a second ([`Self::tps`]).
|
||||
tps: [f64; 3],
|
||||
/// When the second being counted started (at a recorded tick's end) and the ticks since.
|
||||
tps_second: Option<(std::time::Instant, u32)>,
|
||||
/// A server's client: its level's tick rate only slows the timer down
|
||||
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
|
||||
client: bool,
|
||||
@@ -349,6 +355,8 @@ impl FixedStep {
|
||||
tick_times: [0; SAMPLES],
|
||||
aggregated: 0,
|
||||
tick_count: 0,
|
||||
tps: [20.0; 3],
|
||||
tps_second: None,
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
@@ -530,6 +538,41 @@ impl FixedStep {
|
||||
let end = self.now_ns() + took;
|
||||
self.manager.end_tick_work(end);
|
||||
}
|
||||
self.count_tick(std::time::Instant::now());
|
||||
}
|
||||
/// A recorded tick ended at `now`: once a second has passed since the second being
|
||||
/// counted started, its ticks a second join each average of [`Self::tps`], weighted as a
|
||||
/// load average is (`exp(-elapsed / window)` kept of the old value).
|
||||
fn count_tick(&mut self, now: std::time::Instant) {
|
||||
let Some((start, ticks)) = &mut self.tps_second else {
|
||||
self.tps_second = Some((now, 0));
|
||||
return;
|
||||
};
|
||||
*ticks += 1;
|
||||
let elapsed = now.saturating_duration_since(*start).as_secs_f64();
|
||||
if elapsed < 1.0 {
|
||||
return;
|
||||
}
|
||||
let rate = f64::from(*ticks) / elapsed;
|
||||
for (average, window) in self.tps.iter_mut().zip(TPS_WINDOWS) {
|
||||
let kept = (-elapsed / window).exp();
|
||||
*average = *average * kept + rate * (1.0 - kept);
|
||||
}
|
||||
self.tps_second = Some((now, 0));
|
||||
}
|
||||
/// The ticks a second the server kept over the last one, five and fifteen minutes (what
|
||||
/// Bukkit's `Server.getTPS` reports; vanilla keeps no such average): measured between
|
||||
/// recorded tick ends, so paused passes count where they are recorded. Starts at 20.
|
||||
pub fn tps(&self) -> [f64; 3] {
|
||||
self.tps
|
||||
}
|
||||
/// `getTickTimesNanos` as vanilla lays it out: the tick numbered `n` (from 1, as
|
||||
/// `MinecraftServer.tickCount` after its increment) at index `n % 100`, so the
|
||||
/// newest is at the recorded ticks' count `% 100`; 0 where no tick was recorded yet.
|
||||
pub fn tick_times_nanos(&self) -> Vec<u64> {
|
||||
(0..SAMPLES)
|
||||
.map(|i| self.tick_times[(i + SAMPLES - 1) % SAMPLES].max(0) as u64)
|
||||
.collect()
|
||||
}
|
||||
/// `getAverageTickTimeNanos`.
|
||||
pub fn average_nanos(&self) -> u64 {
|
||||
@@ -547,6 +590,51 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Twenty ticks a second keep 20; ten a second pull the one-minute average down
|
||||
/// fastest; a second not yet over changes nothing.
|
||||
#[test]
|
||||
fn tps_averages_follow_the_ticks_a_second() {
|
||||
let mut clock = FixedStep::new(false);
|
||||
let t0 = std::time::Instant::now();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
for i in 0..=40 {
|
||||
clock.count_tick(t0 + ms(50 * i));
|
||||
}
|
||||
assert_eq!(
|
||||
clock.tps().map(|v| (v * 1000.0).round() / 1000.0),
|
||||
[20.0; 3]
|
||||
);
|
||||
let slow = t0 + ms(2000);
|
||||
for i in 1..=10 {
|
||||
clock.count_tick(slow + ms(100 * i));
|
||||
}
|
||||
let [one, five, fifteen] = clock.tps();
|
||||
assert!(
|
||||
one < five && five < fifteen && fifteen < 20.0,
|
||||
"{:?}",
|
||||
clock.tps()
|
||||
);
|
||||
assert!((one - (20.0 - 10.0 * (1.0 - (-1.0f64 / 60.0).exp()))).abs() < 1e-9);
|
||||
clock.count_tick(slow + ms(1050));
|
||||
assert_eq!(clock.tps(), [one, five, fifteen]);
|
||||
}
|
||||
|
||||
/// The tick times sit where vanilla's array has them: tick n at n % 100.
|
||||
#[test]
|
||||
fn tick_times_are_laid_out_as_vanillas() {
|
||||
let mut clock = FixedStep::new(false);
|
||||
assert_eq!(clock.tick_times_nanos(), vec![0; SAMPLES]);
|
||||
for n in 1..=150u64 {
|
||||
clock.record(std::time::Duration::from_nanos(n));
|
||||
}
|
||||
let times = clock.tick_times_nanos();
|
||||
assert_eq!(times.len(), SAMPLES);
|
||||
assert_eq!(times[50], 150, "the newest, tick 150");
|
||||
assert_eq!(times[51], 51, "tick 51, overwritten by none yet");
|
||||
assert_eq!(times[0], 100);
|
||||
assert_eq!(times[1], 101);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -666,7 +666,7 @@ impl ItemEntity {
|
||||
wanted
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still), and
|
||||
// too (a boat, a shulker, a happy ghast held still; not a minecart), and
|
||||
// so does the world border (`Entity.collectColliders`).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
@@ -1313,6 +1313,35 @@ impl ExperienceOrb {
|
||||
border: Option<&BorderGeometry>,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
self.tick_among(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
player,
|
||||
server,
|
||||
next_float,
|
||||
border,
|
||||
fluids,
|
||||
on_sound,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
|
||||
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick_among(
|
||||
&mut self,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
entities: &[Aabb],
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -1374,10 +1403,11 @@ impl ExperienceOrb {
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop the orb too.
|
||||
// Entity.collide: the world border's walls and the entities that
|
||||
// `canBeCollidedWith` it (a boat, a shulker) stop the orb too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
|
||||
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
|
||||
@@ -7763,6 +7763,23 @@ mod generic_home_nbt_tests {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawned_trader_home_restricts_and_survives_save() {
|
||||
let mut m = mob(Species::WanderingTrader, "minecraft:wandering_trader");
|
||||
crate::mobs::trader::start_visit(&mut m, [4, 201, 0]);
|
||||
m.position = Vec3::new(19.5, 201.0, 0.5);
|
||||
assert!(crate::mobs::trader::within_home(&m));
|
||||
m.position = Vec3::new(20.5, 201.0, 0.5);
|
||||
assert!(!crate::mobs::trader::within_home(&m));
|
||||
let saved = mob_to_nbt(&m, [0; 4], None, None, |_| None);
|
||||
assert_eq!(saved.get("home_radius"), Some(&Tag::Int(16)));
|
||||
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 201, 0])));
|
||||
let mut loaded = mob(Species::WanderingTrader, "minecraft:wandering_trader");
|
||||
loaded.position = m.position;
|
||||
read_mob_home(&mut loaded, &saved);
|
||||
assert!(!crate::mobs::trader::within_home(&loaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_home_output_uses_model_and_removes_stale_raw_fields() {
|
||||
let mut m = mob(Species::Pig, "minecraft:pig");
|
||||
|
||||
@@ -46,6 +46,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
}
|
||||
(logging::game_home(), "client", "Render thread")
|
||||
};
|
||||
// Before the log opens, so a mod's injection hears its first lines.
|
||||
logging::set_line_hook(crate::native_client_hooks::native_log_line);
|
||||
if !help {
|
||||
logging::init(
|
||||
logging::player_dir_override().unwrap_or(home.as_path()),
|
||||
@@ -98,6 +100,35 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
{
|
||||
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if horizon_world::worldgen::native_generate::install(
|
||||
horizon_world::worldgen::native_generate::NativeGenerateHooks {
|
||||
chunk_generate: |dimension, chunk, seed, flat, chunk_out, generate| {
|
||||
crate::mod_api::dispatch_chunk_generate(
|
||||
crate::mod_api::mods(),
|
||||
dimension,
|
||||
chunk,
|
||||
seed,
|
||||
flat,
|
||||
chunk_out,
|
||||
generate,
|
||||
);
|
||||
},
|
||||
lod_column: |dimension, column, seed, flat, column_out| {
|
||||
crate::mod_api::dispatch_lod_column(
|
||||
crate::mod_api::mods(),
|
||||
dimension,
|
||||
column,
|
||||
seed,
|
||||
flat,
|
||||
column_out,
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Native generation hooks already installed; keeping the first callbacks");
|
||||
}
|
||||
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
|
||||
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
|
||||
crate::mod_api::dispatch_menu_slot_rules(
|
||||
@@ -168,6 +199,23 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
register_goals: |mob| {
|
||||
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
|
||||
},
|
||||
player_pre_login: |player, name, address, defer| {
|
||||
crate::mod_api::dispatch_player_pre_login(
|
||||
crate::mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
address,
|
||||
defer,
|
||||
);
|
||||
},
|
||||
player_profile: |player, name, properties| {
|
||||
crate::mod_api::dispatch_player_profile(
|
||||
crate::mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
properties,
|
||||
);
|
||||
},
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
@@ -180,6 +228,19 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
accept,
|
||||
);
|
||||
},
|
||||
log_line: |level, thread, message, line| {
|
||||
crate::mod_api::dispatch_log_line(crate::mod_api::mods(), level, thread, message, line);
|
||||
},
|
||||
handshake: |address, host, intent, cancel, reason| {
|
||||
crate::mod_api::dispatch_handshake(
|
||||
crate::mod_api::mods(),
|
||||
address,
|
||||
host,
|
||||
intent,
|
||||
reason,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -223,8 +223,10 @@ mod native_biome_colors;
|
||||
pub mod native_block_entity_draw;
|
||||
mod native_block_sprites;
|
||||
mod native_bots;
|
||||
mod native_chunk_snapshot;
|
||||
mod native_chunk_tickets;
|
||||
mod native_claimed_items;
|
||||
mod native_client_entities;
|
||||
mod native_client_particles;
|
||||
mod native_client_particles_fixture;
|
||||
pub mod native_client_services;
|
||||
@@ -232,7 +234,9 @@ mod native_client_world;
|
||||
mod native_components;
|
||||
mod native_configs;
|
||||
mod native_criteria;
|
||||
mod native_damage;
|
||||
mod native_data_maps;
|
||||
mod native_diagnostics;
|
||||
mod native_entities;
|
||||
mod native_entity_data;
|
||||
mod native_entity_motion;
|
||||
@@ -256,8 +260,15 @@ mod native_mobs;
|
||||
mod native_motion;
|
||||
mod native_mounts;
|
||||
mod native_net;
|
||||
mod native_npcs;
|
||||
mod native_packets;
|
||||
mod native_panel;
|
||||
mod native_panel_recipes;
|
||||
mod native_permissions;
|
||||
mod native_player_fx;
|
||||
mod native_player_reads;
|
||||
mod native_player_state;
|
||||
mod native_profiles;
|
||||
mod native_projectiles;
|
||||
mod native_quick_move;
|
||||
mod native_recipes;
|
||||
@@ -267,11 +278,15 @@ mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_structures;
|
||||
mod native_ticks;
|
||||
mod native_travel;
|
||||
mod native_view_distance;
|
||||
mod native_visuals;
|
||||
mod native_world_border;
|
||||
mod native_world_columns;
|
||||
pub mod native_world_draw;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod native_world_seed;
|
||||
mod natural_spawning;
|
||||
mod nautili;
|
||||
mod net_harness;
|
||||
@@ -459,6 +474,7 @@ use horizon_world::{
|
||||
pub use machines::native_fluids::NativeFluidTank;
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_chunk_snapshot::NativeChunkSnapshot;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
|
||||
pub use native_components::NativeComponentError;
|
||||
@@ -467,8 +483,10 @@ pub use native_criteria::NativeCriterion;
|
||||
pub use native_data_maps::NativeDataMapType;
|
||||
pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
|
||||
pub use native_inventories::NativeInventoryKind;
|
||||
pub use native_item_energy::ItemEnergyBuffer;
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_player_state::NativeEffect;
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_structures::{NativeStructureBlock, NativeStructureTemplate};
|
||||
@@ -1112,6 +1130,24 @@ impl EntityObstructionWorld for BuildingObstruction {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
/// Whether a block's successful use is `InteractionResult.SUCCESS_SERVER`,
|
||||
/// which a client does not predict: `useWithoutItem` of a bed, a light
|
||||
/// block, a charged respawn anchor or a sign. Every other block use the
|
||||
/// level answers with SUCCESS is `SUCCESS` (`SwingSource.PREDICTED`).
|
||||
fn server_only_block_use(
|
||||
block: &horizon_items::blocks::StateFacts,
|
||||
branch: horizon_world::level::UseBranch,
|
||||
) -> bool {
|
||||
branch == horizon_world::level::UseBranch::WithoutItem
|
||||
&& [
|
||||
"AbstractBedBlock",
|
||||
"LightBlock",
|
||||
"RespawnAnchorBlock",
|
||||
"SignBlock",
|
||||
]
|
||||
.iter()
|
||||
.any(|class| block.has_class(class))
|
||||
}
|
||||
/// An entity's box (`EntityDimensions.makeBoundingBox`): `width` wide and
|
||||
/// `height` tall, standing on `feet`.
|
||||
fn point_box(feet: [f64; 3], width: f64, height: f64) -> Aabb {
|
||||
@@ -1252,6 +1288,11 @@ impl LevelAccess for GameLevel<'_> {
|
||||
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
|
||||
}
|
||||
}
|
||||
fn redstone_change(&self, pos: BlockPos, old: i32, new: &mut i32) {
|
||||
if self.native_authoritative {
|
||||
self.native_block_redstone(self.dimension, pos, old, new);
|
||||
}
|
||||
}
|
||||
fn allow_spread(
|
||||
&self,
|
||||
kind: horizon_world::level::Spread,
|
||||
@@ -2679,6 +2720,8 @@ struct Game<'a> {
|
||||
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
|
||||
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
|
||||
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
|
||||
/// The GPU-posed vertex count at each of `entity_motion`'s marks (`Frame::entity_motion_posed`).
|
||||
entity_motion_posed: std::cell::RefCell<Vec<u32>>,
|
||||
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
|
||||
/// and this frame's draws.
|
||||
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
|
||||
@@ -2872,6 +2915,9 @@ struct Game<'a> {
|
||||
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
|
||||
swing: horizon_client::first_person::SwingState,
|
||||
swings_started: u32,
|
||||
/// Swings of uses whose client predicts them (`SwingSource.PREDICTED`):
|
||||
/// a server does not send these back to the player who swung.
|
||||
predicted_swings: u32,
|
||||
/// The first-person equip heights and the items the hands show (with
|
||||
/// the slot each came from).
|
||||
heights: horizon_client::first_person::HandHeights,
|
||||
@@ -2964,6 +3010,8 @@ struct Game<'a> {
|
||||
map_frame_model: frames::FrameModel,
|
||||
glow_map_frame_model: frames::FrameModel,
|
||||
armor_stands: horizon_client::join_seq::JoinList<horizon_client::entities::ArmorStand>,
|
||||
/// Worn armor as of the last `collectEquipmentChanges` look (frost walker).
|
||||
location_armor: [Option<ItemStack>; 4],
|
||||
/// `Cushion` decoration entities (game/cushions.rs).
|
||||
cushions: Vec<horizon_client::entities::Cushion>,
|
||||
/// Entities of types Horizon does not simulate.
|
||||
@@ -2999,6 +3047,9 @@ struct Game<'a> {
|
||||
carts: horizon_client::join_seq::JoinList<minecarts::Minecart>,
|
||||
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
|
||||
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
|
||||
/// What the mods asked for server players out of reach (`native_teleport_player`...),
|
||||
/// done after the players' packets of the tick (native_travel.rs).
|
||||
native_moves: Vec<(u128, native_travel::NativeMove)>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
bolts: Vec<lightning::Bolt>,
|
||||
@@ -3133,6 +3184,10 @@ struct Game<'a> {
|
||||
native_configs: native_configs::NativeConfigs,
|
||||
/// Native mods' messages waiting (`game/native_net.rs`).
|
||||
native_net: native_net::NativeNet,
|
||||
/// What native mods send one player, waiting for the hub (`game/native_player_fx.rs`).
|
||||
native_player_fx: native_player_fx::NativePlayerFx,
|
||||
/// Native mods' kicks waiting for the tick's end (`game/native_packets.rs`).
|
||||
native_packets: native_packets::NativePackets,
|
||||
/// The mod whose own screen is open (`Screen::Mod`).
|
||||
native_mod_screen: Option<String>,
|
||||
/// Meshed sections waiting for an upload budget (Horizon settings).
|
||||
@@ -3217,6 +3272,10 @@ struct Game<'a> {
|
||||
debug_options: Option<debug_options::DebugOptionsUi>,
|
||||
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
|
||||
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
|
||||
/// The F3 overlay's vertices between rebuilds (`draw_debug_overlay_under`).
|
||||
debug_paint: RefCell<horizon_client::debug_overlay::PaintCache>,
|
||||
/// The F3 overlay's last reading of the refresh rate, and when it was taken.
|
||||
debug_refresh: std::cell::Cell<Option<(Instant, Option<u32>)>>,
|
||||
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
|
||||
/// CPU time, in percent.
|
||||
gpu_utilization: f64,
|
||||
@@ -3275,6 +3334,9 @@ struct Game<'a> {
|
||||
/// The last `use_hand` that ended the click did so with `FAIL`, which
|
||||
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
|
||||
use_failed: bool,
|
||||
/// A mod canceled a use the acting guest's client predicted (a throw, a
|
||||
/// placement): their inventory goes out whole when the turn ends.
|
||||
guest_inventory_resend: bool,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
|
||||
@@ -3327,6 +3389,9 @@ struct Game<'a> {
|
||||
lan_acting_name: Option<String>,
|
||||
/// Their UUID (`Player.getUUID`).
|
||||
lan_acting_uuid: Option<u128>,
|
||||
/// The player and the stack on their cursor as they clicked a native
|
||||
/// menu, while `native_menu_clicked` hears it (`native_menu_cursor`).
|
||||
native_click_cursor: Option<(u128, Option<horizon_client::items::ItemStack>)>,
|
||||
/// Where the mods refused to prime TNT: the sound and game event the level
|
||||
/// queued right after the prime are dropped (`prime_tnt_event`).
|
||||
tnt_refused: Option<BlockPos>,
|
||||
@@ -3335,6 +3400,9 @@ struct Game<'a> {
|
||||
/// Their entity id, which the hub (often taken out of `lan` while they
|
||||
/// act) would otherwise have to be asked for.
|
||||
lan_acting_entity: Option<u32>,
|
||||
/// The player `native_player_login` hears and their permission level, while it runs
|
||||
/// (`native_player_permission`): the hub is out of reach there.
|
||||
native_login_permission: Option<(u128, u8)>,
|
||||
/// Command feedback to forward to a guest.
|
||||
chat_capture: Option<Vec<horizon_client::text::Text>>,
|
||||
/// Packets for the guest acting now, sent once its action returns.
|
||||
@@ -3342,6 +3410,9 @@ struct Game<'a> {
|
||||
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
|
||||
/// hub's item registry, which the guest's turn does not hold.
|
||||
guest_cooldowns: Vec<(Arc<str>, i32)>,
|
||||
/// Players whose command tree the mods asked to send again
|
||||
/// (`native_resend_commands`), sent in the next server tick.
|
||||
native_resend_trees: Vec<u128>,
|
||||
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
|
||||
/// sent with their entity id, which the guest's turn does not hold.
|
||||
guest_entity_events: Vec<i8>,
|
||||
@@ -4368,6 +4439,15 @@ impl Game<'_> {
|
||||
self.award_stat("dropped", &thrown.item, thrown.count as i32);
|
||||
self.award_custom_stat("drop", 1);
|
||||
let player = self.native_acting_id();
|
||||
let mut cancel = false;
|
||||
self.native_item_dropped_v2(player, &thrown, &mut cancel);
|
||||
if cancel {
|
||||
// The stack stays the player's: what doesn't fit is thrown after all.
|
||||
if let Some(rest) = self.give(thrown) {
|
||||
self.throw_from_eyes(rest, eye, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.native_item_dropped(player, &thrown.item, thrown.count);
|
||||
self.throw_from_eyes(thrown, eye, true);
|
||||
}
|
||||
@@ -4644,7 +4724,7 @@ impl Game<'_> {
|
||||
&& b.max_position().z > min[2];
|
||||
// ItemEntity.playerTouch: no pickup delay, and the target (if any) is this player.
|
||||
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
|
||||
if self.native_pickup_refused(&item.stack) {
|
||||
if self.native_pickup_refused(&item.stack, item.thrower, item.position) {
|
||||
item.pickup_delay = 10;
|
||||
kept.push(item);
|
||||
continue;
|
||||
@@ -6156,11 +6236,12 @@ impl Game<'_> {
|
||||
/// One falling block's `tick`: places or drops its block in its own tick,
|
||||
/// so a block falling onto one that just landed lands on top of it. `i` is
|
||||
/// its place in the list; the caller has checked it is ticking.
|
||||
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
|
||||
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
|
||||
fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
|
||||
let entity_drops = self.ui.rules.bool("entity_drops");
|
||||
let mut f = self.falling.remove(i);
|
||||
let original = f.clone();
|
||||
match self.fall_step(&mut f, entity_drops) {
|
||||
match self.fall_step(&mut f, entity_drops, entities) {
|
||||
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
|
||||
Ok(FallStep::Gone) => {}
|
||||
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
|
||||
@@ -6173,7 +6254,12 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
}
|
||||
/// One `FallingBlockEntity.tick` move of `f`.
|
||||
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
|
||||
fn fall_step(
|
||||
&mut self,
|
||||
f: &mut FallingBlock,
|
||||
entity_drops: bool,
|
||||
entities: &[Aabb],
|
||||
) -> Result<FallStep> {
|
||||
f.previous = f.position;
|
||||
f.age = f.age.wrapping_add(1);
|
||||
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
|
||||
@@ -6207,10 +6293,11 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
// On an error the caller keeps the block as it was before this tick.
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
// Entity.collide: the world border's walls and the entities that
|
||||
// `canBeCollidedWith` it (a boat, a shulker) stop it too.
|
||||
let movement = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &[],
|
||||
entities,
|
||||
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
|
||||
}
|
||||
.resolve_with_context(
|
||||
@@ -6723,6 +6810,7 @@ impl Game<'_> {
|
||||
}
|
||||
if !claimed {
|
||||
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
|
||||
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
|
||||
}
|
||||
if claimed {
|
||||
self.swing_arm();
|
||||
@@ -6821,6 +6909,8 @@ impl Game<'_> {
|
||||
let mut canceled = false;
|
||||
horizon_client::spans::time("use_mods", || {
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
let hand = self.hand;
|
||||
self.native_right_click_block_v2(hit.pos, hit.face, hand, &mut canceled);
|
||||
});
|
||||
if canceled {
|
||||
// Nothing was used, so nothing consumed the action.
|
||||
@@ -7006,7 +7096,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if result.should_swing() {
|
||||
self.swing_arm();
|
||||
// SUCCESS swings on the client too; the beds, light,
|
||||
// respawn anchor and sign's own uses are SUCCESS_SERVER.
|
||||
if server_only_block_use(self.res.facts().get(cake), branch) {
|
||||
self.swing_arm();
|
||||
} else {
|
||||
self.swing_arm_predicted();
|
||||
}
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -7246,6 +7342,13 @@ impl Game<'_> {
|
||||
item: stack.item.to_string(),
|
||||
can_use_game_master_blocks: self.can_use_game_master_blocks(),
|
||||
};
|
||||
// The mods, once the position and state are known: a canceled
|
||||
// placement is a failed one, and the client's prediction is undone.
|
||||
if self.block_place_canceled(&stack, &ctx) {
|
||||
self.use_failed = true;
|
||||
self.guest_inventory_resend = true;
|
||||
return Ok(true);
|
||||
}
|
||||
let outcome = self.with_level(|b, level, ticks| b.place_block(level, ticks, &ctx));
|
||||
let PlaceOutcome::Placed { pos, state } = outcome else {
|
||||
self.process_level_events()?;
|
||||
@@ -7284,7 +7387,8 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
// BlockItem.place's SUCCESS: the client swings with its own prediction.
|
||||
self.swing_arm_predicted();
|
||||
// BlockItem.place's sound is among the level events;
|
||||
// SolidBucketItem.getPlaceSound replaces it with the bucket's own.
|
||||
let solid_bucket = &*stack.item == "minecraft:powder_snow_bucket";
|
||||
@@ -7311,6 +7415,30 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// Asks the mods whether the acting guest may place `stack` where and as
|
||||
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
|
||||
/// one canceled it. A game's own player is not asked, nor a placement
|
||||
/// vanilla refuses.
|
||||
fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
|
||||
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((pos, state)) =
|
||||
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_block_place(
|
||||
uuid,
|
||||
&name,
|
||||
stack,
|
||||
[pos.x, pos.y, pos.z],
|
||||
state,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// Asks the mods whether the acting guest may empty `stack` at `pos`
|
||||
/// (`native_bucket_empty`): true when one cancelled it. A game's own
|
||||
/// player is not asked.
|
||||
@@ -7544,12 +7672,18 @@ impl Game<'_> {
|
||||
.instrument(&stack)
|
||||
.map(|i| i.use_ticks().max(0) as u32)
|
||||
{
|
||||
// InstrumentItem.getUseDuration: held up that long, nothing more.
|
||||
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
|
||||
// InstrumentItem.getUseDuration: held up that long. When it runs
|
||||
// out, LivingEntity.stopUsingItem ends the use and causes the
|
||||
// interaction vibration; letting go of the button already does.
|
||||
if ticks + 1 < duration {
|
||||
self.using = Some((slot, ticks + 1));
|
||||
} else {
|
||||
self.stop_using_item(&stack);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*stack.item == "minecraft:brush" {
|
||||
self.tick_brush(slot, ticks);
|
||||
self.tick_brush(&stack, slot, ticks);
|
||||
return;
|
||||
}
|
||||
if info.bundle {
|
||||
@@ -9053,9 +9187,6 @@ impl Game<'_> {
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let show_hud = !self.ui.hide_gui;
|
||||
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
|
||||
// the action bar sit above it.
|
||||
let mut bar_height = 39;
|
||||
if show_hud {
|
||||
self.draw_spyglass(&mut batch);
|
||||
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
|
||||
@@ -9154,8 +9285,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.draw_mount_hud(&mut batch, wi, hi)
|
||||
};
|
||||
// Without the survival bars only the mount's hearts raise it.
|
||||
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
|
||||
// The one bar above the hotbar: the mount's jump bar, the locator
|
||||
// bar (with its background) or the experience bar, with the level
|
||||
// number that a survival player always has.
|
||||
@@ -9248,10 +9377,6 @@ impl Game<'_> {
|
||||
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
});
|
||||
bar_height = self
|
||||
.health_hud
|
||||
.left_height
|
||||
.max(self.health_hud.right_height);
|
||||
// Hud.playAirBubblePoppedSound: Player.playSound (PLAYERS), heard
|
||||
// by this client only.
|
||||
if let Some((volume, pitch)) = self.health_hud.bubble_pop_sound.take() {
|
||||
@@ -9267,7 +9392,7 @@ impl Game<'_> {
|
||||
}
|
||||
if !spectator {
|
||||
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
|
||||
g.draw_selected_name(batch, bar_height, !g.creative);
|
||||
g.draw_selected_name(batch, !g.creative);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9278,7 +9403,7 @@ impl Game<'_> {
|
||||
});
|
||||
if show_hud {
|
||||
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
|
||||
g.draw_overlay_message(batch, bar_height);
|
||||
g.draw_overlay_message(batch);
|
||||
});
|
||||
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
|
||||
g.draw_title(batch);
|
||||
@@ -11393,6 +11518,10 @@ impl Game<'_> {
|
||||
/// generation to follow its own motion. Each run lasts until the next mark.
|
||||
pub(super) fn mark_motion(&self, start: usize, id: u64) {
|
||||
self.entity_motion.borrow_mut().push((start as u32, id));
|
||||
// Where its GPU-posed vertices start (`entity_skin`): a shader pack's `at_velocity`
|
||||
// follows those too.
|
||||
let posed = self.skin_frame.borrow().vertices;
|
||||
self.entity_motion_posed.borrow_mut().push(posed);
|
||||
}
|
||||
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
|
||||
fn entity_geometry(
|
||||
@@ -11414,6 +11543,7 @@ impl Game<'_> {
|
||||
let mut static_block_entity_stats = StaticCacheFrameStats::default();
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
self.entity_motion_posed.borrow_mut().clear();
|
||||
// LevelExtractor.extract: the transient blocks queued since the last frame.
|
||||
self.drain_transient_block_queue();
|
||||
self.prepare_entity_culling();
|
||||
@@ -12276,8 +12406,12 @@ impl Game<'_> {
|
||||
if let Some(hit) = self.tick_mob {
|
||||
return hit;
|
||||
}
|
||||
self.mob_hit_within(self.entity_reach)
|
||||
}
|
||||
/// [`Game::mob_hit`] out to `reach` instead of the entity interaction range
|
||||
/// (`ProjectileUtil.getHitResultOnViewVector` with another range).
|
||||
pub(super) fn mob_hit_within(&self, reach: f64) -> Option<(usize, f64, [f64; 3])> {
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, m) in self.mobs.iter().enumerate() {
|
||||
// ProjectileUtil.getEntityHitResult: the mob the player rides
|
||||
@@ -13471,6 +13605,7 @@ impl Game<'_> {
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
self.entity_motion_posed.borrow_mut().clear();
|
||||
self.skin_models
|
||||
.borrow_mut()
|
||||
.restart(&mut self.skin_frame.borrow_mut());
|
||||
@@ -15145,7 +15280,7 @@ impl Game<'_> {
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
@@ -15186,7 +15321,7 @@ impl Game<'_> {
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
@@ -16937,6 +17072,7 @@ impl Game<'_> {
|
||||
hud.patches.append(&mut self.map_patches);
|
||||
let entity_spans = std::mem::take(self.entity_spans.get_mut());
|
||||
let entity_motion = std::mem::take(self.entity_motion.get_mut());
|
||||
let entity_motion_posed = std::mem::take(self.entity_motion_posed.get_mut());
|
||||
let glowing = std::mem::take(self.glowing_vertices.get_mut());
|
||||
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
|
||||
let skin = std::mem::take(self.skin_frame.get_mut());
|
||||
@@ -17026,6 +17162,7 @@ impl Game<'_> {
|
||||
visible_static_block_entity_sections: &visible_static_block_entity_sections,
|
||||
static_block_entity_stats,
|
||||
entity_motion: &entity_motion,
|
||||
entity_motion_posed: &entity_motion_posed,
|
||||
entity_spans: &entity_spans,
|
||||
skin: skin.pending().then_some(&skin),
|
||||
glowing: &glowing,
|
||||
@@ -17100,9 +17237,16 @@ impl Game<'_> {
|
||||
pr.lap("pr_frame");
|
||||
// Renderer encoding and GPU waiting belong to render, not prepare.
|
||||
drop(prepare);
|
||||
let debug_passes = self.debug_lists_gpu_passes();
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug || self.gpu_passes);
|
||||
renderer.set_pass_timing(debug_passes || self.gpu_passes);
|
||||
// `gpu_passes` is a measurement: it takes every sample it can.
|
||||
renderer.set_pass_timing_gap(if self.gpu_passes {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
horizon_client::debug_overlay::GPU_TIMING_GAP
|
||||
});
|
||||
horizon_client::spans::time("renderer", || renderer.render(&frame))
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
@@ -17114,6 +17258,7 @@ impl Game<'_> {
|
||||
self.entity_vertices = entities;
|
||||
*self.entity_spans.get_mut() = entity_spans;
|
||||
*self.entity_motion.get_mut() = entity_motion;
|
||||
*self.entity_motion_posed.get_mut() = entity_motion_posed;
|
||||
*self.glowing_vertices.get_mut() = glowing;
|
||||
*self.shadow_only_vertices.get_mut() = shadow_only;
|
||||
let mut skin = skin;
|
||||
@@ -17545,6 +17690,7 @@ impl Game<'_> {
|
||||
_ if self.screen == Screen::Dead => {
|
||||
self.death_key(key)?;
|
||||
}
|
||||
_ if self.screen == Screen::Stats => self.stats_key(raw),
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -18159,6 +18305,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
debug_keys::warm_debug_cpu();
|
||||
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
@@ -20085,6 +20232,7 @@ impl<'a> Game<'a> {
|
||||
shadow_only_vertices: Default::default(),
|
||||
entity_spans: Default::default(),
|
||||
entity_motion: Default::default(),
|
||||
entity_motion_posed: Default::default(),
|
||||
skin_models: Default::default(),
|
||||
skin_frame: Default::default(),
|
||||
glowing_vertices: Default::default(),
|
||||
@@ -20197,6 +20345,7 @@ impl<'a> Game<'a> {
|
||||
level_up_sound: Default::default(),
|
||||
swing: Default::default(),
|
||||
swings_started: 0,
|
||||
predicted_swings: 0,
|
||||
heights: Default::default(),
|
||||
sway: Default::default(),
|
||||
visible_hands: [(0, None), (0, None)],
|
||||
@@ -20249,6 +20398,7 @@ impl<'a> Game<'a> {
|
||||
map_frame_model: prepared.map_frame_model,
|
||||
glow_map_frame_model: prepared.glow_map_frame_model,
|
||||
armor_stands: Default::default(),
|
||||
location_armor: Default::default(),
|
||||
cushions: Vec::new(),
|
||||
others: Vec::new(),
|
||||
armor_stand_assets: prepared.armor_stand_assets,
|
||||
@@ -20268,6 +20418,7 @@ impl<'a> Game<'a> {
|
||||
entity_models: prepared.entity_models,
|
||||
carts: Default::default(),
|
||||
watched_vehicles: Vec::new(),
|
||||
native_moves: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
maps: maps::Maps::default(),
|
||||
@@ -20366,6 +20517,8 @@ impl<'a> Game<'a> {
|
||||
native_lifecycle: Default::default(),
|
||||
native_configs: Default::default(),
|
||||
native_net: Default::default(),
|
||||
native_player_fx: Default::default(),
|
||||
native_packets: Default::default(),
|
||||
native_mod_screen: None,
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
@@ -20396,6 +20549,8 @@ impl<'a> Game<'a> {
|
||||
),
|
||||
debug_options: None,
|
||||
memory_meter: Default::default(),
|
||||
debug_paint: Default::default(),
|
||||
debug_refresh: Default::default(),
|
||||
gpu_utilization: 0.0,
|
||||
rendered_mobs: Default::default(),
|
||||
screen_blur_requested: Default::default(),
|
||||
@@ -20421,6 +20576,7 @@ impl<'a> Game<'a> {
|
||||
block_reach: 4.5,
|
||||
guest_use: None,
|
||||
use_failed: false,
|
||||
guest_inventory_resend: false,
|
||||
lan_events: Vec::new(),
|
||||
lan_songs: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
@@ -20442,14 +20598,17 @@ impl<'a> Game<'a> {
|
||||
lan_break_pos: None,
|
||||
guest_packets: Vec::new(),
|
||||
guest_cooldowns: Vec::new(),
|
||||
native_resend_trees: Vec::new(),
|
||||
guest_entity_events: Vec::new(),
|
||||
guest_equipment_breaks: Vec::new(),
|
||||
guest_player_sounds: Vec::new(),
|
||||
lan_acting_name: None,
|
||||
lan_acting_uuid: None,
|
||||
native_click_cursor: None,
|
||||
tnt_refused: None,
|
||||
lan_acting_permission: None,
|
||||
lan_acting_entity: None,
|
||||
native_login_permission: None,
|
||||
chat_capture: None,
|
||||
admin_feedback: Vec::new(),
|
||||
console_broadcast: None,
|
||||
@@ -21867,7 +22026,7 @@ fn build_renderer(
|
||||
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
shader_pack: shader_packs::PickedPack,
|
||||
filtering: (i32, i32),
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
@@ -21906,7 +22065,8 @@ fn build_renderer(
|
||||
api,
|
||||
translucent_batching,
|
||||
wide_frame,
|
||||
shader_pack,
|
||||
shader_pack.draws_flat,
|
||||
shader_pack.linear,
|
||||
))?;
|
||||
renderer.hud.sprite_animations = atlas
|
||||
.sprite_animations
|
||||
@@ -21978,6 +22138,7 @@ fn loading_renderer(
|
||||
// An 8-bit frame, which HDR output takes as well.
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
if let Some(atlas) = atlas {
|
||||
renderer.hud.sprite_animations = atlas
|
||||
@@ -22051,6 +22212,7 @@ fn loading_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: &[],
|
||||
@@ -22594,7 +22756,7 @@ impl Loading {
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
shader_packs::picked_pack_shape(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
@@ -22878,11 +23040,17 @@ pub fn run_with() -> Result<()> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// The packets the server sends its players reach the mods that ask (native_packets.rs).
|
||||
let _ = horizon_client::lan::conn::SERVER_PACKET.set(Game::native_server_packet);
|
||||
let _ = horizon_client::lan::conn::ENTITY_DATA_TO.set(Game::native_entity_data_to);
|
||||
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
|
||||
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
|
||||
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
|
||||
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
|
||||
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
|
||||
let _ = horizon_client::lan::hub::PLAYER_MOVE.set(Game::native_player_move);
|
||||
let _ = horizon_client::lan::hub::PLAYER_TELEPORT.set(Game::native_player_teleport);
|
||||
let _ = horizon_client::block_contact::TRAMPLE.set(Game::native_trample);
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
// and the render thread.
|
||||
horizon_threads::put_helpers_behind();
|
||||
@@ -22910,7 +23078,7 @@ pub fn run_with() -> Result<()> {
|
||||
{
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
if options.execution_role.is_client()
|
||||
|
||||
@@ -392,6 +392,15 @@ impl Game<'_> {
|
||||
let tree = self.adv.tree.clone();
|
||||
self.adv.progress = progress;
|
||||
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
|
||||
// `PlayerAdvancements.checkForAutomaticTriggers`: an advancement with no
|
||||
// criteria is never done (`AdvancementRequirements.test` is false), but
|
||||
// its rewards are granted as the progress loads. Vanilla's `award(holder, "")` finds no `""`
|
||||
// criterion to grant and so returns without a toast or an announcement;
|
||||
// the reward grant is the whole effect, and it happens again on the next
|
||||
// load because there is no criterion to record it.
|
||||
for index in tree.automatic() {
|
||||
self.grant_rewards(index);
|
||||
}
|
||||
}
|
||||
pub(super) fn save_advancements(&self) {
|
||||
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
|
||||
@@ -920,47 +929,7 @@ impl Game<'_> {
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_advancement_done(player, &name, &a.id);
|
||||
}
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
|
||||
let gained_level = self.experience.give(points);
|
||||
if let Some(level) = gained_level {
|
||||
self.play_level_up(1, level);
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
// Inventory.add returns false when anything is left, and
|
||||
// AdvancementRewards then drops that remainder with no
|
||||
// pickup sound. A stack the inventory takes whole plays
|
||||
// entity.item.pickup at the player's feet.
|
||||
if let Some(rest) = self.give(s) {
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.spawn_item(rest, eye, [0.0; 3], 0);
|
||||
}
|
||||
} else {
|
||||
self.advancement_pickup_sound();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(function) = &a.rewards.function {
|
||||
self.run_advancement_reward_function(function);
|
||||
}
|
||||
let unlocked = self.grant_rewards(index);
|
||||
if let Some(d) = &a.display {
|
||||
// A network player's client shows its own toast.
|
||||
if d.show_toast
|
||||
@@ -1012,6 +981,56 @@ impl Game<'_> {
|
||||
}
|
||||
self.recipe_unlocked(&unlocked);
|
||||
}
|
||||
/// `AdvancementRewards.grant`: the experience, recipes, loot and function of
|
||||
/// a finished advancement, and the recipes it unlocked for the
|
||||
/// `recipe_unlocked` trigger, which the caller runs after its toast and
|
||||
/// announcement. Returns those recipes.
|
||||
fn grant_rewards(&mut self, index: usize) -> Vec<String> {
|
||||
let tree = self.adv.tree.clone();
|
||||
let a = &tree.list[index];
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
|
||||
let gained_level = self.experience.give(points);
|
||||
if let Some(level) = gained_level {
|
||||
self.play_level_up(1, level);
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
.rewards
|
||||
.recipes
|
||||
.iter()
|
||||
.filter(|r| world_stations!(self).recipe_index.contains(r))
|
||||
.cloned()
|
||||
.collect();
|
||||
let (_, unlocked) = self
|
||||
.level
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
// Inventory.add returns false when anything is left, and
|
||||
// AdvancementRewards then drops that remainder with no
|
||||
// pickup sound. A stack the inventory takes whole plays
|
||||
// entity.item.pickup at the player's feet.
|
||||
if let Some(rest) = self.give(s) {
|
||||
if let Some(eye) = self.eye_position() {
|
||||
self.spawn_item(rest, eye, [0.0; 3], 0);
|
||||
}
|
||||
} else {
|
||||
self.advancement_pickup_sound();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(function) = &a.rewards.function {
|
||||
self.run_advancement_reward_function(function);
|
||||
}
|
||||
unlocked
|
||||
}
|
||||
/// `InventoryChangeTrigger`, checked when the inventory changes.
|
||||
pub(super) fn check_inventory_trigger(&mut self) {
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
@@ -26,6 +26,9 @@ pub(super) struct BossState {
|
||||
loaded: bool,
|
||||
/// What each viewer was last sent, so a change becomes one packet.
|
||||
sent: HashMap<u128, HashMap<u128, Offer>>,
|
||||
/// The bars server mods show single players (`native_boss_bar`): by
|
||||
/// player UUID, each bar's id and bar, in the order they were added.
|
||||
pub(super) native: HashMap<u128, Vec<(u128, horizon_client::mod_api::NativeBossBar)>>,
|
||||
}
|
||||
|
||||
impl BossState {
|
||||
@@ -551,6 +554,20 @@ impl Game<'_> {
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
// Server mods' bars for this player alone (`native_boss_bar`).
|
||||
for (id, bar) in self.bosses.native.get(&viewer.uuid).into_iter().flatten() {
|
||||
out.push(Offer {
|
||||
id: *id,
|
||||
name: horizon_client::lan::data::component_nbt(&bar.name.component()),
|
||||
progress: bar.progress,
|
||||
color: bar.color,
|
||||
overlay: bar.overlay,
|
||||
darken: bar.darken,
|
||||
music: bar.music,
|
||||
fog: bar.fog,
|
||||
scope: Scope::Global,
|
||||
});
|
||||
}
|
||||
if viewer.here
|
||||
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
|
||||
{
|
||||
|
||||
@@ -40,19 +40,49 @@ pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
|
||||
impl Game<'_> {
|
||||
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
|
||||
/// hand of `slot` acting.
|
||||
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
|
||||
// No block under the crosshair: releaseUsingItem.
|
||||
pub(super) fn tick_brush(
|
||||
&mut self,
|
||||
stack: &horizon_client::items::ItemStack,
|
||||
slot: usize,
|
||||
ticks: u32,
|
||||
) {
|
||||
// No block under the crosshair: vanilla calls releaseUsingItem, which
|
||||
// stops the use and causes the interaction vibration.
|
||||
let Some(hit) = self.pick() else {
|
||||
self.using = None;
|
||||
self.stop_using_item(stack);
|
||||
return;
|
||||
};
|
||||
// `BrushItem.onUseTick` aims with `getHitResultOnViewVector` out to the
|
||||
// block interaction range, for mobs too: a mob between the player and
|
||||
// the block is nearer, so the hit is not a block and the use releases
|
||||
// the same way. A guest acts on its client's targeted block, whose view
|
||||
// we cannot trace here, so only the local aim runs the entity test.
|
||||
if self.forced_hit.is_none()
|
||||
&& let Some((eye, _)) = self.aim()
|
||||
&& let Some((_, mob, _)) = self.mob_hit_within(self.block_reach)
|
||||
{
|
||||
let block = (
|
||||
hit.point[0] - eye[0],
|
||||
hit.point[1] - eye[1],
|
||||
hit.point[2] - eye[2],
|
||||
);
|
||||
if mob <= (block.0 * block.0 + block.1 * block.1 + block.2 * block.2).sqrt() {
|
||||
self.stop_using_item(stack);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let elapsed = ticks + 1;
|
||||
// The last tick before the backswing.
|
||||
if elapsed % 10 == 5 {
|
||||
self.brush_stroke(&hit, slot);
|
||||
}
|
||||
// The use ends after 200 ticks; the held button starts it again.
|
||||
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
|
||||
// The use ends after USE_DURATION; stopUsingItem causes the vibration
|
||||
// (vanilla's tickUsingItem), the held button starts it again.
|
||||
if elapsed < USE_DURATION {
|
||||
self.using = Some((slot, elapsed));
|
||||
} else {
|
||||
self.stop_using_item(stack);
|
||||
}
|
||||
}
|
||||
|
||||
/// One stroke on the block aimed at: dust (this window only), the brush
|
||||
|
||||
@@ -252,6 +252,7 @@ mod tests {
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[20.5, 64.0, 0.5],
|
||||
|
||||
@@ -720,10 +720,13 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.filter(|d| !command || d.command)
|
||||
.map(|d| d.text.clone());
|
||||
self.open_chat(draft.as_deref().unwrap_or(prefix));
|
||||
self.open_chat(draft.as_deref().unwrap_or(prefix), draft.is_some());
|
||||
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
|
||||
}
|
||||
pub(super) fn open_chat(&mut self, initial: &str) {
|
||||
/// Open the chat box on `initial`; `is_draft` when that text is the saved
|
||||
/// draft (`ChatComponent.createScreen`), which draws grey italic until it
|
||||
/// is edited.
|
||||
pub(super) fn open_chat(&mut self, initial: &str, is_draft: bool) {
|
||||
if self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -743,6 +746,7 @@ impl Game<'_> {
|
||||
&m,
|
||||
&c,
|
||||
initial,
|
||||
is_draft,
|
||||
self.ui.chat.recent.len(),
|
||||
b.width as i32,
|
||||
b.height as i32,
|
||||
@@ -757,6 +761,36 @@ impl Game<'_> {
|
||||
self.right = false;
|
||||
self.capture(false);
|
||||
}
|
||||
/// The open chat box's text and its cursor, counted in characters; `None`
|
||||
/// while the chat screen isn't open. API @API:chat_input@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_chat_input(&self) -> Option<(String, usize)> {
|
||||
let screen = self.ui.chat_screen.as_ref()?;
|
||||
Some((screen.input.value.clone(), screen.input.cursor))
|
||||
}
|
||||
/// Replaces the open chat box's text and puts the cursor at `cursor`
|
||||
/// (characters, clamped to the text), as an edit: completions refresh as
|
||||
/// they do for typing. What typing can't put in the box isn't put there
|
||||
/// either (`chat_box_text`), and the 256-character limit holds. An error
|
||||
/// while the chat screen isn't open. API @API:chat_input@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_set_chat_input(
|
||||
&mut self,
|
||||
text: &str,
|
||||
cursor: usize,
|
||||
) -> Result<(), String> {
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let Some(screen) = self.ui.chat_screen.as_mut() else {
|
||||
return Err("the chat isn't open".into());
|
||||
};
|
||||
screen.suggest_command(&m, &c, &chat_box_text(text));
|
||||
screen.input.move_cursor_to(&m, cursor, false);
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn close_chat(&mut self, reason: ChatExit) {
|
||||
// InBedChatScreen.onClose: leaving the screen means getting up; the
|
||||
// screen goes once the player has woken (`tick_in_bed_chat`).
|
||||
@@ -836,11 +870,13 @@ impl Game<'_> {
|
||||
.map_or_else(String::new, |s| s.value().to_owned());
|
||||
self.close_chat(ChatExit::Interrupted);
|
||||
if !text.is_empty() {
|
||||
self.open_chat(&text);
|
||||
// InBedChatScreen.onPlayerWokeUp: a new ChatScreen with
|
||||
// the typed text, not a draft.
|
||||
self.open_chat(&text, false);
|
||||
}
|
||||
}
|
||||
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
|
||||
self.open_chat("");
|
||||
self.open_chat("", false);
|
||||
self.ui.in_bed_chat = self.screen == Screen::Chat;
|
||||
}
|
||||
}
|
||||
@@ -1627,23 +1663,23 @@ impl Game<'_> {
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
}
|
||||
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
|
||||
pub(super) fn take_screenshot(&mut self) {
|
||||
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
|
||||
/// file it will write, `None` without a window to draw.
|
||||
pub(super) fn take_screenshot(&mut self) -> Option<std::path::PathBuf> {
|
||||
let dir = horizon_client::logging::player_dir().join("screenshots");
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let name = screenshot_name(&dir);
|
||||
let Some(renderer) = self.renderer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let renderer = self.renderer.as_mut()?;
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
self.ui.screenshot_message = Some(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
/// `key.screenshot` mapping (F2 by default) takes the picture before a
|
||||
@@ -1699,6 +1735,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
|
||||
/// What a mod may write into the chat box: the characters `ChatScreen`'s
|
||||
/// typing lets in (`StringUtil.isAllowedChatCharacter`: no control
|
||||
/// characters, no DEL, no section sign).
|
||||
fn chat_box_text(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
|
||||
.collect()
|
||||
}
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
// Util.getFilenameFormattedDateTime: the local date and time.
|
||||
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
|
||||
@@ -1730,6 +1774,17 @@ const LINK_COPY: usize = 1;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A mod's text reaches the chat box as typing would leave it: emoji and
|
||||
/// accents stay, control characters, DEL and the section sign go.
|
||||
#[test]
|
||||
fn a_mods_chat_text_keeps_what_typing_allows() {
|
||||
assert_eq!(
|
||||
super::chat_box_text("hi :smile: → 😀 é"),
|
||||
"hi :smile: → 😀 é"
|
||||
);
|
||||
assert_eq!(super::chat_box_text("a\nb\tc\u{7f}d§6e"), "abcd6e");
|
||||
}
|
||||
|
||||
/// `ChatScreen.shouldDiscardDraft`.
|
||||
#[test]
|
||||
fn draft_discard_follows_the_exit_reason() {
|
||||
@@ -2057,6 +2112,7 @@ mod tests {
|
||||
&m,
|
||||
&c,
|
||||
"",
|
||||
false,
|
||||
0,
|
||||
width as i32,
|
||||
height as i32,
|
||||
|
||||
@@ -906,7 +906,7 @@ impl Game<'_> {
|
||||
/// the entity lists (a kind added there joins `@e` once it names its
|
||||
/// `Target`), then the kinds that are not on the walk. An
|
||||
/// unsorted selector lists them in that order.
|
||||
fn level_candidates(&self) -> Vec<Target> {
|
||||
pub(in crate::game) fn level_candidates(&self) -> Vec<Target> {
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
let mut candidates = Vec::new();
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
@@ -1559,6 +1559,7 @@ impl Game<'_> {
|
||||
// there at once: the arrival they were waiting for elsewhere
|
||||
// in it (whose chunks lose their tickets) is abandoned.
|
||||
if let Some(conn) = self.lan_acting {
|
||||
horizon_client::lan::hub::teleport_cause(Some("command"));
|
||||
self.server_trips.retain(|trip| !trip.is(conn));
|
||||
}
|
||||
let from = self
|
||||
@@ -1816,6 +1817,7 @@ impl Game<'_> {
|
||||
at: pos,
|
||||
rotation,
|
||||
vehicle: None,
|
||||
cause: Some("command"),
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
@@ -4009,6 +4011,7 @@ impl Game<'_> {
|
||||
));
|
||||
Ok(count as i32)
|
||||
}
|
||||
/// `SayCommand.register`: resolve the source name and broadcast unsigned announcements.
|
||||
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
|
||||
Some(Arg::Str(s)) => s.clone(),
|
||||
@@ -4016,13 +4019,42 @@ impl Game<'_> {
|
||||
};
|
||||
let message = self.expand_selectors(&raw);
|
||||
// CommandSourceStack.getDisplayName: the executing entity's name.
|
||||
let name = match self.command_source.map(|s| s.entity) {
|
||||
Some(Some(t)) => self.target_name(t),
|
||||
Some(None) => Text::literal("Server"),
|
||||
let name = match self.command_source {
|
||||
Some(source) => match source.entity {
|
||||
Some(target) => self.target_name(target),
|
||||
None => source
|
||||
.block
|
||||
.and_then(|pos| match self.world.block_entities.get(&pos) {
|
||||
Some(horizon_client::containers::BlockEntity::CommandBlock(block)) => {
|
||||
Some(block.name())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| {
|
||||
source.cart.and_then(|id| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|cart| cart.id == id)
|
||||
.map(|cart| cart.command.name())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Text::literal("Server")),
|
||||
},
|
||||
None => self.player_display_name(),
|
||||
};
|
||||
let line = match id {
|
||||
"say" => Text::translate("chat.type.announcement", &[name, message]),
|
||||
"say" => {
|
||||
// SayCommand broadcasts independently of the command source's feedback.
|
||||
if let Some(host) = &self.lan {
|
||||
use horizon_client::lan::data::component_nbt;
|
||||
host.hub.broadcast_source_chat(
|
||||
"say_command",
|
||||
component_nbt(&name.component()),
|
||||
component_nbt(&message.component()),
|
||||
);
|
||||
}
|
||||
Text::translate("chat.type.announcement", &[name, message])
|
||||
}
|
||||
"me" => Text::translate("chat.type.emote", &[name, message]),
|
||||
_ => {
|
||||
let _ = self.entities(ctx, "targets", true)?;
|
||||
@@ -4042,6 +4074,21 @@ impl Game<'_> {
|
||||
.italic()
|
||||
}
|
||||
};
|
||||
if id == "say" {
|
||||
if self.lan.is_some() && self.options.dedicated.is_some() {
|
||||
self.lan_log_event(horizon_client::lan::hub::HostEvent::Chat {
|
||||
line: line.plain(),
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
// SayCommand sends no feedback: the broadcast above already reached
|
||||
// the guest whose command is being captured.
|
||||
if self.lan.is_some() && self.chat_capture.is_some() {
|
||||
return Ok(1);
|
||||
}
|
||||
self.player_message(line);
|
||||
return Ok(1);
|
||||
}
|
||||
if let Some(capture) = self.chat_capture.as_mut() {
|
||||
capture.push(line.clone());
|
||||
}
|
||||
|
||||
@@ -313,6 +313,7 @@ impl Game<'_> {
|
||||
return Err(err("commands.data.entity.invalid", &[]));
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
Self::normalize_loaded_mob_tags(&mut tag);
|
||||
let uuid = self.uuid_for(id);
|
||||
let kind = self
|
||||
.mobs
|
||||
|
||||
@@ -77,6 +77,14 @@ impl Game<'_> {
|
||||
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
|
||||
let changed =
|
||||
m.add_effect_with(name, duration, amplifier, false, particles);
|
||||
// MobEffect.onEffectAdded: an omen's sound, at the mob's feet.
|
||||
if new
|
||||
&& changed
|
||||
&& let Some(sound) = super::super::potions::sound_on_added(name)
|
||||
{
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.mod_effect_added(name, Some(id as i32), amplifier, new);
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -1156,7 +1156,7 @@ impl Game<'_> {
|
||||
Ok((true, count))
|
||||
}
|
||||
/// The biome name at a block position.
|
||||
fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
|
||||
pub(in crate::game) fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
|
||||
let chunk = self.world.chunks.get(&(p[0] >> 4, p[2] >> 4))?;
|
||||
// The world's table (`world_biomes!`): a packed biome's id is past
|
||||
// the built-in list, and `Biomes::get` would clamp it to the last
|
||||
|
||||
@@ -195,9 +195,37 @@ impl Game<'_> {
|
||||
_ => by.and_then(|t| self.target_position(t)),
|
||||
};
|
||||
// Destruction can remove the target before its success message is built.
|
||||
let discarded_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
|
||||
let item_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
|
||||
.then(|| self.target_name(target));
|
||||
let hurt = match target {
|
||||
let hurt = self.damage_target(target, amount, &ty, cause, by, from);
|
||||
if !hurt {
|
||||
return Err(err("commands.damage.invulnerable", &[]));
|
||||
}
|
||||
let name = item_name.unwrap_or_else(|| self.target_name(target));
|
||||
self.feedback(Text::translate(
|
||||
"commands.damage.success",
|
||||
&[
|
||||
Text::literal(horizon_client::snbt::java_float(amount)),
|
||||
name,
|
||||
],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `DamageCommand.damage`'s hurt: `target` takes `amount` of `ty` (`minecraft:mob_attack`)
|
||||
/// from the causing entity `cause` and the direct one `by` (the causing one is either when
|
||||
/// the other is none), from `from` (the knockback's direction): whether it was hurt. Also
|
||||
/// `native_hurt_entity`'s.
|
||||
pub(in crate::game) fn damage_target(
|
||||
&mut self,
|
||||
target: Target,
|
||||
amount: f32,
|
||||
ty: &str,
|
||||
cause: Option<Target>,
|
||||
by: Option<Target>,
|
||||
from: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
match target {
|
||||
// The causing mob is the one the hit is credited to and thorns
|
||||
// answer; the source position gives the knockback its direction.
|
||||
Target::Player | Target::Guest(_) => {
|
||||
@@ -217,9 +245,9 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
if let Some(name) = named {
|
||||
g.note_direct_entity(&ty, name);
|
||||
g.note_direct_entity(ty, name);
|
||||
}
|
||||
g.hurt_player_from(amount, from, &ty, attacker)
|
||||
g.hurt_player_from(amount, from, ty, attacker)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -234,7 +262,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let damage = Damage {
|
||||
kind: Self::damage_kind(&ty),
|
||||
kind: Self::damage_kind(ty),
|
||||
attacker,
|
||||
from,
|
||||
attacker_species,
|
||||
@@ -264,15 +292,15 @@ impl Game<'_> {
|
||||
self.command_damage_mob(id, amount, damage)
|
||||
}
|
||||
}
|
||||
Target::Item(id) => self.command_damage_item(id, amount, &ty, cause.or(by)),
|
||||
Target::Item(id) => self.command_damage_item(id, amount, ty, cause.or(by)),
|
||||
Target::Boat(_) | Target::Cart(_) => {
|
||||
self.command_damage_vehicle(target, amount, &ty, cause.or(by))
|
||||
self.command_damage_vehicle(target, amount, ty, cause.or(by))
|
||||
}
|
||||
Target::Dragon(id) => self.command_damage_dragon(
|
||||
id,
|
||||
amount,
|
||||
&ty,
|
||||
Self::damage_kind(&ty),
|
||||
ty,
|
||||
Self::damage_kind(ty),
|
||||
(
|
||||
by,
|
||||
cause.or(by),
|
||||
@@ -280,22 +308,10 @@ impl Game<'_> {
|
||||
),
|
||||
),
|
||||
Target::Crystal(id) => {
|
||||
self.command_damage_crystal(id, &ty, Self::damage_kind(&ty), cause.or(by))
|
||||
self.command_damage_crystal(id, ty, Self::damage_kind(ty), cause.or(by))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !hurt {
|
||||
return Err(err("commands.damage.invulnerable", &[]));
|
||||
}
|
||||
let name = discarded_name.unwrap_or_else(|| self.target_name(target));
|
||||
self.feedback(Text::translate(
|
||||
"commands.damage.success",
|
||||
&[
|
||||
Text::literal(horizon_client::snbt::java_float(amount)),
|
||||
name,
|
||||
],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `VehicleEntity.hurtServer`: report acceptance even when the vehicle survives.
|
||||
@@ -1509,6 +1525,20 @@ impl Game<'_> {
|
||||
return Ok(0);
|
||||
};
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
self.ride_mount(target, vehicle)?;
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
/// `RideCommand.mount` once the targets are known: `target` (the player
|
||||
/// in place or a mob) rides `vehicle`, as `startRiding(vehicle, true,
|
||||
/// true)` forces it (also for `native_mount`).
|
||||
pub(in crate::game) fn ride_mount(&mut self, target: Target, vehicle: Target) -> R<()> {
|
||||
let name = self.target_name(target);
|
||||
let vehicle_name = self.target_name(vehicle);
|
||||
if let Some(current) = self.ride_vehicle(target) {
|
||||
let current = self.target_name(current);
|
||||
return Err(err(
|
||||
@@ -1574,11 +1604,7 @@ impl Game<'_> {
|
||||
// sendEventAndTriggers: START_RIDING_TRIGGER.
|
||||
self.start_riding(id);
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(());
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
if target != Target::Player
|
||||
@@ -1588,11 +1614,7 @@ impl Game<'_> {
|
||||
return Err(generic());
|
||||
}
|
||||
self.start_riding(id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1608,11 +1630,7 @@ impl Game<'_> {
|
||||
if !self.try_mount_mob(rider, mob_id) {
|
||||
return Err(generic());
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
return Ok(1);
|
||||
return Ok(());
|
||||
}
|
||||
if !self.native_may_board(mob_id) {
|
||||
return Err(generic());
|
||||
@@ -1625,11 +1643,7 @@ impl Game<'_> {
|
||||
self.mount.jump_ticks = 0;
|
||||
self.mount.jump_scale = 0.0;
|
||||
self.start_riding(mob_id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
));
|
||||
Ok(1)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entity.getVehicle: the player's mount, or a mob's.
|
||||
|
||||
@@ -386,6 +386,9 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
|
||||
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
|
||||
*target = Vec3::new(to[0], y, to[2]);
|
||||
}
|
||||
if !cancel {
|
||||
horizon_client::lan::hub::teleport_cause(Some("chorus_fruit"));
|
||||
}
|
||||
Ok(!cancel)
|
||||
}
|
||||
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
|
||||
|
||||
@@ -17,7 +17,7 @@ use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
crafting::Recipes,
|
||||
graphics::EntityCullingMode,
|
||||
health::{HurtSource, Hurting, Knock, knockback_canceled},
|
||||
health::{HealthEvent, HurtSource, Hurting, Knock, knockback_canceled},
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{
|
||||
@@ -108,6 +108,9 @@ pub(super) struct Creatures {
|
||||
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
|
||||
/// thousands of mobs would allocate and free megabytes every tick.
|
||||
seen_buf: Vec<Seen>,
|
||||
/// The targets the mobs had before their ticks, kept between ticks
|
||||
/// (empty) as `seen_buf` is: `native_living_change_target` hears a change.
|
||||
target_buf: Vec<Option<Who>>,
|
||||
/// The mobs whose own tick ran in the last `tick_creature_entities`:
|
||||
/// `ServerLevel.tick` ticks only the entities in entity-ticking range, so
|
||||
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
|
||||
@@ -124,6 +127,17 @@ pub(super) struct Creatures {
|
||||
/// Particles from a guest's turn, waiting for it to end.
|
||||
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
|
||||
}
|
||||
/// Whether `p`'s next step goes from inside the world border (`bounds`: [minX, minZ, maxX,
|
||||
/// maxZ]) to outside it, as `clip_including_border` would stop it.
|
||||
fn border_crossing(p: &Projectile, bounds: Option<[f64; 4]>) -> bool {
|
||||
let Some([min_x, min_z, max_x, max_z]) = bounds else {
|
||||
return false;
|
||||
};
|
||||
let within = |x: f64, z: f64| x >= min_x && x < max_x && z >= min_z && z < max_z;
|
||||
let (at, v) = (p.position, p.velocity);
|
||||
within(at.x, at.z) && !within(at.x + v.x, at.z + v.z)
|
||||
}
|
||||
|
||||
pub(super) struct ProjWorld<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
pub targets: Vec<(Who, Aabb)>,
|
||||
@@ -296,6 +310,53 @@ fn flame_geometry(
|
||||
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
|
||||
m.seen(player)
|
||||
}
|
||||
/// A mob's target as `native_living_change_target` names it: a player's UUID
|
||||
/// or a mob's id, both None for no target; None for one it cannot name.
|
||||
/// `Who::Player` is the guest the mob sees (`seen`, a connection) or, with
|
||||
/// none, the host's own player (`host`); a guest's entity (`guest_ids`, by
|
||||
/// connection) is that guest.
|
||||
fn target_named(
|
||||
target: Option<Who>,
|
||||
seen: Option<u32>,
|
||||
guests: &super::lan_mobs::GuestViews,
|
||||
guest_ids: &[(u32, u32)],
|
||||
host: Option<&PlayerView>,
|
||||
) -> Option<(Option<u128>, Option<u32>)> {
|
||||
let uuid = |v: &PlayerView| horizon_client::lan::playerdata::ints_uuid(v.uuid);
|
||||
let guest = |conn: u32| guests.iter().find(|g| g.0 == conn).map(|g| uuid(&g.1));
|
||||
match target {
|
||||
None => Some((None, None)),
|
||||
Some(Who::Player) => {
|
||||
let player = match seen {
|
||||
Some(conn) => guest(conn),
|
||||
None => host.map(uuid),
|
||||
};
|
||||
Some((Some(player?), None))
|
||||
}
|
||||
Some(Who::Mob(id)) => match guest_ids.iter().find(|g| g.1 == id) {
|
||||
Some(&(conn, _)) => Some((Some(guest(conn)?), None)),
|
||||
None => Some((None, Some(id))),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Whether `e`, from a mob's tick, is a hit it deals `target`: a melee hit
|
||||
/// (with its effect), a sting, a sonic boom.
|
||||
fn hits_target(e: &MobEvent, want: Who) -> bool {
|
||||
match (e, want) {
|
||||
(
|
||||
MobEvent::AttackPlayer(..)
|
||||
| MobEvent::AttackPlayerWithEffect { .. }
|
||||
| MobEvent::StingPlayer { .. },
|
||||
Who::Player,
|
||||
) => true,
|
||||
(
|
||||
MobEvent::AttackMob { target, .. } | MobEvent::AttackMobWithEffect { target, .. },
|
||||
Who::Mob(id),
|
||||
) => *target == id,
|
||||
(MobEvent::SonicBoom { target, .. }, _) => *target == want,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
|
||||
/// its death animation, the one the player rides (it shares the player's root
|
||||
/// vehicle) and the camera entity the pick starts from, so a packet that names
|
||||
@@ -1020,6 +1081,11 @@ impl Game<'_> {
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
let mut events = Vec::new();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
// Each mob's target as the mods last heard it, by its place in `mobs`
|
||||
// (`native_living_change_target`).
|
||||
let mut targets = std::mem::take(&mut self.creatures.target_buf);
|
||||
targets.clear();
|
||||
targets.extend(mobs.iter().map(|m| m.ai.target));
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
@@ -1209,6 +1275,46 @@ impl Game<'_> {
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// Its tick changed its target: the mods may keep the one it had
|
||||
// (`native_living_change_target`), and then what the tick did to
|
||||
// the new one is not dealt.
|
||||
if let Some(&before) = targets.get(i)
|
||||
&& m.ai.target != before
|
||||
{
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
if let Some(new) = m.ai.target {
|
||||
let mut k = from;
|
||||
while k < events.len() {
|
||||
if hits_target(&events[k], new) {
|
||||
events.remove(k);
|
||||
} else {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
targets[i] = m.ai.target;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
@@ -1272,6 +1378,43 @@ impl Game<'_> {
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
}
|
||||
// Targets changed after the mob's own tick (another mob's alert):
|
||||
// heard now, and a cancel puts the old one back before its next tick.
|
||||
for (i, m) in mobs.iter_mut().enumerate() {
|
||||
let Some(&before) = targets.get(i) else {
|
||||
break;
|
||||
};
|
||||
if m.ai.target == before {
|
||||
continue;
|
||||
}
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
bindings.get(&m.id).copied()
|
||||
};
|
||||
let named = target_named(
|
||||
m.ai.target,
|
||||
seen_guest,
|
||||
&guests,
|
||||
&guest_ids,
|
||||
base.player.as_ref(),
|
||||
);
|
||||
if let Some((player, mob)) = named {
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
let mut cancel = false;
|
||||
Self::native_living_change_target(
|
||||
level_id,
|
||||
m.id,
|
||||
&kind,
|
||||
player,
|
||||
mob,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
m.ai.target = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
@@ -1303,6 +1446,8 @@ impl Game<'_> {
|
||||
snapshot.clear();
|
||||
self.creatures.seen_buf = snapshot;
|
||||
self.creatures.ticked = ticked;
|
||||
targets.clear();
|
||||
self.creatures.target_buf = targets;
|
||||
self.native_tick_post_batch(round);
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
// 🤖 Agent: the split follows the poof, as `LivingEntity.tickDeath`
|
||||
@@ -3844,6 +3989,7 @@ impl Game<'_> {
|
||||
// The hit's own push, held back until after the shield's (`hurtServer`
|
||||
// runs `applyItemBlocking` before `dealDefaultKnockback`).
|
||||
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
|
||||
let events_before = self.player.as_ref().map_or(0, |p| p.events.len());
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
@@ -3879,15 +4025,36 @@ impl Game<'_> {
|
||||
default_push = Some(([xd, zd], source));
|
||||
}
|
||||
// The thorns hurt the entity that caused the hit, if there is one.
|
||||
// Thorns' own hit is no attack, so nothing answers it
|
||||
// (`doPostAttackEffects` runs from the attacks, not `hurtServer`).
|
||||
let killed = self.player.as_ref().is_some_and(|p| {
|
||||
super::wither::killed(true, p.health.dead_or_dying(), p.health.totem_used)
|
||||
});
|
||||
if let Some(a) = attacker.or(owner)
|
||||
&& !(post_attack_if_alive && killed)
|
||||
&& damage_type != "minecraft:thorns"
|
||||
{
|
||||
self.thorns(a);
|
||||
}
|
||||
}
|
||||
// LivingEntity.playSecondaryHurtSound: a thorns hit that landed whole
|
||||
// sounds where the player stands, for everyone around.
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& p.events.get(events_before..).is_some_and(|new| {
|
||||
new.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
horizon_client::motion::MotionEvent::Health(HealthEvent::Phase(
|
||||
"thorns-sound"
|
||||
))
|
||||
)
|
||||
})
|
||||
})
|
||||
{
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(at), 1.0, 1.0);
|
||||
}
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
|
||||
// early (invulnerable, or inside the hurt cooldown).
|
||||
// A hit a native mod canceled records no damage (hurtServer never ran).
|
||||
@@ -4113,6 +4280,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let border = Some(self.border.bounds());
|
||||
let dimension = self.dim.id();
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
targets,
|
||||
@@ -4158,6 +4326,23 @@ impl Game<'_> {
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
// MC-310476: the mods may let a thrown projectile through the world border.
|
||||
let through = p.arrow.is_none() && !p.stuck && border_crossing(p, border) && {
|
||||
let (at, v) = (p.position, p.velocity);
|
||||
let (mut pos, mut velocity) = ([at.x, at.y, at.z], [v.x, v.y, v.z]);
|
||||
let mut collide = true;
|
||||
let kind = p.kind.id();
|
||||
let (vel, c) = (&mut velocity, &mut collide);
|
||||
Self::native_projectile_world_border(dimension, p.id, kind, &mut pos, vel, c);
|
||||
if pos.iter().chain(&velocity).all(|v| v.is_finite())
|
||||
&& (pos != [at.x, at.y, at.z] || velocity != [v.x, v.y, v.z])
|
||||
{
|
||||
p.position = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
p.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
!collide
|
||||
};
|
||||
// The projectile as it was, to fly on if a mod cancels its impact.
|
||||
let before = (hear && !p.stuck).then(|| p.clone());
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
@@ -4188,6 +4373,16 @@ impl Game<'_> {
|
||||
targets.extend(winds);
|
||||
horizon_client::arrows::tick(p, &scene, &targets)
|
||||
}
|
||||
// Through the border: this tick's ray does not stop on it.
|
||||
(false, with_guests, true) if through => p.tick(&ProjWorld {
|
||||
w: pw.w,
|
||||
targets: if with_guests {
|
||||
pw_guests.targets.clone()
|
||||
} else {
|
||||
pw.targets.clone()
|
||||
},
|
||||
border: None,
|
||||
}),
|
||||
(false, false, true) => p.tick(&pw),
|
||||
(false, true, true) => p.tick(&pw_guests),
|
||||
(false, pvp_shot, false) => {
|
||||
@@ -4200,7 +4395,7 @@ impl Game<'_> {
|
||||
p.tick(&ProjWorld {
|
||||
w: if pvp_shot { pw_guests.w } else { pw.w },
|
||||
targets,
|
||||
border,
|
||||
border: border.filter(|_| !through),
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -4440,6 +4635,23 @@ impl Game<'_> {
|
||||
None => self.projectile_hit(p, hit),
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.doPostAttackEffects` after the player's shot `p`
|
||||
/// (its `direct` entity, a hit of `damage_type`) hurt the mob `who`: the
|
||||
/// victim's own post-attack enchantments answer the player (Thorns).
|
||||
pub(super) fn shot_post_attack(
|
||||
&mut self,
|
||||
p: &Projectile,
|
||||
who: Who,
|
||||
direct: &str,
|
||||
damage_type: &str,
|
||||
) {
|
||||
if p.owner == Some(Who::Player)
|
||||
&& let Who::Mob(id) = who
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
|
||||
{
|
||||
self.victim_post_attack(index, direct, Some(damage_type));
|
||||
}
|
||||
}
|
||||
/// What an arrow or a thrown trident hits an entity of type `victim`
|
||||
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
|
||||
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
|
||||
@@ -4746,6 +4958,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
let result = self.projectile_hit_inner(p, hit);
|
||||
// LivingEntity.actuallyHurt credits the shooter (a ServerPlayer) with the absorbed damage.
|
||||
self.settle_dealt_absorbed(p.owner == Some(Who::Player));
|
||||
result
|
||||
}
|
||||
fn projectile_hit_inner(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
let _attacker = self.knockback_scope_for(p.owner);
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
|
||||
// projectile (a wind charge out of the world's top has no face).
|
||||
@@ -4949,6 +5167,7 @@ impl Game<'_> {
|
||||
}),
|
||||
};
|
||||
let outcome = if hurt {
|
||||
self.shot_post_attack(p, who, "minecraft:trident", "minecraft:trident");
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
@@ -5123,6 +5342,16 @@ impl Game<'_> {
|
||||
}])?;
|
||||
}
|
||||
if hurt {
|
||||
let spectral = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
|
||||
let direct = if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
};
|
||||
self.shot_post_attack(p, who, direct, "minecraft:arrow");
|
||||
self.arrow_post_hurt_effects(p, who);
|
||||
}
|
||||
match (hurt, who) {
|
||||
@@ -5327,6 +5556,9 @@ impl Game<'_> {
|
||||
pl.fall_distance = 0.0;
|
||||
pl.teleported = true;
|
||||
}
|
||||
if self.lan_acting.is_some() {
|
||||
horizon_client::lan::hub::teleport_cause(Some("ender_pearl"));
|
||||
}
|
||||
self.previous_position = to;
|
||||
if damage > 0.0 {
|
||||
self.hurt_player_by("minecraft:ender_pearl", damage);
|
||||
@@ -6711,6 +6943,17 @@ impl Game<'_> {
|
||||
let Some(eye) = self.eye_position() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// The mods, before the projectile exists: a canceled throw is a
|
||||
// failed use, and the guest's client gets back the item it took.
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let mut cancel = false;
|
||||
self.native_player_launch_projectile_v2(player, &name, stack, &mut cancel);
|
||||
if cancel {
|
||||
self.use_failed = true;
|
||||
self.guest_inventory_resend = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
@@ -15,12 +15,27 @@ use horizon_client::{
|
||||
use horizon_core::geometry::{Axis, Vec3};
|
||||
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `ChatFormatting.YELLOW` and `RED`.
|
||||
const YELLOW: u32 = 0xFF_FF55;
|
||||
const RED: u32 = 0xFF_5555;
|
||||
|
||||
/// The overlay's CPU name. Reading it can start a process (`reg`, `sysctl`), which the render
|
||||
/// thread shouldn't wait for, so [`warm_debug_cpu`] reads it ahead.
|
||||
static DEBUG_CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
|
||||
/// Reads the overlay's CPU name in the background when the window opens, for F3's first frame.
|
||||
pub(super) fn warm_debug_cpu() {
|
||||
// A thread that can't start only leaves the read to that first frame.
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("debug-cpu-name".into())
|
||||
.spawn(|| {
|
||||
DEBUG_CPU.get_or_init(horizon_client::crash::cpu);
|
||||
});
|
||||
}
|
||||
|
||||
/// The game as `KeyboardHandler` sees it while it runs one key event.
|
||||
struct Host<'g, 'a> {
|
||||
game: &'g mut Game<'a>,
|
||||
@@ -289,7 +304,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
|
||||
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
|
||||
/// the overlay is open. F1 hides it unless a screen is open.
|
||||
/// the overlay is open. F1 hides it unless a screen is open. Horizon paints
|
||||
/// it 20 times a second and copies those vertices in between.
|
||||
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
let screen_open = self.screen != Screen::Playing;
|
||||
let reduced = self.reduced_debug_info();
|
||||
@@ -297,8 +313,19 @@ impl Game<'_> {
|
||||
|| !self.debug_entries.any_enabled(self.debug, reduced)
|
||||
|| (self.ui.hide_gui && !screen_open)
|
||||
{
|
||||
// Nothing reads the server's lines now: it stops computing them each tick.
|
||||
if let Some(server) = &self.integrated {
|
||||
server.request_debug(None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let layout = (size, batch.scale.to_bits(), self.gui.atlas.font.epoch());
|
||||
if let Some(vertices) = self.debug_paint.borrow().fresh(now, layout) {
|
||||
batch.vertices.extend_from_slice(vertices);
|
||||
return;
|
||||
}
|
||||
let first_vertex = batch.vertices.len();
|
||||
let framebuffer = size.map(|v| v as i32);
|
||||
let (scale, [width, height]) = debug_overlay::scale(
|
||||
framebuffer,
|
||||
@@ -343,6 +370,33 @@ impl Game<'_> {
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
}
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
self.debug_paint
|
||||
.borrow_mut()
|
||||
.store(now, layout, &batch.vertices[first_vertex..]);
|
||||
}
|
||||
/// Whether the overlay is listing GPU pass times (its `system_specs` lines), the only reason
|
||||
/// it has to time passes.
|
||||
pub(super) fn debug_lists_gpu_passes(&self) -> bool {
|
||||
self.debug
|
||||
&& self.title.is_none()
|
||||
&& !(self.ui.hide_gui && self.screen == Screen::Playing)
|
||||
&& self.debug_entry("system_specs")
|
||||
}
|
||||
/// The monitor's refresh rate in millihertz, asked of the OS at most once a second (on
|
||||
/// Windows, winit calls `EnumDisplaySettingsExW` each time).
|
||||
fn debug_refresh_rate(&self) -> Option<u32> {
|
||||
if let Some((at, rate)) = self.debug_refresh.get()
|
||||
&& at.elapsed() < Duration::from_secs(1)
|
||||
{
|
||||
return rate;
|
||||
}
|
||||
let rate = self
|
||||
.window
|
||||
.as_ref()
|
||||
.and_then(|w| w.current_monitor())
|
||||
.and_then(|m| m.refresh_rate_millihertz());
|
||||
self.debug_refresh.set(Some((Instant::now(), rate)));
|
||||
rate
|
||||
}
|
||||
/// `DebugEntryFps`.
|
||||
pub(super) fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
|
||||
@@ -357,14 +411,9 @@ impl Game<'_> {
|
||||
wgpu::PresentMode::FifoRelaxed => " (fifo relaxed)",
|
||||
_ => "",
|
||||
});
|
||||
let refresh = self
|
||||
.window
|
||||
.as_ref()
|
||||
.and_then(|w| w.current_monitor())
|
||||
.and_then(|m| m.refresh_rate_millihertz())
|
||||
.map_or("0".into(), |mhz| {
|
||||
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
||||
});
|
||||
let refresh = self.debug_refresh_rate().map_or("0".into(), |mhz| {
|
||||
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
|
||||
});
|
||||
// Horizon: with frame generation running, the frames reaching the screen too.
|
||||
let generated = self
|
||||
.renderer
|
||||
@@ -420,12 +469,11 @@ impl Game<'_> {
|
||||
framebuffer: [i32; 2],
|
||||
) {
|
||||
if let Some(r) = self.renderer.as_ref() {
|
||||
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
d.group(
|
||||
"system",
|
||||
[format!(
|
||||
"CPU: {}",
|
||||
CPU.get_or_init(horizon_client::crash::cpu)
|
||||
DEBUG_CPU.get_or_init(horizon_client::crash::cpu)
|
||||
)],
|
||||
);
|
||||
let info = &r.info;
|
||||
@@ -445,7 +493,8 @@ impl Game<'_> {
|
||||
);
|
||||
if let Some(w) = self.window.as_ref() {
|
||||
let density = w.scale_factor();
|
||||
let logical = w.inner_size().to_logical::<f64>(density);
|
||||
let logical = winit::dpi::PhysicalSize::new(framebuffer[0], framebuffer[1])
|
||||
.to_logical::<f64>(density);
|
||||
d.group(
|
||||
"system",
|
||||
[format!(
|
||||
|
||||
@@ -168,6 +168,16 @@ pub(super) fn player_joined(id: u128, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A player in the user cache by name (`UserCache::find_by_name`), on a dedicated server.
|
||||
pub(super) fn cached_user_by_name(name: &str) -> Option<NameAndId> {
|
||||
USER_CACHE.lock().unwrap().as_ref()?.find_by_name(name)
|
||||
}
|
||||
|
||||
/// A player in the user cache by id, on a dedicated server.
|
||||
pub(super) fn cached_user_by_id(id: u128) -> Option<NameAndId> {
|
||||
USER_CACHE.lock().unwrap().as_mut()?.get_by_id(id)
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
|
||||
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
|
||||
fn load_anticheat() -> horizon_anticheat::Config {
|
||||
@@ -1616,6 +1626,15 @@ impl Game<'_> {
|
||||
/// server (permission level 4). The dedicated-only commands are here;
|
||||
/// everything else goes to the game's dispatcher.
|
||||
fn console_command(&mut self, state: &mut ServerState, line: &str) {
|
||||
let (mut cancel, mut replies) = (false, Vec::new());
|
||||
let source = if self.rcon_source { "rcon" } else { "console" };
|
||||
self.native_console_line(source, line, &mut cancel, &mut replies);
|
||||
for reply in replies {
|
||||
self.console_reply(reply);
|
||||
}
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let line = line.strip_prefix('/').unwrap_or(line);
|
||||
let mut words = line.split_whitespace();
|
||||
let Some(command) = words.next() else {
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(super) struct Recording {
|
||||
impl Recording {
|
||||
pub(super) fn start(seconds: u32, wire: Vec<(u32, (u64, u64))>) -> Self {
|
||||
let spans_were_on = spans::enabled();
|
||||
spans::enable(true);
|
||||
tick_profile::want_spans(tick_profile::SPANS_REPORT, true);
|
||||
job_stats::enable(true);
|
||||
job_stats::take();
|
||||
tick_profile::take();
|
||||
@@ -355,9 +355,8 @@ impl Game<'_> {
|
||||
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
|
||||
let span_ns = span_cost_nanos();
|
||||
let mob_ns = tick_profile::sample_cost_nanos();
|
||||
if !r.spans_were_on {
|
||||
spans::enable(false);
|
||||
}
|
||||
// Off unless `HORIZON_SPIKES` or a mod (`native_spans`) still wants them.
|
||||
tick_profile::want_spans(tick_profile::SPANS_REPORT, false);
|
||||
job_stats::enable(false);
|
||||
let window_s = r.started.elapsed().as_secs_f64();
|
||||
let (ticks, spans, worst) = tick_summary(&r, window_s);
|
||||
|
||||
@@ -655,6 +655,8 @@ impl Game<'_> {
|
||||
v[2].as_f64().unwrap_or(0.0),
|
||||
)
|
||||
};
|
||||
// A new player: no combat of an earlier case's.
|
||||
self.combat = Default::default();
|
||||
let mut player = PlayerMotion::new(self.motion, v3(&op["player"]))?;
|
||||
player.velocity = v3(&op["motion"]);
|
||||
player.fall_distance = op["fall"].as_f64().unwrap_or(0.0);
|
||||
@@ -670,6 +672,7 @@ impl Game<'_> {
|
||||
let used_before = stack
|
||||
.as_ref()
|
||||
.map_or(0, |s| self.stats.get("used", &s.item));
|
||||
let taken_before = self.stats.get("custom", "minecraft:damage_taken");
|
||||
let base = op["base"].as_f64().unwrap_or(0.0) as f32;
|
||||
let source = self.attack_source(feet);
|
||||
let (bonus, hurt) = match targets.first() {
|
||||
@@ -707,12 +710,39 @@ impl Game<'_> {
|
||||
};
|
||||
self.process_level_events()?;
|
||||
let mut out = Vec::new();
|
||||
// What the player's tick makes of its own hurt (the victim's Thorns):
|
||||
// the combat record with its statistic, the hurt sound where it
|
||||
// stands and the damage game event of the health it lost.
|
||||
let events = self
|
||||
.player
|
||||
.as_mut()
|
||||
.map(|p| std::mem::take(&mut p.events))
|
||||
.unwrap_or_default();
|
||||
self.record_combat(&events);
|
||||
for event in &events {
|
||||
if let horizon_client::motion::MotionEvent::HurtSound(sound) = event {
|
||||
out.push(format!("S {} {sound}", floor_pos(feet)));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.health() < 20.0)
|
||||
{
|
||||
self.post_entity_event("minecraft:entity_damage", horizon_client::mobs::Who::Player);
|
||||
}
|
||||
if let Some(s) = &stack {
|
||||
let used = self.stats.get("used", &s.item) - used_before;
|
||||
if used != 0 {
|
||||
out.push(format!("stat {} minecraft:used {used}", s.item));
|
||||
}
|
||||
}
|
||||
let taken = self.stats.get("custom", "minecraft:damage_taken") - taken_before;
|
||||
if taken != 0 {
|
||||
out.push(format!(
|
||||
"stat minecraft:damage_taken minecraft:custom {taken}"
|
||||
));
|
||||
}
|
||||
let source = match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
@@ -743,6 +773,25 @@ impl Game<'_> {
|
||||
num(v.z)
|
||||
));
|
||||
}
|
||||
// What the first target wears, worn by its Thorns.
|
||||
if let Some(&Target::Mob(id)) = targets.first()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
use horizon_client::mobs::finalize as f;
|
||||
for (slot, name) in [
|
||||
(f::FEET, "feet"),
|
||||
(f::LEGS, "legs"),
|
||||
(f::CHEST, "chest"),
|
||||
(f::HEAD, "head"),
|
||||
] {
|
||||
if let Some(s) = f::item_stack(m, slot) {
|
||||
out.push(format!("W {name} {}", canon_stack(Some(&s))));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
out.push(format!("H {}", num(f64::from(p.health.health()))));
|
||||
}
|
||||
if let Some(p) = self.player.take() {
|
||||
let impact = p.impulse.impact_position.map_or("-".to_owned(), |i| {
|
||||
format!("{},{},{}", num(i.x), num(i.y), num(i.z))
|
||||
@@ -1044,12 +1093,20 @@ impl Game<'_> {
|
||||
if flag("brown") && s == Species::Mooshroom {
|
||||
mob.extra.variant = 1;
|
||||
}
|
||||
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
|
||||
for (name, snbt) in e["equip"].as_object().into_iter().flatten() {
|
||||
if let (Some(slot), Some(stack)) = (
|
||||
Slot::from_name(slot),
|
||||
Slot::from_name(name),
|
||||
parse_stack(snbt.as_str().unwrap_or("-"))?,
|
||||
) {
|
||||
mob_set_slot(&mut mob, slot, stack);
|
||||
// `setItemSlot` keeps the whole stack: a piece's enchantments and damage.
|
||||
let kept = ["mainhand", "offhand", "feet", "legs", "chest", "head"]
|
||||
.iter()
|
||||
.position(|s| s == name)
|
||||
.filter(|_| stack.extra.is_some() || stack.damage > 0);
|
||||
match kept {
|
||||
Some(at) => horizon_client::mobs::finalize::set_item_stack(&mut mob, at, stack),
|
||||
None => mob_set_slot(&mut mob, slot, stack),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
|
||||
@@ -17,6 +17,9 @@ use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
|
||||
use serde_json::Value;
|
||||
|
||||
/// `EquipmentSlot.getName` of each `mobs::finalize` slot (hands, feet to head).
|
||||
const MOB_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum MendSlot {
|
||||
@@ -178,8 +181,12 @@ impl Game<'_> {
|
||||
let _ = p.set_sprinting(false);
|
||||
}
|
||||
}
|
||||
/// After a successful melee hit: fire aspect and wind burst.
|
||||
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource` after a successful
|
||||
/// melee hit (`Player.itemAttackInteraction`, the sweep's
|
||||
/// `doPostAttackEffects`): the victim's equipment first (Thorns), then the
|
||||
/// attacker's weapon on the victim (fire aspect and wind burst).
|
||||
pub(super) fn post_attack(&mut self, mob: usize) {
|
||||
self.victim_post_attack(mob, "minecraft:player", None);
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -519,6 +526,152 @@ impl Game<'_> {
|
||||
}
|
||||
hits
|
||||
}
|
||||
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
|
||||
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
|
||||
/// direct entity: the player, or its arrow or trident):
|
||||
/// `runIterationOnEquipment` over what the mob holds and wears, in
|
||||
/// `EquipmentSlot` order, runs the `minecraft:post_attack` effects with
|
||||
/// `enchanted: victim` of each enchantment on the player (Thorns).
|
||||
pub(super) fn victim_post_attack(
|
||||
&mut self,
|
||||
victim: usize,
|
||||
direct: &str,
|
||||
damage_type: Option<&str>,
|
||||
) {
|
||||
use horizon_client::mobs::finalize as f;
|
||||
let Some(m) = self.mobs.get(victim) else {
|
||||
return;
|
||||
};
|
||||
let (id, kind) = (m.id, m.kind.id.clone());
|
||||
// EquipmentSlot.VALUES: the hands, then the feet up to the head (the
|
||||
// body and the saddle keep no enchantments here).
|
||||
for slot in [f::MAINHAND, f::OFFHAND, f::FEET, f::LEGS, f::CHEST, f::HEAD] {
|
||||
let Some(stack) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| f::item_stack(m, slot))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = MOB_SLOTS[slot];
|
||||
let hand = slot == f::MAINHAND || slot == f::OFFHAND;
|
||||
let mut effects = Vec::new();
|
||||
for (enchantment, level) in stack.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(enchantment) else {
|
||||
continue;
|
||||
};
|
||||
// Enchantment.matchingSlot
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == name || s == "any" || (s == "armor" && !hand) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:post_attack"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["enchanted"] == "victim" {
|
||||
effects.push((effect.clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, level) in effects {
|
||||
// Enchantment.damageContext: random_chance draws from the level's random.
|
||||
let ctx = EffectContext {
|
||||
this: Some(&kind),
|
||||
attacker: Some("minecraft:player"),
|
||||
direct_attacker: Some(direct),
|
||||
is_direct: direct == "minecraft:player",
|
||||
damage_type,
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
world_stations!(self).enchantments.condition(
|
||||
r,
|
||||
level,
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
)
|
||||
});
|
||||
// The player is the attacker; with no attacker nothing applies.
|
||||
if ok && effect["affected"] == "attacker" && self.player.is_some() {
|
||||
self.victim_effect_on_player(&effect["effect"], level, id, slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `DamageEntity.apply`, `ChangeItemDamage.apply` (on the piece) and
|
||||
/// `AllOf` for one `post_attack` effect of the piece the mob `wearer` has
|
||||
/// in `slot`, on the player that hit it.
|
||||
fn victim_effect_on_player(&mut self, effect: &Value, level: u32, wearer: u32, slot: usize) {
|
||||
match effect["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("minecraft:"))
|
||||
{
|
||||
Some("all_of") => {
|
||||
for e in effect["effects"].as_array().into_iter().flatten() {
|
||||
self.victim_effect_on_player(e, level, wearer, slot);
|
||||
}
|
||||
}
|
||||
// Mth.randomBetween from the hurt entity's own random (the
|
||||
// player's, which no level draw shares), then its hurtServer by a
|
||||
// source the wearer caused: from where the wearer stands.
|
||||
Some("damage_entity") => {
|
||||
let min = level_value(&effect["min_damage"], level);
|
||||
let max = level_value(&effect["max_damage"], level);
|
||||
let damage = super::dispenser::entity_random().next_float() * (max - min) + min;
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == wearer) else {
|
||||
return;
|
||||
};
|
||||
let from = [m.position.x, m.position.y, m.position.z];
|
||||
let kind = effect["damage_type"].as_str().unwrap_or("minecraft:thorns");
|
||||
self.mob_hits_player_caused(damage, from, kind, Some(wearer));
|
||||
}
|
||||
Some("change_item_damage") => {
|
||||
let amount = level_value(&effect["amount"], level) as u32;
|
||||
self.wear_mob_piece(wearer, slot, amount);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak` with no player for what the mob `id` has in
|
||||
/// `slot`: Unbreaking takes its share from the level's random, and a
|
||||
/// piece worn out leaves the slot with its break event
|
||||
/// (`LivingEntity.onEquippedItemBroken`).
|
||||
fn wear_mob_piece(&mut self, id: u32, slot: usize, amount: u32) {
|
||||
use horizon_client::mobs::finalize as f;
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let Some(stack) = f::item_stack(m, slot).filter(|s| {
|
||||
amount > 0 && horizon_client::stations::is_damageable(world_items!(self), s)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
if loss == 0 {
|
||||
return;
|
||||
}
|
||||
let damage = stack.damage + loss;
|
||||
let max = world_items!(self)
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if damage < max {
|
||||
f::set_slot_damage(m, slot, damage);
|
||||
return;
|
||||
}
|
||||
horizon_client::mobs::drops::clear_slot(m, MOB_SLOTS[slot]);
|
||||
// LivingEntity.entityEventForEquipmentBreak
|
||||
let event = [47, 48, 52, 51, 50, 49][slot];
|
||||
self.lan_entity_event(id as i32, event, at);
|
||||
}
|
||||
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
|
||||
pub(super) fn mending(&mut self, amount: u32) -> u32 {
|
||||
let mut amount = amount;
|
||||
@@ -840,16 +993,26 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let on_ground = p.ground.on_ground;
|
||||
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
|
||||
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
|
||||
// LivingEntity.collectEquipmentChanges: newly worn armor runs its
|
||||
// location_changed effects at once, even when standing still.
|
||||
let changed = armor_changes(&mut self.location_armor, &self.armor);
|
||||
let moved = cell(from) != cell(at) || (on_ground && !was_on_ground);
|
||||
if !moved && !changed.iter().any(|c| *c) {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let disks = location_disks(
|
||||
&self.stations().enchantments,
|
||||
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
|
||||
on_ground,
|
||||
riding,
|
||||
);
|
||||
let slots = ["head", "chest", "legs", "feet"];
|
||||
let mut disks = Vec::new();
|
||||
for i in 0..4 {
|
||||
if moved || changed[i] {
|
||||
disks.extend(location_disks(
|
||||
&self.stations().enchantments,
|
||||
std::iter::once((&self.armor[i], slots[i])),
|
||||
on_ground,
|
||||
riding,
|
||||
));
|
||||
}
|
||||
}
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at, Who::Player);
|
||||
}
|
||||
@@ -1182,9 +1345,43 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.collectEquipmentChanges`: which armor slots differ from the
|
||||
/// `snapshot` taken at the last look, which is then brought up to date.
|
||||
fn armor_changes(
|
||||
snapshot: &mut [Option<ItemStack>; 4],
|
||||
armor: &[Option<ItemStack>; 4],
|
||||
) -> [bool; 4] {
|
||||
let changed: [bool; 4] = std::array::from_fn(|i| armor[i] != snapshot[i]);
|
||||
// Only a changed slot is copied: the check runs every tick for every player.
|
||||
for ((kept, worn), changed) in snapshot.iter_mut().zip(armor).zip(changed) {
|
||||
if changed {
|
||||
kept.clone_from(worn);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::block_break_speed_factor;
|
||||
use super::{armor_changes, block_break_speed_factor};
|
||||
use horizon_client::items::ItemStack;
|
||||
|
||||
/// The snapshot reports a newly worn piece once, then settles.
|
||||
#[test]
|
||||
fn armor_changes_settle_after_one_look() {
|
||||
let worn = [
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(ItemStack::new("minecraft:iron_boots", 1)),
|
||||
];
|
||||
let mut snapshot: [Option<ItemStack>; 4] = Default::default();
|
||||
assert_eq!(
|
||||
armor_changes(&mut snapshot, &worn),
|
||||
[false, false, false, true]
|
||||
);
|
||||
assert_eq!(armor_changes(&mut snapshot, &worn), [false; 4]);
|
||||
}
|
||||
|
||||
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
|
||||
#[test]
|
||||
|
||||
@@ -408,6 +408,8 @@ impl Game<'_> {
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, state, &mut cancel);
|
||||
let hand = self.hand;
|
||||
self.native_player_fish_v2(uuid, &name, state, hand, &mut cancel);
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
@@ -766,6 +768,17 @@ impl Game<'_> {
|
||||
{
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, 1, &mut cancel);
|
||||
// A bite has no hand of its own: the one holding the rod, the main
|
||||
// hand first, as `FishingHook.shouldStopFishing` looks.
|
||||
let hand = if self
|
||||
.stack_in(self.selected)
|
||||
.is_some_and(|s| &*s.item == "minecraft:fishing_rod")
|
||||
{
|
||||
horizon_client::first_person::Hand::Main
|
||||
} else {
|
||||
horizon_client::first_person::Hand::Off
|
||||
};
|
||||
self.native_player_fish_v2(uuid, &name, 1, hand, &mut cancel);
|
||||
if cancel {
|
||||
h.time_until_lured = 0;
|
||||
h.time_until_hooked = 0;
|
||||
@@ -896,6 +909,10 @@ impl Game<'_> {
|
||||
];
|
||||
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
|
||||
let in_water = liquid > 0.0;
|
||||
// The block a flying bobber meets or rests on this tick (and the face), for
|
||||
// `native_fishing_hook_landed` once the tick is done.
|
||||
let rested = h.on_ground;
|
||||
let mut landed: Option<([i32; 3], [i32; 3])> = None;
|
||||
match h.state {
|
||||
State::Flying => {
|
||||
if in_water {
|
||||
@@ -908,7 +925,8 @@ impl Game<'_> {
|
||||
// checkCollision: find the first block and entity on the movement
|
||||
// segment so a fast hook cannot tunnel or catch through a wall.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let block_hit = self.hook_block_hit(h.position, to);
|
||||
let block_hit_at = self.hook_block_hit_at(h.position, to);
|
||||
let block_hit = block_hit_at.map(|hit| hit.location);
|
||||
let entity_end = block_hit.unwrap_or(to);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
@@ -979,6 +997,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else if let Some(hit) = block_hit {
|
||||
landed = block_hit_at
|
||||
.map(|at| ([at.block.x, at.block.y, at.block.z], at.direction.normal()));
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
@@ -1101,7 +1121,24 @@ impl Game<'_> {
|
||||
h.velocity.y * 0.92,
|
||||
h.velocity.z * 0.92,
|
||||
);
|
||||
// A bobber lying on a block (on the ground as the tick began or ends) rests on
|
||||
// the block under its centre, on that block's top.
|
||||
if landed.is_none() && h.state == State::Flying && (rested || h.on_ground) {
|
||||
let below = [
|
||||
h.position.x.floor() as i32,
|
||||
(h.position.y - 1.0e-4).floor() as i32,
|
||||
h.position.z.floor() as i32,
|
||||
];
|
||||
landed = Some((below, [0, 1, 0]));
|
||||
}
|
||||
let id = h.id;
|
||||
self.hook = Some(h);
|
||||
// The mods hear a server player's bobber on a block with the bobber in place, so
|
||||
// `native_remove_entity` can take it.
|
||||
if let (Some((block, face)), Some(player)) = (landed, self.lan_acting_uuid) {
|
||||
let dimension = self.dim.id();
|
||||
self.native_fishing_hook_landed(player, id, dimension, block, face);
|
||||
}
|
||||
}
|
||||
/// The players a hook can catch: everyone but spectators and the dead
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
@@ -1601,6 +1638,10 @@ impl Game<'_> {
|
||||
|
||||
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
|
||||
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
|
||||
self.hook_block_hit_at(from, to).map(|hit| hit.location)
|
||||
}
|
||||
/// [`Self::hook_block_hit`] with the block and the face it meets.
|
||||
fn hook_block_hit_at(&self, from: Vec3, to: Vec3) -> Option<horizon_core::geometry::BlockHit> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
@@ -1618,7 +1659,6 @@ impl Game<'_> {
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|hit| hit.location)
|
||||
}
|
||||
|
||||
/// `FishingHook.tick`: one tick of movement without the fishing (the
|
||||
@@ -1726,6 +1766,43 @@ impl Game<'_> {
|
||||
(position, velocity, state, on_ground)
|
||||
}
|
||||
|
||||
/// Takes the server's fishing bobber `id` out of the level in place, for
|
||||
/// `native_remove_entity`: the bobber of the player in place, or another of this
|
||||
/// level's players' (out of reach during a guest's turn, when the hub is taken out).
|
||||
/// Its tracking removes it from the clients; the rod is left as when the bobber goes
|
||||
/// by itself. Whether it was there.
|
||||
pub(super) fn remove_server_hook(&mut self, id: u32) -> bool {
|
||||
if self.remote.is_some() || id == 0 {
|
||||
return false;
|
||||
}
|
||||
if self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
return true;
|
||||
}
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let owner = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.map(|g| g.conn)
|
||||
.find(|conn| {
|
||||
host.play
|
||||
.get(conn)
|
||||
.and_then(|p| p.hook.as_ref())
|
||||
.is_some_and(|h| h.id == id)
|
||||
});
|
||||
match owner.and_then(|conn| host.play.get_mut(&conn)) {
|
||||
Some(play) => {
|
||||
play.hook = None;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_hook(&mut self, id: u32) {
|
||||
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
|
||||
self.hook = None;
|
||||
|
||||
@@ -116,12 +116,7 @@ impl Game<'_> {
|
||||
));
|
||||
let supported = behind.legacy_solid || (s[1] == 0 && behind.has_class("DiodeBlock"));
|
||||
// canCoexist(true): only hanging entities facing the same way block it.
|
||||
let overlaps = |o: ([f64; 3], [f64; 3])| (0..3).all(|i| o.0[i] < hi[i] && lo[i] < o.1[i]);
|
||||
supported
|
||||
&& !self
|
||||
.item_frames
|
||||
.iter()
|
||||
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
|
||||
supported && super::paintings::frame_coexists(f, &self.item_frames, &self.paintings)
|
||||
}
|
||||
/// Asks the mods whether the acting guest may place a hanging entity
|
||||
/// of type `kind` at `pos` (`native_hanging_place`): true when one
|
||||
@@ -288,6 +283,13 @@ impl Game<'_> {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
// A map tracking more than 256 counted markers stays in the hand
|
||||
// (InteractionResult.FAIL): nothing else is tried, no swing (#11383).
|
||||
if let Some(id) = super::maps::map_id(&held)
|
||||
&& self.map(id).is_some_and(|m| m.tracked_over_limit(256))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
|
||||
action = "put";
|
||||
self.audio
|
||||
@@ -709,3 +711,76 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod map_limit_tests {
|
||||
use super::super::maps::{Decoration, Key, MapData};
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
|
||||
fn frame(id: u32) -> ItemFrame {
|
||||
ItemFrame {
|
||||
id,
|
||||
pos: [0, 64, id as i32],
|
||||
facing: 3,
|
||||
glow: false,
|
||||
item: None,
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
}
|
||||
}
|
||||
/// A map whose `count` frame markers all count toward the limit.
|
||||
fn map_tracking(count: i32) -> MapData {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
map.decorations = (0..count)
|
||||
.map(|i| Decoration {
|
||||
sprite: "map/decorations/frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rot: 0,
|
||||
name: None,
|
||||
key: Key::Frame(1000 + i),
|
||||
})
|
||||
.collect();
|
||||
map
|
||||
}
|
||||
fn filled_map(id: i32) -> ItemStack {
|
||||
let mut stack = ItemStack::new("minecraft:filled_map", 1);
|
||||
stack.data_mut().map_id = Some(id);
|
||||
stack
|
||||
}
|
||||
fn at(id: u32) -> Option<InteractTarget> {
|
||||
Some(InteractTarget {
|
||||
id: id as i32,
|
||||
location: [0.0; 3],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_map_tracking_more_than_256_markers_stays_in_the_hand() {
|
||||
let mut game = server_game();
|
||||
game.item_frames = vec![frame(1), frame(2)];
|
||||
game.maps.data.insert(7, map_tracking(257));
|
||||
game.maps.data.insert(8, map_tracking(256));
|
||||
|
||||
// ItemFrame.interact: FAIL, the frame stays empty and the map is kept.
|
||||
*game.held_mut() = Some(filled_map(7));
|
||||
assert!(
|
||||
game.interact_frame(at(1)),
|
||||
"the refusal ends the interaction"
|
||||
);
|
||||
assert!(game.item_frames[0].item.is_none());
|
||||
assert_eq!(game.held().and_then(|s| s.data()?.map_id), Some(7));
|
||||
|
||||
// At the limit (not over it) the map goes in.
|
||||
*game.held_mut() = Some(filled_map(8));
|
||||
assert!(game.interact_frame(at(2)));
|
||||
let framed = game.item_frames[1].item.as_ref();
|
||||
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,10 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
|
||||
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
|
||||
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
|
||||
/// back keeps it still, so an item dropped there stays on top.
|
||||
/// `canBeCollidedWith(item)` is true. A boat always, a living shulker, and a happy
|
||||
/// ghast only while it is held still (`isOnStillTimeout`): a player on its back keeps
|
||||
/// it still, so an item dropped there stays on top. A minecart keeps `Entity`'s false
|
||||
/// (`AbstractMinecart` overrides only `canCollideWith`), so an item falls through it.
|
||||
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
|
||||
self.mobs
|
||||
.iter()
|
||||
@@ -52,7 +53,6 @@ impl Game<'_> {
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
@@ -179,3 +179,88 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod item_collision_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item dropped onto a minecart falls through it to the floor, and one
|
||||
/// dropped onto a boat rests on the boat (`canBeCollidedWith`).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dropped_item_rests_on_a_boat_but_falls_through_a_minecart() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::env::temp_dir().join(format!("horizon-item-cart-unit-{}", std::process::id())),
|
||||
);
|
||||
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();
|
||||
game.set_allow_commands(true);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("summon minecart 4.5 200 4.5"));
|
||||
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
|
||||
for (x, z) in [(4.5, 4.5), (10.5, 10.5)] {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon item {x} 201.5 {z} {{Item:{{id:\"minecraft:stick\",count:1}},Motion:[0.0d,0.0d,0.0d]}}"
|
||||
)));
|
||||
}
|
||||
for _ in 0..60 {
|
||||
game.tick_world(true).unwrap();
|
||||
}
|
||||
let rest = |x: f64| {
|
||||
game.dropped
|
||||
.iter()
|
||||
.find(|d| (d.position.x - x).abs() < 0.5)
|
||||
.map(|d| d.position.y)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(rest(4.5), 200.0, "through the minecart to the stone");
|
||||
let boat_top = game.boats[0].bounds().max_position().y;
|
||||
assert_eq!(rest(10.5), boat_top, "on the boat");
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,15 @@ impl Game<'_> {
|
||||
pub(super) fn swing_arm(&mut self) {
|
||||
self.swing_hand(self.hand, SwingAnimation::DEFAULT);
|
||||
}
|
||||
/// `swing_arm` for a use whose result is `SwingSource.PREDICTED`: the
|
||||
/// player's client swings by itself, so a server keeps it to the others.
|
||||
pub(super) fn swing_arm_predicted(&mut self) {
|
||||
let before = self.swings_started;
|
||||
self.swing_arm();
|
||||
if self.swings_started != before {
|
||||
self.predicted_swings += 1;
|
||||
}
|
||||
}
|
||||
/// `Player.resetAttackStrengthTicker`.
|
||||
pub(super) fn reset_attack_strength(&mut self) {
|
||||
self.attack_ticks = 0;
|
||||
|
||||
@@ -561,6 +561,7 @@ impl Game<'_> {
|
||||
self.server_packs_world_pack(&folder);
|
||||
self.connect_local(pipe, &player)?;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.save_folder = Some(folder.clone());
|
||||
// `new LevelLoadTracker(newWorld ? 500L : 0L)`.
|
||||
r.level_load = horizon_client::level_loading::LevelLoadTracker::new(if new_world {
|
||||
horizon_client::level_loading::LEVEL_LOAD_CLOSE_DELAY_MS
|
||||
@@ -971,7 +972,8 @@ impl Game<'_> {
|
||||
g.chat_capture = Some(Vec::new());
|
||||
// A command a native mod registered goes to the mods
|
||||
// first, as the owner's own chat does (the console is
|
||||
// level 4, so every registered usage is allowed).
|
||||
// level 4, so every registered usage is allowed; one with
|
||||
// a permission node asks the mods for the owner).
|
||||
if !g.perform_native_command(conn, 4, command.trim()) {
|
||||
g.run_command(&command);
|
||||
}
|
||||
|
||||
@@ -262,6 +262,11 @@ pub(super) struct LanHost {
|
||||
/// Native mods' commands and the level each needs, once asked
|
||||
/// (game/native_server.rs).
|
||||
pub native_commands: Option<HashMap<String, u8>>,
|
||||
/// The permission node of each native mod's command that has one
|
||||
/// (`NativeCommand::permission`), in registration order.
|
||||
pub native_command_nodes: Vec<(String, String)>,
|
||||
/// The usages behind them, as the mods registered them.
|
||||
pub native_usages: Vec<horizon_client::mod_api::NativeCommand>,
|
||||
/// The players a native mod leaves out of the sleep count when they
|
||||
/// aren't in bed (`native_sleep_ignored`).
|
||||
pub sleep_ignored: std::collections::HashSet<u128>,
|
||||
@@ -1450,6 +1455,8 @@ impl Game<'_> {
|
||||
quiet: std::collections::VecDeque::new(),
|
||||
far,
|
||||
native_commands: None,
|
||||
native_command_nodes: Vec::new(),
|
||||
native_usages: Vec::new(),
|
||||
sleep_ignored: Default::default(),
|
||||
states: Default::default(),
|
||||
states_next_entity: None,
|
||||
@@ -2082,12 +2089,16 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
// The mods may refuse the login before the player is placed.
|
||||
if let Some((uuid, name)) =
|
||||
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
if let Some((uuid, name, permission)) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| (g.uuid, g.name.clone(), g.permission))
|
||||
{
|
||||
let mut cancel = false;
|
||||
let mut reason = None;
|
||||
self.native_login_permission = Some((uuid, permission));
|
||||
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
|
||||
self.native_login_permission = None;
|
||||
if cancel {
|
||||
let reason = match reason {
|
||||
Some(text) => {
|
||||
@@ -2158,6 +2169,8 @@ impl Game<'_> {
|
||||
{
|
||||
self.lan = Some(host);
|
||||
self.native_player_logged_in(uuid, &name);
|
||||
// What the client said in its configuration (native_packets.rs).
|
||||
self.configuration_heard(uuid, &name);
|
||||
// A hook's command can close the hub.
|
||||
host = self.lan.take()?;
|
||||
}
|
||||
@@ -2172,6 +2185,11 @@ impl Game<'_> {
|
||||
let conn = guest.conn;
|
||||
let at = host.hub.guests.len();
|
||||
host.hub.guests.push(*guest);
|
||||
// Bukkit's PlayerQuitEvent: the mods hear them leave while
|
||||
// they are still here.
|
||||
if !host.players_told_left {
|
||||
host = self.tell_mods_logging_out(host, conn)?;
|
||||
}
|
||||
self.guest_disconnected(&mut host, conn);
|
||||
*guest = host.hub.guests.remove(at);
|
||||
}
|
||||
@@ -2404,7 +2422,7 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the advancement grants");
|
||||
}
|
||||
if !host.hub.native_menu_events.is_empty() {
|
||||
if !host.hub.native_menu_events.is_empty() || !host.hub.native_menu_returns.is_empty() {
|
||||
self.lan = Some(host);
|
||||
horizon_client::spans::time("lan_menu_events", || self.run_native_menu_events());
|
||||
host = self
|
||||
@@ -2412,6 +2430,14 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the menu events");
|
||||
}
|
||||
if !self.native_moves.is_empty() {
|
||||
self.lan = Some(host);
|
||||
horizon_client::spans::time("lan_native_moves", || self.run_native_moves());
|
||||
host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the mods' moves");
|
||||
}
|
||||
host.states = entities;
|
||||
self.level_pass.packets_drained = true;
|
||||
drop(holding);
|
||||
@@ -2549,6 +2575,7 @@ impl Game<'_> {
|
||||
// What the tick sends a guest goes to its connection at the end.
|
||||
let holding = lan::Holding::start();
|
||||
self.register_native_commands(&mut host);
|
||||
self.resend_native_trees(&mut host);
|
||||
// The host's own commands' (and command blocks') players moving
|
||||
// to another level, and their broadcastToAdmins lines.
|
||||
if !self.level_teleports.is_empty() {
|
||||
@@ -2591,13 +2618,7 @@ impl Game<'_> {
|
||||
let mut states = std::mem::take(&mut host.states);
|
||||
if has_guests {
|
||||
self.lan_assign_ids();
|
||||
let guests: Vec<(u32, i32)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(&guests, &mut host.hub.riding);
|
||||
self.lan_riding(&host.hub.guests, &mut host.hub.riding);
|
||||
// Idle states are kept only when the players' moves are in:
|
||||
// the clients that could pair are known. The range scale is
|
||||
// the one the tracker gets below.
|
||||
@@ -2765,7 +2786,7 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the advancement grants");
|
||||
}
|
||||
if !host.hub.native_menu_events.is_empty() {
|
||||
if !host.hub.native_menu_events.is_empty() || !host.hub.native_menu_returns.is_empty() {
|
||||
self.lan = Some(host);
|
||||
self.run_native_menu_events();
|
||||
host = self
|
||||
@@ -2803,6 +2824,7 @@ impl Game<'_> {
|
||||
drop(holding);
|
||||
self.flush_attachment_sync(&mut host);
|
||||
self.flush_native_net(&mut host);
|
||||
self.flush_native_player_fx(&mut host);
|
||||
self.flush_far_terrain(&mut host);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
@@ -2870,6 +2892,27 @@ impl Game<'_> {
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
}
|
||||
// Spigot's PlayerSpawnLocationEvent: the mods may place them elsewhere.
|
||||
let (mut dimension, mut position, mut rotation) =
|
||||
(g.dimension.clone(), g.position, [g.yaw, g.pitch]);
|
||||
self.native_player_spawn_location(
|
||||
g.uuid,
|
||||
&g.name,
|
||||
&mut dimension,
|
||||
&mut position,
|
||||
&mut rotation,
|
||||
);
|
||||
if dimension != g.dimension && super::Dim::from_id(&dimension).id() == dimension {
|
||||
g.dimension = dimension;
|
||||
}
|
||||
if position != g.position && position.iter().all(|v| v.is_finite()) {
|
||||
g.position = position;
|
||||
g.previous_position = position;
|
||||
}
|
||||
if rotation != [g.yaw, g.pitch] && rotation.iter().all(|v| v.is_finite()) {
|
||||
(g.yaw, g.pitch) = (rotation[0], rotation[1]);
|
||||
g.head_yaw = g.yaw;
|
||||
}
|
||||
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
|
||||
let back = self.dim;
|
||||
let level = super::Dim::from_id(&g.dimension);
|
||||
|
||||
@@ -137,6 +137,9 @@ pub(super) struct GuestPlay {
|
||||
/// What `ServerPlayer` last recorded for the guest's scoreboard criteria
|
||||
/// (`lastRecordedHealthAndAbsorption` and the others).
|
||||
score_recorded: super::scoreboard::Recorded,
|
||||
/// The window slots of the drag the guest is making, for
|
||||
/// `native_inventory_drag` at its end.
|
||||
drag_heard: Option<Vec<i32>>,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -309,6 +312,25 @@ fn direction(face: i32) -> [i32; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The windows a guest has open besides their inventory: the block or mod
|
||||
/// container, the crafting table, a station, a lectern, a merchant, a mount.
|
||||
fn guest_open_windows(host: &LanHost, conn: u32) -> Vec<i32> {
|
||||
let mut windows: Vec<i32> = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.menu.as_ref().map(|m| m.window))
|
||||
.into_iter()
|
||||
.collect();
|
||||
if let Some(p) = host.play.get(&conn) {
|
||||
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
|
||||
windows.extend(p.station.as_ref().map(|s| s.window));
|
||||
windows.extend(p.lectern.as_ref().map(|l| l.window));
|
||||
windows.extend(p.merchant.as_ref().map(|m| m.window));
|
||||
windows.extend(p.mount_window.as_ref().map(|m| m.window));
|
||||
}
|
||||
windows
|
||||
}
|
||||
|
||||
/// The hub's current bodies, across dimensions. A borrowed ancestor's
|
||||
/// live body takes precedence over this saved state until its turn returns.
|
||||
fn building_players(host: &LanHost) -> Vec<BuildingPlayer> {
|
||||
@@ -497,14 +519,15 @@ impl Game<'_> {
|
||||
motion.yaw = g.yaw;
|
||||
motion.pitch = g.pitch;
|
||||
motion.sprinting = g.sprinting;
|
||||
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
|
||||
let mode_flies = g.creative() || g.game_mode == SPECTATOR;
|
||||
motion.may_fly = mode_flies || g.allow_flight;
|
||||
motion.flying = g.flying;
|
||||
motion.flying_speed = g.flying_speed;
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
// A player frozen for a hand-over takes no damage (`Abilities.invulnerable`, which the
|
||||
// void and `/kill` still pass), so its record stays true until the release.
|
||||
motion.invulnerable = motion.may_fly || g.handover.is_some();
|
||||
motion.invulnerable = mode_flies || g.handover.is_some();
|
||||
// `ServerPlayer.isInvulnerableTo`'s connection term, as of now.
|
||||
motion.client_loaded = g.client_loaded();
|
||||
let _ = motion.set_health(g.health, false);
|
||||
@@ -519,6 +542,7 @@ impl Game<'_> {
|
||||
self.pitch = g.pitch;
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.location_armor, &mut g.location_armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
std::mem::swap(&mut self.carried, &mut g.carried);
|
||||
let selected = std::mem::replace(&mut self.selected, g.selected);
|
||||
@@ -620,6 +644,8 @@ impl Game<'_> {
|
||||
self.lan_acting_permission.replace(g.permission),
|
||||
self.lan_acting_entity.replace(g.entity_id as u32),
|
||||
);
|
||||
// The damage hooks name the guest by UUID from here on.
|
||||
super::native_damage::note_player(g.entity_id, g.uuid);
|
||||
let packets = std::mem::take(&mut self.guest_packets);
|
||||
let entity_events = std::mem::take(&mut self.guest_entity_events);
|
||||
let equipment_breaks = std::mem::take(&mut self.guest_equipment_breaks);
|
||||
@@ -1021,6 +1047,7 @@ impl Game<'_> {
|
||||
g.selected = std::mem::replace(&mut self.selected, selected);
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.location_armor, &mut g.location_armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
std::mem::swap(&mut self.carried, &mut g.carried);
|
||||
// A teleport with a rotation (commands) turns the guest. `/rotate`
|
||||
@@ -1074,7 +1101,15 @@ impl Game<'_> {
|
||||
passengers: Vec::new(),
|
||||
}));
|
||||
}
|
||||
// What moved them in their turn, for `native_player_teleport`.
|
||||
let cause = horizon_client::lan::hub::teleport_cause(None);
|
||||
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
|
||||
let other = if left_vehicle.is_some() {
|
||||
"dismount"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
horizon_client::lan::hub::teleport_cause(Some(cause.unwrap_or(other)));
|
||||
host.hub.teleport(i, Some(at));
|
||||
} else if teleported {
|
||||
// ServerPlayer.startRiding and an asPassenger transition also
|
||||
@@ -1149,6 +1184,10 @@ impl Game<'_> {
|
||||
if let Some(mob) = guest_mount {
|
||||
self.lan_open_mount(host, conn, mob);
|
||||
}
|
||||
// A use a mod canceled: the client's prediction of it is taken back.
|
||||
if std::mem::take(&mut self.guest_inventory_resend) {
|
||||
host.hub.send_inventory(i);
|
||||
}
|
||||
// The turn's teleports are in the hub now: the mods hear them.
|
||||
self.flush_teleport_notes(host);
|
||||
// An inner turn may move, kill or change a player's mode. Once its
|
||||
@@ -1654,6 +1693,8 @@ impl Game<'_> {
|
||||
}
|
||||
host.hub.container_closed(conn, window);
|
||||
}
|
||||
// A mod's menu with input slots gives them back (`clearContainer`).
|
||||
self.native_menu_returned(host, conn, removal);
|
||||
self.lan_merchant_closed(host, conn, None);
|
||||
self.lan_crafting_removed(host, conn, None, removal);
|
||||
if removal == MenuRemoval::Disconnected {
|
||||
@@ -1806,19 +1847,7 @@ impl Game<'_> {
|
||||
if self.guest_menu_still_valid(host, conn) {
|
||||
return;
|
||||
}
|
||||
let mut windows: Vec<i32> = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.menu.as_ref().map(|m| m.window))
|
||||
.into_iter()
|
||||
.collect();
|
||||
if let Some(p) = host.play.get(&conn) {
|
||||
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
|
||||
windows.extend(p.station.as_ref().map(|s| s.window));
|
||||
windows.extend(p.lectern.as_ref().map(|l| l.window));
|
||||
windows.extend(p.merchant.as_ref().map(|m| m.window));
|
||||
windows.extend(p.mount_window.as_ref().map(|m| m.window));
|
||||
}
|
||||
let windows = guest_open_windows(host, conn);
|
||||
self.guest_close_all(host, conn);
|
||||
for container in windows {
|
||||
host.hub
|
||||
@@ -1826,6 +1855,76 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mods hear a guest's click in their own inventory or a vanilla
|
||||
/// menu they have open (`native_inventory_click`, and a drag's end
|
||||
/// `native_inventory_drag`) before any handler does. A canceled click
|
||||
/// becomes a drag start of no type, which every menu takes as
|
||||
/// `resetQuickCraft` and nothing else, so it moves nothing and the
|
||||
/// client's predicted slots and cursor are answered as for any click the
|
||||
/// server refused.
|
||||
fn lan_inventory_click_hooks(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
p: &mut play::ContainerClick,
|
||||
) {
|
||||
let Some(g) = host.hub.guest(conn).filter(|g| !g.spectator()) else {
|
||||
return;
|
||||
};
|
||||
// A mod's own menu is `native_menu_clicked`'s.
|
||||
if g.menu
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.window == p.container && m.slots == Slots::Native)
|
||||
|| (p.container != 0 && !guest_open_windows(host, conn).contains(&p.container))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
let play = host.play.entry(conn).or_default();
|
||||
let mut cancel = false;
|
||||
if p.input == 5 {
|
||||
// A drag is heard once, at its end, with the slots it went over.
|
||||
match p.button & 3 {
|
||||
0 => play.drag_heard = Some(Vec::new()),
|
||||
1 => {
|
||||
let slot = i32::from(p.slot);
|
||||
if let Some(slots) = play.drag_heard.as_mut()
|
||||
&& !slots.contains(&slot)
|
||||
{
|
||||
slots.push(slot);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(slots) = play.drag_heard.take() {
|
||||
let kind = i32::from(p.button >> 2) & 3;
|
||||
self.native_inventory_drag(
|
||||
uuid,
|
||||
&name,
|
||||
p.container,
|
||||
kind,
|
||||
&slots,
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
play.drag_heard = None;
|
||||
self.native_inventory_click(
|
||||
uuid,
|
||||
&name,
|
||||
p.container,
|
||||
i32::from(p.slot),
|
||||
i32::from(p.button),
|
||||
p.input,
|
||||
&mut cancel,
|
||||
);
|
||||
}
|
||||
if cancel {
|
||||
(p.slot, p.button, p.input) = (-999, 3 << 2, 5);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
|
||||
/// boat is still there and within the guest's entity interaction range
|
||||
/// plus 4 of its box.
|
||||
@@ -1952,6 +2051,27 @@ impl Game<'_> {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
// Plugin channels a client registers in play (minecraft:register), from a dead player
|
||||
// too; the packet goes on as before.
|
||||
if let S::CustomPayload(p) = &packet
|
||||
&& let Some(register) = horizon_client::lan::channel_registration(&p.channel)
|
||||
{
|
||||
self.guest_channels(host, gi, &p.data, register);
|
||||
}
|
||||
// A plugin channel's message on a channel that isn't one of Horizon's own (the match below
|
||||
// takes those) is for the mods, from a dead player too: vanilla's handleCustomPayload doesn't
|
||||
// look at health.
|
||||
let packet = match packet {
|
||||
S::CustomPayload(p)
|
||||
if p.channel != horizon_protocol::quiet::CHANNEL
|
||||
&& p.channel != horizon_protocol::mod_net::CHANNEL
|
||||
&& p.channel != horizon_client::lod_net::CHANNEL =>
|
||||
{
|
||||
self.guest_custom_payload(host, conn, p.channel, p.data);
|
||||
return;
|
||||
}
|
||||
packet => packet,
|
||||
};
|
||||
// A dead player acts no more, but the death screen still respawns and chats: its
|
||||
// commands run as anyone's (ServerGamePacketListenerImpl.handleChatCommand doesn't
|
||||
// look at the player's health).
|
||||
@@ -1976,6 +2096,13 @@ impl Game<'_> {
|
||||
) {
|
||||
self.guest_validate_menu(host, conn);
|
||||
}
|
||||
let mut packet = packet;
|
||||
if let S::ContainerClick(p) = &mut packet {
|
||||
self.lan_inventory_click_hooks(host, conn, p);
|
||||
}
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
let spectator = host.hub.guests[gi].spectator();
|
||||
match packet {
|
||||
S::PlayerAction(a) => match a.action {
|
||||
@@ -2300,6 +2427,9 @@ impl Game<'_> {
|
||||
.border
|
||||
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
|
||||
{
|
||||
if spectator && self.spectator_use_portal(host, conn, gi, p.pos) {
|
||||
return;
|
||||
}
|
||||
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
@@ -2307,6 +2437,7 @@ impl Game<'_> {
|
||||
g.forced_hit = Some(hit);
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let predicted = g.predicted_swings;
|
||||
// InteractionResult.consumesAction: a FAIL (`use_failed`,
|
||||
// as BlockItem.place's failed placement) does not count,
|
||||
// even though the click ended on it.
|
||||
@@ -2324,7 +2455,8 @@ impl Game<'_> {
|
||||
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
(g.swings_started != swings, consumed, attempt)
|
||||
let echo = g.swings_started != swings && g.predicted_swings == predicted;
|
||||
(echo, consumed, attempt)
|
||||
});
|
||||
let swung = used.map(|u| u.0);
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
@@ -2551,6 +2683,7 @@ impl Game<'_> {
|
||||
S::ResourcePack(r) => {
|
||||
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.native_resource_pack_status_v2(uuid, &name, r.id, r.action);
|
||||
g.native_resource_pack_status(uuid, &name, r.action);
|
||||
});
|
||||
}
|
||||
@@ -2606,6 +2739,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
// A mod's menu on an away player's file: read before the click.
|
||||
let away = self.away_menu_click_start(host, gi, p.container);
|
||||
let rules = ItemRules(world_items!(self));
|
||||
let slots = host.hub.guests[gi]
|
||||
.menu
|
||||
@@ -2614,6 +2749,10 @@ impl Game<'_> {
|
||||
.map(|m| m.slots);
|
||||
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
|
||||
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
|
||||
let drops = match away {
|
||||
Some(click) => self.away_menu_click_end(host, gi, click, drops),
|
||||
None => drops,
|
||||
};
|
||||
// A click that changed the container's slots: BlockEntity.setChanged,
|
||||
// so comparators read it again (a double chest's other half follows).
|
||||
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
|
||||
@@ -2650,6 +2789,7 @@ impl Game<'_> {
|
||||
self.close_guest_vehicle_menu(host, conn, key);
|
||||
}
|
||||
host.hub.container_closed(conn, p.container);
|
||||
self.native_menu_returned(host, conn, MenuRemoval::Live);
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
|
||||
@@ -2952,7 +3092,10 @@ impl Game<'_> {
|
||||
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
|
||||
let mut cancel = false;
|
||||
let mut hook = |game: &mut Self| {
|
||||
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
|
||||
game.native_creative_slot_v2(uuid, &name, slot, stack, &mut cancel);
|
||||
if !cancel {
|
||||
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel);
|
||||
}
|
||||
};
|
||||
// The guest whose turn it is is in place already.
|
||||
if self.lan_acting == Some(conn) {
|
||||
@@ -3019,6 +3162,7 @@ impl Game<'_> {
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
vehicle: None,
|
||||
cause: Some("spectate"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -3027,6 +3171,7 @@ impl Game<'_> {
|
||||
// A new body there: no fall distance or motion carried over, but the
|
||||
// player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
horizon_client::lan::hub::teleport_cause(Some("spectate"));
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
@@ -3440,12 +3585,24 @@ impl Game<'_> {
|
||||
self.guest_tempban_command(host, conn, &words);
|
||||
return;
|
||||
}
|
||||
// A native mod's command, for every player its level allows,
|
||||
// whether the world allows commands or not (game/native_server.rs).
|
||||
// A native mod's command, for every player its level allows (or,
|
||||
// for one with a permission node, whom the node lets: asked when it
|
||||
// runs), whether the world allows commands or not
|
||||
// (game/native_server.rs).
|
||||
let integrated = host.hub.config.integrated;
|
||||
if let Some(level) = Self::native_command_level(host, command.trim_start_matches('/'))
|
||||
&& let Some(permission) = host.hub.guest(conn).map(|g| g.permission)
|
||||
&& permission >= level
|
||||
let line = command.trim_start_matches('/');
|
||||
// The level this one command runs at, which the mods may change.
|
||||
let Some((uuid, name, mut permission)) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| (g.uuid, g.name.clone(), g.permission))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.native_player_command_level(uuid, &name, line, &mut permission);
|
||||
let permission = permission.min(4);
|
||||
if let Some(level) = Self::native_command_level(host, line)
|
||||
&& (permission >= level || Self::native_command_node(host, line).is_some())
|
||||
{
|
||||
host.commands.push(GuestCommand {
|
||||
conn,
|
||||
@@ -3472,10 +3629,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// The guest's own level (Commands.performCommand with the player's
|
||||
// source): an operator's on a dedicated server, never the host's.
|
||||
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
|
||||
return;
|
||||
};
|
||||
// source): an operator's on a dedicated server, never the host's,
|
||||
// unless a mod changed it for this command.
|
||||
host.commands.push(GuestCommand {
|
||||
conn,
|
||||
command: command.trim_start_matches('/').to_owned(),
|
||||
@@ -3503,12 +3658,19 @@ impl Game<'_> {
|
||||
/// One guest's command as that guest, its feedback sent to them; false
|
||||
/// when it failed (or the guest is gone).
|
||||
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
self.run_guest_command_told(c).is_some_and(|(ok, _)| ok)
|
||||
}
|
||||
|
||||
/// `run_guest_command`, with the lines it sent the guest; None when the
|
||||
/// guest is gone.
|
||||
pub(super) fn run_guest_command_told(
|
||||
&mut self,
|
||||
c: GuestCommand,
|
||||
) -> Option<(bool, Vec<horizon_client::text::Text>)> {
|
||||
let mut host = self.lan.take()?;
|
||||
let Some(swap) = self.enter_guest(&mut host, c.conn, self.dim) else {
|
||||
self.lan = Some(host);
|
||||
return false;
|
||||
return None;
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.chat_capture = Some(Vec::new());
|
||||
@@ -3536,7 +3698,7 @@ impl Game<'_> {
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
for line in &lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
@@ -3548,7 +3710,7 @@ impl Game<'_> {
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
ok
|
||||
Some((ok, lines))
|
||||
}
|
||||
|
||||
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
|
||||
@@ -3653,8 +3815,31 @@ impl Game<'_> {
|
||||
let own_banned_ips = std::mem::replace(&mut reg.banned_ips, banned_ips);
|
||||
let own_whitelisted = std::mem::replace(&mut reg.whitelisted, whitelisted);
|
||||
let parse = d.parse(command, &self.ui.registries);
|
||||
let ((start, end), mut suggestions) =
|
||||
let ((mut start, mut end), mut suggestions) =
|
||||
d.suggestions(&parse, command.len(), &self.ui.registries);
|
||||
let (label, rest) = command.split_once(' ').unwrap_or((command, ""));
|
||||
let arguments: Vec<String> = if command.contains(' ') {
|
||||
rest.split(' ').map(str::to_owned).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
// A mod's command, which vanilla's tree doesn't know: the word being typed.
|
||||
if let Some(typed) = arguments.last()
|
||||
&& Self::native_command_level(host, command).is_some_and(|l| l <= permission)
|
||||
{
|
||||
(start, end) = (command.len() - typed.len(), command.len());
|
||||
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
|
||||
if super::native_server::completes_player_name(
|
||||
&host.native_usages,
|
||||
permission,
|
||||
label,
|
||||
&words,
|
||||
) {
|
||||
let mut names = horizon_client::command::SuggestionsBuilder::new(command, start);
|
||||
names.suggest_matching(host.hub.guests.iter().map(|g| g.name.as_str()));
|
||||
suggestions.extend(names.out);
|
||||
}
|
||||
}
|
||||
self.native_tab_complete(
|
||||
player_uuid,
|
||||
&player_name,
|
||||
@@ -3662,6 +3847,14 @@ impl Game<'_> {
|
||||
[start, end],
|
||||
&mut suggestions,
|
||||
);
|
||||
self.native_tab_complete_v2(
|
||||
player_uuid,
|
||||
&player_name,
|
||||
label,
|
||||
&arguments,
|
||||
[start, end],
|
||||
&mut suggestions,
|
||||
);
|
||||
let reg = &mut self.ui.registries;
|
||||
reg.permission = level;
|
||||
reg.players = own_players;
|
||||
@@ -3693,7 +3886,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
pub(super) fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -3737,11 +3930,11 @@ impl Game<'_> {
|
||||
.respawn_elsewhere
|
||||
.as_ref()
|
||||
.map(RespawnConfig::from_saved);
|
||||
let elsewhere = own.filter(|_| server);
|
||||
let mut elsewhere = own.filter(|_| server);
|
||||
// In the window's world a guest has no level of its own to wait in:
|
||||
// its bed is resolved at once when its chunk is there, else it
|
||||
// comes back on the bed.
|
||||
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
|
||||
let mut in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
|
||||
let spawn = in_frame_bed.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
@@ -3749,14 +3942,34 @@ impl Game<'_> {
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
|
||||
let spawn = elsewhere.map_or(spawn, |c| {
|
||||
let mut level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
|
||||
let mut spawn = elsewhere.map_or(spawn, |c| {
|
||||
[
|
||||
f64::from(c.pos[0]) + 0.5,
|
||||
f64::from(c.pos[1]),
|
||||
f64::from(c.pos[2]) + 0.5,
|
||||
]
|
||||
});
|
||||
// PlayerRespawnPositionEvent: a point the mods choose is taken as it is.
|
||||
let (mut dimension, mut at, mut rotation) = (level.id().to_owned(), spawn, [0.0f32; 2]);
|
||||
let (uuid, name) = (host.hub.guests[i].uuid, host.hub.guests[i].name.clone());
|
||||
let [yaw, pitch] = &mut rotation;
|
||||
self.native_player_respawn_position(uuid, &name, &mut dimension, &mut at, yaw, pitch);
|
||||
let known = super::Dim::from_id(&dimension).id() == dimension;
|
||||
let moved = at != spawn && at.iter().all(|v| v.is_finite());
|
||||
let chosen = (known && dimension != level.id()) || moved;
|
||||
if chosen {
|
||||
(elsewhere, in_frame_bed) = (None, None);
|
||||
if known && server {
|
||||
level = super::Dim::from_id(&dimension);
|
||||
}
|
||||
if moved {
|
||||
spawn = at;
|
||||
}
|
||||
}
|
||||
if !rotation.iter().all(|v| v.is_finite()) {
|
||||
rotation = [0.0; 2];
|
||||
}
|
||||
let back = self.dim;
|
||||
let left = super::Dim::from_id(&host.hub.guests[i].dimension);
|
||||
let away = server && host.hub.guests[i].dimension != level.id();
|
||||
@@ -3812,7 +4025,7 @@ impl Game<'_> {
|
||||
border: &self.border,
|
||||
streaming: Some(&self.streaming),
|
||||
};
|
||||
host.hub.respawn(i, spawn, &ctx);
|
||||
host.hub.respawn(i, spawn, rotation, &ctx);
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.level.hardcore {
|
||||
@@ -3831,7 +4044,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
|
||||
} else if !chosen && in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
|
||||
// TeleportTransition.createDefault: no point of its own, so the
|
||||
// world spawn's free column once its chunks have loaded.
|
||||
self.world_spawn_trip(conn);
|
||||
@@ -4047,6 +4260,30 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_player_logging_out` for guest `conn`, who has left but is still in the hub
|
||||
/// and in their level (`HostEvent::Left`): the hub goes back into `self.lan` for the
|
||||
/// hook, so its helpers and commands reach them, and a death it caused (`kill`) is
|
||||
/// died at once, as they get no tick to die in. None when a hook's command closed the hub.
|
||||
pub(super) fn tell_mods_logging_out(&mut self, host: LanHost, conn: u32) -> Option<LanHost> {
|
||||
let Some((uuid, name, alive)) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.map(|g| (g.uuid, g.name.clone(), g.health > 0.0 && !g.dead))
|
||||
else {
|
||||
return Some(host);
|
||||
};
|
||||
self.lan = Some(host);
|
||||
self.native_player_logging_out(uuid, &name);
|
||||
let mut host = self.lan.take()?;
|
||||
if alive
|
||||
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
|
||||
&& host.hub.guests[gi].health <= 0.0
|
||||
{
|
||||
self.guest_die(&mut host, gi, conn);
|
||||
}
|
||||
Some(host)
|
||||
}
|
||||
|
||||
/// `ServerPlayer.die` for a guest whose health has run out: the death
|
||||
/// message, the loot and experience, the death screen.
|
||||
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
|
||||
@@ -5346,9 +5583,10 @@ impl Game<'_> {
|
||||
let Some(item) = self.dropped.iter().find(|d| d.id == item_id) else {
|
||||
continue;
|
||||
};
|
||||
let stack = item.stack.clone();
|
||||
let refused =
|
||||
self.as_guest(host, conn, |g| g.native_pickup_refused(&stack)) == Some(true);
|
||||
let (stack, thrower, at) = (item.stack.clone(), item.thrower, item.position);
|
||||
let refused = self
|
||||
.as_guest(host, conn, |g| g.native_pickup_refused(&stack, thrower, at))
|
||||
== Some(true);
|
||||
// The hook can remove or change any item entity.
|
||||
let Some(i) = self.dropped.iter().position(|d| d.id == item_id) else {
|
||||
continue;
|
||||
@@ -5711,10 +5949,10 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// A use the server ran swung the guest's hand: their own client is told
|
||||
/// too (`swing(hand, true)` for a SwingSource.SERVER result). Horizon's
|
||||
/// client does not run uses itself, so it does not predict the swing; a
|
||||
/// client that did (a block placed) is still in the first half of its
|
||||
/// swing and ignores it (`SwingState.startIfAble`).
|
||||
/// too (`swing(hand, true)` for a result that is not SwingSource.PREDICTED).
|
||||
/// A predicted swing (a block placed, a block's own SUCCESS) is the
|
||||
/// client's own: sent back, it arrived after the first half of that swing
|
||||
/// on a slow connection and swung the arm again (#9887).
|
||||
fn lan_swing_back(&mut self, host: &mut LanHost, conn: u32, swung: Option<bool>) {
|
||||
if swung != Some(true) {
|
||||
return;
|
||||
@@ -6208,6 +6446,7 @@ mod respawn_world_point_tests {
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[0.5, 100.0, 0.5],
|
||||
@@ -6260,4 +6499,283 @@ mod respawn_world_point_tests {
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
|
||||
/// A mod's health 0 for a guest dies as `/kill` does: the death message
|
||||
/// and the death screen, not only a zero in the record.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mods_zero_health_kills_a_guest_as_kill_does() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let scratch = std::env::temp_dir().join(format!(
|
||||
"horizon-status-death-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&scratch).unwrap();
|
||||
game.world_root = scratch.clone();
|
||||
game.level.world_spawn = Some([0, 100, 0]);
|
||||
game.spawn = Some(Vec3::new(0.5, 100.0, 0.5));
|
||||
game.lan_host(
|
||||
super::super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "status-death-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 331,
|
||||
name: "StatusGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[0.5, 100.0, 0.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
// Inside the player's own turn, as a tick hook sets it.
|
||||
let set = game.as_guest(&mut host, 2, |g| {
|
||||
g.native_set_player_status(331, Some(0.0), None, None)
|
||||
});
|
||||
assert_eq!(set, Some(true));
|
||||
assert_eq!(host.hub.guests[0].health, 0.0);
|
||||
game.guest_die(&mut host, 0, 2);
|
||||
assert!(host.hub.guests[0].dead, "health 0 must kill");
|
||||
drop(host);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod swing_echo_tests {
|
||||
use super::ItemStack;
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{self, Clientbound, Serverbound},
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
/// handleUseItemOn swings the player's arm, but sends the swing to the
|
||||
/// player's own client only when the client did not predict it: a block
|
||||
/// placed (SUCCESS) is the client's own swing, a bed's use
|
||||
/// (SUCCESS_SERVER) is not (#9887).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn only_a_server_only_use_swings_back_to_the_player() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::env::temp_dir().join(format!("horizon-swing-echo-unit-{}", std::process::id())),
|
||||
);
|
||||
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();
|
||||
game.set_allow_commands(true);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("setblock 6 200 4 red_bed[facing=north,part=foot]"));
|
||||
assert!(game.run_command("setblock 6 200 3 red_bed[facing=north,part=head]"));
|
||||
game.lan_host(
|
||||
super::super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "swing-echo-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 331,
|
||||
name: "SwingGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[4.5, 200.0, 4.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.inventory[0] = Some(ItemStack::new("minecraft:stone", 16));
|
||||
guest.selected = 0;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let id = host.hub.guests[0].entity_id;
|
||||
let swings_back = || {
|
||||
let mut n = 0;
|
||||
for out in rx.try_iter() {
|
||||
let bodies = match out {
|
||||
Out::Packet(body) => vec![body],
|
||||
Out::Packets(bodies) => bodies,
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
};
|
||||
for body in bodies {
|
||||
let mut r = horizon_protocol::buf::Reader::new(&body);
|
||||
let packet_id = r.varint().unwrap();
|
||||
if let Ok(Clientbound::SwingAnimation(p)) =
|
||||
Clientbound::decode(packet_id, r.rest())
|
||||
&& p.id == id
|
||||
{
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
};
|
||||
let use_on = |pos: [i32; 3], cursor: [f32; 3], sequence: i32| {
|
||||
Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos,
|
||||
face: 1,
|
||||
cursor,
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
};
|
||||
// BlockItem.place: SUCCESS, the client's own swing.
|
||||
game.lan_guest_packet(&mut host, 2, use_on([4, 199, 6], [0.5, 1.0, 0.5], 1));
|
||||
assert_eq!(game.world.block(4, 200, 6).0, stone, "the stone is placed");
|
||||
assert_eq!(swings_back(), 0, "a placement is not swung back");
|
||||
// The swing ends before the next use can start one.
|
||||
for _ in 0..8 {
|
||||
game.lan_swings(&mut host);
|
||||
}
|
||||
host.hub.guests[0].selected = 1;
|
||||
// BedBlock.useWithoutItem: SUCCESS_SERVER, the server's swing only.
|
||||
game.lan_guest_packet(&mut host, 2, use_on([6, 200, 4], [0.5, 0.5625, 0.5], 2));
|
||||
assert_eq!(swings_back(), 1, "a bed's use is swung back");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::lan::{
|
||||
entity::{EntityState, Riding, States},
|
||||
guest::Guest,
|
||||
hub::Hub,
|
||||
playerdata::ints_uuid,
|
||||
};
|
||||
@@ -157,10 +158,17 @@ impl Game<'_> {
|
||||
|
||||
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
|
||||
/// mobs on mobs.
|
||||
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
|
||||
pub(super) fn lan_riding(&self, guests: &[Guest], riding: &mut Riding) {
|
||||
riding.clear();
|
||||
// The server's players in their boats and minecarts.
|
||||
for &(vehicle, guest) in guests {
|
||||
// The server's players in their boats and minecarts, in the order they
|
||||
// got in: the first one drives (`getControllingPassenger`), and its
|
||||
// client is the one that moves the vehicle.
|
||||
let mut seated: Vec<(u64, u32, i32)> = guests
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle_order, g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
seated.sort_by_key(|&(order, ..)| order);
|
||||
for (_, vehicle, guest) in seated {
|
||||
riding.add(vehicle as i32, guest);
|
||||
}
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
@@ -462,7 +470,7 @@ impl Game<'_> {
|
||||
/// The synched data of a text, block or item display kept unsimulated,
|
||||
/// from its compound (`Display.readAdditionalSaveData`), with the
|
||||
/// defaults to send when a value goes back to one.
|
||||
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
|
||||
pub(super) fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
|
||||
use horizon_client::text_display::{display_data, text_display_data};
|
||||
let empty = Tag::Compound(Default::default());
|
||||
let (meta, defaults) = match e.kind.as_str() {
|
||||
|
||||
@@ -159,6 +159,26 @@ fn guest_view(mut v: PlayerView, uuid: u128, moved: [f64; 3]) -> PlayerView {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// How mobs see the guest `conn`, as `lan_guest_views` gives each: None
|
||||
/// for no such guest in play here, or while the hub is taken out.
|
||||
pub(super) fn lan_guest_view(&mut self, conn: u32) -> Option<PlayerView> {
|
||||
let mut host = self.lan.take()?;
|
||||
let found = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn && host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| {
|
||||
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
|
||||
(g.uuid, moved)
|
||||
});
|
||||
let view = found.and_then(|(uuid, moved)| {
|
||||
let v = self.as_guest(&mut host, conn, |g| g.player_view())??;
|
||||
Some(guest_view(v, uuid, moved))
|
||||
});
|
||||
self.lan = Some(host);
|
||||
view
|
||||
}
|
||||
/// How mobs see each guest in play (`player_view` with the guest swapped
|
||||
/// in).
|
||||
pub(super) fn lan_guest_views(&mut self) -> GuestViews {
|
||||
|
||||
@@ -978,6 +978,7 @@ mod tests {
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: format!("127.0.0.1:{}", 25565 + conn).parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
1,
|
||||
at,
|
||||
|
||||
@@ -30,6 +30,42 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The player-versus-player hit being landed, which `native_player_hurt_by_player`
|
||||
/// hears at the victim's `hurtServer` (`native_hooks::hurt_server`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct PvpHit {
|
||||
pub(super) attacker: u128,
|
||||
pub(super) attacker_entity: i32,
|
||||
pub(super) victim: u128,
|
||||
pub(super) victim_entity: i32,
|
||||
/// `melee`, `sweep` or the projectile's entity type.
|
||||
pub(super) source: &'static str,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PVP_HIT: std::cell::Cell<Option<PvpHit>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The hit being landed on the player `victim_entity`, if one is.
|
||||
pub(super) fn pvp_hit(victim_entity: i32) -> Option<PvpHit> {
|
||||
PVP_HIT
|
||||
.get()
|
||||
.filter(|hit| hit.victim_entity == victim_entity)
|
||||
}
|
||||
|
||||
/// Names `hit` as the one being landed until it drops.
|
||||
struct PvpScope(Option<PvpHit>);
|
||||
|
||||
impl Drop for PvpScope {
|
||||
fn drop(&mut self) {
|
||||
PVP_HIT.set(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
|
||||
PvpScope(PVP_HIT.replace(hit))
|
||||
}
|
||||
|
||||
/// Who a player's attack lands on: a player by connection.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Victim {
|
||||
@@ -259,10 +295,25 @@ impl Game<'_> {
|
||||
at,
|
||||
};
|
||||
let shooter = self.player_attacker(&host, p.guest);
|
||||
let spectral = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.spectral || &*a.pickup_item.item == "minecraft:spectral_arrow");
|
||||
let source = if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
p.kind.id()
|
||||
};
|
||||
let hit = self.pvp_hit_of(&host, p.guest, victim, source);
|
||||
let zero = matches!(p.kind, ProjectileKind::Snowball | ProjectileKind::Egg);
|
||||
let result = match victim {
|
||||
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
|
||||
g.combat.refuse_player_hit = refused;
|
||||
g.combat.projectile_attacker = shooter;
|
||||
let _hit = pvp_scope(hit);
|
||||
if zero && !refused {
|
||||
g.pvp_zero_hurt(hit, "minecraft:thrown");
|
||||
}
|
||||
let r = g.projectile_hit(p, on_player);
|
||||
g.combat.refuse_player_hit = false;
|
||||
g.combat.projectile_attacker = None;
|
||||
@@ -273,6 +324,58 @@ impl Game<'_> {
|
||||
result.unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
/// The hit of `attacker` (a guest; none: this game's player) on `victim` as
|
||||
/// `native_player_hurt_by_player` names it, none when either has gone.
|
||||
fn pvp_hit_of(
|
||||
&mut self,
|
||||
host: &LanHost,
|
||||
attacker: Option<u32>,
|
||||
victim: Victim,
|
||||
source: &'static str,
|
||||
) -> Option<PvpHit> {
|
||||
let Victim::Guest(conn) = victim;
|
||||
let (victim, victim_entity) = host.hub.guest(conn).map(|g| (g.uuid, g.entity_id))?;
|
||||
let (attacker, attacker_entity) = match attacker {
|
||||
Some(conn) => host.hub.guest(conn).map(|g| (g.uuid, g.entity_id))?,
|
||||
None => (self.native_acting_id(), self.player.as_ref()?.entity_id),
|
||||
};
|
||||
Some(PvpHit {
|
||||
attacker,
|
||||
attacker_entity,
|
||||
victim,
|
||||
victim_entity,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// `Player.hurtServer` of a snowball's or egg's zero damage on the player in place: past
|
||||
/// its guards (not creative, a spectator or dead) the mods hear it, then it stops on zero.
|
||||
fn pvp_zero_hurt(&mut self, hit: Option<PvpHit>, damage_type: &str) {
|
||||
let Some(hit) = hit else {
|
||||
return;
|
||||
};
|
||||
if self.creative
|
||||
|| self.spectator()
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.health.dead_or_dying())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (mut amount, mut canceled) = (0.0, false);
|
||||
Self::native_player_hurt_by_player(
|
||||
hit.attacker,
|
||||
hit.attacker_entity,
|
||||
hit.victim,
|
||||
hit.victim_entity,
|
||||
hit.source,
|
||||
damage_type,
|
||||
&mut amount,
|
||||
&mut canceled,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn player_attacker(&self, host: &LanHost, conn: Option<u32>) -> Option<(u32, Text)> {
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
@@ -314,6 +417,7 @@ impl Game<'_> {
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
|
||||
});
|
||||
let attacker_id = self.player.as_ref().map_or(0, |p| p.entity_id);
|
||||
let hit = self.pvp_hit_of(host, s.attacker, victim, "melee");
|
||||
let land = |g: &mut Self| {
|
||||
let _attacker =
|
||||
g.knockback_scope(attacker_note.as_ref().map_or(attacker_id, |(id, _)| *id));
|
||||
@@ -321,7 +425,10 @@ impl Game<'_> {
|
||||
if let Some((id, name)) = attacker_note.clone() {
|
||||
g.note_player_attacker("minecraft:player_attack", id as u32, name, s.item.clone());
|
||||
}
|
||||
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
|
||||
let hurt = {
|
||||
let _hit = pvp_scope(hit);
|
||||
g.mob_hits_player(s.amount, s.from, "minecraft:player_attack")
|
||||
};
|
||||
g.combat.player_attacker = None;
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
if hurt && strength > 0.0 {
|
||||
@@ -537,8 +644,12 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let hit = self.pvp_hit_of(host, sweep.attacker, victim, "sweep");
|
||||
let Victim::Guest(conn) = victim;
|
||||
self.guest_struck_by(host, conn, land);
|
||||
self.guest_struck_by(host, conn, |g| {
|
||||
let _hit = pvp_scope(hit);
|
||||
land(g)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,13 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
|
||||
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
|
||||
}
|
||||
|
||||
/// A boat or minecart a server player rode, followed for
|
||||
/// `native_vehicle_moved`.
|
||||
/// A boat, minecart or mount a server player rode, followed for
|
||||
/// `native_vehicle_moved` (a mount only for its v2).
|
||||
pub(super) struct WatchedVehicle {
|
||||
id: u32,
|
||||
dim: Dim,
|
||||
/// A mob (a horse, a pig), which v1 does not hear.
|
||||
mount: bool,
|
||||
/// The server player it carries now.
|
||||
rider: Option<u128>,
|
||||
/// Where the hook last heard of it.
|
||||
@@ -52,18 +54,25 @@ pub(super) struct WatchedVehicle {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Where the boat or minecart `id` of this level is.
|
||||
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
|
||||
/// Where the boat, minecart or mount `id` of this level is, and whether
|
||||
/// it is a mount.
|
||||
fn watched_vehicle_position(&self, id: u32) -> Option<([f64; 3], bool)> {
|
||||
let at = |v: Vec3| [v.x, v.y, v.z];
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| at(b.position))
|
||||
.map(|b| (at(b.position), false))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| at(c.position))
|
||||
.map(|c| (at(c.position), false))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && !m.removed)
|
||||
.map(|m| (at(m.position), true))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,7 +91,7 @@ impl Game<'_> {
|
||||
}
|
||||
let dim = self.dim;
|
||||
for &(id, _, uuid) in &riders {
|
||||
let Some(at) = self.watched_vehicle_position(id) else {
|
||||
let Some((at, mount)) = self.watched_vehicle_position(id) else {
|
||||
continue;
|
||||
};
|
||||
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
|
||||
@@ -90,6 +99,7 @@ impl Game<'_> {
|
||||
None => self.watched_vehicles.push(WatchedVehicle {
|
||||
id,
|
||||
dim,
|
||||
mount,
|
||||
rider: Some(uuid),
|
||||
at,
|
||||
still: 0,
|
||||
@@ -97,7 +107,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
let mut kept = Vec::new();
|
||||
// (rider's connection and UUID, vehicle type, from, to)
|
||||
// (rider's connection and UUID, vehicle, type, mount, from, to)
|
||||
let mut moved = Vec::new();
|
||||
for mut w in std::mem::take(&mut self.watched_vehicles) {
|
||||
// Another level's vehicle is told of on that level's tick.
|
||||
@@ -106,7 +116,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
// Broken, or gone from this level.
|
||||
let Some(now) = self.watched_vehicle_position(w.id) else {
|
||||
let Some((now, _)) = self.watched_vehicle_position(w.id) else {
|
||||
continue;
|
||||
};
|
||||
let rider = riders.iter().find(|r| r.0 == w.id);
|
||||
@@ -118,7 +128,7 @@ impl Game<'_> {
|
||||
self.level_entity(w.id as i32)
|
||||
.unwrap_or(super::commands::Target::Boat(w.id)),
|
||||
);
|
||||
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
|
||||
moved.push((rider.map(|r| (r.1, r.2)), w.id, kind, w.mount, from, now));
|
||||
} else {
|
||||
w.still += 1;
|
||||
if w.rider.is_none() && w.still > 200 {
|
||||
@@ -130,21 +140,66 @@ impl Game<'_> {
|
||||
self.watched_vehicles = kept;
|
||||
// The rider's state is swapped in for the call, so the helpers reach
|
||||
// them; a vehicle nobody rides is told of with nobody swapped in.
|
||||
for (rider, kind, from, to) in moved {
|
||||
let Some((conn, uuid)) = rider else {
|
||||
self.native_vehicle_moved(None, &kind, from, to);
|
||||
continue;
|
||||
for (rider, id, kind, mount, from, to) in moved {
|
||||
let (mut dest, mut cancel) = (to, false);
|
||||
let mut hook = |game: &mut Self| {
|
||||
if !mount {
|
||||
game.native_vehicle_moved(rider.map(|r| r.1), &kind, from, to);
|
||||
}
|
||||
let player = rider.map(|r| r.1);
|
||||
game.native_vehicle_moved_v2(
|
||||
player,
|
||||
dim.id(),
|
||||
id,
|
||||
&kind,
|
||||
from,
|
||||
&mut dest,
|
||||
&mut cancel,
|
||||
);
|
||||
};
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
continue;
|
||||
};
|
||||
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(&mut host, conn, hook);
|
||||
match rider {
|
||||
None => hook(self),
|
||||
Some((conn, _)) => {
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
continue;
|
||||
};
|
||||
if self.lan_acting == Some(conn) {
|
||||
hook(self);
|
||||
} else {
|
||||
self.as_guest(&mut host, conn, hook);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
self.native_vehicle_move_answer(id, from, to, dest, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_vehicle_moved_v2`'s answer: a canceled move puts the vehicle
|
||||
/// back at `from`, a changed `to` puts it there, with its riders
|
||||
/// (`native_teleport_vehicle`), and the watch hears it from there.
|
||||
fn native_vehicle_move_answer(
|
||||
&mut self,
|
||||
id: u32,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
dest: [f64; 3],
|
||||
cancel: bool,
|
||||
) {
|
||||
let at = if cancel {
|
||||
from
|
||||
} else if dest != to && dest.iter().all(|v| v.is_finite()) {
|
||||
dest
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if !self.native_teleport_vehicle(self.dim.id(), id, at) {
|
||||
return;
|
||||
}
|
||||
if let Some((now, _)) = self.watched_vehicle_position(id)
|
||||
&& let Some(w) = self.watched_vehicles.iter_mut().find(|w| w.id == id)
|
||||
{
|
||||
w.at = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +442,13 @@ impl Game<'_> {
|
||||
if g.awaiting_teleport.is_some() || !g.client_loaded() {
|
||||
return;
|
||||
}
|
||||
// Only the vehicle's controlling passenger moves it: the move of a
|
||||
// player in a boat's second seat is ignored (#11541).
|
||||
if g.vehicle
|
||||
.is_some_and(|v| !self.guest_controls_vehicle(host, conn, v))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let owner = host.hub.singleplayer_owner == Some(g.uuid);
|
||||
let (vehicle, good) = (g.vehicle, g.vehicle_good);
|
||||
let back = |host: &mut LanHost, position: Vec3, yaw: f32| {
|
||||
@@ -563,3 +625,164 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod controller_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::{configuration::ClientInformation, play};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn guest(conn: u32, uuid: u128) -> Guest {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut g = Guest::new(
|
||||
Joined {
|
||||
conn,
|
||||
uuid,
|
||||
name: format!("Rider{conn}"),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
1000 + conn as i32,
|
||||
[4.5, 200.0, 4.5],
|
||||
2,
|
||||
);
|
||||
g.dimension = "minecraft:overworld".into();
|
||||
g.placed = true;
|
||||
g.loaded = true;
|
||||
// PlayerLoaded came in: its moves count.
|
||||
g.load_timeout = 0;
|
||||
g
|
||||
}
|
||||
|
||||
/// Two players in one boat: the one that got in first drives it
|
||||
/// (`getControllingPassenger`), whichever joined the server first. Its
|
||||
/// client hears it sits first, and only its moves count; the second
|
||||
/// seat's client, which still sends where it sees the boat, used to put
|
||||
/// the boat back every tick (#11541).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_first_rider_in_drives_the_boat() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::env::temp_dir().join(format!("horizon-boat-driver-unit-{}", std::process::id())),
|
||||
);
|
||||
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();
|
||||
game.set_allow_commands(true);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
assert!(game.run_command("summon minecraft:oak_boat 4.5 200 4.5"));
|
||||
let boat = game.boats[0].id;
|
||||
let start = game.boats[0].position;
|
||||
game.lan_host(
|
||||
super::super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "boat-driver-test".into(), 3)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 3,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut host = game.lan.take().unwrap();
|
||||
// The second seat's player joined the server first; the driver got
|
||||
// into the boat first.
|
||||
let (mut passenger, mut driver) = (guest(2, 21), guest(3, 22));
|
||||
(driver.vehicle, driver.vehicle_order) = (Some(boat), 1);
|
||||
(passenger.vehicle, passenger.vehicle_order) = (Some(boat), 2);
|
||||
let (passenger_id, driver_id) = (passenger.entity_id, driver.entity_id);
|
||||
host.hub.guests.push(passenger);
|
||||
host.hub.guests.push(driver);
|
||||
|
||||
game.lan_riding(&host.hub.guests, &mut host.hub.riding);
|
||||
assert_eq!(
|
||||
host.hub.riding.passengers(boat as i32),
|
||||
[driver_id, passenger_id],
|
||||
"the driver sits first"
|
||||
);
|
||||
|
||||
let move_to = |x: f64| play::MoveVehicleRequest {
|
||||
position: [x, start.y, start.z],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
on_ground: true,
|
||||
};
|
||||
game.guest_move_vehicle(&mut host, 2, &move_to(start.x + 0.3));
|
||||
assert_eq!(
|
||||
game.boats[0].position, start,
|
||||
"the second seat moves nothing"
|
||||
);
|
||||
game.guest_move_vehicle(&mut host, 3, &move_to(start.x + 0.3));
|
||||
assert!(
|
||||
(game.boats[0].position.x - (start.x + 0.3)).abs() < 1.0e-9,
|
||||
"the driver moves the boat: {:?}",
|
||||
game.boats[0].position
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,6 @@ impl Levels {
|
||||
self.parked.get(&dim).map(|level| &level.world)
|
||||
}
|
||||
|
||||
/// The worlds of the levels not in place.
|
||||
/// A parked level's world border.
|
||||
pub(super) fn parked_border(
|
||||
&self,
|
||||
@@ -153,6 +152,7 @@ impl Levels {
|
||||
self.parked.get(&dim).map(|level| &level.border)
|
||||
}
|
||||
|
||||
/// The worlds of the levels not in place.
|
||||
pub(super) fn parked_worlds(&self) -> impl Iterator<Item = &World> {
|
||||
self.parked.values().map(|level| &level.world)
|
||||
}
|
||||
@@ -162,6 +162,11 @@ impl Levels {
|
||||
self.parked.get_mut(&dim).map(|level| &mut level.world)
|
||||
}
|
||||
|
||||
/// The streaming of the level not in place, in `dim`, to change.
|
||||
pub(super) fn parked_streaming_mut(&mut self, dim: Dim) -> Option<&mut Streaming> {
|
||||
self.parked.get_mut(&dim).map(|level| &mut level.streaming)
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
@@ -413,6 +418,8 @@ impl Game<'_> {
|
||||
if opened {
|
||||
// The new streaming's radius and meshing options.
|
||||
self.apply_settings();
|
||||
// A mod's tickets held for the level while it was closed.
|
||||
self.native_chunk_tickets_opened();
|
||||
self.native_level_opened(target);
|
||||
}
|
||||
}
|
||||
@@ -578,6 +585,10 @@ impl Game<'_> {
|
||||
// `ServerTickEnd`: an errored tick or an idle-close failure (at
|
||||
// the `?` above) never reaches this site.
|
||||
self.native_lifecycle_server_tick_end(paused);
|
||||
// The packets counted for `native_server_packets` (only while a mod counts them).
|
||||
if server {
|
||||
self.diagnostics_packets_tick();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -628,6 +639,7 @@ impl Game<'_> {
|
||||
slowest_steps(¬es)
|
||||
);
|
||||
}
|
||||
self.native_server_tick_timed(wall, cpu, &spans);
|
||||
record(wall, cpu, spans, in_tick);
|
||||
result
|
||||
}
|
||||
@@ -702,6 +714,8 @@ impl Game<'_> {
|
||||
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|
||||
|| self.player_bed_levels().any(|b| b == *d)
|
||||
|| forced
|
||||
// A native mod's tickets for a level not open yet (`native_force_chunk_in`).
|
||||
|| self.mod_chunk_tickets.get(d).is_some_and(|l| !l.is_empty())
|
||||
// A game test runs in its own level, players or not.
|
||||
|| self.game_tests.has_tests_in(*d)
|
||||
|| self.pregen_level() == Some(*d)
|
||||
|
||||
@@ -300,7 +300,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
|
||||
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
|
||||
/// wherever `BaseFireBlock.getState` fire can survive, when a player is
|
||||
/// within the fire-spread radius. Returns how many
|
||||
/// blocks it set on fire.
|
||||
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
|
||||
let mut set = 0;
|
||||
@@ -309,6 +310,14 @@ impl Game<'_> {
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
// ServerLevel.canSpreadFireAround at the bolt: no fire at all, and no
|
||||
// draws for the extra sources, away from every player (#11376).
|
||||
let origin = BlockPos::new(base[0], base[1], base[2]);
|
||||
if !self.with_level(|_, level, _| {
|
||||
horizon_world::level::LevelAccess::can_spread_fire_around(level, origin)
|
||||
}) {
|
||||
return 0;
|
||||
}
|
||||
let mut spots = vec![base];
|
||||
for _ in 0..extra {
|
||||
let r = &mut self.world.random;
|
||||
@@ -756,6 +765,52 @@ mod tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `LightningBolt.spawnFire` places nothing, and draws no extra sources,
|
||||
/// unless `canSpreadFireAround` passes at the bolt (#11376).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_bolt_away_from_players_starts_no_fire() {
|
||||
use horizon_client::gamerules::Value;
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
let air = game.res.facts().air;
|
||||
game.world.set(8, 64, 8, stone).unwrap();
|
||||
let bolt = [8.5, 65.0, 8.5];
|
||||
// (radius, player's x): fires expected at the strike.
|
||||
for (radius, player_x, fires) in [
|
||||
(128, 12.5, true),
|
||||
(1, 12.5, false),
|
||||
(0, 8.5, false),
|
||||
(-1, 1000.0, true),
|
||||
] {
|
||||
game.world.set(8, 65, 8, air).unwrap();
|
||||
game.ui
|
||||
.rules
|
||||
.set("fire_spread_radius_around_player", Value::Int(radius));
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(
|
||||
game.motion,
|
||||
Vec3::new(player_x, 65.0, 8.5),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let random = game.world.random.clone();
|
||||
let set = game.bolt_fire(bolt, 4);
|
||||
let burning = game.world.block(8, 65, 8) != air;
|
||||
assert_eq!((set > 0, burning), (fires, fires), "radius {radius}");
|
||||
if !fires {
|
||||
// No offset draws either.
|
||||
let mut before = random;
|
||||
assert_eq!(game.world.random.next_long(), before.next_long());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_box_reaches_15_around_and_down_and_21_up() {
|
||||
let bolt = [0.0, 64.0, 0.0];
|
||||
|
||||
@@ -280,12 +280,27 @@ impl Game<'_> {
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// Takes the absorbed tenths a projectile's hit left on the mobs; the shooter gets them
|
||||
/// as `damage_dealt_absorbed` when it is the player (`LivingEntity.actuallyHurt`).
|
||||
pub(super) fn settle_dealt_absorbed(&mut self, by_player: bool) {
|
||||
let absorbed: i32 = self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.map(|m| std::mem::take(&mut m.dealt_absorbed))
|
||||
.fold(0, i32::saturating_add);
|
||||
if by_player && absorbed > 0 {
|
||||
self.award_custom_stat("damage_dealt_absorbed", absorbed);
|
||||
}
|
||||
}
|
||||
/// `Player.itemAttackInteraction` after the player's hit landed on the mob
|
||||
/// at `index`: the item's `hurtEnemy` (the "used" statistic of a weapon),
|
||||
/// the post-attack enchantments, then `postHurtEnemy` with the weapon's
|
||||
/// wear.
|
||||
pub(super) fn weapon_hit(&mut self, index: usize) {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
// An empty hand still meets the victim's own post-attack
|
||||
// enchantments (Thorns).
|
||||
self.post_attack(index);
|
||||
return;
|
||||
};
|
||||
let weapon = self
|
||||
|
||||
@@ -646,6 +646,7 @@ impl Game<'_> {
|
||||
let was_empty = c.is_empty(view);
|
||||
let max = c.max_stack(&stack);
|
||||
let mut success = false;
|
||||
let mut filled_empty = false;
|
||||
let Some(target) = self.slot_mut_at(view, slot) else {
|
||||
return (Some(stack), false);
|
||||
};
|
||||
@@ -663,6 +664,7 @@ impl Game<'_> {
|
||||
});
|
||||
stack.count = 0;
|
||||
success = true;
|
||||
filled_empty = true;
|
||||
}
|
||||
Some(current)
|
||||
// HopperBlockEntity.canMergeItems
|
||||
@@ -676,6 +678,17 @@ impl Game<'_> {
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
// tryMoveInItem's setItem on an empty slot: AbstractFurnaceBlockEntity.setItem restarts
|
||||
// the cooking for a new input (a merge only grows the stack, and keeps it).
|
||||
let recipes = world_recipes!(self);
|
||||
if filled_empty
|
||||
&& let Some((pos, 0)) = view.locate(slot)
|
||||
&& let Some(BlockEntity::Furnace(furnace)) = self.world.block_entities.get_mut(&pos)
|
||||
{
|
||||
use horizon_client::furnace::FurnaceCooking;
|
||||
let input = furnace.items[0].take();
|
||||
furnace.set_input(recipes, input);
|
||||
}
|
||||
if success && was_empty && view.kind == Kind::Hopper {
|
||||
let dest = view.parts[0].0;
|
||||
let from_ticked = from_hopper.and_then(|p| self.hoppers.get(&p).map(|h| h.ticked));
|
||||
|
||||
@@ -252,6 +252,21 @@ impl MapData {
|
||||
texture_dirty: true,
|
||||
}
|
||||
}
|
||||
/// The whole picture replaced by a mod (`native_paint_map`): saved with
|
||||
/// the maps and sent in full to everyone carrying it or seeing it framed,
|
||||
/// as `update_color` would over every pixel (`setColorsDirty`).
|
||||
pub(super) fn paint(&mut self, colors: &[u8]) {
|
||||
if self.colors.as_slice() == colors {
|
||||
return;
|
||||
}
|
||||
self.colors.clear();
|
||||
self.colors.extend_from_slice(colors);
|
||||
self.dirty = true;
|
||||
self.texture_dirty = true;
|
||||
for h in &mut self.holders {
|
||||
h.dirty = Some([0, 0, 127, 127]);
|
||||
}
|
||||
}
|
||||
fn update_color(&mut self, x: i32, y: i32, color: u8) -> bool {
|
||||
let i = (x + y * 128) as usize;
|
||||
if self.colors[i] == color {
|
||||
@@ -428,7 +443,7 @@ impl MapData {
|
||||
}
|
||||
/// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit`
|
||||
/// decorations count (`MapDecorationType.trackCount`).
|
||||
fn tracked_over_limit(&self, limit: usize) -> bool {
|
||||
pub(super) fn tracked_over_limit(&self, limit: usize) -> bool {
|
||||
self.decorations
|
||||
.iter()
|
||||
.filter(|d| tracks_count(d.sprite))
|
||||
|
||||
@@ -966,8 +966,12 @@ mod tests {
|
||||
living_fall: |_, _, _, _, _| {},
|
||||
living_jump: |_, _| {},
|
||||
register_goals: |mob| dispatch_register_goals(&MODS, mob),
|
||||
player_pre_login: |_, _, _, _| {},
|
||||
player_profile: |_, _, _| {},
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
log_line: |_, _, _, _| {},
|
||||
handshake: |_, _, _, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Where blocks' textures are in the block sprite arrays, for a client mod drawing blocks with the player's own
|
||||
//! textures (resource packs included) in its own pass: with `WorldDrawn::block_sprites`, the arrays themselves.
|
||||
//! Also every block state's facts and looks, and the resources' files, for a mod working on them off the main thread.
|
||||
use super::Game;
|
||||
use horizon_client::block_sprites::{BlockSprites, Lookup, SpriteRef};
|
||||
use horizon_client::block_states::BlockStates;
|
||||
use horizon_client::client_resources::ClientResources;
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
@@ -56,12 +59,30 @@ impl Game<'_> {
|
||||
pub(super) fn native_block_sprites_version(&self) -> u64 {
|
||||
self.pack.context.render.generation
|
||||
}
|
||||
/// For a client mod: every block state by its id, with its facts, what its model draws on each side and the
|
||||
/// texture behind each sprite, as the resources loaded now; a copy to keep and use on any thread. Ask again when
|
||||
/// `native_block_sprites_version` changes. API @API:block_appearance@.
|
||||
pub(super) fn native_block_states(&self) -> BlockStates {
|
||||
BlockStates::new(
|
||||
self.res.facts(),
|
||||
self.res.collision(),
|
||||
self.pack.context.clone(),
|
||||
self.native_sprite_split(),
|
||||
)
|
||||
}
|
||||
/// For a client mod: the files of the resources loaded now, resource packs' over vanilla's, to read textures
|
||||
/// and other resources on any thread. Ask again when `native_block_sprites_version` changes. API
|
||||
/// @API:block_appearance@.
|
||||
pub(super) fn native_client_resources(&self) -> ClientResources {
|
||||
ClientResources::new(self.options.resources.clone())
|
||||
}
|
||||
fn native_sprite_lookup(&self) -> Lookup<'_> {
|
||||
Lookup::new(&self.pack.context, self.native_sprite_split())
|
||||
}
|
||||
fn native_sprite_split(&self) -> u32 {
|
||||
// (no renderer, no arrays: every layer counts as array 0's)
|
||||
let split = self
|
||||
.renderer
|
||||
self.renderer
|
||||
.as_ref()
|
||||
.map_or(u32::MAX, |r| r.sprite_split());
|
||||
Lookup::new(&self.pack.context, split)
|
||||
.map_or(u32::MAX, |r| r.sprite_split())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Read-only chunk snapshots for native server mods (docs/native-mods.md,
|
||||
//! "Server hooks for plugin ports: world"): a web map or a backup tool takes
|
||||
//! a loaded chunk as it is now and reads it later, off the server thread,
|
||||
//! with the block map colours and biome tints to draw it.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::{biome::BiomeColors, blocks::MAP_COLORS, chunk::Chunk};
|
||||
use horizon_core::{biome::BiomeZoom, position::BlockPos};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A loaded chunk as it was when `native_chunk_snapshot` took it: its block
|
||||
/// states and biomes, shared with the level until the level changes them
|
||||
/// (copy on write), so taking one is cheap and holding it doesn't stop the
|
||||
/// level. It can be sent to another thread. Coordinates are the level's
|
||||
/// block coordinates.
|
||||
#[derive(Clone)]
|
||||
pub struct NativeChunkSnapshot {
|
||||
chunk: Arc<Chunk>,
|
||||
pos: [i32; 2],
|
||||
min_y: i32,
|
||||
top_y: i32,
|
||||
ceiling: bool,
|
||||
zoom: BiomeZoom,
|
||||
/// Air, cave air and void air (`BlockState.isAir`).
|
||||
air: [Option<BlockStateId>; 3],
|
||||
}
|
||||
|
||||
impl NativeChunkSnapshot {
|
||||
/// The chunk's position, `[x, z]` (block coordinates `>> 4`).
|
||||
pub fn chunk(&self) -> [i32; 2] {
|
||||
self.pos
|
||||
}
|
||||
/// The level's lowest block `y` and one above its highest
|
||||
/// (`getMinY`, `getMaxY` + 1).
|
||||
pub fn height(&self) -> (i32, i32) {
|
||||
(self.min_y, self.top_y)
|
||||
}
|
||||
/// Whether the level has a ceiling (`DimensionType.hasCeiling`: the Nether).
|
||||
pub fn has_ceiling(&self) -> bool {
|
||||
self.ceiling
|
||||
}
|
||||
/// The block state at `x, y, z` (`getBlockState`), as the state helpers
|
||||
/// take it (`native_state_block`); None outside this chunk or the level's
|
||||
/// height.
|
||||
pub fn block_state(&self, x: i32, y: i32, z: i32) -> Option<BlockStateId> {
|
||||
let inside =
|
||||
(x >> 4, z >> 4) == (self.pos[0], self.pos[1]) && (self.min_y..self.top_y).contains(&y);
|
||||
inside.then(|| self.chunk.block(x & 15, y, z & 15))
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the
|
||||
/// highest block that isn't air, the level's lowest `y` in an empty
|
||||
/// column. None outside this chunk.
|
||||
pub fn surface(&self, x: i32, z: i32) -> Option<i32> {
|
||||
let air = self.air;
|
||||
((x >> 4, z >> 4) == (self.pos[0], self.pos[1])).then(|| {
|
||||
self.chunk
|
||||
.world_surface(x & 15, z & 15, |v| air.contains(&Some(BlockStateId(v))))
|
||||
})
|
||||
}
|
||||
/// The biome of quart cell `qx, qy, qz` (block coordinates `>> 2`,
|
||||
/// `getNoiseBiome`), the `y` clamped to the level, as a biome id of
|
||||
/// `native_biome_name` and `native_biome_tint`. None for a cell outside
|
||||
/// this chunk.
|
||||
pub fn noise_biome(&self, qx: i32, qy: i32, qz: i32) -> Option<u16> {
|
||||
let (x, z) = (qx << 2, qz << 2);
|
||||
((x >> 4, z >> 4) == (self.pos[0], self.pos[1])).then(|| self.chunk.biome(x, qy << 2, z))
|
||||
}
|
||||
/// The quart cell whose biome is the biome at block `x, y, z`
|
||||
/// (`BiomeManager.getBiome`'s fuzzy zoom with the world's seed): it can
|
||||
/// lie in a neighbouring chunk, so read it with `noise_biome` of that
|
||||
/// chunk's snapshot.
|
||||
pub fn biome_cell(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
|
||||
self.zoom.quart_at_block(BlockPos::new(x, y, z))
|
||||
}
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// A snapshot of loaded chunk `chunk` (`[x, z]`) of the level `dimension`
|
||||
/// (`minecraft:the_nether`), in place or open with nobody in it. None off
|
||||
/// a server, for a level not open and for a chunk not loaded; nothing is
|
||||
/// loaded or generated for it. API @API:chunk_snapshot@.
|
||||
pub(super) fn native_chunk_snapshot(
|
||||
&self,
|
||||
dimension: &str,
|
||||
chunk: [i32; 2],
|
||||
) -> Option<NativeChunkSnapshot> {
|
||||
if !self.native_authoritative() {
|
||||
return None;
|
||||
}
|
||||
let world = if dimension == self.dim.id() {
|
||||
&self.world
|
||||
} else {
|
||||
let dim = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.find(|d| d.id() == dimension)?;
|
||||
self.levels.parked_world(dim)?
|
||||
};
|
||||
Some(NativeChunkSnapshot {
|
||||
chunk: world.chunks.get(&(chunk[0], chunk[1]))?.clone(),
|
||||
pos: chunk,
|
||||
min_y: world.shape.min_y,
|
||||
top_y: world.shape.max_y(),
|
||||
ceiling: world.shape.has_ceiling,
|
||||
zoom: self.streaming.biome_zoom,
|
||||
air: ["minecraft:air", "minecraft:cave_air", "minecraft:void_air"]
|
||||
.map(|name| self.pack.assets.registry.default_state(name)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The map colour of block state `state` (`BlockState.getMapColor`):
|
||||
/// `(id, colour)`, the `MapColor` id and its `col` (0xRRGGBB), `(0, 0)`
|
||||
/// for `MapColor.NONE` (air, glass). None for an unknown state. API
|
||||
/// @API:chunk_snapshot@.
|
||||
pub(super) fn native_block_map_color(&self, state: BlockStateId) -> Option<(u8, u32)> {
|
||||
let known = usize::try_from(state.0).is_ok_and(|s| s < self.pack.assets.registry.len());
|
||||
if !known {
|
||||
return None;
|
||||
}
|
||||
let id = self.res.facts().get(state).map_color;
|
||||
Some((id, MAP_COLORS.get(usize::from(id)).copied().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// The name of biome id `biome` (`minecraft:plains`), as a snapshot's
|
||||
/// `noise_biome` gives it. None for an unknown id. API
|
||||
/// @API:chunk_snapshot@.
|
||||
pub(super) fn native_biome_name(&self, biome: u16) -> Option<&str> {
|
||||
let biomes = world_biomes!(self);
|
||||
biomes.list.get(usize::from(biome)).map(|b| b.name.as_str())
|
||||
}
|
||||
|
||||
/// The tints of biome id `biome` at block column `x, z`, each 0xRRGGBB:
|
||||
/// grass (`Biome.getGrassColor`, with the dark forest's and the swamp's
|
||||
/// modifiers), foliage, dry foliage and water, after
|
||||
/// `native_biome_colors`. The biome's own, not blended over the biome
|
||||
/// blend radius as the client draws them. None for an unknown id. API
|
||||
/// @API:chunk_snapshot@.
|
||||
pub(super) fn native_biome_tint(&self, biome: u16, x: i32, z: i32) -> Option<BiomeColors> {
|
||||
let biomes = world_biomes!(self);
|
||||
let b = biomes.list.get(usize::from(biome))?;
|
||||
Some(BiomeColors {
|
||||
grass: biomes.grass(biome, f64::from(x), f64::from(z)),
|
||||
foliage: b.foliage,
|
||||
dry_foliage: b.dry_foliage,
|
||||
water: b.water,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! working (docs/native-mods.md, "Chunk tickets"). A held chunk is as a
|
||||
//! `/forceload`ed one, its level stays open, but `/forceload` neither lists
|
||||
//! nor releases it and it isn't saved.
|
||||
use super::Game;
|
||||
use super::{Dim, Game};
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
@@ -21,6 +21,34 @@ impl Game<'_> {
|
||||
/// set of owners changed; false for an owner without a namespace, a chunk
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
|
||||
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
self.native_chunk_ticket(self.dim, owner, chunk, hold)
|
||||
}
|
||||
|
||||
/// `native_force_chunk` for the level `dimension` (`minecraft:the_nether`),
|
||||
/// whether it is in place, open with nobody in it, or not open yet: a
|
||||
/// level held this way opens at the next server tick and ticks from then
|
||||
/// on, so a mod can generate or run the Nether and the End from any
|
||||
/// server hook. False also for a dimension other than the Overworld, the
|
||||
/// Nether and the End. API @API:chunk_tickets_in_level@.
|
||||
pub(super) fn native_force_chunk_in(
|
||||
&mut self,
|
||||
owner: &str,
|
||||
dimension: &str,
|
||||
chunk: [i32; 2],
|
||||
hold: bool,
|
||||
) -> bool {
|
||||
let dim = match dimension {
|
||||
"minecraft:overworld" => Dim::Overworld,
|
||||
"minecraft:the_nether" => Dim::Nether,
|
||||
"minecraft:the_end" => Dim::End,
|
||||
_ => return false,
|
||||
};
|
||||
self.native_chunk_ticket(dim, owner, chunk, hold)
|
||||
}
|
||||
|
||||
/// Hold or release `owner`'s ticket on `chunk` of `dim`'s level, and
|
||||
/// hand the level's held chunks to its streaming if it is open.
|
||||
fn native_chunk_ticket(&mut self, dim: Dim, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
let namespaced = owner
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
@@ -32,7 +60,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let key = (chunk[0], chunk[1]);
|
||||
let level = self.mod_chunk_tickets.entry(self.dim).or_default();
|
||||
let level = self.mod_chunk_tickets.entry(dim).or_default();
|
||||
let changed = if hold {
|
||||
level.entry(key).or_default().insert(owner.to_owned())
|
||||
} else {
|
||||
@@ -45,14 +73,34 @@ impl Game<'_> {
|
||||
if !changed {
|
||||
return false;
|
||||
}
|
||||
if level.contains_key(&key) {
|
||||
let held = level.contains_key(&key);
|
||||
// A level not open yet gets its tickets when it opens
|
||||
// (`native_chunk_tickets_opened`).
|
||||
let streaming = if dim == self.dim {
|
||||
Some(&mut self.streaming)
|
||||
} else {
|
||||
self.levels.parked_streaming_mut(dim)
|
||||
};
|
||||
if let Some(streaming) = streaming {
|
||||
if !held {
|
||||
streaming.mod_forced.remove(&key);
|
||||
} else if streaming.mod_forced.insert(key) {
|
||||
streaming.rank_forced(key.0, key.1, key.0, key.1);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The level just opened in place takes the tickets mods hold for it.
|
||||
pub(super) fn native_chunk_tickets_opened(&mut self) {
|
||||
let Some(level) = self.mod_chunk_tickets.get(&self.dim) else {
|
||||
return;
|
||||
};
|
||||
for &key in level.keys() {
|
||||
if self.streaming.mod_forced.insert(key) {
|
||||
self.streaming.rank_forced(key.0, key.1, key.0, key.1);
|
||||
}
|
||||
} else {
|
||||
self.streaming.mod_forced.remove(&key);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The chunks of the level in place `owner` holds (`native_force_chunk`),
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Entities shown to one player's client alone, for native server mods
|
||||
//! (docs/native-mods.md, "Server hooks for plugin ports: vanish and per-viewer
|
||||
//! visuals"): a hologram or a board only its owner sees. No level holds them:
|
||||
//! they are never simulated, saved or tracked, nobody else sees them, and the
|
||||
//! client forgets them when its player changes dimension or leaves. Their
|
||||
//! synched data comes from a compound as `/summon` reads it.
|
||||
use super::Game;
|
||||
use horizon_client::lan::entity::EntityState;
|
||||
use horizon_protocol::{
|
||||
buf::pack_degrees,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
|
||||
/// A synched data entry: its id, serializer and value.
|
||||
type Entry = (u8, i32, Value);
|
||||
|
||||
/// `Entity`'s own synched data from a compound as `Entity.load` reads it:
|
||||
/// `Invisible` and `Glowing` (DATA_SHARED_FLAGS_ID 0x20 and 0x40),
|
||||
/// `CustomName`, `CustomNameVisible`, `Silent` and `NoGravity` (ids 2 to 5);
|
||||
/// then the defaults of all of them.
|
||||
fn entity_data(tag: &Tag) -> (Vec<Entry>, Vec<Entry>) {
|
||||
let flag = |key: &str| match tag.get(key) {
|
||||
Some(Tag::Byte(v)) => *v != 0,
|
||||
Some(Tag::Int(v)) => *v != 0,
|
||||
_ => false,
|
||||
};
|
||||
let mut meta = Vec::new();
|
||||
let flags = i8::from(flag("Invisible")) << 5 | i8::from(flag("Glowing")) << 6;
|
||||
if flags != 0 {
|
||||
meta.push((0, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
if let Some(name) = tag.get("CustomName") {
|
||||
meta.push((
|
||||
2,
|
||||
serializer::OPTIONAL_COMPONENT,
|
||||
Value::OptText(Some(name.clone())),
|
||||
));
|
||||
}
|
||||
for (id, key) in [(3, "CustomNameVisible"), (4, "Silent"), (5, "NoGravity")] {
|
||||
if flag(key) {
|
||||
meta.push((id, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
}
|
||||
let defaults = vec![
|
||||
(0, serializer::BYTE, Value::Byte(0)),
|
||||
(2, serializer::OPTIONAL_COMPONENT, Value::OptText(None)),
|
||||
(3, serializer::BOOLEAN, Value::Bool(false)),
|
||||
(4, serializer::BOOLEAN, Value::Bool(false)),
|
||||
(5, serializer::BOOLEAN, Value::Bool(false)),
|
||||
];
|
||||
(meta, defaults)
|
||||
}
|
||||
|
||||
/// An entity type's id with its namespace (`text_display` is
|
||||
/// `minecraft:text_display`).
|
||||
fn namespaced(kind: &str) -> String {
|
||||
if kind.contains(':') {
|
||||
kind.to_owned()
|
||||
} else {
|
||||
["minecraft:", kind].concat()
|
||||
}
|
||||
}
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The synched data `nbt` gives an entity of type `kind`: a display's
|
||||
/// (`Display`, `TextDisplay`, `BlockDisplay`, `ItemDisplay`) and every
|
||||
/// entity's own (`entity_data`). With `all`, also the defaults of what
|
||||
/// `nbt` leaves out. None where the server is out of reach.
|
||||
fn native_client_entity_data(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
nbt: &Compound,
|
||||
all: bool,
|
||||
) -> Option<Vec<Entry>> {
|
||||
let tag = Tag::Compound(nbt.clone());
|
||||
let mut e = EntityState {
|
||||
kind: kind.to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
// `lan_display_data` reads the hub's item ids; the hub goes back at once.
|
||||
let mut host = self.lan.take()?;
|
||||
self.lan_display_data(&tag, &mut host.hub, &mut e);
|
||||
self.lan = Some(host);
|
||||
let (meta, defaults) = entity_data(&tag);
|
||||
e.meta.extend(meta);
|
||||
e.defaults.extend(defaults);
|
||||
if !all {
|
||||
return Some(e.meta);
|
||||
}
|
||||
let mut values = e.defaults;
|
||||
for v in &mut values {
|
||||
if let Some(m) = e.meta.iter().find(|m| m.0 == v.0) {
|
||||
v.clone_from(m);
|
||||
}
|
||||
}
|
||||
let rest: Vec<Entry> = e
|
||||
.meta
|
||||
.iter()
|
||||
.filter(|m| !values.iter().any(|v| v.0 == m.0))
|
||||
.cloned()
|
||||
.collect();
|
||||
values.extend(rest);
|
||||
Some(values)
|
||||
}
|
||||
|
||||
/// The type of `viewer`'s client-side entity `id`, if it is one.
|
||||
fn native_client_entity_kind(&self, viewer: u128, id: i32) -> Option<String> {
|
||||
self.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.uuid == viewer)?
|
||||
.client_entities
|
||||
.get(&id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// An entity id no entity of this server will take
|
||||
/// (`Entity.nextEntityId`'s counter), for a mod's own packets to a
|
||||
/// client. API @API:client_entities@.
|
||||
pub(super) fn native_reserve_entity_id(&mut self) -> i32 {
|
||||
self.take_entity_id() as i32
|
||||
}
|
||||
|
||||
/// Shows an entity of type `kind` (`minecraft:text_display`, `text_display`)
|
||||
/// to the player `viewer` alone, at `pos` (the feet) facing `yaw`, with the
|
||||
/// synched data `nbt` gives it as `/summon` reads it: all of a display's
|
||||
/// (`text`, `background`, `billboard`, `transformation`, `item`,
|
||||
/// `block_state`, `teleport_duration`...), and of any type `CustomName`,
|
||||
/// `CustomNameVisible`, `Invisible`, `Glowing`, `Silent` and `NoGravity`;
|
||||
/// other types' own data is not read. No level holds it: it is not
|
||||
/// simulated, saved, tracked or seen by anyone else, and the client
|
||||
/// forgets it when the player changes dimension or leaves. Returns its id
|
||||
/// (one the server's entities never take), for `native_client_entity_set`,
|
||||
/// `_move` and `_remove`. None when `viewer` isn't on, for an unknown
|
||||
/// type, a player, a position or yaw that isn't finite, and inside a
|
||||
/// player's hook where the server is out of reach. Bukkit has no such API
|
||||
/// (plugins send the packets). API @API:client_entities@.
|
||||
pub(super) fn native_client_entity_spawn(
|
||||
&mut self,
|
||||
viewer: u128,
|
||||
kind: &str,
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
nbt: &Compound,
|
||||
) -> Option<i32> {
|
||||
if !pos.iter().all(|v| v.is_finite()) || !yaw.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let kind = namespaced(kind);
|
||||
// A player's entity needs a player list entry the server doesn't have.
|
||||
if kind == "minecraft:player" {
|
||||
return None;
|
||||
}
|
||||
let hub = &self.lan.as_ref()?.hub;
|
||||
hub.guests.iter().find(|g| g.placed && g.uuid == viewer)?;
|
||||
let type_id = hub.registry_id("minecraft:entity_type", &kind)?;
|
||||
let values = self.native_client_entity_data(&kind, nbt, false)?;
|
||||
let id = self.take_entity_id();
|
||||
let add = C::AddEntity(play::AddEntity {
|
||||
id: id as i32,
|
||||
uuid: super::lan::entity_uuid(id),
|
||||
kind: type_id,
|
||||
position: pos,
|
||||
velocity: [0.0; 3],
|
||||
pitch: 0,
|
||||
yaw: pack_degrees(yaw),
|
||||
head_yaw: pack_degrees(yaw),
|
||||
data: 0,
|
||||
});
|
||||
let id = id as i32;
|
||||
let host = self.lan.as_mut()?;
|
||||
let g = host
|
||||
.hub
|
||||
.guests
|
||||
.iter_mut()
|
||||
.find(|g| g.placed && g.uuid == viewer)?;
|
||||
g.client_entities.insert(id, kind);
|
||||
self.native_send(viewer, add);
|
||||
if !values.is_empty() {
|
||||
self.native_send(viewer, C::SetEntityData(play::SetEntityData { id, values }));
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Gives `viewer`'s client-side entity `id` the synched data `nbt` gives
|
||||
/// its type, as `native_client_entity_spawn` reads it: what `nbt` leaves
|
||||
/// out goes back to its default, so pass the whole of what it shows. A
|
||||
/// display's `start_interpolation` is sent with it, so a changed
|
||||
/// transformation eases over `interpolation_duration`. False when it is
|
||||
/// not their client-side entity, and where the server is out of reach.
|
||||
/// API @API:client_entities@.
|
||||
pub(super) fn native_client_entity_set(
|
||||
&mut self,
|
||||
viewer: u128,
|
||||
id: i32,
|
||||
nbt: &Compound,
|
||||
) -> bool {
|
||||
let Some(kind) = self.native_client_entity_kind(viewer, id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(values) = self.native_client_entity_data(&kind, nbt, true) else {
|
||||
return false;
|
||||
};
|
||||
self.native_send(viewer, C::SetEntityData(play::SetEntityData { id, values }))
|
||||
}
|
||||
|
||||
/// Moves `viewer`'s client-side entity `id` to `pos` facing `yaw`
|
||||
/// (`ClientboundTeleportEntityPacket`, absolute; a display eases over its
|
||||
/// `teleport_duration`). False when it is not their client-side entity,
|
||||
/// for a position or yaw that isn't finite, and where the server is out
|
||||
/// of reach. API @API:client_entities@.
|
||||
pub(super) fn native_client_entity_move(
|
||||
&mut self,
|
||||
viewer: u128,
|
||||
id: i32,
|
||||
pos: [f64; 3],
|
||||
yaw: f32,
|
||||
) -> bool {
|
||||
if !pos.iter().all(|v| v.is_finite()) || !yaw.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if self.native_client_entity_kind(viewer, id).is_none() {
|
||||
return false;
|
||||
}
|
||||
let packet = C::TeleportEntity(play::TeleportEntity {
|
||||
id,
|
||||
position: pos,
|
||||
delta: [0.0; 3],
|
||||
yaw,
|
||||
pitch: 0.0,
|
||||
relatives: 0,
|
||||
on_ground: false,
|
||||
});
|
||||
self.native_send(viewer, packet)
|
||||
}
|
||||
|
||||
/// Takes `viewer`'s client-side entities `ids` away from their client
|
||||
/// (`ClientboundRemoveEntitiesPacket`); ids that are not theirs are left
|
||||
/// alone (an entity the server tracks is hidden with
|
||||
/// `native_hide_entity_from`). False when none of them was theirs, and
|
||||
/// where the server is out of reach. API @API:client_entities@.
|
||||
pub(super) fn native_client_entity_remove(&mut self, viewer: u128, ids: &[i32]) -> bool {
|
||||
let Some(host) = &mut self.lan else {
|
||||
return false;
|
||||
};
|
||||
let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter_mut()
|
||||
.find(|g| g.placed && g.uuid == viewer)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let ids: Vec<i32> = ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| g.client_entities.remove(id).is_some())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.native_send(viewer, C::RemoveEntities(play::RemoveEntities { ids }))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
//! Client services for trait mods, without exposing `Game` or the atlas.
|
||||
use super::Game;
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
client_resources::TexturePixels,
|
||||
collisions::SceneCollisions,
|
||||
hud::{HudBatch, rgba},
|
||||
hud_layout::GuiDraw,
|
||||
items::ItemStack,
|
||||
mod_api::Text,
|
||||
net::FRIEND,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_friends::invite::{self, Parsed};
|
||||
use std::borrow::Cow;
|
||||
use std::net::SocketAddr;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// This window's state; unavailable on a server or before a window is attached.
|
||||
@@ -25,6 +30,89 @@ pub struct NativeClientState {
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub menu_blur: f32,
|
||||
/// What covers the world. API @API:session_state@.
|
||||
pub screen: NativeScreenKind,
|
||||
/// F3's debug screen is showing (`DebugScreenOverlay.showDebugScreen`).
|
||||
/// API @API:session_state@.
|
||||
pub debug_screen: bool,
|
||||
/// The world joined, None before one is. API @API:session_state@.
|
||||
pub world: Option<NativeWorldId>,
|
||||
/// The head of the bed the player sleeps in, as the server says
|
||||
/// (`LivingEntity.getSleepingPos`). API @API:session_state@.
|
||||
pub sleeping_pos: Option<[i32; 3]>,
|
||||
}
|
||||
|
||||
/// What covers the world, in the kinds a HUD tells apart (vanilla's
|
||||
/// `Minecraft.screen`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NativeScreenKind {
|
||||
/// No screen: the world is played.
|
||||
#[default]
|
||||
Playing,
|
||||
/// The chat box (`ChatScreen`), which a minimap still draws under.
|
||||
Chat,
|
||||
/// The death screen (`DeathScreen`).
|
||||
Death,
|
||||
/// Any other: the pause menu, an inventory or container, a sign, book,
|
||||
/// dialog or prompt, the loading screen, the title's screens.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl NativeScreenKind {
|
||||
/// `covered`: a screen Horizon keeps outside `Screen` is up over it.
|
||||
fn of(screen: Screen, covered: bool) -> Self {
|
||||
match screen {
|
||||
_ if covered => Self::Other,
|
||||
Screen::Playing => Self::Playing,
|
||||
Screen::Chat => Self::Chat,
|
||||
Screen::Dead => Self::Death,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The world this window plays, for a mod that keeps data per world, as
|
||||
/// Xaero's minimap and JourneyMap do. A later Horizon may add kinds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
#[non_exhaustive]
|
||||
pub enum NativeWorldId {
|
||||
/// Single player, its LAN host and splitscreen seats: the save's folder
|
||||
/// in `saves/`, not its shown name.
|
||||
Singleplayer(String),
|
||||
/// A server, a LAN world joined included: its address as the player
|
||||
/// joined it (`mc.example.com:25565`). A friend's world is `friend:` and
|
||||
/// what its invite shows anyone (its host's addresses and its relay
|
||||
/// nameplate), never the invite's secret.
|
||||
Server(String),
|
||||
}
|
||||
|
||||
impl NativeWorldId {
|
||||
/// `save` is set for single player's world joined through memory.
|
||||
fn of(save: Option<&str>, address: &str) -> Self {
|
||||
match (save, address.strip_prefix(FRIEND)) {
|
||||
(Some(folder), _) => Self::Singleplayer(folder.to_owned()),
|
||||
(None, Some(code)) => Self::Server(format!("{FRIEND}{}", invite_public_part(code))),
|
||||
(None, None) => Self::Server(address.to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a friend's invite anyone may see. A host's code is fresh each
|
||||
/// time it shares, so a friend's world is known for that sharing only. The
|
||||
/// relay is kept beside the addresses: two hosts on other networks can share
|
||||
/// a private address and the fixed direct port.
|
||||
fn invite_public_part(code: &str) -> String {
|
||||
match invite::parse(code) {
|
||||
Some(Parsed::Short(code)) => code.nameplate,
|
||||
Some(Parsed::Long(invite)) => {
|
||||
let relay = invite
|
||||
.relay
|
||||
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
|
||||
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
|
||||
endpoints.chain(relay).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A living entity known to the client, before interpolation. No authoritative writes.
|
||||
@@ -42,6 +130,51 @@ pub struct NativeClientEntity {
|
||||
pub head_pitch: f32,
|
||||
pub alive: bool,
|
||||
pub invisible: bool,
|
||||
/// Its entity type (`minecraft:zombie`). API @API:client_entity_list@.
|
||||
pub kind: String,
|
||||
/// `Entity.getYRot` in degrees, 0 facing south and 90 west.
|
||||
pub yaw: f32,
|
||||
pub baby: bool,
|
||||
/// A player in spectator mode, whom vanilla draws only to spectators.
|
||||
pub spectator: bool,
|
||||
/// The texture its body draws with (`EntityRenderer.getTextureLocation`):
|
||||
/// `entity/cow/cow_temperate_baby`, or a player's skin. Pixels through
|
||||
/// `native_client_texture`.
|
||||
pub texture: Option<String>,
|
||||
}
|
||||
|
||||
impl NativeClientEntity {
|
||||
/// Where this frame draws it (`Entity.getPosition(partialTick)`), given
|
||||
/// `NativeClientState::world_partial_tick`. API @API:client_entity_list@.
|
||||
pub fn position_at(&self, partial_tick: f64) -> [f64; 3] {
|
||||
position_at(self.previous_position, self.position, partial_tick)
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropped item the client shows. API @API:client_entity_list@.
|
||||
#[derive(Clone, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct NativeClientItem {
|
||||
pub id: u32,
|
||||
pub stack: ItemStack,
|
||||
pub position: [f64; 3],
|
||||
pub previous_position: [f64; 3],
|
||||
}
|
||||
|
||||
impl NativeClientItem {
|
||||
/// Where this frame draws it, as `NativeClientEntity::position_at`.
|
||||
pub fn position_at(&self, partial_tick: f64) -> [f64; 3] {
|
||||
position_at(self.previous_position, self.position, partial_tick)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Downloader::image` keeps every skin at 64x64.
|
||||
const SKIN_SIDE: u32 = 64;
|
||||
|
||||
fn position_at(previous: [f64; 3], position: [f64; 3], partial_tick: f64) -> [f64; 3] {
|
||||
let vec = |[x, y, z]: [f64; 3]| Vec3::new(x, y, z);
|
||||
let at = vec(previous).lerp(vec(position), partial_tick);
|
||||
[at.x, at.y, at.z]
|
||||
}
|
||||
|
||||
/// A posed model head, relative to its interpolated origin, in block units.
|
||||
@@ -68,6 +201,23 @@ pub struct NativeClientProfile {
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// A screenshot as F2 takes it (`ScreenShot.grab`): of the next frame, at
|
||||
/// the game's resolution times Screenshot Quality, into `screenshots/` with
|
||||
/// vanilla's file name, and the same "Saved screenshot as" line in chat.
|
||||
/// The file it will write, as a path string; an error without a window to draw, or while
|
||||
/// another screenshot waits for the frame (F2's, an agent's, the world
|
||||
/// icon's), which a mod's would replace. API @API:take_screenshot@.
|
||||
pub(super) fn native_take_screenshot(&mut self) -> Result<String, String> {
|
||||
let Some(renderer) = self.renderer.as_ref() else {
|
||||
return Err("no window to take it from".into());
|
||||
};
|
||||
if renderer.screenshot.is_some() {
|
||||
return Err("another screenshot is waiting for the frame".into());
|
||||
}
|
||||
self.take_screenshot()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.ok_or_else(|| "the screenshots folder can't be made".into())
|
||||
}
|
||||
/// Client/window/connection state, including when menus cover the world.
|
||||
pub(super) fn native_client_state(&self) -> Option<NativeClientState> {
|
||||
if !self.native_window() {
|
||||
@@ -86,6 +236,20 @@ impl Game<'_> {
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
menu_blur: self.settings.menu_blur as f32,
|
||||
screen: NativeScreenKind::of(
|
||||
self.screen,
|
||||
self.title.is_some()
|
||||
|| self.screen_over_playing()
|
||||
|| self.remote_screen_up()
|
||||
|| self.pack_reload.is_some(),
|
||||
),
|
||||
debug_screen: self.debug,
|
||||
world: self
|
||||
.remote
|
||||
.as_ref()
|
||||
.filter(|r| r.logged_in)
|
||||
.map(|r| NativeWorldId::of(r.save_folder.as_deref(), &r.session.address)),
|
||||
sleeping_pos: self.sleeping,
|
||||
})
|
||||
}
|
||||
/// Bulk client render data. Unlike EntityRef, this snapshot expires independently of handles.
|
||||
@@ -111,10 +275,53 @@ impl Game<'_> {
|
||||
head_pitch: m.head_pitch,
|
||||
alive: m.alive(),
|
||||
invisible: m.invisible || m.has_effect("minecraft:invisibility"),
|
||||
kind: m.kind.id.to_string(),
|
||||
yaw: m.yaw,
|
||||
baby: m.is_baby(),
|
||||
spectator: m.extra.spectator,
|
||||
texture: Some(horizon_client::mobs::render::body(m).1)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(Cow::into_owned),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The dropped items the server shows this window, by the same rules as
|
||||
/// `native_client_entities`. API @API:client_entity_list@.
|
||||
pub(super) fn native_client_items(&self) -> Vec<NativeClientItem> {
|
||||
if !self.native_window() || self.remote.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.dropped
|
||||
.iter()
|
||||
.filter(|e| e.stack.count > 0)
|
||||
.map(|e| NativeClientItem {
|
||||
id: e.id,
|
||||
stack: e.stack.clone(),
|
||||
position: [e.position.x, e.position.y, e.position.z],
|
||||
previous_position: [e.previous.x, e.previous.y, e.previous.z],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The pixels of a texture as this window draws it, by the name
|
||||
/// `NativeClientEntity::texture` and `NativeClientProfile::skin` give: a
|
||||
/// downloaded skin while the client holds it, else
|
||||
/// `native_client_resources().texture(name)`. Decodes on each call; None
|
||||
/// on a server or for a texture it can't read. API
|
||||
/// @API:client_entity_list@.
|
||||
pub(super) fn native_client_texture(&self, texture: &str) -> Option<TexturePixels> {
|
||||
if !self.native_window() {
|
||||
return None;
|
||||
}
|
||||
if let Some(rgba) = self.skin_downloads.borrow().image(texture) {
|
||||
return Some(TexturePixels {
|
||||
width: SKIN_SIDE,
|
||||
height: SKIN_SIDE,
|
||||
rgba: rgba.to_vec(),
|
||||
});
|
||||
}
|
||||
self.native_client_resources().texture(texture)
|
||||
}
|
||||
/// Requested model heads at a finite partial tick in 0..=1; at most 4096 IDs.
|
||||
/// Name bounds are vanilla's, without dispatching name hooks recursively.
|
||||
pub(super) fn native_client_heads(&self, ids: &[u32], partial: f64) -> Vec<NativeClientHead> {
|
||||
@@ -330,3 +537,336 @@ impl Game<'_> {
|
||||
self.physical_keys.contains(&key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_friends::code::Code;
|
||||
|
||||
#[test]
|
||||
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
|
||||
assert_eq!(
|
||||
NativeWorldId::of(Some("New World"), "local"),
|
||||
NativeWorldId::Singleplayer("New World".into())
|
||||
);
|
||||
assert_eq!(
|
||||
NativeWorldId::of(None, "mc.example.com:25565"),
|
||||
NativeWorldId::Server("mc.example.com:25565".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
|
||||
const PORT: u16 = 50000;
|
||||
const RELAY: &str = "relay.example.com:443";
|
||||
// A short code: its nameplate, then its secret.
|
||||
let short = |nameplate: &str, secret: &str| Code {
|
||||
nameplate: nameplate.into(),
|
||||
secret: secret.into(),
|
||||
};
|
||||
let invite = |secret: u8, host: &str, relay: Option<Code>| {
|
||||
let code = invite::Invite {
|
||||
secret: [secret; invite::SECRET],
|
||||
port: PORT,
|
||||
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
|
||||
relay: relay.map(|code| invite::RelayRef {
|
||||
address: RELAY.into(),
|
||||
code,
|
||||
}),
|
||||
}
|
||||
.encode();
|
||||
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
|
||||
};
|
||||
assert_ne!(
|
||||
invite(1, "192.168.1.5", None),
|
||||
invite(1, "192.168.1.6", None),
|
||||
"two friends"
|
||||
);
|
||||
assert_ne!(
|
||||
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
|
||||
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
|
||||
"two friends on other networks behind the same private address"
|
||||
);
|
||||
assert_eq!(
|
||||
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
|
||||
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
|
||||
"the invite's and the relay code's secrets take no part"
|
||||
);
|
||||
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
|
||||
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
|
||||
assert_eq!(
|
||||
typed(short("ABCD", "WXYZ")),
|
||||
NativeWorldId::Server(format!("{FRIEND}ABCD"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hud_tells_chat_and_death_from_other_screens() {
|
||||
assert_eq!(
|
||||
NativeScreenKind::of(Screen::Playing, false),
|
||||
NativeScreenKind::Playing
|
||||
);
|
||||
assert_eq!(
|
||||
NativeScreenKind::of(Screen::Chat, false),
|
||||
NativeScreenKind::Chat
|
||||
);
|
||||
assert_eq!(
|
||||
NativeScreenKind::of(Screen::Dead, false),
|
||||
NativeScreenKind::Death
|
||||
);
|
||||
assert_eq!(
|
||||
NativeScreenKind::of(Screen::Inventory, false),
|
||||
NativeScreenKind::Other
|
||||
);
|
||||
assert_eq!(
|
||||
NativeScreenKind::of(Screen::Chat, true),
|
||||
NativeScreenKind::Other,
|
||||
"a sign or dialog over chat covers the world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_is_drawn_between_its_last_two_tick_positions() {
|
||||
let entity = NativeClientEntity {
|
||||
previous_position: [0.0, 64.0, -2.0],
|
||||
position: [2.0, 65.0, 2.0],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(entity.position_at(0.0), [0.0, 64.0, -2.0]);
|
||||
assert_eq!(entity.position_at(0.25), [0.5, 64.25, -1.0]);
|
||||
assert_eq!(entity.position_at(1.0), [2.0, 65.0, 2.0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A window joined to a server through memory, fed the server's packets as
|
||||
/// it would receive them: what a client mod lists through the mod `Host`.
|
||||
#[cfg(test)]
|
||||
mod client_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::mod_api::Host;
|
||||
use horizon_protocol::entity_data::{Value, serializer};
|
||||
use horizon_protocol::item::Stack;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// `AgeableMob.DATA_BABY_ID` and `ItemEntity.DATA_ITEM`, the entity data
|
||||
/// a calf and a dropped item come with.
|
||||
const DATA_BABY_ID: u8 = 16;
|
||||
const DATA_ITEM: u8 = 8;
|
||||
/// Vanilla's `zombie.png`, like every skin, is 64x64 RGBA.
|
||||
const TEXTURE_SIDE: u32 = 64;
|
||||
const RGBA_BYTES: usize = 4;
|
||||
/// The network ids the server gives the window's player and the entities.
|
||||
const OWN_PLAYER: i32 = 7;
|
||||
const ZOMBIE: i32 = 20;
|
||||
const CALF: i32 = 21;
|
||||
const DIAMONDS: i32 = 22;
|
||||
const ALEX: i32 = 23;
|
||||
/// `UUID.hashCode` 2: `DefaultPlayerSkin.get` picks the third default
|
||||
/// skin, slim Efe.
|
||||
const ALEX_UUID: u128 = 2;
|
||||
/// `ClientboundPlayerInfoUpdatePacket`'s `ADD_PLAYER` and
|
||||
/// `UPDATE_GAME_MODE` actions, and `GameType.SPECTATOR`.
|
||||
const ADD_PLAYER: u8 = 1;
|
||||
const UPDATE_GAME_MODE: u8 = 4;
|
||||
const SPECTATOR: i32 = 3;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_client_lists_the_mobs_and_items_the_server_shows_it() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (pipe, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(pipe, "Steve").unwrap();
|
||||
let builtin = game.remote.as_ref().unwrap().builtin.clone();
|
||||
let id = |registry: &str, name: &str| builtin.get(registry).unwrap().id(name).unwrap();
|
||||
let entity = |entity: i32, kind: &str, position: [f64; 3]| play::AddEntity {
|
||||
id: entity,
|
||||
uuid: entity as u128,
|
||||
kind: id("minecraft:entity_type", kind),
|
||||
position,
|
||||
velocity: [0.0; 3],
|
||||
pitch: 0,
|
||||
yaw: 64,
|
||||
head_yaw: 64,
|
||||
data: 0,
|
||||
};
|
||||
let add =
|
||||
|id: i32, kind: &str, position: [f64; 3]| C::AddEntity(entity(id, kind, position));
|
||||
let data = |entity: i32, value: (u8, i32, Value)| {
|
||||
C::SetEntityData(play::SetEntityData {
|
||||
id: entity,
|
||||
values: vec![value],
|
||||
})
|
||||
};
|
||||
game.handle_packet(C::Login(play::Login {
|
||||
player_id: OWN_PLAYER,
|
||||
hardcore: false,
|
||||
levels: vec!["minecraft:overworld".into()],
|
||||
max_players: 20,
|
||||
chunk_radius: 8,
|
||||
simulation_distance: 8,
|
||||
reduced_debug_info: false,
|
||||
show_death_screen: true,
|
||||
limited_crafting: false,
|
||||
spawn: play::SpawnInfo {
|
||||
dimension_type: 0,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
seed: 1,
|
||||
game_type: 0,
|
||||
previous_game_type: None,
|
||||
debug: false,
|
||||
flat: false,
|
||||
last_death: None,
|
||||
portal_cooldown: 0,
|
||||
sea_level: 63,
|
||||
},
|
||||
online_mode: false,
|
||||
enforces_secure_chat: false,
|
||||
}))
|
||||
.unwrap();
|
||||
game.handle_packet(C::PlayerPosition(play::PlayerPosition {
|
||||
teleport: 1,
|
||||
position: [0.5, 65.0, 0.5],
|
||||
delta: [0.0; 3],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
relatives: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// What the server sends for `/summon zombie`, and for a calf.
|
||||
game.handle_packet(add(ZOMBIE, "minecraft:zombie", [1.5, 64.0, 2.5]))
|
||||
.unwrap();
|
||||
game.handle_packet(add(CALF, "minecraft:cow", [-3.5, 64.0, 0.5]))
|
||||
.unwrap();
|
||||
game.handle_packet(data(
|
||||
CALF,
|
||||
(DATA_BABY_ID, serializer::BOOLEAN, Value::Bool(true)),
|
||||
))
|
||||
.unwrap();
|
||||
let entities = game.client_entities();
|
||||
let listed = |id: i32| entities.iter().find(|e| e.id == id as u32);
|
||||
assert_eq!(entities.len(), 2);
|
||||
assert!(listed(OWN_PLAYER).is_none(), "the window's own player");
|
||||
let zombie = listed(ZOMBIE).unwrap();
|
||||
assert_eq!(zombie.kind, "minecraft:zombie");
|
||||
assert_eq!(zombie.position_at(0.5), [1.5, 64.0, 2.5]);
|
||||
assert_eq!(zombie.yaw, 90.0);
|
||||
assert!(!zombie.baby);
|
||||
assert_eq!(zombie.texture.as_deref(), Some("entity/zombie/zombie"));
|
||||
let calf = listed(CALF).unwrap();
|
||||
assert!(calf.baby);
|
||||
assert_eq!(
|
||||
calf.texture.as_deref(),
|
||||
Some("entity/cow/cow_temperate_baby")
|
||||
);
|
||||
|
||||
// A dropped item is listed once its stack arrives.
|
||||
game.handle_packet(add(DIAMONDS, "minecraft:item", [0.5, 65.0, 4.5]))
|
||||
.unwrap();
|
||||
assert!(game.client_items().is_empty());
|
||||
let diamonds = Stack::new(id("minecraft:item", "minecraft:diamond"), 3);
|
||||
game.handle_packet(data(
|
||||
DIAMONDS,
|
||||
(
|
||||
DATA_ITEM,
|
||||
serializer::ITEM_STACK,
|
||||
Value::Item(Some(diamonds)),
|
||||
),
|
||||
))
|
||||
.unwrap();
|
||||
let items = game.client_items();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].id, DIAMONDS as u32);
|
||||
assert_eq!(
|
||||
(&*items[0].stack.item, items[0].stack.count),
|
||||
("minecraft:diamond", 3)
|
||||
);
|
||||
assert_eq!(items[0].position_at(1.0), [0.5, 65.0, 4.5]);
|
||||
|
||||
// Another player: its list entry, a spectator, then its entity. The
|
||||
// frame's skin refresh (`draw_tab_list`) and a client tick
|
||||
// (`isSpectator` from the list) follow.
|
||||
game.handle_packet(C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: ADD_PLAYER | UPDATE_GAME_MODE,
|
||||
entries: vec![play::PlayerInfo {
|
||||
uuid: ALEX_UUID,
|
||||
name: Some("Alex".into()),
|
||||
game_mode: Some(SPECTATOR),
|
||||
..Default::default()
|
||||
}],
|
||||
}))
|
||||
.unwrap();
|
||||
game.handle_packet(C::AddEntity(play::AddEntity {
|
||||
uuid: ALEX_UUID,
|
||||
..entity(ALEX, "minecraft:player", [3.5, 64.0, -1.5])
|
||||
}))
|
||||
.unwrap();
|
||||
game.update_remote_skins();
|
||||
game.tick_remote().unwrap();
|
||||
let alex = game
|
||||
.client_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.id == ALEX as u32)
|
||||
.unwrap();
|
||||
assert_eq!(alex.player_uuid, Some(ALEX_UUID));
|
||||
assert_eq!(alex.player_name.as_deref(), Some("Alex"));
|
||||
assert!(alex.spectator);
|
||||
assert_eq!(alex.texture.as_deref(), Some("entity/player/slim/efe"));
|
||||
|
||||
let pixels = (TEXTURE_SIDE * TEXTURE_SIDE) as usize * RGBA_BYTES;
|
||||
let face = game.client_texture("entity/zombie/zombie").unwrap();
|
||||
assert_eq!((face.width, face.height), (TEXTURE_SIDE, TEXTURE_SIDE));
|
||||
assert_eq!(face.rgba.len(), pixels);
|
||||
// A downloaded skin comes from memory.
|
||||
let skin = vec![7; pixels];
|
||||
game.skin_downloads
|
||||
.borrow_mut()
|
||||
.remember_image("skins/0123abcd", &skin);
|
||||
assert_eq!(game.client_texture("skins/0123abcd").unwrap().rgba, skin);
|
||||
assert!(
|
||||
game.client_texture("entity/zombie/no_such_zombie")
|
||||
.is_none()
|
||||
);
|
||||
let skin = game.client_texture("entity/player/slim/efe").unwrap();
|
||||
assert_eq!((skin.width, skin.height), (TEXTURE_SIDE, TEXTURE_SIDE));
|
||||
|
||||
// A name that climbs out of the packs is refused, even where a picture
|
||||
// is there.
|
||||
let packs = std::env::temp_dir().join(format!("horizon-texture-{}", std::process::id()));
|
||||
let textures = packs.join("assets/minecraft/textures");
|
||||
std::fs::create_dir_all(&textures).unwrap();
|
||||
let png =
|
||||
std::fs::read(root.join("assets/minecraft/textures/entity/zombie/zombie.png")).unwrap();
|
||||
std::fs::write(textures.join("inside.png"), &png).unwrap();
|
||||
std::fs::write(packs.join("outside.png"), &png).unwrap();
|
||||
game.options.resources = packs.clone();
|
||||
assert!(game.client_texture("inside").is_some());
|
||||
assert!(game.client_texture("../../../outside").is_none());
|
||||
std::fs::remove_dir_all(&packs).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Who a hit names, for `native_living_incoming_damage_v3` (docs/native-mods.md, "Server hooks
|
||||
//! for plugin ports"): the hit arrives through `health::HURT_SERVER` with entity ids only, and
|
||||
//! no `Game` to look a player up in, so the server's players are noted by entity id as each
|
||||
//! guest's turn starts. Kept out of `native_hooks.rs`, whose lines may not hold macros.
|
||||
use horizon_client::health::{Hurting, knockback_attacker};
|
||||
use horizon_client::mobs::Who;
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
/// More players than a server holds at once: past it, the notes start over (an entity id
|
||||
/// is never reused, so a left or respawned player's note is only stale).
|
||||
const MAX_NOTED: usize = 4096;
|
||||
|
||||
thread_local! {
|
||||
/// The server's players' UUIDs by entity id, noted on the thread that ticks the world.
|
||||
static PLAYERS: RefCell<HashMap<i32, u128>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Notes that the player with entity id `entity` is `uuid` (a guest whose turn starts), while
|
||||
/// a native mod is loaded: a hit on them or by them is named by it from then on.
|
||||
pub(super) fn note_player(entity: i32, uuid: u128) {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
return;
|
||||
}
|
||||
PLAYERS.with(|players| {
|
||||
let mut players = players.borrow_mut();
|
||||
if players.len() >= MAX_NOTED && !players.contains_key(&entity) {
|
||||
players.clear();
|
||||
}
|
||||
players.insert(entity, uuid);
|
||||
});
|
||||
}
|
||||
|
||||
/// The UUID of the player with entity id `entity`, if one was noted.
|
||||
fn player_uuid(entity: i32) -> Option<u128> {
|
||||
PLAYERS.with(|players| players.borrow().get(&entity).copied())
|
||||
}
|
||||
|
||||
/// The victim's UUID (a player's), the hit's attacker (the causing entity), its UUID and the
|
||||
/// direct entity, each none when not known.
|
||||
pub(super) struct Parties {
|
||||
pub victim_uuid: Option<u128>,
|
||||
pub attacker: Option<u32>,
|
||||
/// A player attacker's UUID.
|
||||
pub attacker_uuid: Option<u128>,
|
||||
pub direct: Option<u32>,
|
||||
}
|
||||
|
||||
/// Who `hurting` names. The causing entity is the source's, or a mob's attacker; a player
|
||||
/// attacker (`Who::Player`) and a source that names none fall back to the synchronous attack in
|
||||
/// progress (`health::knockback_attacker`), as `native_living_knockback_v2` does. An attacker
|
||||
/// that is the victim itself (thorns inside the victim's own attack) is none.
|
||||
pub(super) fn parties(hurting: &Hurting<'_>) -> Parties {
|
||||
let scope = knockback_attacker();
|
||||
let (victim, victim_uuid, causing, direct) = match hurting {
|
||||
Hurting::Player(entity, source) => {
|
||||
(*entity, player_uuid(*entity), source.causing, source.direct)
|
||||
}
|
||||
Hurting::Mob(mob, damage) => {
|
||||
let causing = match damage.attacker {
|
||||
Some(Who::Mob(id)) => id as i32,
|
||||
Some(Who::Player) => scope,
|
||||
None => 0,
|
||||
};
|
||||
let direct = if super::creatures::melee(damage.kind) {
|
||||
causing
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// A mob's UUID is the game's (`persist.uuids`), out of reach here.
|
||||
(mob.id as i32, None, causing, direct)
|
||||
}
|
||||
Hurting::Dragon(entity, source) | Hurting::ArmorStand(entity, source) => {
|
||||
(*entity, None, source.causing, source.direct)
|
||||
}
|
||||
};
|
||||
let causing = if causing != 0 { causing } else { scope };
|
||||
let known = |id: i32| (id > 0 && id != victim).then_some(id as u32);
|
||||
let attacker = known(causing);
|
||||
Parties {
|
||||
victim_uuid,
|
||||
attacker,
|
||||
attacker_uuid: attacker.and_then(|a| player_uuid(a as i32)),
|
||||
direct: known(direct),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! What a server's diagnostics mod reads (docs/native-mods.md, "Server hooks
|
||||
//! for plugin ports: diagnostics"): the tick clock as vanilla and Bukkit
|
||||
//! report it, what each open level holds, the packets each player's
|
||||
//! connection carries, and the switches of the tick's profiler (spans and
|
||||
//! sampled mob costs). Reads only; nothing here changes what the server
|
||||
//! does.
|
||||
use super::{Dim, Game, commands::Target, native_entities::target_id, tick_profile};
|
||||
use horizon_client::{
|
||||
mod_api::{NativeLevelStats, NativeMobCost},
|
||||
world::World,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The last 100 server ticks' durations in nanoseconds, as vanilla's
|
||||
/// `MinecraftServer.getTickTimesNanos` lays them out: the tick numbered
|
||||
/// `n` at index `n % 100` (0 where none was recorded yet). Sort a copy
|
||||
/// for percentiles; the average is `native_tick_stats`'. On a client, its
|
||||
/// own ticks. API @API:tick_times@.
|
||||
pub(super) fn native_tick_times(&self) -> Vec<u64> {
|
||||
self.clock.tick_times_nanos()
|
||||
}
|
||||
/// The ticks a second over the last one, five and fifteen minutes
|
||||
/// (Bukkit's `Server.getTPS`), measured between the ends of the recorded
|
||||
/// ticks and averaged once a second as a load average is; 20 at the
|
||||
/// start. A sprint reads more than the rate, a server that falls behind
|
||||
/// less. API @API:tick_times@.
|
||||
pub(super) fn native_tps(&self) -> [f64; 3] {
|
||||
self.clock.tps()
|
||||
}
|
||||
/// The counts of the open level `dimension` (`minecraft:the_nether`):
|
||||
/// its loaded and forced chunks, its block entities (and how many tick),
|
||||
/// and its entities and block entities by type. A level not in place is
|
||||
/// swapped in for the count and back, as `/execute in` does; the players
|
||||
/// are those the hub holds, so inside a player's own hook (the hub taken
|
||||
/// out) none are counted. A walk over every entity: once a second or
|
||||
/// less, not every tick. None on a client and for a level that isn't
|
||||
/// open. API @API:level_stats@.
|
||||
pub(super) fn native_level_stats(&mut self, dimension: &str) -> Option<NativeLevelStats> {
|
||||
let dim = self.diagnosed_level(dimension)?;
|
||||
self.with_level_in_place(dim, |game| {
|
||||
let mut entities = BTreeMap::new();
|
||||
for (_, kind, _) in game.entities_in_place() {
|
||||
*entities.entry(kind).or_default() += 1;
|
||||
}
|
||||
let mut block_entity_types = BTreeMap::new();
|
||||
for (_, kind) in game.block_entity_types(&game.world) {
|
||||
*block_entity_types.entry(kind).or_default() += 1;
|
||||
}
|
||||
NativeLevelStats {
|
||||
chunks: game.world.chunks.len(),
|
||||
forced_chunks: game.streaming.forced.len(),
|
||||
block_entities: game.world.block_entities.len(),
|
||||
ticking_block_entities: game.world.block_entities.ticking_len(),
|
||||
entities: most_first(entities),
|
||||
block_entity_types: most_first(block_entity_types),
|
||||
}
|
||||
})
|
||||
}
|
||||
/// Every entity of the open level `dimension`: its id (the one events
|
||||
/// and `native_entity` take), its type and its position, players
|
||||
/// included (as `native_level_stats` counts them). Empty on a client and
|
||||
/// for a level that isn't open. API @API:level_stats@.
|
||||
pub(super) fn native_level_entities(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
) -> Vec<(u32, String, [f64; 3])> {
|
||||
let Some(dim) = self.diagnosed_level(dimension) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.with_level_in_place(dim, Self::entities_in_place)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Every block entity of the open level `dimension`: its position and
|
||||
/// its type, the vanilla block entity type of its block (`minecraft:chest`,
|
||||
/// `minecraft:barrel`, `minecraft:mob_spawner`), else the block's name (a
|
||||
/// mod's). Empty on a client and for a level that isn't open. API
|
||||
/// @API:level_stats@.
|
||||
pub(super) fn native_level_block_entities(&self, dimension: &str) -> Vec<([i32; 3], String)> {
|
||||
let world = match self.diagnosed_level(dimension) {
|
||||
Some(dim) if dim == self.dim => Some(&self.world),
|
||||
Some(dim) => self.levels.parked_world(dim),
|
||||
None => None,
|
||||
};
|
||||
world.map_or_else(Vec::new, |world| self.block_entity_types(world))
|
||||
}
|
||||
|
||||
/// Keep (or stop keeping) every player's play packets for
|
||||
/// `native_server_packets`, from now on: while on, each packet costs the
|
||||
/// network thread a lock and a list entry, so turn it on for a session
|
||||
/// and off after. Off drops what is kept. A switch for the whole server,
|
||||
/// shared by every mod. API @API:server_packets@.
|
||||
pub(super) fn native_count_packets(&self, on: bool) {
|
||||
horizon_client::lan::conn::count_packets(on);
|
||||
if !on {
|
||||
for g in self.lan.iter().flat_map(|h| h.hub.guests.iter()) {
|
||||
g.sender.take_packets();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The end of a server tick: each player's packets kept since the last
|
||||
/// one go to `native_server_packets`. One relaxed load while nobody counts.
|
||||
pub(super) fn diagnostics_packets_tick(&mut self) {
|
||||
if !horizon_client::lan::conn::counting_packets() {
|
||||
return;
|
||||
}
|
||||
let kept: Vec<_> = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.map(|g| (g.uuid, g.sender.take_packets()))
|
||||
.collect();
|
||||
for (player, [sent, received]) in kept {
|
||||
if !sent.is_empty() {
|
||||
self.native_server_packets(player, &sent, true);
|
||||
}
|
||||
if !received.is_empty() {
|
||||
self.native_server_packets(player, &received, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the server tick's spans for `native_server_tick_timed` (or no
|
||||
/// longer): elsewhere than on a dedicated server, which records them
|
||||
/// anyway, the hook fires only while they are on. One switch shared with
|
||||
/// `HORIZON_SPIKES`, performance reports and every mod: spans stay on
|
||||
/// while anyone wants them. Spans are recorded on every thread (two clock
|
||||
/// reads a step): turn them on for a profiling session and off after.
|
||||
/// Only on a server. API @API:server_tick_timed@.
|
||||
pub(super) fn native_spans(&self, on: bool) {
|
||||
if self.native_authoritative() {
|
||||
tick_profile::want_spans(tick_profile::SPANS_MOD, on);
|
||||
}
|
||||
}
|
||||
/// Time every mob's own tick (its riders inside it) from now on, or no
|
||||
/// longer, summed by level and mob type for `native_take_mob_costs`:
|
||||
/// two clock reads and a map update a mob while on, one thread-local
|
||||
/// read off. Sampled apart from a performance report's, so both can run.
|
||||
/// For a lighter profile, turn it on in `ServerTickStart` of one tick in
|
||||
/// N and off after. Call it from a server hook (it belongs to the server
|
||||
/// thread). API @API:server_tick_timed@.
|
||||
pub(super) fn native_mob_cost_sampling(&self, on: bool) {
|
||||
tick_profile::set_mod_sampling(on);
|
||||
}
|
||||
/// The mob costs sampled since the last call (`native_mob_cost_sampling`),
|
||||
/// costliest first; clears them. API @API:server_tick_timed@.
|
||||
pub(super) fn native_take_mob_costs(&self) -> Vec<NativeMobCost> {
|
||||
tick_profile::take_mod()
|
||||
.into_iter()
|
||||
.map(|c| NativeMobCost {
|
||||
dimension: c.dimension.to_owned(),
|
||||
kind: c.kind,
|
||||
nanos: c.nanos,
|
||||
mob_ticks: c.mob_ticks,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The open level `dimension` names, on a server.
|
||||
fn diagnosed_level(&self, dimension: &str) -> Option<Dim> {
|
||||
if !self.native_authoritative() {
|
||||
return None;
|
||||
}
|
||||
std::iter::once(self.dim)
|
||||
.chain(self.levels.parked_dims())
|
||||
.find(|d| d.id() == dimension)
|
||||
}
|
||||
/// `f` with the level `dim` in place, then the level that was in place
|
||||
/// back (`/execute in`'s swap). None when it could not be opened.
|
||||
fn with_level_in_place<R>(&mut self, dim: Dim, f: impl FnOnce(&Self) -> R) -> Option<R> {
|
||||
let back = self.enter_command_level(dim);
|
||||
let result = (self.dim == dim).then(|| f(self));
|
||||
if let Some(back) = back {
|
||||
self.enter_command_level(back);
|
||||
}
|
||||
result
|
||||
}
|
||||
/// The entities of the level in place, as a selector finds them (`@e`),
|
||||
/// and the hub's players in it.
|
||||
fn entities_in_place(&self) -> Vec<(u32, String, [f64; 3])> {
|
||||
let mut out = Vec::new();
|
||||
for t in self.level_candidates() {
|
||||
if let Target::Mob(id) = t
|
||||
&& !self.mobs.iter().any(|m| m.id == id && !m.removed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let (Some(id), Some(pos)) = (target_id(t), self.target_position(t)) {
|
||||
out.push((id, self.target_type(t), pos));
|
||||
}
|
||||
}
|
||||
let dimension = self.dim.id();
|
||||
for g in self.lan.iter().flat_map(|h| h.hub.guests.iter()) {
|
||||
if g.placed && g.dimension == dimension {
|
||||
out.push((
|
||||
g.entity_id as u32,
|
||||
"minecraft:player".to_owned(),
|
||||
g.position,
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// A world's block entities with their types (`native_level_block_entities`).
|
||||
fn block_entity_types(&self, world: &World) -> Vec<([i32; 3], String)> {
|
||||
let facts = self.res.facts();
|
||||
world
|
||||
.block_entities
|
||||
.iter()
|
||||
.map(|(&(x, y, z), _)| {
|
||||
let name = &facts.get(world.block(x, y, z)).name;
|
||||
let kind = horizon_client::lan::chunks::block_entity_type(name)
|
||||
.map_or_else(|| name.to_string(), str::to_owned);
|
||||
([x, y, z], kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts by name, the most first and ties by name.
|
||||
fn most_first(counts: BTreeMap<String, usize>) -> Vec<(String, usize)> {
|
||||
let mut out: Vec<_> = counts.into_iter().collect();
|
||||
out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Most first; a tie keeps the names in order.
|
||||
#[test]
|
||||
fn counts_come_most_first() {
|
||||
let counts = [
|
||||
("minecraft:cow", 2),
|
||||
("minecraft:bat", 2),
|
||||
("minecraft:zombie", 5),
|
||||
]
|
||||
.map(|(k, n)| (k.to_owned(), n))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let names: Vec<_> = super::most_first(counts)
|
||||
.into_iter()
|
||||
.map(|(k, n)| format!("{k} {n}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["minecraft:zombie 5", "minecraft:bat 2", "minecraft:cow 2"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ fn root_id(target: Option<Target>, first: u32, live: impl Fn(u32) -> bool) -> Op
|
||||
}
|
||||
|
||||
/// The entity id a summoned stack's root is known by.
|
||||
fn target_id(t: Target) -> Option<u32> {
|
||||
pub(super) fn target_id(t: Target) -> Option<u32> {
|
||||
match t {
|
||||
Target::Mob(id)
|
||||
| Target::Item(id)
|
||||
@@ -134,10 +134,12 @@ impl Game<'_> {
|
||||
/// Spawns an entity of `kind` (`minecraft:item_display`) at `pos` in the
|
||||
/// level in place, `dimension`, from `nbt` (its compound, as `/summon
|
||||
/// <kind> <pos> <nbt>` takes it; passengers included): its id. None on a
|
||||
/// client's copy of the world, for another dimension, a position outside
|
||||
/// client's copy of the world, for a position outside
|
||||
/// `/summon`'s bounds, a chunk that isn't loaded, a kind Horizon can't
|
||||
/// load, or one a mod kept out (`native_entity_join_level`). API
|
||||
/// 59.
|
||||
/// 59. Since API @API:npc_any_level@, `dimension` may be any level the
|
||||
/// server has loaded, which is put in place for the summon (`execute in
|
||||
/// <dimension> run summon`); a level not loaded is refused.
|
||||
pub(super) fn native_summon(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -145,6 +147,13 @@ impl Game<'_> {
|
||||
pos: [f64; 3],
|
||||
nbt: Compound,
|
||||
) -> Option<u32> {
|
||||
if (self.serving.is_some() || self.options.dedicated.is_some())
|
||||
&& dimension != self.dim.id()
|
||||
{
|
||||
return self
|
||||
.npc_in_level(dimension, |g| g.native_summon(dimension, kind, pos, nbt))
|
||||
.flatten();
|
||||
}
|
||||
if !self.native_simulates_level(dimension) || !position_ok(pos) {
|
||||
return None;
|
||||
}
|
||||
@@ -250,14 +259,94 @@ impl Game<'_> {
|
||||
/// Removes entity `id`, one Horizon keeps without simulating, as `/kill`
|
||||
/// does (it drops nothing and isn't saved). False for no such entity or
|
||||
/// another dimension. Mobs go with `native_remove_mob`. API
|
||||
/// 59.
|
||||
/// 59. Since API @API:remove_projectiles@ it also takes projectiles
|
||||
/// (arrows, tridents, snowballs, eggs, pearls, fireballs, wind charges,
|
||||
/// llama spit, shulker bullets, firework rockets) as `/kill` does, and a
|
||||
/// server player's fishing bobber (`Entity.discard`; the player in place's,
|
||||
/// or, outside a guest's turn, any of this level's players').
|
||||
pub(super) fn native_remove_entity(&mut self, dimension: &str, id: u32) -> bool {
|
||||
if !self.native_simulates_level(dimension) || !self.others.iter().any(|o| o.id == id) {
|
||||
if !self.native_simulates_level(dimension) {
|
||||
return false;
|
||||
}
|
||||
self.others.retain(|o| o.id != id);
|
||||
self.persist.raw.remove(&id);
|
||||
true
|
||||
if self.others.iter().any(|o| o.id == id) {
|
||||
self.others.retain(|o| o.id != id);
|
||||
self.persist.raw.remove(&id);
|
||||
return true;
|
||||
}
|
||||
// An id of 0 is a projectile no server tracks yet: never one a mod was told of.
|
||||
if id != 0
|
||||
&& let Some(
|
||||
projectile @ (Target::Projectile(_) | Target::Trident(_) | Target::Rocket(_)),
|
||||
) = self.level_entity(id as i32)
|
||||
{
|
||||
self.kill_target(projectile);
|
||||
return true;
|
||||
}
|
||||
self.remove_server_hook(id)
|
||||
}
|
||||
/// Hurts the living entity `target` (a mob, or a player of the level `dimension`) by
|
||||
/// `amount` of `damage_type` (`minecraft:mob_attack`), from `attacker` (a mob's or a
|
||||
/// player's entity id: the causing and direct entity, as vanilla's
|
||||
/// `damageSources().mobAttack(attacker)`; none for no entity), as `LivingEntity.hurtServer`
|
||||
/// and `/damage <target> <amount> <damage_type> by <attacker>` do: armour, effects,
|
||||
/// invulnerability time, knockback from the attacker, death and its message, and the damage
|
||||
/// hooks (`native_living_incoming_damage` and its versions) hear it. Whether it was hurt:
|
||||
/// false for an invulnerable, dead or creative target, another dimension, an entity that
|
||||
/// isn't living, an attacker that isn't there, or an amount that isn't a finite number.
|
||||
/// API @API:hurt_entity@.
|
||||
pub(super) fn native_hurt_entity(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
target: u32,
|
||||
amount: f32,
|
||||
damage_type: &str,
|
||||
attacker: Option<u32>,
|
||||
) -> bool {
|
||||
if !self.native_simulates_level(dimension) || !amount.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let Some(victim) = self.hurt_entity_target(dimension, target) else {
|
||||
return false;
|
||||
};
|
||||
let by = match attacker {
|
||||
Some(id) => match self.hurt_entity_target(dimension, id) {
|
||||
Some(t) => Some(t),
|
||||
None => return false,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let from = by.and_then(|t| self.target_position(t));
|
||||
self.damage_target(victim, amount, damage_type, None, by, from)
|
||||
}
|
||||
/// The player or mob `id` names in the level `dimension`: a player by their entity id
|
||||
/// (the one in place, whose turn it is, as `Target::Player`), a mob that is there.
|
||||
fn hurt_entity_target(&self, dimension: &str, id: u32) -> Option<Target> {
|
||||
let entity = i32::try_from(id).ok()?;
|
||||
let guest = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.find(|g| g.placed && g.entity_id == entity && g.dimension == dimension);
|
||||
if let Some(g) = guest {
|
||||
let acting = self.lan_acting == Some(g.conn) && self.player.is_some();
|
||||
return Some(if acting {
|
||||
Target::Player
|
||||
} else {
|
||||
Target::Guest(g.conn)
|
||||
});
|
||||
}
|
||||
// The player in place while the hub is out (a guest's turn), or a LAN host.
|
||||
if (self.lan_acting.is_some() || self.options.dedicated.is_none())
|
||||
&& self.player.as_ref().is_some_and(|p| p.entity_id == entity)
|
||||
{
|
||||
return Some(Target::Player);
|
||||
}
|
||||
match self.level_entity(entity)? {
|
||||
Target::Mob(mob) if self.mobs.iter().any(|m| m.id == mob && m.alive()) => {
|
||||
Some(Target::Mob(mob))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 451 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user