mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
427
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089a7c1242 | ||
|
|
6cfb512e1e | ||
|
|
6f6042da38 | ||
|
|
345a66c710 | ||
|
|
2cc12be695 | ||
|
|
8126b95fb6 | ||
|
|
a36ce4ef76 | ||
|
|
d53d7bf02a | ||
|
|
e42f12db85 | ||
|
|
988f7d1ade | ||
|
|
8e3cade985 | ||
|
|
8ca4e1d985 | ||
|
|
b5c12d0f76 | ||
|
|
2251cfb716 | ||
|
|
e5b2d2ea71 | ||
|
|
c1a7b0d35e | ||
|
|
b502fe41ac | ||
|
|
a8c5f833e4 | ||
|
|
8ca4e05005 | ||
|
|
adcc59b920 | ||
|
|
f42833e146 | ||
|
|
1bd640668a | ||
|
|
cc5f7ff239 | ||
|
|
f7a5a3809f | ||
|
|
1faeccc439 | ||
|
|
57512281ba | ||
|
|
ebafaf775a | ||
|
|
2f890f7df9 | ||
|
|
62a7855892 | ||
|
|
cc3714e6ad | ||
|
|
d08bf63777 | ||
|
|
92dbf8406e | ||
|
|
77ac54a216 | ||
|
|
8815f97746 | ||
|
|
7e74759055 | ||
|
|
6c0d2781e6 | ||
|
|
45a678e309 | ||
|
|
efaa2a0e3f | ||
|
|
ed5ac7df1f | ||
|
|
3e92f5e6fd | ||
|
|
a0e3c383a2 | ||
|
|
1c9379c29e | ||
|
|
46e88c47b1 | ||
|
|
8671d460bf | ||
|
|
9f282e42ea | ||
|
|
7b0e4fee81 | ||
|
|
c76e42d88d | ||
|
|
fe8b0c622f | ||
|
|
c8137fbec9 | ||
|
|
a4d6ba2781 | ||
|
|
84cc6b5d64 | ||
|
|
5258de5549 | ||
|
|
c939630a79 | ||
|
|
932d351e11 | ||
|
|
8504efabb8 | ||
|
|
63db515796 | ||
|
|
d629a4f6f6 | ||
|
|
77f2a09212 | ||
|
|
aa1a7c0c42 | ||
|
|
36e6c2dc3f | ||
|
|
aca06a277b | ||
|
|
fedc1c7313 | ||
|
|
df6139520e | ||
|
|
1a57f3660d | ||
|
|
1e030fd397 | ||
|
|
4c1c81539f | ||
|
|
bf6bb5d470 | ||
|
|
f4cc8528ae | ||
|
|
c12ad4d2cf | ||
|
|
b8dccdaa55 | ||
|
|
50ac3abcda | ||
|
|
f4eb3234a8 | ||
|
|
a02fdabe6f | ||
|
|
d16b78e715 | ||
|
|
e5cc020034 | ||
|
|
799fcccba4 | ||
|
|
54feca80d1 | ||
|
|
c1f8fee513 | ||
|
|
8ca476e489 | ||
|
|
efabb913d0 | ||
|
|
bed239a2cd | ||
|
|
63ddd76a15 | ||
|
|
208f1c010f | ||
|
|
935e3927ab | ||
|
|
412594e68d | ||
|
|
63d25cbd63 | ||
|
|
43aba8dbe5 | ||
|
|
9ee9bb4d0e | ||
|
|
4d5f0a965f | ||
|
|
585e28d1fb | ||
|
|
966a63278e | ||
|
|
cf8a5a51b4 | ||
|
|
22462182e3 | ||
|
|
c8c4a29367 | ||
|
|
f24cc569db | ||
|
|
c3140e1bae | ||
|
|
10b0942e4f | ||
|
|
81aeeacf55 | ||
|
|
a0261a708a | ||
|
|
894f7a0684 | ||
|
|
d5b1c59f67 | ||
|
|
b0ea840d13 | ||
|
|
479d2082a8 | ||
|
|
d4b24f27d6 | ||
|
|
3ad30a2021 | ||
|
|
061648308e | ||
|
|
f80bf5d7f3 | ||
|
|
b6e947c9a4 | ||
|
|
0d21d5520e | ||
|
|
02bdb03444 | ||
|
|
c7183d6232 | ||
|
|
8b33877963 | ||
|
|
3b56859e04 | ||
|
|
98af9399cd | ||
|
|
bebf670623 | ||
|
|
5daf0853e3 | ||
|
|
bb8a9d9524 | ||
|
|
a4586852b1 | ||
|
|
3a81751180 | ||
|
|
ec72efe92d | ||
|
|
87c51ab9c9 | ||
|
|
f2a63474b9 | ||
|
|
24224f4728 | ||
|
|
4e580b2732 | ||
|
|
0a14bac24c | ||
|
|
4eb7a27e58 | ||
|
|
108c130237 | ||
|
|
73fdce3c01 | ||
|
|
bf22a35ab3 | ||
|
|
e41b6599ec | ||
|
|
0c2aec59f6 | ||
|
|
50c2ade6c6 | ||
|
|
3281781334 | ||
|
|
5871fa922f | ||
|
|
16bb8b3e65 | ||
|
|
1e35996644 | ||
|
|
8be5c59fce | ||
|
|
3aaff8da93 | ||
|
|
499b049803 | ||
|
|
29825eb774 | ||
|
|
ded0d1a283 | ||
|
|
1c8729793f | ||
|
|
69c1400e58 | ||
|
|
948c6d6a88 | ||
|
|
b8c058a249 | ||
|
|
5a0c5d522f | ||
|
|
ff27525aaf | ||
|
|
54112649da | ||
|
|
ef207a7c92 | ||
|
|
c86e080406 | ||
|
|
26243ba3b7 | ||
|
|
a4f021ec67 | ||
|
|
a7bf97e67d | ||
|
|
11e2482ae5 | ||
|
|
363906684c | ||
|
|
a567b64f20 | ||
|
|
faa7e2746f | ||
|
|
b4197ddd94 | ||
|
|
ad8663977c | ||
|
|
0b3a4bda9c | ||
|
|
5fb8f59ea0 | ||
|
|
62d2b18c16 | ||
|
|
17541e8bff | ||
|
|
a8107ee025 | ||
|
|
927b546c9a | ||
|
|
fff2a81e41 | ||
|
|
1f883e7a79 | ||
|
|
c956e11ff8 | ||
|
|
f6a9444deb | ||
|
|
0610b830ca | ||
|
|
8a21328406 | ||
|
|
d165254d41 | ||
|
|
0125ca5281 | ||
|
|
743d6229fe | ||
|
|
6df8d31833 | ||
|
|
cc4911fc2b | ||
|
|
8d1a46ef24 | ||
|
|
a6a228a193 | ||
|
|
57140bb20f | ||
|
|
0c7ddd45c5 | ||
|
|
c9b0011952 | ||
|
|
a25a11fbf7 | ||
|
|
9f9daee867 | ||
|
|
468a73f128 | ||
|
|
c542322582 | ||
|
|
264d5f38c7 | ||
|
|
dd23018283 | ||
|
|
3b575a0016 | ||
|
|
94d295d20c | ||
|
|
625f6ca1a5 | ||
|
|
ec080faa9f | ||
|
|
1d1b060e1a | ||
|
|
5e8d9a5123 | ||
|
|
d12a260a53 | ||
|
|
0d70c87082 | ||
|
|
3649dfb9eb | ||
|
|
94d8386027 | ||
|
|
b32520088e | ||
|
|
460ecfce99 | ||
|
|
c9bc6104b3 | ||
|
|
02c05de455 | ||
|
|
6a737285e8 | ||
|
|
1354db6f08 | ||
|
|
d503874bff | ||
|
|
f97e4b9527 | ||
|
|
504b987937 | ||
|
|
c81874d772 | ||
|
|
a589a3dd9b | ||
|
|
8d38356bf4 | ||
|
|
4c0ef1d7df | ||
|
|
4b24e21a8a | ||
|
|
ebafc91999 | ||
|
|
1fe1d5e860 | ||
|
|
382f264b6a | ||
|
|
679f8c6e3f | ||
|
|
c6d5769e4f | ||
|
|
1ad899c7b5 | ||
|
|
b9439b3508 | ||
|
|
6a8ea6fc18 | ||
|
|
1e1c6a900f | ||
|
|
ff381b5dc4 | ||
|
|
7dc8ba568f | ||
|
|
a1710b6962 | ||
|
|
e7956591f3 | ||
|
|
4b352e89c1 | ||
|
|
2726d8040b | ||
|
|
6da74ac228 | ||
|
|
6716cb7407 | ||
|
|
58d5f3b519 | ||
|
|
8181ce0222 | ||
|
|
76b8b4eb83 | ||
|
|
6303017b03 | ||
|
|
e16a5ef0da | ||
|
|
76addba6cc | ||
|
|
cc643f4af4 | ||
|
|
c843c1aa05 | ||
|
|
283c6723c0 | ||
|
|
1532c9c153 | ||
|
|
23a880843d | ||
|
|
7b4634a912 | ||
|
|
2daa740748 | ||
|
|
cd547654d9 | ||
|
|
0ed3868329 | ||
|
|
7b80ab60c7 | ||
|
|
6beb50ebcd | ||
|
|
c1d16aabde | ||
|
|
c0c3096e85 | ||
|
|
74150d5ef8 | ||
|
|
d3678c9112 | ||
|
|
dfb9757d97 | ||
|
|
a58d5700f3 | ||
|
|
9cdcf66e69 | ||
|
|
c442edabd9 | ||
|
|
29f04e40ed | ||
|
|
04f6bd1214 | ||
|
|
27d093c94f | ||
|
|
36c0d3f938 | ||
|
|
6bd507f89d | ||
|
|
7a846eebc6 | ||
|
|
bd152b1ecf | ||
|
|
6f9981bfbf | ||
|
|
6cf1b6b74c | ||
|
|
12454e54bd | ||
|
|
6b2c83656c | ||
|
|
d7e1eaf955 | ||
|
|
7c57f5f906 | ||
|
|
7cc9057fa6 | ||
|
|
69fab597a6 | ||
|
|
8dd47b5ef2 | ||
|
|
790e5f14d4 | ||
|
|
3c01dce30d | ||
|
|
66dc3229a0 | ||
|
|
2948fb86ed | ||
|
|
9f8f6842db | ||
|
|
1525ae0e3b | ||
|
|
09450fb6f9 | ||
|
|
97d414cc4d | ||
|
|
53bc816fa1 | ||
|
|
1411281eea | ||
|
|
92bc3bc40f | ||
|
|
d6d7cc3e29 | ||
|
|
3f11bff706 | ||
|
|
27b110085e | ||
|
|
5bb149a282 | ||
|
|
4b3116c54f | ||
|
|
b08f1074b1 | ||
|
|
9a076b6a24 | ||
|
|
b40c996fb9 | ||
|
|
1d13a300fa | ||
|
|
99e1caf4db | ||
|
|
400335608e | ||
|
|
54642a5e80 | ||
|
|
5905a4977f | ||
|
|
98d37c0887 | ||
|
|
be24d83d97 | ||
|
|
89ca6821b8 | ||
|
|
4eafef1180 | ||
|
|
561f797c04 | ||
|
|
e82ac45192 | ||
|
|
cb65d4f80d | ||
|
|
04116c33c6 | ||
|
|
9734a8a255 | ||
|
|
cd7d6a3dcc | ||
|
|
153044c095 | ||
|
|
1d8a1699ad | ||
|
|
4f3355c151 | ||
|
|
7dcc5b46f3 | ||
|
|
b73ef3faa3 | ||
|
|
868e494b0c | ||
|
|
576c5942f1 | ||
|
|
575fadbeeb | ||
|
|
6df80522aa | ||
|
|
f16012e26c | ||
|
|
5a92cfb6b1 | ||
|
|
22807a3f3a | ||
|
|
b27965b4ca | ||
|
|
9027fcd390 | ||
|
|
9c5982d8e0 | ||
|
|
1665d177c6 | ||
|
|
36dac313c8 | ||
|
|
009378de29 | ||
|
|
b9607ce998 | ||
|
|
9fb4fde63b | ||
|
|
d2cd07f1ac | ||
|
|
dec4b523f3 | ||
|
|
3236b57773 | ||
|
|
cb7babf1cf | ||
|
|
c572bb7f88 | ||
|
|
9329e154cb | ||
|
|
72a1b13eae | ||
|
|
2ea59c309b | ||
|
|
cf92894885 | ||
|
|
0e05b77d95 | ||
|
|
4fea4cfd63 | ||
|
|
61e96ef269 | ||
|
|
9ee63c0510 | ||
|
|
9794dd7ca9 | ||
|
|
5d76ff0225 | ||
|
|
caa5c31dd0 | ||
|
|
4465421885 | ||
|
|
1d38cdeba7 | ||
|
|
249341e7cc | ||
|
|
85c277b5e2 | ||
|
|
309ddb48ed | ||
|
|
87a0ccc90c | ||
|
|
1e36a5da1f | ||
|
|
6ad03dd306 | ||
|
|
837ec11a1a | ||
|
|
a26ffbb007 | ||
|
|
eb6d139e34 | ||
|
|
fc6f53a337 | ||
|
|
a13106bc0b | ||
|
|
16d11c6bb2 | ||
|
|
b248489d15 | ||
|
|
878342f98e | ||
|
|
6165c33ebe | ||
|
|
6e083af988 | ||
|
|
404cf600c1 | ||
|
|
b66525f609 | ||
|
|
1b8b51978d | ||
|
|
dcffd6cae9 | ||
|
|
7fcf18b6df | ||
|
|
ed01ae3029 | ||
|
|
a9c7b82772 | ||
|
|
694d953496 | ||
|
|
2f9ec5be67 | ||
|
|
cf31690cae | ||
|
|
3f782b89ee | ||
|
|
5062da5567 | ||
|
|
69ecedfc01 | ||
|
|
f2dfddea11 | ||
|
|
0e33837c78 | ||
|
|
53aad00102 | ||
|
|
3c794c2825 | ||
|
|
29662d963c | ||
|
|
c69d03f96e | ||
|
|
de4029c1df | ||
|
|
62a47df275 | ||
|
|
232b0e85aa | ||
|
|
05bc471106 | ||
|
|
4272d9ae41 | ||
|
|
45af2b23b2 | ||
|
|
de7d92e134 | ||
|
|
8c6933bda4 | ||
|
|
4ea8baade5 | ||
|
|
15047ffac4 | ||
|
|
b20ab60d01 | ||
|
|
c0e2f62b35 | ||
|
|
817c37749f | ||
|
|
4f49676a37 | ||
|
|
24987d6660 | ||
|
|
391ce2975d | ||
|
|
ce92f7e45a | ||
|
|
a7f6ecbdfb | ||
|
|
31c1bd1f8b | ||
|
|
7e5063a4d5 | ||
|
|
185908fd38 | ||
|
|
dd0bffd667 | ||
|
|
c8f1559eb0 | ||
|
|
1f031f6bd3 | ||
|
|
2e7afbfe64 | ||
|
|
5a3dc52e1f | ||
|
|
bb4d24e909 | ||
|
|
513312eb8b | ||
|
|
6c44743c21 | ||
|
|
7cead764e8 | ||
|
|
ef05a384d5 | ||
|
|
d4a2f07032 | ||
|
|
0801940eeb | ||
|
|
5cede4ff4e | ||
|
|
574b20a662 | ||
|
|
2a767017b9 | ||
|
|
a8ea55f39a | ||
|
|
9fcd6133ed | ||
|
|
812702d809 | ||
|
|
bfc26f944a | ||
|
|
7658a24435 | ||
|
|
394db9011a | ||
|
|
b1896906d1 | ||
|
|
debd86ea59 | ||
|
|
f9a93d080a | ||
|
|
caeaa41bdc | ||
|
|
15f071ff3b | ||
|
|
4985864055 | ||
|
|
9d9617fe29 | ||
|
|
d9d4d19a1d | ||
|
|
2f9c963068 |
No files matched your search
Generated
+203
@@ -147,6 +147,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
@@ -303,6 +309,21 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "btoi"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bufstream"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -657,6 +678,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -774,6 +804,17 @@ version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_utils"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc05a5d33db20c784f873e84934ad94bb209a090987ac5f62fede2c178234f23"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "des"
|
||||
version = "0.8.1"
|
||||
@@ -1615,6 +1656,16 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-deps"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mysql",
|
||||
"redis",
|
||||
"rusty-opus",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
@@ -2010,6 +2061,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-enum"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f859dc81e05e585e93cfc84dffa4fd068d0651ca229c51bca6101c056b63b93a"
|
||||
dependencies = [
|
||||
"derive_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
@@ -2257,6 +2317,12 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.14.0"
|
||||
@@ -2346,6 +2412,59 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "mysql"
|
||||
version = "27.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
|
||||
dependencies = [
|
||||
"bufstream",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"io-enum",
|
||||
"libc",
|
||||
"lru",
|
||||
"mysql_common",
|
||||
"named_pipe",
|
||||
"pem",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"socket2 0.6.5",
|
||||
"twox-hash",
|
||||
"url",
|
||||
"webpki",
|
||||
"webpki-roots 1.0.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bitflags 2.13.2",
|
||||
"btoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"saturating",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
@@ -2383,6 +2502,15 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "named_pipe"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad9c443cce91fc3e12f017290db75dde490d685cdaaf508d7159d7cf41f0eb2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2982,6 +3110,16 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -3309,6 +3447,22 @@ dependencies = [
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"combine",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"ryu",
|
||||
"socket2 0.6.5",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@@ -3450,6 +3604,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -3476,6 +3639,12 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-opus"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46604e6809c06441da1cfdde1f0cc25a4f2bf42c7d005aa387c2875c02d78762"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.1"
|
||||
@@ -3485,6 +3654,12 @@ dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.10.2"
|
||||
@@ -3503,6 +3678,12 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "saturating"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -4111,6 +4292,18 @@ version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"log",
|
||||
"rand",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.5"
|
||||
@@ -4454,6 +4647,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
||||
@@ -39,6 +39,7 @@ members = [
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
"xtask",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -394,7 +394,7 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
|
||||
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
|
||||
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
|
||||
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
|
||||
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
|
||||
pub fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
|
||||
use horizon_world::datafix::json::{Json, parse_lenient};
|
||||
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
|
||||
map.extend(entries);
|
||||
|
||||
@@ -81,7 +81,7 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
|
||||
}
|
||||
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
|
||||
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
|
||||
fn int_value(t: &Tag) -> Option<i32> {
|
||||
pub fn int_value(t: &Tag) -> Option<i32> {
|
||||
Some(match t {
|
||||
Tag::Byte(v) => i32::from(*v),
|
||||
Tag::Short(v) => i32::from(*v),
|
||||
|
||||
@@ -11,7 +11,7 @@ macro_rules! actions {
|
||||
$($variant,)*
|
||||
}
|
||||
impl Action {
|
||||
/// In `Options.keyMappings` order, the pad-only actions last.
|
||||
/// In `Options.keyMappings` order, then Horizon's own mappings, the pad-only actions last.
|
||||
pub const ALL: &[Action] = &[$(Action::$variant,)*];
|
||||
/// The saved name: vanilla's for a mapping, `pad.*` for Horizon's own.
|
||||
pub fn name(self) -> &'static str {
|
||||
@@ -49,7 +49,6 @@ actions! {
|
||||
PickItem, "key.pickItem", None;
|
||||
Command, "key.command", Some(KeyCode::Slash);
|
||||
Friends, "key.friends", Some(KeyCode::KeyO);
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
SocialInteractions, "key.socialInteractions", Some(KeyCode::KeyP);
|
||||
ToggleGui, "key.toggleGui", Some(KeyCode::F1);
|
||||
ToggleSpectatorShaderEffects, "key.toggleSpectatorShaderEffects", Some(KeyCode::F4);
|
||||
@@ -96,6 +95,7 @@ actions! {
|
||||
DebugNetworkCharts, "key.debug.networkCharts", None;
|
||||
DebugLightmapTexture, "key.debug.lightmapTexture", None;
|
||||
DebugImprovedTransparency, "key.debug.improvedTransparency", None;
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
}
|
||||
@@ -109,21 +109,24 @@ impl Action {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keybinds::{self, KeyBinds, MAPPINGS};
|
||||
use crate::keybinds::{self, HORIZON_MAPPINGS, KeyBinds, MAPPINGS, all_mappings};
|
||||
|
||||
#[test]
|
||||
fn every_mapping_is_an_action_in_order() {
|
||||
let names: Vec<&str> = Action::ALL.iter().map(|a| a.name()).collect();
|
||||
let mappings: Vec<&str> = MAPPINGS.iter().map(|m| m.0).collect();
|
||||
assert_eq!(names[..mappings.len()], mappings[..]);
|
||||
for action in &names[mappings.len()..] {
|
||||
let horizon: Vec<&str> = HORIZON_MAPPINGS.iter().map(|m| m.0).collect();
|
||||
let end = mappings.len() + horizon.len();
|
||||
assert_eq!(names[mappings.len()..end], horizon[..]);
|
||||
for action in &names[end..] {
|
||||
assert!(action.starts_with("pad."), "{action}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_actions_key_is_its_mappings_default_key() {
|
||||
for (name, _, category) in MAPPINGS {
|
||||
for (name, _, category) in all_mappings() {
|
||||
let action = Action::parse(name).unwrap();
|
||||
let want = if *category == "debug" {
|
||||
None
|
||||
|
||||
@@ -1742,11 +1742,11 @@ bind_key.hotbar.2:north
|
||||
assert_eq!(s.actions_of(Button::North), ["key.inventory"]);
|
||||
assert_eq!(s.actions_of(Button::RightBumper), [HOTBAR_NEXT]);
|
||||
assert!(s.actions_of(Button::Start).is_empty());
|
||||
// Every default action names a vanilla mapping or a pad action.
|
||||
// Every default action names a key mapping or a pad action.
|
||||
for (action, _) in ACTIONS {
|
||||
assert!(
|
||||
action.starts_with("pad.")
|
||||
|| crate::keybinds::MAPPINGS.iter().any(|m| m.0 == *action),
|
||||
|| crate::keybinds::all_mappings().any(|m| m.0 == *action),
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
("key.pickItem", "key.mouse.middle", "gameplay"),
|
||||
("key.command", "key.keyboard.slash", "multiplayer"),
|
||||
("key.friends", "key.keyboard.o", "multiplayer"),
|
||||
// Horizon's emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
|
||||
("key.emote", "key.keyboard.b", "multiplayer"),
|
||||
("key.socialInteractions", "key.keyboard.p", "multiplayer"),
|
||||
("key.toggleGui", "key.keyboard.f1", "misc"),
|
||||
(
|
||||
@@ -76,6 +74,17 @@ pub const MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
("key.debug.lightmapTexture", "key.keyboard.4", "debug"),
|
||||
("key.debug.improvedTransparency", "key.keyboard.x", "debug"),
|
||||
];
|
||||
/// Horizon's own key mappings: bound in Controls like the vanilla ones, but kept in
|
||||
/// `horizon-extras.txt` (`emoteKey`), never in `options.txt`, which stays exactly vanilla's
|
||||
/// (`MAPPINGS` is what the options parity harness compares with the pinned client).
|
||||
pub const HORIZON_MAPPINGS: &[(&str, &str, &str)] = &[
|
||||
// The emote wheel (docs/cosmetics.md), on a key vanilla leaves free.
|
||||
("key.emote", "key.keyboard.b", "multiplayer"),
|
||||
];
|
||||
/// Every mapping the game binds: vanilla's in `Options.keyMappings` order, then Horizon's.
|
||||
pub fn all_mappings() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
|
||||
MAPPINGS.iter().chain(HORIZON_MAPPINGS)
|
||||
}
|
||||
/// Mappings Options builds as `ToggleKeyMapping` (hold or toggle by option).
|
||||
pub fn is_toggle(name: &str) -> bool {
|
||||
matches!(name, "key.sneak" | "key.sprint" | "key.use" | "key.attack")
|
||||
@@ -314,8 +323,7 @@ pub struct KeyBinds {
|
||||
impl Default for KeyBinds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keys: MAPPINGS
|
||||
.iter()
|
||||
keys: all_mappings()
|
||||
.map(|(n, d, _)| (*n, (*d).to_owned()))
|
||||
.collect(),
|
||||
}
|
||||
@@ -341,8 +349,7 @@ impl KeyBinds {
|
||||
.map_or("key.keyboard.unknown", String::as_str)
|
||||
}
|
||||
pub fn default_of(name: &str) -> &'static str {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.find(|m| m.0 == name)
|
||||
.map_or("key.keyboard.unknown", |m| m.1)
|
||||
}
|
||||
@@ -350,16 +357,24 @@ impl KeyBinds {
|
||||
self.get(name) == Self::default_of(name)
|
||||
}
|
||||
pub fn set(&mut self, name: &str, key: &str) {
|
||||
if let Some((n, _, _)) = MAPPINGS.iter().find(|m| m.0 == name) {
|
||||
if let Some((n, _, _)) = all_mappings().find(|m| m.0 == name) {
|
||||
self.keys.insert(n, key.to_owned());
|
||||
}
|
||||
}
|
||||
/// The bindings `options.txt` holds: vanilla's mappings only.
|
||||
pub fn saved(&self) -> Vec<(&'static str, String)> {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
/// Horizon's own bindings, for `horizon-extras.txt`.
|
||||
pub fn horizon_saved(&self) -> Vec<(&'static str, String)> {
|
||||
HORIZON_MAPPINGS
|
||||
.iter()
|
||||
.map(|(n, _, _)| (*n, self.get(n).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
pub fn reset_all(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
@@ -369,8 +384,7 @@ impl KeyBinds {
|
||||
if key == "key.keyboard.unknown" {
|
||||
return Vec::new();
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.filter(|(n, _, _)| {
|
||||
*n != name && self.get(n) == key && (!self.is_default(n) || !self.is_default(name))
|
||||
})
|
||||
@@ -384,8 +398,7 @@ impl KeyBinds {
|
||||
/// Every mapping a physical input triggers, in list order: like
|
||||
/// `KeyMapping.set`/`click`, one key drives all the mappings bound to it.
|
||||
pub fn mappings_for<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
}
|
||||
@@ -395,8 +408,7 @@ impl KeyBinds {
|
||||
/// key and the binding itself); their defaults (A, S, D, T, L, 1 to 4...)
|
||||
/// would otherwise turn the key back into its old gameplay meaning.
|
||||
fn translated_mappings<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'static str> + 'a {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
all_mappings()
|
||||
.filter(|m| m.2 != "debug")
|
||||
.map(|m| m.0)
|
||||
.filter(move |n| self.get(n) == key)
|
||||
@@ -404,9 +416,7 @@ impl KeyBinds {
|
||||
/// Whether a mapping (other than a debug key) whose default is `key` was
|
||||
/// bound elsewhere.
|
||||
fn default_moved(&self, key: &str) -> bool {
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
all_mappings().any(|(n, d, c)| *d == key && *c != "debug" && !self.is_default(n))
|
||||
}
|
||||
/// Translate a physical key into the default key of the mapping it is
|
||||
/// bound to, so game code written against vanilla defaults follows the
|
||||
|
||||
@@ -6,13 +6,7 @@ use horizon_world::{
|
||||
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs::File,
|
||||
io::{self, BufReader},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
|
||||
|
||||
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -190,15 +184,12 @@ fn invalid(message: &str) -> io::Error {
|
||||
}
|
||||
impl Assets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let manifest: Value = serde_json::from_reader(BufReader::new(File::open(
|
||||
root.join("horizon-resources.json"),
|
||||
)?))?;
|
||||
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
|
||||
if manifest["data_version"].as_i64() != Some(i64::from(DATA_VERSION)) {
|
||||
return Err(invalid("resources do not match Horizon's pinned data version").into());
|
||||
}
|
||||
let (registry, flags) = horizon_world::level::data::load_registry_with_flags(root)?;
|
||||
let bundle: Value =
|
||||
serde_json::from_reader(BufReader::new(File::open(root.join("block-tags.json"))?))?;
|
||||
let bundle: Value = horizon_world::json_file::read(&root.join("block-tags.json"))?;
|
||||
let mut definitions = TagDefinitions::default();
|
||||
for (name, text) in bundle
|
||||
.as_object()
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! small mixer standing in for OpenAL: one channel per source with its gain,
|
||||
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
|
||||
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
|
||||
//! (streams on a worker thread) and OpenAL Soft's stereo panning.
|
||||
//! (streams on a worker thread) and OpenAL Soft's mixing: mono sources panned
|
||||
//! and attenuated, multi-channel buffers (stereo music and records) played
|
||||
//! direct as OpenAL does.
|
||||
use crate::Result;
|
||||
use crate::sound_engine::{
|
||||
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
|
||||
@@ -29,7 +31,7 @@ enum State {
|
||||
}
|
||||
/// An OpenAL source.
|
||||
struct Channel {
|
||||
samples: Option<(Arc<Vec<f32>>, u32)>,
|
||||
samples: Option<(Arc<Vec<f32>>, u32, u16)>,
|
||||
position: f64,
|
||||
state: State,
|
||||
gain: f32,
|
||||
@@ -135,8 +137,8 @@ impl Drop for MixerBackend {
|
||||
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// Two players' copies of one sound: started within this of each other.
|
||||
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded interleaved samples, their rate and channel count (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32, u16)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
/// recently used first past `CACHE_BYTES`: kept forever, every sound heard in
|
||||
/// a session stayed (up to 1.4 GiB of samples, villages playing hundreds).
|
||||
@@ -177,7 +179,7 @@ impl SoundCache {
|
||||
fn size(samples: &Samples) -> usize {
|
||||
samples
|
||||
.as_ref()
|
||||
.map_or(0, |(s, _)| s.len() * std::mem::size_of::<f32>())
|
||||
.map_or(0, |(s, _, _)| s.len() * std::mem::size_of::<f32>())
|
||||
}
|
||||
}
|
||||
/// A static sound to decode for a channel waiting on it.
|
||||
@@ -260,7 +262,7 @@ pub struct Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
|
||||
use symphonia::core::{
|
||||
audio::SampleBuffer, codecs::DecoderOptions, formats::FormatOptions, io::MediaSourceStream,
|
||||
meta::MetadataOptions, probe::Hint,
|
||||
@@ -284,7 +286,8 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.ok()?;
|
||||
let mut mono = Vec::new();
|
||||
let mut samples = Vec::new();
|
||||
let mut channels = 0u16;
|
||||
while let Ok(packet) = format.next_packet() {
|
||||
if packet.track_id() != id {
|
||||
continue;
|
||||
@@ -293,14 +296,34 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
continue;
|
||||
};
|
||||
let spec = *buffer.spec();
|
||||
let channels = spec.channels.count().max(1);
|
||||
let mut samples = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
|
||||
samples.copy_interleaved_ref(buffer);
|
||||
for frame in samples.samples().chunks(channels) {
|
||||
mono.push(frame.iter().sum::<f32>() / channels as f32);
|
||||
let frame_channels = spec.channels.count().max(1);
|
||||
let mut decoded = SampleBuffer::<f32>::new(buffer.capacity() as u64, spec);
|
||||
decoded.copy_interleaved_ref(buffer);
|
||||
if channels == 0 {
|
||||
channels = frame_channels.min(2) as u16;
|
||||
}
|
||||
if frame_channels <= 2 {
|
||||
samples.extend_from_slice(decoded.samples());
|
||||
} else {
|
||||
// OpenAL plays multi-channel buffers direct; a stereo output hears the odd/even split.
|
||||
for frame in decoded.samples().chunks(frame_channels) {
|
||||
let (mut left, mut right) = (0.0, 0.0);
|
||||
let (mut lefts, mut rights) = (0.0f32, 0.0f32);
|
||||
for (i, sample) in frame.iter().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
left += sample;
|
||||
lefts += 1.0;
|
||||
} else {
|
||||
right += sample;
|
||||
rights += 1.0;
|
||||
}
|
||||
}
|
||||
samples.push(left / lefts.max(1.0));
|
||||
samples.push(right / rights.max(1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((mono, rate))
|
||||
(channels != 0).then_some((samples, rate, channels))
|
||||
}
|
||||
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
|
||||
/// player's game picks its own variant of the same event.
|
||||
@@ -427,7 +450,7 @@ impl Backend for MixerBackend {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
std::thread::spawn(move || {
|
||||
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
|
||||
let samples = decode(&file).map(|(s, r, c)| (Arc::new(s), r, c));
|
||||
attach(&mixer, channel, samples, looping);
|
||||
});
|
||||
return;
|
||||
@@ -448,7 +471,7 @@ impl Backend for MixerBackend {
|
||||
// Queued twice before the first decode finished.
|
||||
let cached = cache.lock().ok().and_then(|mut c| c.get(&job.key));
|
||||
let samples = cached.unwrap_or_else(|| {
|
||||
let samples = decode(&job.file).map(|(s, r)| (Arc::new(s), r));
|
||||
let samples = decode(&job.file).map(|(s, r, c)| (Arc::new(s), r, c));
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
c.insert(job.key, samples.clone());
|
||||
}
|
||||
@@ -584,9 +607,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
let Some((_, source_rate)) = &c.samples else {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
// OpenAL plays a multi-channel buffer direct: no panning and no distance attenuation
|
||||
// (`CalcVoiceParams`' non-spatialized path, vanilla's HRTF-off stereo context). Vanilla's
|
||||
// music and records are stereo. A mono buffer is the positional one.
|
||||
let direct = *source_channels > 1;
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
@@ -611,24 +638,33 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
distance_gain(c.linear, distance)
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
let [left, right] = [left, right].map(|pan| gain * pan);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
@@ -636,13 +672,15 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let Some((samples, _)) = c.samples.clone() else {
|
||||
let Some((samples, _, source_channels)) = c.samples.clone() else {
|
||||
continue;
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= samples.len() {
|
||||
if c.looping && samples.len() > 1 {
|
||||
if i + 1 >= frames {
|
||||
if c.looping && frames > 1 {
|
||||
c.position = 0.0;
|
||||
i = 0;
|
||||
} else {
|
||||
@@ -651,13 +689,24 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
} else {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
// A mono output has no panning: the source's own gain.
|
||||
frame[0] += s * gain;
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
} else {
|
||||
// A mono output has no panning: the source's own gain.
|
||||
frame[0] += s * gain;
|
||||
}
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
@@ -1687,7 +1736,7 @@ mod tests {
|
||||
|
||||
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
|
||||
Channel {
|
||||
samples: Some((Arc::new(samples), 48_000)),
|
||||
samples: Some((Arc::new(samples), 48_000, 1)),
|
||||
position: 0.0,
|
||||
state: State::Playing,
|
||||
gain: 1.0,
|
||||
@@ -1702,11 +1751,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel whose buffer is interleaved stereo.
|
||||
fn stereo_channel(
|
||||
samples: Vec<f32>,
|
||||
linear: Option<f32>,
|
||||
at: [f32; 3],
|
||||
looping: bool,
|
||||
) -> Channel {
|
||||
let mut c = channel(Vec::new(), linear, at, looping);
|
||||
c.samples = Some((Arc::new(samples), 48_000, 2));
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_sounds_stay_within_the_budget_least_recently_used_out() {
|
||||
let mut cache = SoundCache::default();
|
||||
let quarter = CACHE_BYTES / 4 / std::mem::size_of::<f32>();
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100));
|
||||
let sound = || Some((Arc::new(vec![0.0f32; quarter]), 44100, 1));
|
||||
for key in ["a", "b", "c", "d"] {
|
||||
cache.insert(key.into(), sound());
|
||||
}
|
||||
@@ -1833,6 +1894,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_sounds_play_direct_without_panning_or_distance() {
|
||||
// OpenAL Soft plays a multi-channel buffer direct (its non-spatialized path), so vanilla's
|
||||
// stereo music and records keep their own left/right mix at full volume whichever way the
|
||||
// camera faces and however far it is. Only mono buffers are panned and attenuated.
|
||||
let wave = [0.1, -0.2, 0.3, -0.4, 0.5, -0.6]; // three stereo frames
|
||||
let expected = [wave[0], wave[1], wave[2], wave[3]];
|
||||
for (yaw, at, linear, listener) in [
|
||||
(0.0, [100.0, 0.0, 0.0], Some(16.0), [0.0; 3]),
|
||||
(180.0, [0.0, 0.0, -100.0], Some(16.0), [0.0; 3]),
|
||||
(90.0, [2.0, 1.0, -3.0], Some(16.0), [5.0, 60.0, 5.0]),
|
||||
(0.0, [0.0, 0.0, 0.0], None, [0.0; 3]),
|
||||
] {
|
||||
let mut m = Mixer {
|
||||
listener,
|
||||
listener_yaw: yaw,
|
||||
..Mixer::default()
|
||||
};
|
||||
m.channels
|
||||
.insert(1, stereo_channel(wave.to_vec(), linear, at, false));
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, expected, "yaw {yaw}, source {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panning_follows_the_camera_rotation() {
|
||||
let gains = |yaw: f32, pitch: f32, at: [f32; 3]| {
|
||||
|
||||
@@ -288,7 +288,7 @@ impl Biomes {
|
||||
.collect();
|
||||
files.sort_by_cached_key(|f| format!("{}.json", f.name));
|
||||
for file in files {
|
||||
let v: Value = serde_json::from_slice(file.bytes()?)?;
|
||||
let v: Value = horizon_world::json_file::parse(&file.path, file.bytes()?)?;
|
||||
list.push(BiomeInfo::from_json(
|
||||
format!("minecraft:{}", file.name),
|
||||
&v,
|
||||
|
||||
@@ -896,6 +896,13 @@ pub fn worldgen_overrides(
|
||||
let mut warnings = Vec::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&overrides::PACK_KINDS) {
|
||||
if let Some(id) = overrides::template_id(&file) {
|
||||
match pack.read(&file) {
|
||||
Some(nbt) => out.set_template(&id, nbt),
|
||||
None => warnings.push(format!("{}: {file}: unreadable", pack.id)),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
@@ -956,6 +963,7 @@ mod tests {
|
||||
for pack in ["a", "b", "off"] {
|
||||
write(pack, "pack.mcmeta", "{}");
|
||||
write(pack, "data/minecraft/worldgen/noise/cave.json", pack);
|
||||
write(pack, "data/p/structure/room/a.nbt", pack);
|
||||
write(
|
||||
pack,
|
||||
"data/minecraft/tags/worldgen/biome/is_hill.json",
|
||||
@@ -992,6 +1000,8 @@ mod tests {
|
||||
o.entries("noises").collect::<Vec<_>>(),
|
||||
[("minecraft:cave", "a")]
|
||||
);
|
||||
assert_eq!(o.templates().len(), 1);
|
||||
assert_eq!(&*o.templates()["p:room/a"], b"a");
|
||||
assert_eq!(
|
||||
o.entries("biomes").collect::<Vec<_>>(),
|
||||
[("minecraft:plains", "{}")]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! celestial angles and star brightness for the overworld day cycle.
|
||||
use crate::Result;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Easing {
|
||||
@@ -151,13 +151,13 @@ impl Environment {
|
||||
/// A dimension's base attributes, optionally driven by the day timeline.
|
||||
pub fn load_dimension(root: &Path, dimension: &str, day_cycle: bool) -> Result<Self> {
|
||||
let timeline: Value = if day_cycle {
|
||||
serde_json::from_slice(&fs::read(root.join("data/minecraft/timeline/day.json"))?)?
|
||||
horizon_world::json_file::read(&root.join("data/minecraft/timeline/day.json"))?
|
||||
} else {
|
||||
serde_json::json!({"period_ticks": 24000, "tracks": {}})
|
||||
};
|
||||
let dimension: Value = serde_json::from_slice(&fs::read(
|
||||
root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
|
||||
)?)?;
|
||||
let dimension: Value = horizon_world::json_file::read(
|
||||
&root.join(format!("data/minecraft/dimension_type/{dimension}.json")),
|
||||
)?;
|
||||
let mut tracks = HashMap::new();
|
||||
for (name, track) in timeline["tracks"].as_object().ok_or("invalid timeline")? {
|
||||
let easing = match &track["ease"] {
|
||||
|
||||
@@ -10,6 +10,9 @@ pub const FILE: &str = "horizon-extras.txt";
|
||||
pub const SORT_KEY: &str = "horizon.sort";
|
||||
/// The 3D skin layers line, here and in the `options.txt` that held it first.
|
||||
const SKIN_LAYERS_KEY: &str = "threeDSkinLayers";
|
||||
/// The emote wheel's key, here and in the `key_key.emote` line #10092 wrote to `options.txt`.
|
||||
const EMOTE_KEY: &str = "emoteKey";
|
||||
const OLD_EMOTE_LINE: &str = "key_key.emote";
|
||||
|
||||
/// The minimap's sizes in GUI pixels, and its zooms in blocks per pixel.
|
||||
pub const MINIMAP_SIZES: [(u8, &str); 3] = [(64, "Small"), (96, "Medium"), (128, "Large")];
|
||||
@@ -126,6 +129,10 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// The emote wheel's key (`key.emote`), as the game last saved it. The live binding is
|
||||
/// `Settings::keys`, bound in Controls with the vanilla ones; it is only kept in this file
|
||||
/// so that `options.txt` stays vanilla's (`Settings::load` hands it over, `save` takes it back).
|
||||
pub emote_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
@@ -220,6 +227,7 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
emote_key: crate::keybinds::KeyBinds::default_of("key.emote").into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
@@ -324,6 +332,7 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
EMOTE_KEY if value.starts_with("key.") => e.emote_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
@@ -430,26 +439,37 @@ impl Extras {
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
);
|
||||
text.push_str(&format!("{EMOTE_KEY}:{}\n", self.emote_key));
|
||||
self.pvp.serialize(&mut text);
|
||||
text
|
||||
}
|
||||
/// Adopts the settings `options.txt` held before they moved here, while
|
||||
/// this file (`own`) has no line of its own: 3D skin layers was an
|
||||
/// `options.txt` line. Vanilla's `Options.save` drops lines it doesn't
|
||||
/// this file (`own`) has no line of its own: 3D skin layers and the emote key were
|
||||
/// `options.txt` lines. Vanilla's `Options.save` drops lines it doesn't
|
||||
/// know, so it is read there once, the first start after the update.
|
||||
pub fn read_old_options(&mut self, own: &str, options_txt: &str) {
|
||||
fn line(text: &str) -> Option<&str> {
|
||||
fn line<'a>(text: &'a str, name: &str) -> Option<&'a str> {
|
||||
text.lines().find_map(|l| {
|
||||
l.split_once(':')
|
||||
.filter(|(key, _)| *key == SKIN_LAYERS_KEY)
|
||||
.filter(|(key, _)| *key == name)
|
||||
.map(|(_, value)| value)
|
||||
})
|
||||
}
|
||||
if line(own).is_none()
|
||||
&& let Some(value) = line(options_txt)
|
||||
if line(own, SKIN_LAYERS_KEY).is_none()
|
||||
&& let Some(value) = line(options_txt, SKIN_LAYERS_KEY)
|
||||
{
|
||||
self.three_d_skin_layers = value == "true";
|
||||
}
|
||||
// The emote key was a `key_key.emote` line of options.txt (#10092): a rebound key is kept.
|
||||
if line(own, EMOTE_KEY).is_none()
|
||||
&& let Some(value) = line(options_txt, OLD_EMOTE_LINE).map(str::trim)
|
||||
&& value.starts_with("key.")
|
||||
&& (value == "key.keyboard.unknown"
|
||||
|| crate::keybinds::key_code(value).is_some()
|
||||
|| crate::keybinds::mouse_button(value).is_some())
|
||||
{
|
||||
self.emote_key = value.to_owned();
|
||||
}
|
||||
}
|
||||
/// Binds the extra key `name` (`SORT_KEY`); false for
|
||||
/// another name (a vanilla mapping).
|
||||
|
||||
@@ -5795,6 +5795,31 @@ impl Renderer {
|
||||
blocks.iter().map(move |block| (block, fully_inside))
|
||||
})
|
||||
}
|
||||
/// The block entities of the sections a shader pack's shadow pass drew in the last frame
|
||||
/// (`shadow_visible`), within the 64 blocks `visible_block_entities_in_frustum` keeps.
|
||||
/// Our probe packs' Iris 1.11.7 shadow maps hold a block entity wherever the shadow pass
|
||||
/// draws its section's terrain, whether or not the camera's view takes it in (#8464).
|
||||
pub fn shadow_pass_block_entities(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
) -> impl Iterator<Item = &([i32; 3], u32)> + '_ {
|
||||
block_entities_within_reach(
|
||||
self.shadow_visible.iter().map(|v| v.key),
|
||||
&self.block_entities,
|
||||
camera,
|
||||
self.layout.min_y(),
|
||||
)
|
||||
}
|
||||
/// Whether a shader pack's shadow pass drew the section holding block `pos` in the last
|
||||
/// frame (`shadow_pass_block_entities`).
|
||||
pub fn shadow_pass_drew(&self, pos: [i32; 3]) -> bool {
|
||||
let key = (
|
||||
pos[0] >> 4,
|
||||
(pos[1] - self.layout.min_y()) >> 4,
|
||||
pos[2] >> 4,
|
||||
);
|
||||
self.shadow_visible.iter().any(|v| v.key == key)
|
||||
}
|
||||
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
|
||||
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
|
||||
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
|
||||
@@ -6609,17 +6634,11 @@ impl Renderer {
|
||||
if let Some(atlas) = &mut atlas {
|
||||
atlas.separate_ao = self.separate_ao;
|
||||
}
|
||||
self.pack_material_atlases = atlas.as_ref().and_then(|_| {
|
||||
self.pack_material_atlases = atlas.as_ref().and_then(|colour| {
|
||||
let maps = self.pbr_maps.as_ref()?;
|
||||
let make = |kind: usize, encoder: &mut wgpu::CommandEncoder| {
|
||||
let arrays: Vec<_> = maps.iter().map(|pair| pair[kind].clone()).collect();
|
||||
pack_atlas::PackAtlas::material(
|
||||
&self.device,
|
||||
encoder,
|
||||
&arrays,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
)
|
||||
colour.material_for(&self.device, encoder, &arrays, layers)
|
||||
};
|
||||
Some([make(0, &mut encoder)?, make(1, &mut encoder)?])
|
||||
});
|
||||
@@ -7636,9 +7655,7 @@ impl Renderer {
|
||||
if let Some(v) = &shadow_view {
|
||||
let radius = (frame.detail_distance / 16.0) as i32;
|
||||
let light = ShadowCull {
|
||||
light: Frustum {
|
||||
planes: frustum(v.cull),
|
||||
},
|
||||
light: shadow_box(v),
|
||||
casters: v.view_casters.then(|| shadow_casters(cull_proj, v.light)),
|
||||
reach: v.distance,
|
||||
};
|
||||
@@ -11441,6 +11458,39 @@ impl Cull for ShadowCull {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The block entities of `sections` whose box, a block wider on every side, comes within the
|
||||
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
|
||||
fn block_entities_within_reach<'a>(
|
||||
sections: impl Iterator<Item = SectionKey> + 'a,
|
||||
block_entities: &'a FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
camera: DVec3,
|
||||
min_y: i32,
|
||||
) -> impl Iterator<Item = &'a ([i32; 3], u32)> + 'a {
|
||||
sections
|
||||
.filter_map(move |key| {
|
||||
let min = (DVec3::new(
|
||||
f64::from(key.0 * 16 - 1),
|
||||
f64::from(min_y + key.1 * 16 - 1),
|
||||
f64::from(key.2 * 16 - 1),
|
||||
) - camera)
|
||||
.as_vec3();
|
||||
let max = min + Vec3::splat(18.0);
|
||||
let near = Vec3::ZERO.max(min).min(max).length_squared() <= 64.0 * 64.0;
|
||||
near.then(|| block_entities.get(&key)).flatten()
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -13412,6 +13462,7 @@ mod shadow_face_tests {
|
||||
unculled: None,
|
||||
every_face,
|
||||
view_casters: true,
|
||||
depth_planes: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13431,6 +13482,24 @@ mod shadow_face_tests {
|
||||
mod shadow_caster_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shadow_pass_takes_the_block_entities_of_its_sections_within_64_blocks() {
|
||||
// Sections 64 to 79 high (the world from -64): a chest in each of three. The shadow
|
||||
// pass drew `near` and `far`; `left_out` lies as near but the pass didn't draw it, and
|
||||
// `far`'s nearest block is 78.5 blocks away.
|
||||
let (near, left_out, far) = ((0, 8, 0), (1, 8, 0), (5, 8, 0));
|
||||
let mut block_entities = FxHashMap::default();
|
||||
block_entities.insert(near, vec![([3, 70, 3], 7)]);
|
||||
block_entities.insert(left_out, vec![([20, 70, 3], 7)]);
|
||||
block_entities.insert(far, vec![([85, 70, 3], 7)]);
|
||||
let camera = DVec3::new(0.5, 70.0, 0.5);
|
||||
let drawn = [near, far].into_iter();
|
||||
let taken: Vec<_> = block_entities_within_reach(drawn, &block_entities, camera, -64)
|
||||
.copied()
|
||||
.collect();
|
||||
assert_eq!(taken, vec![([3, 70, 3], 7)]);
|
||||
}
|
||||
|
||||
/// What the shadow pass keeps of a camera-relative section at `min`, the camera at the
|
||||
/// origin looking along -X (yaw 90) with the scenes' 70 degree, 16:9 view, and the light in
|
||||
/// direction `light`; the light's own box takes in everything.
|
||||
@@ -13585,6 +13654,52 @@ mod shadow_caster_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Our probe pack's Iris 1.11.7 shadow maps, under a noon sun with `shadowDistance` 128, hold
|
||||
/// the terrain at every depth along the light whatever planes the pack gives (#10403): from
|
||||
/// a cave at y -7, the surface's sections ~70 blocks toward the light with `shadowNearPlane`
|
||||
/// 0.05 and `shadowFarPlane` 256; from y 600, the ground ~535 blocks away with no planes set;
|
||||
/// and both with the planes at 200 and 210. Only the box's sides cull.
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_shaders::Shadow {
|
||||
distance: 128.0,
|
||||
near,
|
||||
far,
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: true,
|
||||
view_casters: false,
|
||||
depth_planes: depth,
|
||||
};
|
||||
let cull = ShadowCull {
|
||||
light: shadow_box(&view),
|
||||
casters: None,
|
||||
reach: None,
|
||||
};
|
||||
let min = Vec3::from(min);
|
||||
cull.intersects(min, min + 16.0)
|
||||
};
|
||||
// The section holding the surface (y 64..80) above the cave, and the ground under y 600.
|
||||
let above = [-8.0, 64.0 - (-7.0 + 1.62), -8.0];
|
||||
let below = [-8.0, 64.0 - (600.0 + 1.62), -8.0];
|
||||
assert!(kept(Some(0.05), Some(256.0), -7.0, above, false));
|
||||
assert!(kept(None, None, 600.0, below, false));
|
||||
assert!(kept(Some(200.0), Some(210.0), -7.0, above, false));
|
||||
assert!(kept(Some(200.0), Some(210.0), 600.0, below, false));
|
||||
// A section 200 blocks off to the side is still outside the box.
|
||||
let side = [200.0, 0.0, -8.0];
|
||||
assert!(!kept(Some(0.05), Some(256.0), -7.0, side, false));
|
||||
// Horizon's own packs keep culling by their planes.
|
||||
assert!(!kept(Some(0.05), Some(256.0), -7.0, above, true));
|
||||
}
|
||||
|
||||
/// The margin doesn't follow the field of view: from the same camera under a noon sun, our
|
||||
/// probe's Iris 1.11.7 maps keep a section that needs a growth of 1.1224 blocks at a field of
|
||||
/// view of 70 (77° flying) and drop ones that need 1.1252 to 1.1422 at 50 (55° flying) and
|
||||
|
||||
@@ -220,7 +220,7 @@ impl Cull {
|
||||
/// write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
if device
|
||||
.features()
|
||||
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
|
||||
|
||||
@@ -368,6 +368,7 @@ impl NativeFrame {
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
view_casters: false,
|
||||
depth_planes: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ struct Stitched {
|
||||
/// Each layer's sprite at the first level: its top left corner and its side.
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
material: bool,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// One per sprite array.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
@@ -66,10 +67,19 @@ fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1)
|
||||
out.source = source.xy;
|
||||
return out;
|
||||
}
|
||||
fn sprite_texel(in: Out) -> vec2<i32> {
|
||||
return clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
}
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
|
||||
return textureLoad(sprites, sprite_texel(in), i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
@fragment
|
||||
fn fs_material(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = sprite_texel(in);
|
||||
let edge = i32(textureDimensions(sprites, 0).x);
|
||||
let sampled = min((texel * 2 + 1) * edge / (2 * in.rect.z), vec2(edge - 1));
|
||||
return textureLoad(sprites, sampled, i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
"#;
|
||||
|
||||
@@ -210,14 +220,72 @@ impl PackAtlas {
|
||||
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect();
|
||||
Some(Self::with_rects(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
split,
|
||||
sprite,
|
||||
[layout.width.max(1), layout.height.max(1)],
|
||||
rects,
|
||||
layout.padding,
|
||||
mip + 1,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
/// Companion maps use the colour atlas's coordinates, not a second packing.
|
||||
pub fn material_for(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: u32,
|
||||
) -> Option<Self> {
|
||||
let Some(stitched) = &self.stitched else {
|
||||
return Self::material(device, encoder, arrays, self.split, layers);
|
||||
};
|
||||
Some(Self::with_rects(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
self.split,
|
||||
arrays[0].width(),
|
||||
self.size(),
|
||||
stitched.rects.clone(),
|
||||
stitched.padding,
|
||||
1,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn with_rects(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
sprite: u32,
|
||||
size: [u32; 2],
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
levels: u32,
|
||||
material: bool,
|
||||
) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: layout.width.max(1),
|
||||
height: layout.height.max(1),
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip + 1,
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
@@ -268,7 +336,7 @@ impl PackAtlas {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
entry_point: Some(if material { "fs_material" } else { "fs" }),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
@@ -293,12 +361,6 @@ impl PackAtlas {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect::<Vec<_>>();
|
||||
let layers = rects.len() as u32;
|
||||
let atlas = Self {
|
||||
texture,
|
||||
@@ -309,13 +371,14 @@ impl PackAtlas {
|
||||
separate_ao: false,
|
||||
stitched: Some(Stitched {
|
||||
rects,
|
||||
padding: layout.padding,
|
||||
padding,
|
||||
material,
|
||||
pipeline,
|
||||
groups,
|
||||
}),
|
||||
};
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
atlas
|
||||
}
|
||||
|
||||
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
|
||||
@@ -425,7 +488,11 @@ impl PackAtlas {
|
||||
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
|
||||
source: [
|
||||
layer % self.split,
|
||||
(self.sprite / side).ilog2() + level,
|
||||
if stitched.material {
|
||||
0
|
||||
} else {
|
||||
(self.sprite / side).ilog2() + level
|
||||
},
|
||||
size[0],
|
||||
size[1],
|
||||
],
|
||||
@@ -563,7 +630,7 @@ impl PackAtlas {
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
|
||||
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
@@ -610,13 +677,17 @@ impl PackAtlas {
|
||||
// A vertex's place in its section goes down to the 32768th of a block below it: read back
|
||||
// with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` has that for every position
|
||||
// we read (water tops at 8/9 - 0.001, plants' offsets), on all three axes (#8464). A fit,
|
||||
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already.
|
||||
// not Iris's code. Whole and half blocks, and every 1/16, are on that grid already. A place
|
||||
// up to 1/32 of a 32768th (one float step below 16) under a grid line is on it: plants whose
|
||||
// random offset is 9/15 of a block read exactly on a half block in Iris, and a 32768th
|
||||
// under it in Horizon (our guess, not tested: Horizon's float sum lands just under the
|
||||
// line) (#8464). Also a fit.
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_Vertex = vec4(floor(hz_v_pos * 32768.0) / 32768.0 + hz_origin.xyz, 1.0);\n \
|
||||
hz_Vertex = vec4(floor(hz_v_pos * 32768.0 + 0.03125) / 32768.0 + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_uv_scale, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
@@ -637,7 +708,7 @@ impl PackAtlas {
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, -1.0, 0.0, 0.0)"
|
||||
"vec4(-1.0, 0.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
|
||||
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
|
||||
@@ -890,6 +961,144 @@ mod tests {
|
||||
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
|
||||
}
|
||||
|
||||
fn material_fixture(animated: bool) {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
// No adapter (a CI runner without a GPU): skip, as the module's other readback tests do.
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let colour_array = upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let colour = PackAtlas::stitched(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&[colour_array],
|
||||
3,
|
||||
4,
|
||||
&[2, 4, 1],
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
for kind in 0..2 {
|
||||
// Different source resolution, with encoded channel boundaries and defaults.
|
||||
let markers = if kind == 0 {
|
||||
[
|
||||
[128, 126, 255, 19],
|
||||
crate::sprites::FLAT_NORMAL,
|
||||
[130, 125, 254, 21],
|
||||
]
|
||||
} else {
|
||||
[
|
||||
[17, 240, 65, 255],
|
||||
[18, 0, 66, 0],
|
||||
crate::sprites::NO_SPECULAR,
|
||||
]
|
||||
};
|
||||
let pixels: Vec<_> = markers.iter().flat_map(|m| m.repeat(4)).collect();
|
||||
let array = wgpu::util::DeviceExt::create_texture_with_data(
|
||||
&device,
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 3,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
);
|
||||
let arrays = [array];
|
||||
let material = colour
|
||||
.material_for(&device, &mut encoder, &arrays, 3)
|
||||
.unwrap();
|
||||
assert_eq!(material.size(), [16, 8]);
|
||||
assert_eq!(material.rects_table(), colour.rects_table());
|
||||
assert_eq!(material.texture.mip_level_count(), 1);
|
||||
queue.submit([encoder.finish()]);
|
||||
encoder = device.create_command_encoder(&Default::default());
|
||||
let changed = [243, 1, 229, 0];
|
||||
if animated {
|
||||
// Alter every source layer, but copy only the animated layer.
|
||||
queue.write_texture(
|
||||
arrays[0].as_image_copy(),
|
||||
&changed.repeat(12),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(8),
|
||||
rows_per_image: Some(2),
|
||||
offset: 0,
|
||||
},
|
||||
arrays[0].size(),
|
||||
);
|
||||
material.copy(&device, &mut encoder, &arrays, [0]);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
material.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
material.texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
encoder = device.create_command_encoder(&Default::default());
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
// Independent, hand-worked colour rectangles, including all padding pixels.
|
||||
for (layer, [x, y, side]) in [[9usize, 1, 2], [1, 1, 4], [9, 5, 1]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let expected = if animated && layer == 0 {
|
||||
changed
|
||||
} else {
|
||||
markers[layer]
|
||||
};
|
||||
for py in y - 1..=y + side {
|
||||
for px in x - 1..=x + side {
|
||||
let at = py * 256 + px * 4;
|
||||
assert_eq!(
|
||||
&data[at..at + 4],
|
||||
&expected,
|
||||
"kind={kind}, layer={layer}, ({px},{py})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitched_material_maps_share_colour_rectangles() {
|
||||
material_fixture(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animated_stitched_material_updates_only_its_rectangle() {
|
||||
material_fixture(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -174,7 +174,8 @@ fn flat_draws_map_uvs_onto_a_stitched_sprite() {
|
||||
/// Read back with a probe pack of ours, Iris 1.11.7's terrain `gl_Vertex` (camera on the block
|
||||
/// grid) puts a water top, 8/9 - 0.001 of a block up, at 29094/32768, and plants offset by 13/15
|
||||
/// and 0.1 of a block at 28398/32768 and 3276/32768 (#8464): each 32768th below the position.
|
||||
/// Whole blocks and sixteenths stay where they are.
|
||||
/// Whole blocks and sixteenths stay where they are, and so does a place float rounding put a hair
|
||||
/// below a grid line: Iris reads plants offset by 9/15 exactly on a half block.
|
||||
#[test]
|
||||
fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
@@ -204,6 +205,15 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
|
||||
([0.1, water, 13.0 / 15.0], [3276.0, 29094.0, 28398.0]),
|
||||
([13.0 / 15.0, 0.1, water], [28398.0, 3276.0, 29094.0]),
|
||||
([1.0, 0.5, 15.9375], [32768.0, 16384.0, 522240.0]),
|
||||
// One float step below 1, 0.5 and 9.5.
|
||||
(
|
||||
[
|
||||
1.0 - f32::EPSILON / 2.0,
|
||||
0.5 - f32::EPSILON / 4.0,
|
||||
9.5 - f32::EPSILON * 8.0,
|
||||
],
|
||||
[32768.0, 16384.0, 311296.0],
|
||||
),
|
||||
];
|
||||
let vertices: Vec<_> = cases
|
||||
.iter()
|
||||
|
||||
@@ -40,6 +40,9 @@ mod higher_shadowcolor_tests;
|
||||
#[cfg(test)]
|
||||
mod single_side_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod undrawn_terrain_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
@@ -55,6 +58,9 @@ mod custom_type_tests;
|
||||
#[cfg(test)]
|
||||
mod sky_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod translucent_color_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -113,6 +119,8 @@ enum SamplerKind {
|
||||
NearestRepeat,
|
||||
/// The block atlas: nearest texels, blended between the two nearest levels.
|
||||
Atlas,
|
||||
/// colortex buffers: linear, blended between the two nearest levels.
|
||||
Buffer,
|
||||
Comparison,
|
||||
}
|
||||
|
||||
@@ -140,15 +148,18 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
// Iris 1.11.7 blends a buffer's two nearest mipmap levels (our probe packs, #9307:
|
||||
// every colortex read at levels 1.25 and 2.5 in composite and final).
|
||||
Source::Color(_) => Self::Buffer,
|
||||
Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => Self::Linear,
|
||||
}
|
||||
}
|
||||
|
||||
fn binding_type(self) -> wgpu::SamplerBindingType {
|
||||
match self {
|
||||
Self::Linear | Self::Repeat | Self::Atlas => wgpu::SamplerBindingType::Filtering,
|
||||
Self::Linear | Self::Repeat | Self::Atlas | Self::Buffer => {
|
||||
wgpu::SamplerBindingType::Filtering
|
||||
}
|
||||
Self::Nearest | Self::NearestRepeat => wgpu::SamplerBindingType::NonFiltering,
|
||||
Self::Comparison => wgpu::SamplerBindingType::Comparison,
|
||||
}
|
||||
@@ -472,7 +483,7 @@ pub(super) struct World<'a> {
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and `at_midBlock.w`
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
|
||||
/// (its block's light): three values a layer.
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
@@ -550,6 +561,10 @@ pub struct PackPost {
|
||||
terrain_cutout: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
water: Option<Pass>,
|
||||
/// The pack has no program for solid and cutout terrain, or for translucent terrain:
|
||||
/// nothing draws it (`horizon_shaders::Post::terrain_undrawn`).
|
||||
terrain_undrawn: bool,
|
||||
water_undrawn: bool,
|
||||
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
|
||||
sky: Option<Sky>,
|
||||
/// What draws entities and block entities, after terrain, and their vertices.
|
||||
@@ -660,6 +675,8 @@ pub struct PackPost {
|
||||
repeat: wgpu::Sampler,
|
||||
nearest_repeat: wgpu::Sampler,
|
||||
atlas_sampler: wgpu::Sampler,
|
||||
/// colortex buffers' (`SamplerKind::Buffer`).
|
||||
buffer_sampler: wgpu::Sampler,
|
||||
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
|
||||
/// nor buffer to clear.
|
||||
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
@@ -1131,6 +1148,11 @@ pub(super) struct ShadowView {
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
@@ -1261,8 +1283,9 @@ fn bindings(
|
||||
let mut reads = 0u16;
|
||||
let mut shadow_reads = 0u16;
|
||||
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
|
||||
// A gbuffers pass draws into its buffers and the depth buffer: reading them as well would
|
||||
// be a feedback loop (undefined in GL, refused by wgpu), so those read a blank texture.
|
||||
// Opaque gbuffers draws cannot sample their own attachments. Translucent draws can
|
||||
// read colortex4 and above from the preceding deferred stage; those reads use a copy
|
||||
// taken before the draw, so the sampled texture is never also an attachment.
|
||||
let in_place = match geometry {
|
||||
Some(_) => buffers.iter().fold(0u16, |m, &b| m | 1 << b),
|
||||
None => 0,
|
||||
@@ -1372,7 +1395,9 @@ fn bindings(
|
||||
Source::Depth => geometry.is_some(),
|
||||
// Translucent terrain reads the copy; opaque terrain would read itself.
|
||||
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
|
||||
Source::Color(n) => in_place & 1 << n != 0,
|
||||
Source::Color(n) => {
|
||||
(group != Group::Translucent || n < 4) && in_place & 1 << n != 0
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let mut sample_type = match src {
|
||||
@@ -1853,7 +1878,13 @@ fn build_pass(
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
copies: image_buffers(&images) & reads,
|
||||
copies: (image_buffers(&images)
|
||||
| if p.group == Group::Translucent {
|
||||
written
|
||||
} else {
|
||||
0
|
||||
})
|
||||
& reads,
|
||||
reads: reads | image_buffers(&images),
|
||||
buffers: !interface.buffers.is_empty(),
|
||||
images,
|
||||
@@ -2276,6 +2307,7 @@ impl PackPost {
|
||||
cull: g.cull,
|
||||
});
|
||||
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
|
||||
let (terrain_undrawn, water_undrawn) = (post.terrain_undrawn, post.water_undrawn);
|
||||
let mut terrain = post
|
||||
.terrain
|
||||
.zip(terrain_geometry.as_ref())
|
||||
@@ -2822,6 +2854,8 @@ impl PackPost {
|
||||
terrain,
|
||||
terrain_cutout,
|
||||
water,
|
||||
terrain_undrawn,
|
||||
water_undrawn,
|
||||
sky,
|
||||
entities,
|
||||
clouds,
|
||||
@@ -2978,6 +3012,13 @@ impl PackPost {
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
..Default::default()
|
||||
}),
|
||||
buffer_sampler: device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack buffers"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
..Default::default()
|
||||
}),
|
||||
blit,
|
||||
copy,
|
||||
upscale,
|
||||
@@ -3189,6 +3230,7 @@ impl PackPost {
|
||||
SamplerKind::Repeat => &self.repeat,
|
||||
SamplerKind::NearestRepeat => &self.nearest_repeat,
|
||||
SamplerKind::Atlas => &self.atlas_sampler,
|
||||
SamplerKind::Buffer => &self.buffer_sampler,
|
||||
SamplerKind::Comparison => shadow.map_or(&self.shadow.1, |t| &t.comparison),
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
@@ -4037,12 +4079,13 @@ impl PackPost {
|
||||
},
|
||||
every_face: true,
|
||||
view_casters: true,
|
||||
depth_planes: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pack draws terrain itself.
|
||||
/// 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.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
@@ -4070,7 +4113,7 @@ impl PackPost {
|
||||
|
||||
/// Whether the pack draws translucent terrain itself.
|
||||
pub fn has_water(&self) -> bool {
|
||||
self.water.is_some()
|
||||
self.water.is_some() || self.water_undrawn
|
||||
}
|
||||
|
||||
/// The names of the programs that run, in order.
|
||||
@@ -4122,6 +4165,14 @@ impl PackPost {
|
||||
}
|
||||
self.setup_pending = true;
|
||||
self.rgb_unset = true;
|
||||
// Free the old targets before making the new ones: a pack with large buffers can't
|
||||
// hold both sets at once on a card it otherwise fits (a window resize ran out of video
|
||||
// memory). The bind groups hold the old views too, and the GPU may still be using
|
||||
// them, so wait for it; a resize is rare.
|
||||
if self.targets.take().is_some() {
|
||||
self.forget_groups();
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
}
|
||||
let sizes: [[u32; 2]; BUFFERS] =
|
||||
std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
|
||||
let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
|
||||
@@ -4580,10 +4631,8 @@ impl PackPost {
|
||||
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
|
||||
.collect(device);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
// Until the camera's biome is known, `smooth` waits for the world's values.
|
||||
if !inputs.biome_known {
|
||||
self.custom.restart_smoothing();
|
||||
}
|
||||
// `smooth` starts on the first frame that knows the camera's biome.
|
||||
self.custom.world_frame(inputs.biome_known);
|
||||
self.uniforms.eval_custom(&mut self.custom);
|
||||
if self.targets.is_none() {
|
||||
return;
|
||||
@@ -5679,3 +5728,6 @@ mod unknown_sampler_terrain_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mipmap_box_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod colortex_mip_blend_tests;
|
||||
@@ -0,0 +1,61 @@
|
||||
//! A buffer's mipmaps are read blended between the two nearest levels: our probe packs (#9307)
|
||||
//! read every colortex at levels 2.5 and 1.25 in composite and final, in Iris 1.11.7, and got the
|
||||
//! two nearest levels mixed.
|
||||
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 1.0 into the left half of a 4x4 colortex1 and 0 into the right half, so at
|
||||
/// (0.25, 0.5) level 1 reads 1.0 and level 2 (1x1) the mean, 0.5. final shows the read at
|
||||
/// level 1.5 in red and at level 1.25 in green.
|
||||
fn pack() -> 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("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 330 compatibility\n/*\nconst int colortex1Format = RGBA16F;\n*/\n\
|
||||
/* RENDERTARGETS: 1 */\nlayout(location = 0) out vec4 o;\n\
|
||||
void main() {\n o = vec4(gl_FragCoord.x < 2.0 ? 1.0 : 0.0, 0.0, 0.0, 1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\n/*\nconst bool colortex1MipmapEnabled = true;\n*/\n\
|
||||
uniform sampler2D colortex1;\n\
|
||||
void main() {\n vec2 at = vec2(0.25, 0.5);\n\
|
||||
gl_FragColor = vec4(textureLod(colortex1, at, 1.5).r, textureLod(colortex1, at, 1.25).r, 0.0, 1.0);\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-colortex-mip-blend",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_buffer_blends_its_two_nearest_levels() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [4, 4]);
|
||||
// Level 1.5: 1.0 and 0.5 mixed 1:1, 0.75; level 1.25: mixed 3:1, 0.875.
|
||||
let [r, g, ..] = pixels[0];
|
||||
assert!(
|
||||
r.abs_diff(191) <= 2 && g.abs_diff(223) <= 2,
|
||||
"levels 1.5 and 1.25: got red {r}, green {g}, expected 191 and 223"
|
||||
);
|
||||
}
|
||||
@@ -669,6 +669,31 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
/// `shadow_depth_filtering`'s shadow map read by `reads`: `final`'s code after its declarations
|
||||
/// of shadowtex0 and shadowtex1.
|
||||
fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
let ramp = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
shadow_depth_map_reads(
|
||||
nearest,
|
||||
reads,
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}",
|
||||
ramp,
|
||||
)
|
||||
}
|
||||
|
||||
/// A shadow map drawn by `shadow_vsh` from the two triangles `positions` (the rest keeps its
|
||||
/// clear depth, the far plane), read by `reads` as in `shadow_depth_reads`.
|
||||
fn shadow_depth_map_reads(
|
||||
nearest: &str,
|
||||
reads: &str,
|
||||
shadow_vsh: &str,
|
||||
positions: [[f32; 3]; 6],
|
||||
) -> Option<Vec<[u8; 4]>> {
|
||||
let gpu = Gpu::new()?;
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
@@ -704,11 +729,7 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
|
||||
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n{reads}"
|
||||
),
|
||||
),
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
(
|
||||
"shadow.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
|
||||
),
|
||||
("shadow.vsh".into(), shadow_vsh.into()),
|
||||
(
|
||||
"shadow.fsh".into(),
|
||||
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
|
||||
@@ -740,14 +761,6 @@ fn shadow_depth_reads(nearest: &str, reads: &str) -> Option<Vec<[u8; 4]>> {
|
||||
inputs,
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -793,6 +806,40 @@ fn shadow_depth_reads_filter_directly_and_with_a_texel_offset() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_depth_filter_weights_are_256ths_as_in_our_iris_readings() {
|
||||
// The map is at the far plane but for texel (8, 8), which is nearer. Reads between texels
|
||||
// (7, 7) and (8, 8) give (8, 8)'s weight: Iris 1.11.7's filtered reads on our NVIDIA GPU
|
||||
// fit weights of 1/256 along each axis, rounded half up, and a corner's product weight
|
||||
// rounded half up to 1/256 (#8464). Red: a read a + 0.25/256 of the way along x only
|
||||
// moves a/256 (77). Green: at (77.25, 77.25)/256 along both, (8, 8) weighs 23/256
|
||||
// (77 × 77 / 256 = 23.16; exact weights would give 23.31).
|
||||
let reads = "void main() {\n\
|
||||
float far = texelFetch(shadowtex0, ivec2(7, 7), 0).r;\n\
|
||||
float near = texelFetch(shadowtex0, ivec2(8, 8), 0).r;\n\
|
||||
float f = 77.25 / 256.0;\n\
|
||||
float along = texture(shadowtex0, vec2(7.5 + f, 8.5) / 16.0).r;\n\
|
||||
float corner = texture(shadowtex0, vec2(7.5 + f, 7.5 + f) / 16.0).r;\n\
|
||||
float x = (far - along) / (far - near) * 256.0;\n\
|
||||
float w = (far - corner) / (far - near) * 256.0;\n\
|
||||
gl_FragColor = vec4(abs(x - 77.0) < 0.01 ? 1.0 : 0.0,\n\
|
||||
abs(w - 23.0) < 0.01 ? 1.0 : 0.0, 0.0, 1.0);\n\
|
||||
}\n";
|
||||
// Texel (8, 8) of the 16² map: x and y from 0 to 1/8 in clip space, depth 0.
|
||||
let bump = [
|
||||
[0., 0., -1.],
|
||||
[0.125, 0., -1.],
|
||||
[0.125, 0.125, -1.],
|
||||
[0., 0., -1.],
|
||||
[0.125, 0.125, -1.],
|
||||
[0., 0.125, -1.],
|
||||
];
|
||||
let vsh = "#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xyz,1.0);}";
|
||||
if let Some(pixels) = shadow_depth_map_reads("", reads, vsh, bump) {
|
||||
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex filter weights");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowtex_nearest_settings_read_the_nearest_texel() {
|
||||
for (line, expected) in [
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! A translucent draw reads the preceding scene even when it writes the same color buffer.
|
||||
//! Original programs and independently calculated colors; no shader-pack assets.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
#[test]
|
||||
fn translucent_draws_sample_the_color_written_by_deferred() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1.0, -1.0, 0.0],
|
||||
[1.0, -1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[-1.0, -1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[-1.0, 1.0, 0.0],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original translucent quad"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
// Odd and even numbers of deferred passes leave the current scene on different sides.
|
||||
// Exercise two buffer numbers and the legacy sampler aliases too.
|
||||
for (buffer, sampler) in [(4, "colortex4"), (7, "gaux4")] {
|
||||
for stages in [1, 2] {
|
||||
for blended in [false, true] {
|
||||
let mut files = vec![
|
||||
("deferred.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"deferred.fsh".into(),
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.25,0.5,0.75,1.0);}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"gbuffers_water.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}".into(),
|
||||
),
|
||||
(
|
||||
"gbuffers_water.fsh".into(),
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS: {buffer} */\nuniform sampler2D {sampler};\nvoid main(){{gl_FragData[0]=vec4(texture2D({sampler},vec2(0.5)).rgb+vec3(0.125,0.0,0.0),0.5);}}"
|
||||
),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex{buffer};varying vec2 uv;\nvoid main(){{gl_FragColor=vec4(texture2D(colortex{buffer},uv).rgb,1.0);}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"shaders.properties".into(),
|
||||
format!(
|
||||
"blend.gbuffers_water={}\n",
|
||||
if blended {
|
||||
"SRC_ALPHA ONE_MINUS_SRC_ALPHA ONE ONE_MINUS_SRC_ALPHA"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
),
|
||||
),
|
||||
];
|
||||
if stages == 2 {
|
||||
files.extend([
|
||||
("deferred1.vsh".into(), VERTEX.into()),
|
||||
("deferred1.fsh".into(), format!(
|
||||
"#version 120\n/* RENDERTARGETS: {buffer} */\nvoid main(){{gl_FragData[0]=vec4(0.5,0.75,0.25,1.0);}}"
|
||||
)),
|
||||
]);
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);\n".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.has_water(), "{:?}", post.notes);
|
||||
let mut expected = if stages == 1 {
|
||||
[0.25f32, 0.5, 0.75]
|
||||
} else {
|
||||
[0.5, 0.75, 0.25]
|
||||
};
|
||||
expected[0] += if blended { 0.0625 } else { 0.125 };
|
||||
let expected = expected.map(|c| (c * 255.0).round() as u8);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(
|
||||
&mut post,
|
||||
&color,
|
||||
&depth,
|
||||
&fixture_frame(&hud),
|
||||
&mut |rp, list, _, _| {
|
||||
if list == TerrainList::Translucent {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
},
|
||||
);
|
||||
for p in pixels {
|
||||
assert!(
|
||||
p[..3]
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(&a, b)| a.abs_diff(b) <= 2),
|
||||
"buffer {buffer}, {stages} deferred stages, blended {blended}, frame {frame}: {p:?}, expected {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! A pack with no program for terrain draws none (#10455): Iris 1.11.7 drew no terrain, solid
|
||||
//! or translucent, into any buffer or depth with probe packs of ours having no gbuffers
|
||||
//! programs. The game hands the pack its terrain, so it doesn't draw it either. The programs
|
||||
//! here are original and cut down.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
const WHITE: &str =
|
||||
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main() { gl_FragData[0] = vec4(1.0); }\n";
|
||||
|
||||
/// A pack with `programs` (each drawing white into colortex0) loaded for a game that can hand it
|
||||
/// terrain: whether it takes solid and translucent terrain from the game.
|
||||
fn takes_terrain(programs: &[&str]) -> Option<(bool, bool)> {
|
||||
let gpu = Fixture::new()?;
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for name in programs {
|
||||
for (ext, source) in [("vsh", VERTEX), ("fsh", WHITE)] {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}.{ext}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"undrawn-terrain",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(Default::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
let post = gpu.try_build(post).ok()?;
|
||||
Some((post.has_terrain(), post.has_water()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_without_terrain_programs_takes_terrain_from_the_game() {
|
||||
let Some(taken) = takes_terrain(&["composite"]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(taken, (true, true));
|
||||
}
|
||||
@@ -552,7 +552,12 @@ fn invert_generic(m: Mat4) -> Mat4 {
|
||||
|
||||
/// `shadow_camera`'s view and projection in wgpu's clip space (depth 0..1), for culling.
|
||||
pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
let (view, gl) = shadow_camera(shadow_light(frame, shadow), frame.camera, shadow);
|
||||
shadow_culling_at(shadow_light(frame, shadow), frame.camera, shadow)
|
||||
}
|
||||
|
||||
/// `shadow_culling` for the direction toward the light and the camera's position.
|
||||
pub fn shadow_culling_at(light: Vec3, camera: DVec3, shadow: &Shadow) -> Mat4 {
|
||||
let (view, gl) = shadow_camera(light, camera, shadow);
|
||||
// GL's depth -w..w to wgpu's 0..w.
|
||||
let depth = Mat4::from_cols(
|
||||
Vec4::X,
|
||||
|
||||
@@ -93,7 +93,7 @@ pub(crate) fn prepare_with_models(
|
||||
models: &crate::model_library::ParsedModels,
|
||||
) -> Result<PreparedModels> {
|
||||
let facts: std::collections::BTreeMap<String, Value> =
|
||||
serde_json::from_slice(&std::fs::read(root.join("model-states.json"))?)?;
|
||||
horizon_world::json_file::read(&root.join("model-states.json"))?;
|
||||
let blocks: Vec<&str> = facts
|
||||
.keys()
|
||||
.filter(|name| registry.default_state(name).is_some())
|
||||
@@ -158,11 +158,11 @@ pub fn load_visibility(
|
||||
collision::CachedCollisionRegistry,
|
||||
registry::BlockTableFile,
|
||||
};
|
||||
use std::{collections::BTreeMap, fs};
|
||||
use std::collections::BTreeMap;
|
||||
let file = BlockTableFile::open(&root.join("occlusion.nbt"), registry)?;
|
||||
let occlusion = CachedCollisionRegistry::from_table(file.table("occlusion")?)?;
|
||||
let raw: BTreeMap<String, Value> =
|
||||
serde_json::from_slice(&fs::read(root.join("render-definitions.json"))?)?;
|
||||
horizon_world::json_file::read(&root.join("render-definitions.json"))?;
|
||||
let definitions = raw
|
||||
.into_iter()
|
||||
.map(|(name, v)| {
|
||||
|
||||
@@ -9,7 +9,7 @@ pub use crate::settings::Opt;
|
||||
use crate::{
|
||||
gamepad::{self, GlyphStyle},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
|
||||
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
text::{self, Style, Text},
|
||||
@@ -2709,6 +2709,41 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
|
||||
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
|
||||
/// became an official native mod, so Extras has no setting of its own for it (#8713).
|
||||
fn zoom_mod_row(s: &Settings) -> Row {
|
||||
let Some(i) = s
|
||||
.mods
|
||||
.installed
|
||||
.iter()
|
||||
.position(|m| m.id == "zoom" && m.native.is_some())
|
||||
else {
|
||||
// A note, not a button: Options > Mods already has the one way into the Mods page
|
||||
// (`every_setting_and_page_is_reached_from_one_place`).
|
||||
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
|
||||
};
|
||||
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
|
||||
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
|
||||
let settings = s.mods.installed[i]
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
|
||||
if settings {
|
||||
Row::Buttons(
|
||||
toggle(150.0),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
Row::Buttons(toggle(310.0), None)
|
||||
}
|
||||
}
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
@@ -2781,6 +2816,11 @@ impl OptionsUi {
|
||||
// (Horizon Settings has it).
|
||||
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
|
||||
let mut rows = Vec::new();
|
||||
if self.page() == Page::Extras {
|
||||
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
|
||||
// Extras keeps a way to the one players look for here (#8713).
|
||||
rows.push(Self::zoom_mod_row(s));
|
||||
}
|
||||
if self.page() == Page::Pvp {
|
||||
// The editor moves what the game shows: only from a world.
|
||||
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
|
||||
@@ -3339,7 +3379,7 @@ impl OptionsUi {
|
||||
row: 0,
|
||||
});
|
||||
let mut mappings: Vec<(&str, &str)> =
|
||||
MAPPINGS.iter().map(|(n, _, c)| (*n, *c)).collect();
|
||||
all_mappings().map(|(n, _, c)| (*n, *c)).collect();
|
||||
let query = self.settings_search.value.to_lowercase();
|
||||
mappings.retain(|(name, _)| {
|
||||
query.is_empty() || lang::get(name).to_lowercase().contains(&query)
|
||||
@@ -3433,7 +3473,7 @@ impl OptionsUi {
|
||||
}
|
||||
let reset_x = scrollbar_x - 50.0 - 10.0;
|
||||
let change_x = reset_x - 5.0 - 75.0;
|
||||
let name_static = MAPPINGS.iter().find(|m| m.0 == name).map_or("", |m| m.0);
|
||||
let name_static = all_mappings().find(|m| m.0 == name).map_or("", |m| m.0);
|
||||
out.push(button(change_x, y, 75.0, message, Act::Key(name_static)));
|
||||
if !conflicts.is_empty() {
|
||||
out.push(Widget {
|
||||
@@ -6648,6 +6688,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
let english = || lang::Language::load(&[], "en_us");
|
||||
// Without the mod, Extras says where it is installed (a note, not a second way into
|
||||
// Options > Mods).
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
|
||||
)),
|
||||
"Extras has no hint for the Zoom mod"
|
||||
);
|
||||
// Built in, Zoom's settings live on its page: Extras opens that page.
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "zoom".into(),
|
||||
title: "Zoom 2.0.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(crate::mod_list::NativePage {
|
||||
keys: vec![["zoom".into(), "Zoom".into(), "key.keyboard.z".into()]],
|
||||
buttons: Vec::new(),
|
||||
}),
|
||||
});
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain() == "Settings"
|
||||
)),
|
||||
"Extras doesn't open the Zoom mod's settings"
|
||||
);
|
||||
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ToggleMod(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain().starts_with("Zoom")
|
||||
)),
|
||||
"Extras doesn't switch the Zoom mod"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -899,9 +899,24 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
.iter()
|
||||
// SoundManager skips a sounds.json Gson rejects, whole.
|
||||
.filter(|b| kind != Merge::Sounds || crate::sound_events::is_valid_file(b))
|
||||
.filter_map(|b| match serde_json::from_slice(b) {
|
||||
Ok(Value::Object(m)) => Some(m),
|
||||
_ => None,
|
||||
.filter_map(|b| {
|
||||
match serde_json::from_slice(b) {
|
||||
Ok(Value::Object(m)) => Some(m),
|
||||
// `Language.loadFromJson` reads with Gson's lenient reader, so
|
||||
// a lang file with `#` comments still counts; a file that
|
||||
// strict JSON accepts keeps its bytes as they are.
|
||||
_ if kind == Merge::Lang => {
|
||||
let mut strings = std::collections::HashMap::new();
|
||||
horizon_client_core::lang::read_lang_file(b, &mut strings);
|
||||
Some(
|
||||
strings
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Value::String(v)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let list = |key: &str, layers: &mut dyn Iterator<Item = &Map<String, Value>>| {
|
||||
@@ -1424,6 +1439,18 @@ mod tests {
|
||||
parse(lang.unwrap()),
|
||||
serde_json::json!({"a":"vanilla a","b":"pack b"})
|
||||
);
|
||||
// A layer with `#` comments is read leniently, not dropped.
|
||||
let lenient = merge(
|
||||
Merge::Lang,
|
||||
&[
|
||||
json(r#"{"a":"vanilla a"}"#),
|
||||
json("# a pack comment\n{\"b\":\"pack b\", # trailing\n\"a\":\"pack a\"}"),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
parse(lenient.unwrap()),
|
||||
serde_json::json!({"a":"pack a","b":"pack b"})
|
||||
);
|
||||
// SoundManager: sounds add up; `replace` starts the event over.
|
||||
let sounds = merge(
|
||||
Merge::Sounds,
|
||||
|
||||
@@ -975,11 +975,32 @@ impl TextField {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ScreenRectangle.intersection`: the part of both rectangles, or
|
||||
/// `ScreenRectangle.empty()` (`0, 0, 0, 0`, which clips everything) when they
|
||||
/// do not overlap.
|
||||
fn intersect(a: [i32; 4], b: [i32; 4]) -> [i32; 4] {
|
||||
let rect = [
|
||||
a[0].max(b[0]),
|
||||
a[1].max(b[1]),
|
||||
a[2].min(b[2]),
|
||||
a[3].min(b[3]),
|
||||
];
|
||||
if rect[0] < rect[2] && rect[1] < rect[3] {
|
||||
rect
|
||||
} else {
|
||||
[0, 0, 0, 0]
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the calls with the HUD batch: sprites nine-sliced, textures from
|
||||
/// the atlas (`textures/gui/x.png` is `gui/x`; tiled textures from their
|
||||
/// corner), scissor clipping, and the tooltip at the mouse.
|
||||
pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
|
||||
let mut clip: Option<(usize, [i32; 4])> = None;
|
||||
// `GuiGraphicsExtractor.ScissorStack`: a scissor clips what is drawn since
|
||||
// it opened, cut to the screen while none is open and to the one around it
|
||||
// otherwise; closing it puts the enclosing one back.
|
||||
let screen = [0, 0, batch.width as i32, batch.height as i32];
|
||||
let mut clips: Vec<(usize, [i32; 4])> = Vec::new();
|
||||
let mut tooltip = None;
|
||||
for d in draws {
|
||||
match d {
|
||||
@@ -1083,10 +1104,11 @@ pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
|
||||
);
|
||||
}
|
||||
Draw::Scissor { x0, y0, x1, y1 } => {
|
||||
clip = Some((batch.vertices.len(), [*x0, *y0, *x1, *y1]))
|
||||
let outer = clips.last().map_or(screen, |&(_, rect)| rect);
|
||||
clips.push((batch.vertices.len(), intersect(outer, [*x0, *y0, *x1, *y1])));
|
||||
}
|
||||
Draw::NoScissor => {
|
||||
if let Some((start, [x0, y0, x1, y1])) = clip.take() {
|
||||
if let Some((start, [x0, y0, x1, y1])) = clips.pop() {
|
||||
batch.clip_since(start, x0 as f32, y0 as f32, x1 as f32, y1 as f32);
|
||||
}
|
||||
}
|
||||
@@ -1282,3 +1304,126 @@ mod button_label_tests {
|
||||
batch.vertices.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scissor_tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// The bounds of every quad's six vertices, in draw order.
|
||||
fn quads(batch: &HudBatch) -> Vec<[f32; 4]> {
|
||||
batch
|
||||
.vertices
|
||||
.as_chunks::<6>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (mut x0, mut y0) = (f32::MAX, f32::MAX);
|
||||
let (mut x1, mut y1) = (f32::MIN, f32::MIN);
|
||||
for v in q {
|
||||
x0 = x0.min(v.pos[0]);
|
||||
y0 = y0.min(v.pos[1]);
|
||||
x1 = x1.max(v.pos[0]);
|
||||
y1 = y1.max(v.pos[1]);
|
||||
}
|
||||
[x0, y0, x1, y1]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fill(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
|
||||
Draw::Fill {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
color: -1,
|
||||
}
|
||||
}
|
||||
|
||||
fn scissor(x0: i32, y0: i32, x1: i32, y1: i32) -> Draw {
|
||||
Draw::Scissor { x0, y0, x1, y1 }
|
||||
}
|
||||
|
||||
/// `GuiGraphicsExtractor.ScissorStack.push`: a scissor inside another is
|
||||
/// intersected with it (`ScreenRectangle.intersection`), and the outer one
|
||||
/// still clips what is drawn after the inner one closes.
|
||||
#[test]
|
||||
fn a_nested_scissor_is_intersected_with_the_one_around_it() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&[
|
||||
scissor(10, 10, 100, 100),
|
||||
fill(0, 0, 200, 200),
|
||||
scissor(50, 50, 300, 300),
|
||||
fill(0, 0, 400, 400),
|
||||
Draw::NoScissor,
|
||||
fill(0, 0, 200, 200),
|
||||
Draw::NoScissor,
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
quads(&batch),
|
||||
[
|
||||
[10.0, 10.0, 100.0, 100.0],
|
||||
[50.0, 50.0, 100.0, 100.0],
|
||||
[10.0, 10.0, 100.0, 100.0],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A scissor with no scissor around it is cut to the screen, as
|
||||
/// `ScissorStack.push` intersects with the screen while the stack is empty.
|
||||
#[test]
|
||||
fn a_scissor_wider_than_the_screen_is_cut_to_it() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&[
|
||||
scissor(-50, -50, 500, 500),
|
||||
fill(-100, -100, 600, 600),
|
||||
Draw::NoScissor,
|
||||
],
|
||||
);
|
||||
assert_eq!(quads(&batch), [[0.0, 0.0, 320.0, 240.0]]);
|
||||
}
|
||||
|
||||
/// A scissor that misses the one around it intersects to
|
||||
/// `ScreenRectangle.empty()`: nothing inside it is drawn, and the outer
|
||||
/// scissor takes over again when it closes.
|
||||
#[test]
|
||||
fn a_scissor_outside_the_one_around_it_draws_nothing() {
|
||||
let atlas = atlas();
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
&[
|
||||
scissor(10, 10, 50, 50),
|
||||
fill(0, 0, 200, 200),
|
||||
scissor(60, 60, 90, 90),
|
||||
fill(0, 0, 200, 200),
|
||||
Draw::NoScissor,
|
||||
fill(0, 0, 200, 200),
|
||||
Draw::NoScissor,
|
||||
fill(0, 0, 200, 200),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
quads(&batch),
|
||||
[
|
||||
[10.0, 10.0, 50.0, 50.0],
|
||||
[10.0, 10.0, 50.0, 50.0],
|
||||
[0.0, 0.0, 200.0, 200.0],
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ pub fn set_source_volumes(volumes: [f32; 11]) {
|
||||
pub fn sound_source(event: &str) -> usize {
|
||||
let e = event.trim_start_matches("minecraft:");
|
||||
let index = |name: &str| SOUND_SOURCES.iter().position(|n| *n == name).unwrap_or(0);
|
||||
const HOSTILE: [&str; 25] = [
|
||||
const HOSTILE: [&str; 34] = [
|
||||
"zombie",
|
||||
"skeleton",
|
||||
"creeper",
|
||||
@@ -44,6 +44,16 @@ pub fn sound_source(event: &str) -> usize {
|
||||
"silverfish",
|
||||
"zombified_piglin",
|
||||
"wither_skeleton",
|
||||
"bogged",
|
||||
"breeze",
|
||||
"endermite",
|
||||
"parched",
|
||||
"warden",
|
||||
"zombie_villager",
|
||||
"vex",
|
||||
"wither",
|
||||
// Monster's common hurt/death (e.g. Giant) and other shared sounds.
|
||||
"hostile",
|
||||
];
|
||||
if e == "block.potent_sulfur.noxious_gas" {
|
||||
// PotentSulfurBlock.animateTick, its only caller, plays it as AMBIENT.
|
||||
@@ -347,7 +357,7 @@ impl Settings {
|
||||
.parent()
|
||||
.map(|dir| crate::mod_list::ModList::load(&crate::logging::shared_dir(dir)))
|
||||
.unwrap_or_default();
|
||||
let settings = match fs::read(path) {
|
||||
let mut settings = match fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
extras.read_old_options(&extras_text, &text);
|
||||
@@ -367,6 +377,9 @@ impl Settings {
|
||||
..Self::default()
|
||||
},
|
||||
};
|
||||
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
|
||||
let emote = settings.extras.emote_key.clone();
|
||||
settings.keys.set("key.emote", &emote);
|
||||
settings.describe_for_crashes();
|
||||
// Options.load: the backend asked for at startup, what a restart is measured from.
|
||||
Self {
|
||||
@@ -636,14 +649,17 @@ impl Settings {
|
||||
pub fn with_option(&self, key: &str, value: &str) -> Result<Self, String> {
|
||||
let mut file = self.to_file();
|
||||
file.set_text(key, value)?;
|
||||
Ok(Self {
|
||||
let mut next = Self {
|
||||
horizon: self.horizon.clone(),
|
||||
controller: self.controller.clone(),
|
||||
extras: self.extras.clone(),
|
||||
mods: self.mods.clone(),
|
||||
video_modes: self.video_modes.clone(),
|
||||
..Self::from_file(file)
|
||||
})
|
||||
};
|
||||
// Horizon's own bindings aren't in options.txt.
|
||||
next.keys.set("key.emote", self.keys.get("key.emote"));
|
||||
Ok(next)
|
||||
}
|
||||
/// The `options.txt` text (Options.save).
|
||||
pub fn serialize(&self) -> String {
|
||||
@@ -657,7 +673,9 @@ impl Settings {
|
||||
if let Some(dir) = path.parent() {
|
||||
self.horizon.save(dir);
|
||||
self.controller.save(dir);
|
||||
self.extras.save(dir);
|
||||
let mut extras = self.extras.clone();
|
||||
extras.emote_key = self.keys.get("key.emote").to_owned();
|
||||
extras.save(dir);
|
||||
self.mods.save(&crate::logging::shared_dir(dir));
|
||||
}
|
||||
}
|
||||
@@ -856,6 +874,57 @@ mod tests {
|
||||
horizon_narrator::Status::Off
|
||||
);
|
||||
}
|
||||
/// `Monster.getSoundSource`: the verified families and inherited common sounds.
|
||||
#[test]
|
||||
fn hostile_families_keep_their_sound_category() {
|
||||
for (event, source) in [
|
||||
("entity.zombie.hurt", 5),
|
||||
("entity.cow.hurt", 6),
|
||||
("block.stone.break", 4),
|
||||
("entity.player.hurt", 7),
|
||||
("entity.arrow.hit", 7),
|
||||
("music_disc.13", 2),
|
||||
("item.goat_horn.sound.0", 2),
|
||||
("weather.rain", 3),
|
||||
("ambient.cave", 8),
|
||||
] {
|
||||
assert_eq!(sound_source(event), source, "{event}");
|
||||
}
|
||||
// Explicit sources still take precedence, including H331's arrow route.
|
||||
let mut audio = crate::audio::Audio::silent(Path::new("/nonexistent/h068-audio"));
|
||||
audio.capture = Some(Vec::new());
|
||||
audio.play_on(6, "minecraft:entity.arrow.hit", Some([1.0; 3]), 1.0, 1.0);
|
||||
audio.play_on(4, "minecraft:entity.warden.hurt", Some([1.0; 3]), 1.0, 1.0);
|
||||
assert_eq!(
|
||||
audio
|
||||
.capture
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.source)
|
||||
.collect::<Vec<_>>(),
|
||||
[6, 4]
|
||||
);
|
||||
for family in [
|
||||
"bogged",
|
||||
"breeze",
|
||||
"endermite",
|
||||
"parched",
|
||||
"warden",
|
||||
"zombie_villager",
|
||||
"vex",
|
||||
"wither",
|
||||
"hostile",
|
||||
] {
|
||||
for suffix in ["hurt", "death"] {
|
||||
for prefix in ["", "minecraft:"] {
|
||||
let event = format!("{prefix}entity.{family}.{suffix}");
|
||||
assert_eq!(sound_source(&event), 5, "{event}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_skin_layers_move_from_options_txt_to_the_extras_file() {
|
||||
// #8091 wrote the switch into options.txt; a player who had it on keeps
|
||||
@@ -881,6 +950,119 @@ mod tests {
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_txt_lists_only_vanillas_key_mappings() {
|
||||
// Options.keyMappings has the friends key, then social interactions; Horizon's emote key
|
||||
// (#10092 put it between them) is not in the file.
|
||||
let out = Settings::default().serialize();
|
||||
assert!(!out.contains("emote"), "{out}");
|
||||
assert!(out.contains(
|
||||
"\nkey_key.friends:key.keyboard.o\nkey_key.socialInteractions:key.keyboard.p\n"
|
||||
));
|
||||
let mut s = Settings::default();
|
||||
s.keys.set("key.emote", "key.keyboard.g");
|
||||
assert!(!s.serialize().contains("emote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_emote_key_is_kept_in_the_extras_file() {
|
||||
let dir = scratch("emote-key");
|
||||
let options = dir.join("options.txt");
|
||||
// Untouched: no extras file is made for it.
|
||||
Settings::default().save(&options);
|
||||
assert!(!dir.join(crate::extras::FILE).exists());
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.b"
|
||||
);
|
||||
// Rebound in Controls: saved to horizon-extras.txt, and options.txt stays vanilla's.
|
||||
let mut s = Settings::load(&options);
|
||||
s.keys.set("key.emote", "key.keyboard.g");
|
||||
s.save(&options);
|
||||
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
|
||||
let extras = fs::read_to_string(dir.join(crate::extras::FILE)).unwrap();
|
||||
assert!(
|
||||
extras.lines().any(|l| l == "emoteKey:key.keyboard.g"),
|
||||
"{extras}"
|
||||
);
|
||||
let back = Settings::load(&options);
|
||||
assert_eq!(back.keys.get("key.emote"), "key.keyboard.g");
|
||||
// horizon-ctl options-set rebuilds from options.txt text and keeps it.
|
||||
assert_eq!(
|
||||
back.with_option("fov", "90").unwrap().keys.get("key.emote"),
|
||||
"key.keyboard.g"
|
||||
);
|
||||
// Reset to B and back to the default: the file says so.
|
||||
let mut s = back;
|
||||
s.keys.set(
|
||||
"key.emote",
|
||||
crate::keybinds::KeyBinds::default_of("key.emote"),
|
||||
);
|
||||
s.save(&options);
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.b"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_emote_key_moves_from_options_txt_to_the_extras_file() {
|
||||
// #10092 wrote `key_key.emote` into options.txt; a rebound key is kept, the next
|
||||
// options.txt is vanilla's again.
|
||||
let dir = scratch("emote-key-move");
|
||||
let options = dir.join("options.txt");
|
||||
fs::write(
|
||||
&options,
|
||||
"version:4000\nfov:0.5\nkey_key.emote:key.keyboard.h\nkey_key.jump:key.keyboard.j\n",
|
||||
)
|
||||
.unwrap();
|
||||
let s = Settings::load(&options);
|
||||
assert_eq!(s.keys.get("key.emote"), "key.keyboard.h");
|
||||
assert_eq!(s.keys.get("key.jump"), "key.keyboard.j");
|
||||
s.save(&options);
|
||||
assert!(!fs::read_to_string(&options).unwrap().contains("emote"));
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.h"
|
||||
);
|
||||
// Once horizon-extras.txt has its own line, a stale options.txt one is ignored.
|
||||
fs::write(dir.join(crate::extras::FILE), "emoteKey:key.keyboard.k\n").unwrap();
|
||||
fs::write(&options, "version:4000\nkey_key.emote:key.keyboard.h\n").unwrap();
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.k"
|
||||
);
|
||||
// A value that is no key is not adopted; an unbound emote key is.
|
||||
fs::remove_file(dir.join(crate::extras::FILE)).unwrap();
|
||||
fs::write(&options, "version:4000\nkey_key.emote:banana\n").unwrap();
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.b"
|
||||
);
|
||||
fs::write(
|
||||
&options,
|
||||
"version:4000\nkey_key.emote:key.keyboard.unknown\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Settings::load(&options).keys.get("key.emote"),
|
||||
"key.keyboard.unknown"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_set_keeps_horizon_files() {
|
||||
let mut s = Settings::parse("fov:70\n");
|
||||
|
||||
@@ -237,6 +237,7 @@ pub struct Resolver {
|
||||
done: std::sync::mpsc::Receiver<(NameOrId, Option<ResolvedProfile>)>,
|
||||
send: std::sync::mpsc::Sender<(NameOrId, Option<ResolvedProfile>)>,
|
||||
last_sweep: std::time::Instant,
|
||||
heads: HeadProfiles,
|
||||
}
|
||||
struct Entry {
|
||||
touched: std::time::Instant,
|
||||
@@ -304,6 +305,7 @@ impl Resolver {
|
||||
done,
|
||||
send,
|
||||
last_sweep: std::time::Instant::now(),
|
||||
heads: HeadProfiles::default(),
|
||||
}
|
||||
}
|
||||
/// `CacheBuilder.expireAfterAccess` with `maximumSize`.
|
||||
@@ -465,6 +467,64 @@ pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option<NameOrId> {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_HEAD_PROFILES: usize = 4096;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct HeadProfile {
|
||||
info: ProfileInfo,
|
||||
skin: Option<(String, String)>,
|
||||
}
|
||||
|
||||
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
|
||||
let info = parse_profile(profile);
|
||||
let skin = info.textures.as_ref().and_then(|(value, signature)| {
|
||||
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
|
||||
let location = skin_location(&texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
HeadProfile { info, skin }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HeadProfiles {
|
||||
entries: std::collections::HashMap<serde_json::Value, std::sync::Arc<HeadProfile>>,
|
||||
nbt: std::collections::HashMap<horizon_world::nbt::Tag, std::sync::Arc<serde_json::Value>>,
|
||||
resolved: [std::collections::HashMap<String, std::sync::Arc<HeadProfile>>; 2],
|
||||
}
|
||||
|
||||
impl HeadProfiles {
|
||||
fn get(&mut self, profile: &serde_json::Value) -> std::sync::Arc<HeadProfile> {
|
||||
if let Some(parsed) = self.entries.get(profile) {
|
||||
return parsed.clone();
|
||||
}
|
||||
if self.entries.len() >= MAX_HEAD_PROFILES {
|
||||
self.entries.clear();
|
||||
}
|
||||
let parsed = std::sync::Arc::new(prepare_head(profile));
|
||||
self.entries.insert(profile.clone(), parsed.clone());
|
||||
parsed
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
/// Cache by NBT value rather than address: equipment can be replaced or
|
||||
/// mutated in place, and neither must leave a stale profile behind.
|
||||
pub fn head_profile_nbt(
|
||||
&mut self,
|
||||
profile: &horizon_world::nbt::Tag,
|
||||
) -> std::sync::Arc<serde_json::Value> {
|
||||
if let Some(json) = self.heads.nbt.get(profile) {
|
||||
return json.clone();
|
||||
}
|
||||
if self.heads.nbt.len() >= MAX_HEAD_PROFILES {
|
||||
self.heads.nbt.clear();
|
||||
}
|
||||
let json = std::sync::Arc::new(crate::scoreboard::tag_to_json(profile));
|
||||
self.heads.nbt.insert(profile.clone(), json.clone());
|
||||
json
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerSkinRenderCache.getOrDefault` for a head: the resolved skin once
|
||||
/// its texture is in the atlas (`atlas`), else `partialProfile`'s default.
|
||||
pub fn head_skin(
|
||||
@@ -478,32 +538,54 @@ pub fn head_skin(
|
||||
{
|
||||
return sprite;
|
||||
}
|
||||
let p = parse_profile(profile);
|
||||
let property = p.textures.or_else(|| {
|
||||
let key = p.dynamic.as_ref()?;
|
||||
let p = resolver.heads.get(profile);
|
||||
// Dynamic resolution is deliberately not cached: pending answers and the
|
||||
// resolver's expiring service results must still advance on every draw.
|
||||
let resolved = p.info.dynamic.as_ref().and_then(|key| {
|
||||
if p.info.textures.is_some() {
|
||||
return None;
|
||||
}
|
||||
match resolver.state(key) {
|
||||
Resolve::Done(Some(profile)) => profile.textures(),
|
||||
Resolve::Done(Some(profile)) => {
|
||||
let (_, value, signature) = profile
|
||||
.properties
|
||||
.iter()
|
||||
.find(|(n, _, _)| n == "textures")?;
|
||||
let cache = &mut resolver.heads.resolved[usize::from(signature.is_some())];
|
||||
if let Some(parsed) = cache.get(value) {
|
||||
return Some(parsed.clone());
|
||||
}
|
||||
if cache.len() >= MAX_HEAD_PROFILES {
|
||||
cache.clear();
|
||||
}
|
||||
let property = if signature.is_some() {
|
||||
serde_json::json!({"name": "textures", "value": value, "signature": ""})
|
||||
} else {
|
||||
serde_json::json!({"name": "textures", "value": value})
|
||||
};
|
||||
let parsed = std::sync::Arc::new(prepare_head(
|
||||
&serde_json::json!({"properties": [property]}),
|
||||
));
|
||||
cache.insert(value.clone(), parsed.clone());
|
||||
Some(parsed)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
if let Some((value, signature)) = property
|
||||
&& let Some(url) = unpack(&value, signature, &TEXTURE_URIS).skin
|
||||
&& let Some(hash) = texture_hash(&url)
|
||||
{
|
||||
let location = skin_location(&hash);
|
||||
match downloads.state(Kind::Skin, &url) {
|
||||
Download::Ready if atlas(&location) => {
|
||||
downloads.drawn(&location);
|
||||
return location;
|
||||
if let Some((url, location)) = resolved.as_deref().unwrap_or(&p).skin.as_ref() {
|
||||
match downloads.state(Kind::Skin, url) {
|
||||
Download::Ready if atlas(location) => {
|
||||
downloads.drawn(location);
|
||||
return location.clone();
|
||||
}
|
||||
// Its slot was evicted: offer the image again.
|
||||
Download::Ready => {
|
||||
downloads.again(&location);
|
||||
downloads.again(location);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
crate::tab_list::default_skin(p.id).to_owned()
|
||||
crate::tab_list::default_skin(p.info.id).to_owned()
|
||||
}
|
||||
|
||||
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
|
||||
@@ -1137,6 +1219,80 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn head_profile_cache_matches_uncached_and_is_bounded() {
|
||||
use base64::Engine;
|
||||
let mut cache = super::HeadProfiles::default();
|
||||
for (url, slim) in [
|
||||
("https://textures.minecraft.net/texture/abc", false),
|
||||
("https://textures.minecraft.net/texture/abc", true),
|
||||
("https://evil.example/texture/abc", false),
|
||||
] {
|
||||
let payload = serde_json::json!({"textures": {
|
||||
"SKIN": {"url": url, "metadata": {"model": if slim { "slim" } else { "classic" }}},
|
||||
"CAPE": {"url": "http://textures.minecraft.net/texture/cape"}
|
||||
}});
|
||||
let value = base64::engine::general_purpose::STANDARD.encode(payload.to_string());
|
||||
for signed in [false, true] {
|
||||
let mut property = serde_json::json!({"name": "textures", "value": value});
|
||||
if signed {
|
||||
property["signature"] = serde_json::Value::Null;
|
||||
}
|
||||
let profile = serde_json::json!({"properties": [property]});
|
||||
let parsed = cache.get(&profile);
|
||||
assert_eq!(*parsed, super::prepare_head(&profile));
|
||||
let textures = super::unpack(
|
||||
&value,
|
||||
if signed {
|
||||
super::Signature::Signed
|
||||
} else {
|
||||
super::Signature::Unsigned
|
||||
},
|
||||
&super::TEXTURE_URIS,
|
||||
);
|
||||
assert_eq!(textures.slim, slim && !url.contains("evil"));
|
||||
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
|
||||
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
|
||||
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
|
||||
}
|
||||
}
|
||||
for profile in [
|
||||
serde_json::json!({"properties": [{"name": "textures", "value": "!bad"}]}),
|
||||
serde_json::json!({"name": "Notch"}),
|
||||
serde_json::json!({}),
|
||||
] {
|
||||
let parsed = cache.get(&profile);
|
||||
assert_eq!(*parsed, super::prepare_head(&profile));
|
||||
assert!(parsed.skin.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
|
||||
}
|
||||
cache.entries.clear();
|
||||
for i in 0..super::MAX_HEAD_PROFILES {
|
||||
cache.get(&serde_json::json!({"id": i}));
|
||||
}
|
||||
assert_eq!(cache.entries.len(), super::MAX_HEAD_PROFILES);
|
||||
cache.get(&serde_json::json!({}));
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_profile_nbt_cache_tracks_value_changes() {
|
||||
let mut resolver = super::Resolver::new();
|
||||
let mut tag = horizon_world::nbt::Tag::String("Notch".into());
|
||||
let first = resolver.head_profile_nbt(&tag);
|
||||
assert_eq!(*first, crate::scoreboard::tag_to_json(&tag));
|
||||
assert!(std::sync::Arc::ptr_eq(
|
||||
&first,
|
||||
&resolver.head_profile_nbt(&tag)
|
||||
));
|
||||
tag = horizon_world::nbt::Tag::String("Alex".into());
|
||||
assert_eq!(*resolver.head_profile_nbt(&tag), serde_json::json!("Alex"));
|
||||
for i in 0..super::MAX_HEAD_PROFILES {
|
||||
resolver.head_profile_nbt(&horizon_world::nbt::Tag::Int(i as i32));
|
||||
}
|
||||
assert!(resolver.heads.nbt.len() < super::MAX_HEAD_PROFILES);
|
||||
}
|
||||
|
||||
/// A profile's `texture` names a pack texture, never a file outside the
|
||||
/// packs: bad characters and `..` segments draw nothing.
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,11 @@ thread_local! {
|
||||
static NOTES: RefCell<Vec<(f32, String)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The most spans a thread holds before they are taken ([`take_raw`]): a worker thread
|
||||
/// that records spans (`lod_reduce`, `lt_*`) while they are on for every thread is never
|
||||
/// taken from, so it drops what it held instead of growing for as long as they stay on.
|
||||
const UNTAKEN_MAX: usize = 1 << 14;
|
||||
|
||||
/// The shortest step a [`note`] keeps, in milliseconds.
|
||||
pub const NOTE_MS: f32 = 10.0;
|
||||
/// The notes a thread keeps between two [`take_notes`]: its slowest steps.
|
||||
@@ -53,7 +58,7 @@ pub fn record_here(on: bool) {
|
||||
/// Whether this thread records spans: all of them on, or [`record_here`].
|
||||
#[inline]
|
||||
pub fn recording_here() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
@@ -101,16 +106,41 @@ impl Drop for Guard {
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
trace::span(name, started, ms);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Keeps a finished span for the thread's next [`take_raw`].
|
||||
fn push(span: Span) {
|
||||
SPANS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if s.len() >= UNTAKEN_MAX {
|
||||
s.clear();
|
||||
}
|
||||
s.push(span);
|
||||
});
|
||||
}
|
||||
/// Attach per-tick counts without interpreting them as durations.
|
||||
pub fn counters(counts: &[(&str, u64)]) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
NOTES.with(|n| {
|
||||
n.borrow_mut().push((
|
||||
0.0,
|
||||
counts
|
||||
.iter()
|
||||
.map(|(name, count)| format!("{name}={count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
))
|
||||
});
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
|
||||
@@ -163,13 +193,12 @@ impl Laps {
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
let depth = DEPTH.with(Cell::get);
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
trace::span(name, *started, ms);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
@@ -271,6 +300,159 @@ pub fn describe(spans: &[Span]) -> String {
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
|
||||
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
|
||||
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
|
||||
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
|
||||
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
|
||||
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
|
||||
pub mod trace {
|
||||
use std::{
|
||||
cell::Cell,
|
||||
fs::File,
|
||||
io::{BufWriter, Write as _},
|
||||
path::Path,
|
||||
sync::{
|
||||
Mutex, PoisonError,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
|
||||
/// Counts the traces started: a thread names itself again in each one.
|
||||
static TRACES: AtomicU32 = AtomicU32::new(0);
|
||||
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
|
||||
thread_local! {
|
||||
/// This thread's id in traces (0: none yet).
|
||||
static THREAD: Cell<u32> = const { Cell::new(0) };
|
||||
/// The trace this thread last named itself in.
|
||||
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
|
||||
}
|
||||
/// How often a trace's buffer goes to the file, at most: a run that ends without
|
||||
/// [`stop`] loses no more than this.
|
||||
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
struct Sink {
|
||||
file: BufWriter<File>,
|
||||
origin: Instant,
|
||||
/// Until when it records ([`start`]'s `seconds`).
|
||||
until: Option<Instant>,
|
||||
flushed: Instant,
|
||||
/// Events written: the next one is preceded by a comma.
|
||||
events: u64,
|
||||
}
|
||||
impl Sink {
|
||||
fn event(&mut self, event: std::fmt::Arguments) {
|
||||
let comma = if self.events == 0 { "" } else { ",\n" };
|
||||
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
|
||||
self.events += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
|
||||
/// until [`stop`].
|
||||
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
|
||||
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
|
||||
file.write_all(b"[\n")?;
|
||||
let now = Instant::now();
|
||||
let sink = Sink {
|
||||
file,
|
||||
origin: now,
|
||||
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
|
||||
flushed: now,
|
||||
events: 0,
|
||||
};
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(old) = slot.take() {
|
||||
close(old);
|
||||
}
|
||||
*slot = Some(sink);
|
||||
TRACES.fetch_add(1, Ordering::Relaxed);
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts the trace `HORIZON_TRACE` names, if it names one.
|
||||
pub fn start_from_env() {
|
||||
if let Some(path) = std::env::var_os("HORIZON_TRACE")
|
||||
&& let Err(e) = start(Path::new(&path), None)
|
||||
{
|
||||
log::warn!("Horizon: HORIZON_TRACE: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the trace being written: the file is complete JSON. The events written so far
|
||||
/// and whether one was being written.
|
||||
pub fn stop() -> Option<u64> {
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
let events = sink.events;
|
||||
close(sink);
|
||||
Some(events)
|
||||
}
|
||||
|
||||
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
|
||||
/// then [`stop`]s it.
|
||||
pub fn due() -> bool {
|
||||
on() && SINK
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.and_then(|s| s.until)
|
||||
.is_some_and(|until| Instant::now() >= until)
|
||||
}
|
||||
|
||||
fn close(mut sink: Sink) {
|
||||
let _ = sink.file.write_all(b"\n]\n");
|
||||
let _ = sink.file.flush();
|
||||
}
|
||||
|
||||
/// A span of this thread from `started`, `ms` long.
|
||||
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let tid = THREAD.with(|t| {
|
||||
if t.get() == 0 {
|
||||
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
}
|
||||
t.get()
|
||||
});
|
||||
let trace = TRACES.load(Ordering::Relaxed);
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some(sink) = slot.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if NAMED_IN.with(|n| n.replace(trace)) != trace {
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.replace(['"', '\\'], "_");
|
||||
sink.event(format_args!(
|
||||
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
|
||||
));
|
||||
}
|
||||
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
|
||||
sink.event(format_args!(
|
||||
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
|
||||
f64::from(ms) * 1e3
|
||||
));
|
||||
let now = Instant::now();
|
||||
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
|
||||
let _ = sink.file.flush();
|
||||
sink.flushed = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
|
||||
/// the start (resource tables, the renderer, the title, a world's open, a
|
||||
/// server's join, a dimension change) on whichever thread ran it, one Chrome
|
||||
|
||||
@@ -544,6 +544,12 @@ pub struct Streaming {
|
||||
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
|
||||
/// generated and columns are only unloaded when the server says so.
|
||||
pub remote: bool,
|
||||
/// This server sends bulk block edits as whole columns whose light is
|
||||
/// from before its relight (a Horizon server): a resent resident column
|
||||
/// is lit here. Any other server's packet carries the light vanilla
|
||||
/// applies, so it is not lit again (a proxy's next server resends its
|
||||
/// whole world at the same coordinates).
|
||||
pub relight_resent: bool,
|
||||
/// More areas to keep loaded (centre chunk, radius), e.g. around the
|
||||
/// players of a server. Each is a square, like vanilla's ticket levels:
|
||||
/// chunks finish out to `radius + 1` (Chebyshev) and generation reaches
|
||||
@@ -1108,6 +1114,7 @@ impl Streaming {
|
||||
generated_total: 0,
|
||||
meshed_total: 0,
|
||||
remote: false,
|
||||
relight_resent: false,
|
||||
extra: Vec::new(),
|
||||
cover: None,
|
||||
mesh: true,
|
||||
@@ -1154,14 +1161,15 @@ impl Streaming {
|
||||
}
|
||||
/// A resident column a server sent again whole (it changed it in bulk):
|
||||
/// its new blocks taken, the sections that did not change kept as they
|
||||
/// were, and its light and its neighbours' worked out again here, off
|
||||
/// the game thread, as light is for the blocks a server changes one at a
|
||||
/// time; the sections that changed remesh (with theirs) once lit. Until
|
||||
/// then the sections that changed show the light the packet carried (a
|
||||
/// Horizon server sends the light from before its relight, then the
|
||||
/// new light; another server's is already right, which matters where
|
||||
/// the neighbours this light needs aren't all here).
|
||||
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
|
||||
/// were, and (for a Horizon server's bulk edit) its light and its
|
||||
/// neighbours' worked out again here, off the game thread, as light is for
|
||||
/// the blocks a server changes one at a time; the sections that changed
|
||||
/// remesh (with theirs) once lit. Until then the sections that changed
|
||||
/// show the light the packet carried (a Horizon server sends the light
|
||||
/// from before its relight, then the new light; another server's is already
|
||||
/// right, which matters where the neighbours this light needs aren't all
|
||||
/// here).
|
||||
pub fn replace_remote(&mut self, world: &mut World, chunk: Chunk) {
|
||||
let key = (chunk.x, chunk.z);
|
||||
let Some(old) = world.chunks.get(&key).cloned() else {
|
||||
return self.insert_remote(world, chunk);
|
||||
@@ -1169,34 +1177,7 @@ impl Streaming {
|
||||
if !self.lit.contains(&key) {
|
||||
return self.insert_remote(world, chunk);
|
||||
}
|
||||
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
||||
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
||||
// section often changes in a bulk edit, is mostly sections that did not).
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(world.layout());
|
||||
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
|
||||
chunk.light_loaded = old.light_loaded;
|
||||
chunk.lit = true;
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
world.revision = world.revision.wrapping_add(1);
|
||||
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
||||
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
||||
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
||||
Self::light_updated(world, key.0, key.1, &kept.biomes);
|
||||
// A section with the same blocks and new light takes the packet's light (vanilla's
|
||||
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
||||
Self::light_updated(world, key.0, key.1, &kept.light);
|
||||
if !kept.blocks {
|
||||
return;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
cells.dirty_sections(|section| {
|
||||
world.dirty_sections.insert(section);
|
||||
});
|
||||
take_resent_column(world, key, &old, chunk, self.relight_resent);
|
||||
}
|
||||
/// A server's light update for a resident column: the sections whose
|
||||
/// light changed and their neighbours remesh
|
||||
@@ -3046,6 +3027,51 @@ fn keep_unchanged_sections(
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Takes the server's new `chunk` over the resident column `old`: the sections
|
||||
/// whose blocks did not change keep `old`'s (with `chunk`'s biomes and light),
|
||||
/// and the sections whose blocks changed remesh. `relight` says the packet's
|
||||
/// light is from before the server's relight (a Horizon server's bulk edit), so
|
||||
/// the column and its neighbours are lit here; another server's packet already
|
||||
/// carries the light vanilla applies, so no light job is queued for it.
|
||||
fn take_resent_column(
|
||||
world: &mut World,
|
||||
key: (i32, i32),
|
||||
old: &Chunk,
|
||||
mut chunk: Chunk,
|
||||
relight: bool,
|
||||
) {
|
||||
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
||||
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
||||
// section often changes in a bulk edit, is mostly sections that did not).
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(world.layout());
|
||||
let kept = keep_unchanged_sections(old, &mut chunk, &mut cells);
|
||||
chunk.light_loaded = old.light_loaded;
|
||||
chunk.lit = true;
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
world.revision = world.revision.wrapping_add(1);
|
||||
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
||||
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
||||
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
||||
Streaming::light_updated(world, key.0, key.1, &kept.biomes);
|
||||
// A section with the same blocks and new light takes the packet's light (vanilla's
|
||||
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
||||
Streaming::light_updated(world, key.0, key.1, &kept.light);
|
||||
if !kept.blocks {
|
||||
return;
|
||||
}
|
||||
if relight {
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.dirty_sections(|section| {
|
||||
world.dirty_sections.insert(section);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
||||
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
||||
a.same_as(b)
|
||||
@@ -4215,6 +4241,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_column(world: &mut World, light: u8) -> Chunk {
|
||||
let mut chunk = Chunk::empty(2, -3);
|
||||
chunk.set(1, 10, 1, 1);
|
||||
Arc::make_mut(&mut chunk.sections[0]).light = crate::chunk::Light::Uniform(light);
|
||||
chunk.lit = true;
|
||||
world.chunks.insert((2, -3), Arc::new(chunk.clone()));
|
||||
chunk
|
||||
}
|
||||
|
||||
/// A server's resent column uses the light in its packet, as vanilla's
|
||||
/// `handleLevelChunkWithLight` does: only a Horizon server's bulk edit,
|
||||
/// whose packet light is from before its relight, is relit here. A proxy's
|
||||
/// next server resends its whole world at the same coordinates, and
|
||||
/// relighting every column and its neighbours is work that world never
|
||||
/// asked for (#10273).
|
||||
#[test]
|
||||
fn a_resent_column_is_relit_only_for_a_horizon_servers_bulk_edit() {
|
||||
for relight in [false, true] {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let old = resident_column(&mut world, 0x50);
|
||||
let mut new = old.clone();
|
||||
new.set(1, 10, 1, 2);
|
||||
Arc::make_mut(&mut new.sections[0]).light = crate::chunk::Light::Uniform(0xF0);
|
||||
take_resent_column(&mut world, (2, -3), &old, new, relight);
|
||||
let chunk = &world.chunks[&(2, -3)];
|
||||
assert_eq!(chunk.block(1, 10, 1).0, 2);
|
||||
// The packet's light is what the column shows.
|
||||
assert_eq!(chunk.sections[0].light.get(0), 0xF0);
|
||||
// The changed section remeshes either way.
|
||||
assert!(world.dirty_sections.contains(&(2, 0, -3)));
|
||||
// Only a Horizon bulk edit queues light work, for the column and
|
||||
// its eight neighbours.
|
||||
assert_eq!(world.dirty_light.len(), if relight { 9 } else { 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -124,10 +124,18 @@ pub fn face(
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hud.extractTabList`: whether the list shows. A single-player world
|
||||
/// shows it only with another listed player or a `list` objective.
|
||||
pub fn visible(key_down: bool, local_server: bool, listed: usize, objective: bool) -> bool {
|
||||
key_down && !(local_server && listed <= 1 && !objective)
|
||||
/// `Hud.extractTabList`: whether the list shows. `Hud.extractRenderState`
|
||||
/// only calls it inside `if (!isHidden)`, so F1 takes the list away whatever
|
||||
/// else holds it up. A single-player world shows it only with another listed
|
||||
/// player or a `list` objective.
|
||||
pub fn visible(
|
||||
hud_shown: bool,
|
||||
key_down: bool,
|
||||
local_server: bool,
|
||||
listed: usize,
|
||||
objective: bool,
|
||||
) -> bool {
|
||||
hud_shown && key_down && !(local_server && listed <= 1 && !objective)
|
||||
}
|
||||
|
||||
/// `Options.getBackgroundColor(553648127)`: the row background.
|
||||
@@ -702,8 +710,11 @@ mod tests {
|
||||
assert_eq!(ping_sprite(1000), "icon/ping_1");
|
||||
// UUID.hashCode of 0 is 0: the first default skin.
|
||||
assert_eq!(default_skin(0), "entity/player/slim/alex");
|
||||
assert!(!visible(true, true, 1, false));
|
||||
assert!(visible(true, true, 1, true));
|
||||
assert!(visible(true, false, 1, false));
|
||||
assert!(!visible(true, true, true, 1, false));
|
||||
assert!(visible(true, true, true, 1, true));
|
||||
assert!(visible(true, true, false, 1, false));
|
||||
// F1 (`Hud.isHidden`) takes the list away however it was held up.
|
||||
assert!(!visible(false, true, false, 2, false));
|
||||
assert!(!visible(false, true, true, 1, true));
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,11 @@ fn normalize_end(data: &mut [u8]) {
|
||||
}
|
||||
let disk = u16_at(data, end + 4);
|
||||
let with_directory = u16_at(data, end + 6);
|
||||
if disk == with_directory && u16_at(data, end + 8) == count && u16_at(data, end + 10) == count {
|
||||
if disk == 0
|
||||
&& with_directory == 0
|
||||
&& u16_at(data, end + 8) == count
|
||||
&& u16_at(data, end + 10) == count
|
||||
{
|
||||
return;
|
||||
}
|
||||
data[end + 4..end + 10].fill(0);
|
||||
@@ -311,6 +315,14 @@ mod tests {
|
||||
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
|
||||
}
|
||||
#[test]
|
||||
fn a_file_is_opened_with_equal_nonzero_disk_numbers() {
|
||||
let mut bytes = archive();
|
||||
let end = bytes.len() - END_HEADER;
|
||||
bytes[end + 4..end + 8].fill(0xFF);
|
||||
let zip = opened("end-disks", &bytes).unwrap();
|
||||
assert_eq!(zip.read("a/b.txt").unwrap(), b"hello hello hello hello");
|
||||
}
|
||||
#[test]
|
||||
fn a_file_that_is_no_archive_does_not_open() {
|
||||
assert!(opened("empty", b"").is_err());
|
||||
assert!(opened("garbage", b"PK not really").is_err());
|
||||
|
||||
@@ -106,10 +106,6 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
# DirectX 12 driver (`graphicsApi`, graphics.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
# The icon resource in the .exe (build.rs).
|
||||
winresource = "0.1"
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
@@ -152,6 +148,8 @@ path = "examples/native_gui_overlay_probe.rs"
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
# The build script runs on the host, but embeds resources based on the target OS.
|
||||
winresource = "0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
"horizon.extras.no_ai": "Keine KI",
|
||||
"horizon.extras.agent_control": "Agentensteuerung",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: über Mods installieren",
|
||||
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
|
||||
|
||||
@@ -322,6 +322,8 @@
|
||||
"horizon.extras.no_ai": "No AI",
|
||||
"horizon.extras.agent_control": "Agent Control",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: install from Mods",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
|
||||
@@ -242,6 +242,8 @@
|
||||
"horizon.extras.no_ai": "Bez AI",
|
||||
"horizon.extras.agent_control": "Sterowanie agentem",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.zoom": "Zoom",
|
||||
"horizon.extras.zoom_mod_install": "Zoom: zainstaluj w Modach",
|
||||
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
|
||||
|
||||
@@ -9,18 +9,17 @@ fn main() {
|
||||
.expect("generating native mod API");
|
||||
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
|
||||
let target_os =
|
||||
std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo must provide the build target OS");
|
||||
println!("cargo:rerun-if-changed={ico}");
|
||||
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
|
||||
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
if target_os == "windows" {
|
||||
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
|
||||
Ok("msvc") => "/STACK:8388608",
|
||||
_ => "-Wl,--stack,8388608",
|
||||
};
|
||||
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let mut resource = winresource::WindowsResource::new();
|
||||
resource.set_icon(&ico);
|
||||
if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() {
|
||||
@@ -30,8 +29,9 @@ fn main() {
|
||||
}
|
||||
if let Err(e) = resource.compile() {
|
||||
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
|
||||
// with a space, and a Windows user name often has one: such a build goes on without
|
||||
// the icon. Release builds run in CI, where the icon stays required.
|
||||
// with a space, and a Windows user name often has one; a cross build may have no
|
||||
// `windres` at all: such a build goes on without the icon. Release builds run in CI,
|
||||
// where the icon stays required.
|
||||
if std::env::var_os("CI").is_some() {
|
||||
panic!("embedding Windows resources: {e}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Command-tag data carried to a converted mob's new entity ID.
|
||||
use horizon_client::entity_nbt;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::io::Write;
|
||||
|
||||
pub fn main() {
|
||||
let args: Vec<_> = super::args().collect();
|
||||
let input = std::fs::read_to_string(&args[1]).unwrap();
|
||||
let mut out = std::fs::File::create(&args[2]).unwrap();
|
||||
for line in input.lines() {
|
||||
let values: Vec<String> = serde_json::from_str(line).unwrap();
|
||||
let raw = Tag::compound([
|
||||
(
|
||||
"Tags",
|
||||
Tag::List(
|
||||
values
|
||||
.iter()
|
||||
.map(|s| Tag::String(s.as_str().into()))
|
||||
.collect(),
|
||||
),
|
||||
),
|
||||
("UUID", Tag::IntArray(vec![1, 2, 3, 4])),
|
||||
("id", Tag::String("minecraft:husk".into())),
|
||||
]);
|
||||
let converted = entity_nbt::conversion_tags(Some(&raw))
|
||||
.unwrap_or_else(|| Tag::compound([("Tags", Tag::List(Vec::new()))]));
|
||||
assert!(converted.get("UUID").is_none() && converted.get("id").is_none());
|
||||
let Tag::List(tags) = converted.get("Tags").unwrap() else {
|
||||
panic!("missing tags")
|
||||
};
|
||||
let mut encoded: Vec<String> = tags
|
||||
.iter()
|
||||
.map(|tag| {
|
||||
let Tag::String(tag) = tag else {
|
||||
panic!("not a string")
|
||||
};
|
||||
tag.0.iter().map(|unit| format!("{unit:04x}")).collect()
|
||||
})
|
||||
.collect();
|
||||
encoded.sort();
|
||||
writeln!(out, "{}", serde_json::to_string(&encoded).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
mod container_screen_probe;
|
||||
mod container_title_fixture;
|
||||
mod conversion_tags_fixture;
|
||||
mod creative_mining_probe;
|
||||
mod creative_search_fixture;
|
||||
mod cushion_placement_probe;
|
||||
@@ -98,6 +99,7 @@ mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod stack_foil_fixture;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
@@ -139,6 +141,7 @@ probes! {
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
container_title_fixture,
|
||||
conversion_tags_fixture,
|
||||
creative_search_fixture,
|
||||
emitter_particles_probe,
|
||||
enchanting_fixture,
|
||||
@@ -214,6 +217,7 @@ probes! {
|
||||
trading_fixture,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
stack_foil_fixture,
|
||||
vehicle_container_fixture,
|
||||
weather_fixture,
|
||||
world_border_render_fixture,
|
||||
|
||||
@@ -220,6 +220,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"RandomSwimming" => "RandomSwimmingGoal",
|
||||
"LookAtPlayer" => "LookAtPlayerGoal",
|
||||
"RandomLookAround" => "RandomLookAroundGoal",
|
||||
"SpearUse" => "SpearUseGoal",
|
||||
"HurtByTarget" => "HurtByTargetGoal",
|
||||
"NearestTarget" => "NearestAttackableTargetGoal",
|
||||
"Panic" => "PanicGoal",
|
||||
@@ -1449,7 +1450,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(
|
||||
&mobs[i],
|
||||
[0; 4],
|
||||
None,
|
||||
None,
|
||||
|_| None,
|
||||
);
|
||||
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
|
||||
let mut honey = Vec::new();
|
||||
let stored = {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Actual stack decoding -> render view -> item-model foil selection, without a GPU.
|
||||
use horizon_assets::item_model::{
|
||||
ClientItem, Context, DisplayContext, FoilType, ItemModels, LayerContent, NoEnv,
|
||||
};
|
||||
use horizon_client::{item_data::decode_components, item_render::stack_view, items::ItemStack};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, fs};
|
||||
|
||||
fn model_flags(input: &Value) -> horizon_client::Result<Value> {
|
||||
let text = input["nbt"].as_str().ok_or("missing NBT text")?;
|
||||
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
|
||||
if used != text.len() {
|
||||
return Err("trailing NBT".into());
|
||||
}
|
||||
let mut stack = ItemStack::new("minecraft:diamond", 1);
|
||||
(stack.damage, stack.extra) =
|
||||
decode_components(Some(&Tag::compound([("minecraft:custom_model_data", tag)])));
|
||||
let view = stack_view(&stack);
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond".into(),
|
||||
BTreeMap::from([("minecraft:item_model".into(), json!("minecraft:diamond"))]),
|
||||
);
|
||||
models.definitions.insert("minecraft:diamond".into(), ClientItem::decode(&json!({
|
||||
"model": {"type": "minecraft:condition", "property": "minecraft:custom_model_data", "index": input["index"],
|
||||
"on_true": {"type": "minecraft:model", "model": "horizon:item/yes"},
|
||||
"on_false": {"type": "minecraft:model", "model": "horizon:item/no"}}
|
||||
}))?);
|
||||
let resolved = models.resolve(
|
||||
&view,
|
||||
&Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
},
|
||||
&NoEnv,
|
||||
);
|
||||
if resolved.layers.len() != 1 {
|
||||
return Err("expected one conditional model layer".into());
|
||||
}
|
||||
let LayerContent::Model { model } = &resolved.layers[0].content else {
|
||||
return Err("expected model content".into());
|
||||
};
|
||||
Ok(
|
||||
json!({"id": input["id"], "component": view.patch["minecraft:custom_model_data"], "selected": model}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut models = ItemModels::default();
|
||||
models.definitions.insert(
|
||||
"horizon:fixture".into(),
|
||||
ClientItem::from_json(
|
||||
r#"{"model":{"type":"minecraft:model","model":"horizon:item/fixture"}}"#,
|
||||
)?,
|
||||
);
|
||||
let mut output = String::new();
|
||||
for line in fs::read_to_string(args.get(1).ok_or("missing INPUT")?)?.lines() {
|
||||
let input: Value = serde_json::from_str(line)?;
|
||||
if input["kind"] == "flags" {
|
||||
output.push_str(&serde_json::to_string(&model_flags(&input)?)?);
|
||||
output.push('\n');
|
||||
continue;
|
||||
}
|
||||
let item = input["item"].as_str().ok_or("missing item")?;
|
||||
models.prototypes.insert(
|
||||
item.into(),
|
||||
BTreeMap::from([
|
||||
("minecraft:item_model".into(), json!("horizon:fixture")),
|
||||
(
|
||||
"minecraft:enchantment_glint_override".into(),
|
||||
input["default_glint"].clone(),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let mut stack = ItemStack::new(item, 1);
|
||||
if let Some(nbt) = input.get("nbt") {
|
||||
let text = nbt.as_str().ok_or("missing NBT text")?;
|
||||
let (tag, used) = horizon_world::nbt::parse_snbt(text)?;
|
||||
if used != text.len() {
|
||||
return Err("trailing NBT".into());
|
||||
}
|
||||
(stack.damage, stack.extra) = decode_components(Some(&Tag::compound([(
|
||||
"minecraft:enchantment_glint_override",
|
||||
tag,
|
||||
)])));
|
||||
}
|
||||
let view = stack_view(&stack);
|
||||
let resolved = models.resolve(
|
||||
&view,
|
||||
&Context {
|
||||
display: DisplayContext::Gui,
|
||||
owner: None,
|
||||
level: None,
|
||||
seed: 0,
|
||||
},
|
||||
&NoEnv,
|
||||
);
|
||||
if resolved.layers.len() != 1 {
|
||||
return Err("expected one fixture model layer".into());
|
||||
}
|
||||
output.push_str(&serde_json::to_string(&json!({
|
||||
"id": input["id"],
|
||||
"component": view.patch.get("minecraft:enchantment_glint_override").and_then(Option::as_ref),
|
||||
"foil": resolved.layers[0].foil != FoilType::None,
|
||||
}))?);
|
||||
output.push('\n');
|
||||
}
|
||||
fs::write(args.get(2).ok_or("missing OUTPUT")?, output)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -355,7 +355,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
let mob =
|
||||
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None, |_| None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
|
||||
@@ -370,6 +370,8 @@ impl ArrowScene<'_, '_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
// CollisionContext.of(arrow): scaffolding is open from below.
|
||||
let context = horizon_world::collision_context::CollisionContext::position(from.y);
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
if Some(p) == ignored {
|
||||
return None;
|
||||
@@ -380,7 +382,7 @@ impl ArrowScene<'_, '_> {
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.collision_shape_with_context(&mut scene, state, p, &context)
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
});
|
||||
@@ -447,13 +449,15 @@ pub fn clip_block_hit(
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
// CollisionContext.of(projectile): scaffolding is open from below.
|
||||
let context = horizon_world::collision_context::CollisionContext::position(from.y);
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
if facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.collision_shape_with_context(&mut scene, state, p, &context)
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
})
|
||||
|
||||
@@ -584,6 +584,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
|
||||
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
|
||||
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
|
||||
cmd!("render-capture", Scripting, "render-capture on|off|DIR", "keep CPU copies of the meshes the renderer draws (on), or write the next frame's scene to DIR for the scene_replay example",
|
||||
"on meshes every section and far tile again so the copies are complete (HORIZON_RENDER_CAPTURE=1 keeps them from the start). DIR gets the camera, frame inputs, settings and every resident near section and far tile mesh; entities, particles, weather, the hand and the HUD are left out. See docs/graphics.md.",
|
||||
["horizon-ctl render-capture on", "horizon-ctl render-capture /tmp/scene"], &[], mcp: false, local: false),
|
||||
|
||||
@@ -467,6 +467,11 @@ impl Creative {
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
}
|
||||
/// The search tab's index (`CreativeModeTabs.searchTab`), the tab the chat
|
||||
/// key opens while another one is shown.
|
||||
pub fn search_tab(&self) -> Option<usize> {
|
||||
self.tabs.iter().position(|t| t.search)
|
||||
}
|
||||
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
|
||||
/// A tab on another page shows that page.
|
||||
pub fn select(&mut self, tab: usize) {
|
||||
@@ -480,10 +485,13 @@ impl Creative {
|
||||
self.page = t.page;
|
||||
}
|
||||
self.scroll = 0.0;
|
||||
if self.tab().search {
|
||||
self.search.focused = true;
|
||||
// The search box is shown, focused and impossible to unfocus only on
|
||||
// its own tab; another tab hides it and takes the focus away.
|
||||
let search = self.tab().search;
|
||||
if search {
|
||||
self.search.reset_blink();
|
||||
}
|
||||
self.search.focused = search;
|
||||
}
|
||||
/// How many pages of tabs there are (1 without mods' tabs).
|
||||
pub fn pages(&self) -> u32 {
|
||||
@@ -1438,6 +1446,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.keyPressed`: from a tab that is not the
|
||||
/// search, the chat key selects `CreativeModeTabs.searchTab`, which focuses
|
||||
/// the search box and empties the query the other tab left.
|
||||
#[test]
|
||||
fn the_chat_key_opens_the_search_tab() {
|
||||
let mut c = creative(0);
|
||||
c.search.set_value("anvil");
|
||||
let tab = c.search_tab().expect("the fixture has a search tab");
|
||||
assert!(c.tabs[tab].search);
|
||||
assert!(!c.tab().search);
|
||||
c.select(tab);
|
||||
assert!(c.tab().search);
|
||||
assert!(c.search.focused);
|
||||
assert_eq!(c.search.value, "", "another tab empties the query");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_vanilla_trees() {
|
||||
let c = creative(0);
|
||||
@@ -1536,6 +1560,26 @@ mod tests {
|
||||
assert!(!c.scrolling);
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab`: the search box is focused on
|
||||
/// its own tab and loses the focus when another tab is shown (and, hidden,
|
||||
/// is no longer the field a key types into).
|
||||
#[test]
|
||||
fn the_search_box_loses_the_focus_with_its_tab() {
|
||||
let mut c = creative(0);
|
||||
let search = c
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| t.search)
|
||||
.expect("the fixture has a search tab");
|
||||
c.select(search);
|
||||
assert!(c.search.focused);
|
||||
c.select(0);
|
||||
assert!(!c.search.focused, "another tab takes the focus away");
|
||||
assert!(!c.tab().search);
|
||||
c.select(search);
|
||||
assert!(c.search.focused, "and the search tab takes it back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_tab_titles_have_vanilla_keys() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -120,7 +120,13 @@ mod drop_motion_tests {
|
||||
[
|
||||
-4627867408251048793,
|
||||
4597245257657090048,
|
||||
4597448471913462163,
|
||||
// Vanilla's `Math.sin` is HotSpot's intrinsic, which rounds this
|
||||
// one a bit lower on AArch64 than on x86-64 (`jmath`).
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
4597448471913462162
|
||||
} else {
|
||||
4597448471913462163
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -409,6 +415,29 @@ impl ItemEntity {
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub const WIDTH: f64 = 0.25;
|
||||
/// `EntityType.ITEM` declares `eyeHeight(0.2125)`: an item is lit from the
|
||||
/// block its eye is in, most of a block above the one its base rests in.
|
||||
pub const EYE_HEIGHT: f64 = 0.2125;
|
||||
/// `Entity.getLightProbePosition(partialTick)` for a dropped item: the eye
|
||||
/// position (`getEyePosition`) over the position drawn this frame
|
||||
/// (`getPosition(partialTick)`), not this tick's own.
|
||||
pub fn light_probe(&self, partial: f64) -> [f64; 3] {
|
||||
[
|
||||
self.previous.x + (self.position.x - self.previous.x) * partial,
|
||||
self.previous.y + (self.position.y - self.previous.y) * partial + Self::EYE_HEIGHT,
|
||||
self.previous.z + (self.position.z - self.previous.z) * partial,
|
||||
]
|
||||
}
|
||||
/// `EntityRenderer.getPackedLightCoords` for a dropped item: the level's
|
||||
/// sky light over the block light of the cell it is lit from, which
|
||||
/// `EntityRenderer.getBlockLightLevel` puts at 15 while the item
|
||||
/// `isOnFire`. `light` is the level's packed light (sky high, block low);
|
||||
/// `environment_tick` clears the fire ticks of a fire-immune stack, as
|
||||
/// `ItemEntity.fireImmune` makes it never on fire.
|
||||
pub fn render_light(&self, light: u8) -> u32 {
|
||||
let block = if self.fire_ticks > 0 { 15 } else { light & 15 };
|
||||
(u32::from(light >> 4) * 16) | ((u32::from(block) * 16) << 8)
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let h = Self::WIDTH / 2.0;
|
||||
Aabb::new(
|
||||
@@ -1721,6 +1750,8 @@ impl ItemFrame {
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArmorStand {
|
||||
/// `Entity.firstTick`: command equipment events wait for an actual entity tick.
|
||||
pub first_tick: bool,
|
||||
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
|
||||
pub pitch: f32,
|
||||
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
|
||||
@@ -1767,7 +1798,8 @@ pub struct ArmorStand {
|
||||
pub disabled_slots: i32,
|
||||
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
|
||||
pub enchantment_block: Option<BlockPos>,
|
||||
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
|
||||
/// `Entity.fallDistance`, also used by `LivingEntity.checkFallDamage`'s landing callback.
|
||||
/// Each downward movement is narrowed to float before adding to this double.
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
@@ -1893,6 +1925,84 @@ impl ArmorStand {
|
||||
pub fn render_position(&self, alpha: f64) -> Vec3 {
|
||||
self.previous.lerp(self.position, alpha)
|
||||
}
|
||||
/// `ArmorStand.getDimensionsMarker(false)`: the width, height and eye
|
||||
/// height of the box a stand has without the marker flag, which is the box
|
||||
/// a marker's light probe walks (`MARKER_DIMENSIONS` itself is empty, and
|
||||
/// `isBaby()` is `isSmall()`).
|
||||
pub fn probe_dimensions(&self) -> (f64, f64, f64) {
|
||||
if self.small {
|
||||
(0.25, 0.9875, 0.9875)
|
||||
} else {
|
||||
(0.5, 1.975, 1.7775)
|
||||
}
|
||||
}
|
||||
/// `ArmorStand.getLightProbePosition` as the cell the renderer reads
|
||||
/// (`BlockPos.containing` of the position it returns): a marker walks
|
||||
/// `BlockPos.betweenClosed` of its non-marker box at `position()` - it
|
||||
/// takes no partial tick - and returns the first cell whose brightness
|
||||
/// `max(block, sky)` is 15, else the brightest, ties going to the first
|
||||
/// cell walked; every other stand is lit from its eye
|
||||
/// (`Entity.getLightProbePosition` is `getEyePosition(partialTick)`).
|
||||
/// `light` is the level's packed light.
|
||||
pub fn light_probe(
|
||||
&self,
|
||||
partial: f64,
|
||||
mut light: impl FnMut(i32, i32, i32) -> u8,
|
||||
) -> [i32; 3] {
|
||||
let (width, height, eye) = self.probe_dimensions();
|
||||
if !self.marker {
|
||||
let at = self.render_position(partial);
|
||||
return [
|
||||
at.x.floor() as i32,
|
||||
(at.y + eye).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
];
|
||||
}
|
||||
let half = width / 2.0;
|
||||
let (lo_x, hi_x) = (
|
||||
(self.position.x - half).floor() as i32,
|
||||
(self.position.x + half).floor() as i32,
|
||||
);
|
||||
let (lo_z, hi_z) = (
|
||||
(self.position.z - half).floor() as i32,
|
||||
(self.position.z + half).floor() as i32,
|
||||
);
|
||||
let (lo_y, hi_y) = (
|
||||
self.position.y.floor() as i32,
|
||||
(self.position.y + height).floor() as i32,
|
||||
);
|
||||
// `BlockPos.betweenClosed` walks x fastest, then y, then z; the stand's
|
||||
// own cell is what it falls back to when the box is empty.
|
||||
let mut best = [
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
];
|
||||
let mut brightest = i32::MIN;
|
||||
for cell_z in lo_z..=hi_z {
|
||||
for cell_y in lo_y..=hi_y {
|
||||
for cell_x in lo_x..=hi_x {
|
||||
let level = light(cell_x, cell_y, cell_z);
|
||||
let brightness = i32::from((level & 15).max(level >> 4));
|
||||
if brightness == 15 {
|
||||
return [cell_x, cell_y, cell_z];
|
||||
}
|
||||
if brightness > brightest {
|
||||
brightest = brightness;
|
||||
best = [cell_x, cell_y, cell_z];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
/// `EntityRenderer.getPackedLightCoords` for the cell `light_probe` chose:
|
||||
/// the level's sky light over the cell's block light, at 15 block light
|
||||
/// while the stand `isOnFire`. `light` is the level's packed light.
|
||||
pub fn render_light(&self, light: u8) -> u32 {
|
||||
let block = if self.fire_ticks > 0 { 15 } else { light & 15 };
|
||||
(u32::from(light >> 4) * 16) | ((u32::from(block) * 16) << 8)
|
||||
}
|
||||
/// The body's yaw `alpha` of the way through the tick
|
||||
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
|
||||
/// yBodyRotO, yBodyRot)`).
|
||||
@@ -1923,6 +2033,7 @@ impl ArmorStand {
|
||||
}
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
first_tick: true,
|
||||
pitch: 0.0,
|
||||
join: Default::default(),
|
||||
fire_ticks: 0,
|
||||
@@ -2569,6 +2680,113 @@ mod armor_stand_render_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod armor_stand_light_tests {
|
||||
use super::*;
|
||||
|
||||
fn stand(x: f64, y: f64, z: f64) -> ArmorStand {
|
||||
ArmorStand::new(1, Vec3::new(x, y, z), 0.0)
|
||||
}
|
||||
|
||||
/// `ArmorStand.getLightProbePosition` for a stand that is not a marker is
|
||||
/// `Entity.getEyePosition`: `EntityType.ARMOR_STAND` declares
|
||||
/// `eyeHeight(1.7775)`, and a small stand (`isBaby()`) is the same type
|
||||
/// scaled by 0.5 with `withEyeHeight(0.9875)`. Horizon read
|
||||
/// `floor(base + 0.5)` for every stand, so a stand standing on a lit floor
|
||||
/// took the floor's light instead of the light at its head.
|
||||
#[test]
|
||||
fn a_stand_is_lit_from_the_block_its_eye_is_in() {
|
||||
let dark = |_: i32, _: i32, _: i32| 0u8;
|
||||
// Full stand on y = 64: eye 65.7775, cell 65 (the old 64.5 was 64).
|
||||
assert_eq!(stand(0.5, 64.0, 0.5).light_probe(1.0, dark), [0, 65, 0]);
|
||||
assert_eq!((64.0_f64 + 0.5).floor() as i32, 64);
|
||||
// Small stand: eye 0.9875 above its base, so y = 64.2 is 65.1875.
|
||||
let mut small = stand(0.5, 64.2, 0.5);
|
||||
small.small = true;
|
||||
assert_eq!(small.light_probe(1.0, dark), [0, 65, 0]);
|
||||
assert_eq!((64.2_f64 + 0.5).floor() as i32, 64);
|
||||
// A small stand at y = 64.0 stays in its own cell: eye 64.9875.
|
||||
let mut low = stand(0.5, 64.0, 0.5);
|
||||
low.small = true;
|
||||
assert_eq!(low.light_probe(1.0, dark), [0, 64, 0]);
|
||||
}
|
||||
|
||||
/// `getEyePosition(partialTick)` interpolates: a stand falling from 70 to
|
||||
/// 69 is probed 0.5 down at the middle of the frame, 69.5 + 1.7775 in the
|
||||
/// cell at 71, while its feet's cell is 69.
|
||||
#[test]
|
||||
fn a_stand_probe_follows_the_frame() {
|
||||
let dark = |_: i32, _: i32, _: i32| 0u8;
|
||||
let mut falling = stand(0.5, 70.0, 0.5);
|
||||
falling.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
|
||||
assert_eq!(falling.light_probe(0.0, dark), [0, 71, 0]);
|
||||
assert_eq!(falling.light_probe(0.5, dark), [0, 71, 0]);
|
||||
assert_eq!(falling.light_probe(1.0, dark), [0, 70, 0]);
|
||||
assert_eq!(falling.position.y.floor() as i32, 69);
|
||||
}
|
||||
|
||||
/// A marker walks `BlockPos.betweenClosed` of its own box - the size it has
|
||||
/// without the marker flag, 0.5 by 1.975 - from `position()`, taking the
|
||||
/// first cell at brightness 15 and else the brightest. Horizon used the
|
||||
/// stand's own cell, so a marker (a hologram on a dark ceiling) was lit by
|
||||
/// its feet's block.
|
||||
#[test]
|
||||
fn a_marker_scans_the_cells_of_its_own_box() {
|
||||
let at = |want: [i32; 3], level: u8| {
|
||||
move |x: i32, y: i32, z: i32| {
|
||||
if [x, y, z] == want { level } else { 0 }
|
||||
}
|
||||
};
|
||||
let mut marker = stand(0.5, 64.0, 0.5);
|
||||
marker.marker = true;
|
||||
// The box is x/z 0.25..0.75 - the cell at 0 - and y 64..65.975.
|
||||
assert_eq!(marker.light_probe(1.0, at([0, 65, 0], 15)), [0, 65, 0]);
|
||||
assert_eq!(marker.light_probe(1.0, at([0, 65, 0], 9)), [0, 65, 0]);
|
||||
// Nothing lit: the first cell walked, x fastest then y then z.
|
||||
assert_eq!(marker.light_probe(1.0, |_, _, _| 0u8), [0, 64, 0]);
|
||||
// The cell above the box is not scanned, so its 15 does not win.
|
||||
assert_eq!(marker.light_probe(1.0, at([0, 66, 0], 15)), [0, 64, 0]);
|
||||
// 15 wins over a brighter-looking neighbour, and y is walked before z:
|
||||
// (0, 65, 0) comes before (0, 64, 1) though both are at 15.
|
||||
let mut wide = stand(0.5, 64.0, 0.9);
|
||||
wide.marker = true;
|
||||
let two = |x: i32, y: i32, z: i32| {
|
||||
if (x, y, z) == (0, 65, 0) || (x, y, z) == (0, 64, 1) {
|
||||
15
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
assert_eq!(wide.light_probe(1.0, two), [0, 65, 0]);
|
||||
// `getLightProbePosition`'s marker branch reads `position()`, not the
|
||||
// partial-tick position: falling does not move the scan.
|
||||
let mut moving = stand(0.5, 64.0, 0.5);
|
||||
moving.marker = true;
|
||||
moving.interpolate(Some((Vec3::new(0.5, 70.0, 0.5), 0.0)));
|
||||
assert_eq!(moving.light_probe(0.0, |_, _, _| 0u8), [0, 70, 0]);
|
||||
assert_eq!(moving.light_probe(0.5, |_, _, _| 0u8), [0, 70, 0]);
|
||||
}
|
||||
|
||||
/// `EntityRenderer.getBlockLightLevel`: a stand `isOnFire` is drawn at 15
|
||||
/// block light whatever the level says, and the sky light is the probe
|
||||
/// cell's own.
|
||||
#[test]
|
||||
fn a_burning_stand_is_drawn_at_full_block_light() {
|
||||
let light = |sky: u8, block: u8| (sky << 4) | block;
|
||||
let cold = stand(0.5, 64.0, 0.5);
|
||||
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
|
||||
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
|
||||
let burning = ArmorStand {
|
||||
fire_ticks: 100,
|
||||
..stand(0.5, 64.0, 0.5)
|
||||
};
|
||||
assert_eq!(
|
||||
burning.render_light(light(15, 0)),
|
||||
((15 * 16) | ((15 * 16) << 8)) as u32
|
||||
);
|
||||
assert_eq!(burning.render_light(light(0, 0)), ((15 * 16) << 8) as u32);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4243,6 +4461,104 @@ mod render_light_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dropped_item_light_tests {
|
||||
use super::*;
|
||||
|
||||
fn item(previous_y: f64, y: f64) -> ItemEntity {
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
thrower: None,
|
||||
target: None,
|
||||
stack: ItemStack {
|
||||
item: std::sync::Arc::from("minecraft:stone"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
position: Vec3::new(0.5, y, 0.5),
|
||||
previous: Vec3::new(0.5, previous_y, 0.5),
|
||||
velocity: Vec3::ZERO,
|
||||
on_ground: false,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
was_touching_water: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getLightProbePosition` is the eye position, and `EntityType.ITEM`
|
||||
/// declares `eyeHeight(0.2125)`. The item used to be lit from the cell of
|
||||
/// `base + 0.1`, so a falling item at y = 64.85 read the cell at 64 (block
|
||||
/// light of the floor) while its eye at 65.0625 is in the cell at 65.
|
||||
#[test]
|
||||
fn a_dropped_item_is_lit_from_the_block_its_eye_is_in() {
|
||||
let falling = item(64.85, 64.85);
|
||||
let probe = falling.light_probe(1.0);
|
||||
assert!((probe[1] - 65.062_5).abs() < 1e-9);
|
||||
assert_eq!(probe[1].floor(), 65.0);
|
||||
// The old probe, `(y + 0.1).floor()`, and the base cell itself.
|
||||
assert_eq!((64.85_f64 + 0.1).floor(), 64.0);
|
||||
assert_eq!(64.85_f64.floor(), 64.0);
|
||||
// Resting on a block top the two agree: the eye is 0.2125 up.
|
||||
let resting = item(64.0, 64.0);
|
||||
assert_eq!(resting.light_probe(1.0)[1].floor(), 64.0);
|
||||
assert!((resting.light_probe(1.0)[1] - 64.212_5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `Entity.getLightProbePosition(partialTick)` follows `getPosition(partialTick)`:
|
||||
/// an item falling a block in one tick is probed halfway between the two
|
||||
/// positions in the middle of the frame. The old code read the tick's own
|
||||
/// position, so the light jumped a frame early.
|
||||
#[test]
|
||||
fn an_item_probe_follows_the_frame() {
|
||||
let falling = item(64.0, 65.0);
|
||||
let mid = falling.light_probe(0.5);
|
||||
assert!((mid[1] - 64.712_5).abs() < 1e-9);
|
||||
assert_eq!(mid[1].floor(), 64.0);
|
||||
assert_eq!(falling.light_probe(1.0)[1].floor(), 65.0);
|
||||
// Across x and z too, not just down.
|
||||
let sliding = ItemEntity {
|
||||
position: Vec3::new(1.5, 64.0, 0.5),
|
||||
previous: Vec3::new(0.5, 64.0, 0.5),
|
||||
..item(64.0, 64.0)
|
||||
};
|
||||
assert!((sliding.light_probe(0.5)[0] - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `EntityRenderer.getBlockLightLevel` is `isOnFire() ? 15 : the level's
|
||||
/// block light`, and the sky light is never overridden. A dropped item was
|
||||
/// drawn at the level's block light while it burned.
|
||||
#[test]
|
||||
fn a_burning_item_is_drawn_at_full_block_light() {
|
||||
let light = |sky: u8, block: u8| (sky << 4) | block;
|
||||
let cold = item(64.0, 64.0);
|
||||
assert_eq!(cold.render_light(light(15, 0)), (15 * 16) as u32);
|
||||
assert_eq!(cold.render_light(light(0, 9)), ((9 * 16) << 8) as u32);
|
||||
let burning = ItemEntity {
|
||||
fire_ticks: 160,
|
||||
..item(64.0, 64.0)
|
||||
};
|
||||
// Daylight, dark block: the block light goes to 15, the sky stays.
|
||||
assert_eq!(
|
||||
burning.render_light(light(15, 0)),
|
||||
((15 * 16) | ((15 * 16) << 8)) as u32
|
||||
);
|
||||
// A dark cave: 15 block light, still no invented sky light.
|
||||
assert_eq!(burning.render_light(light(0, 0)), ((15 * 16) << 8) as u32);
|
||||
}
|
||||
}
|
||||
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
|
||||
#[cfg(test)]
|
||||
mod orb_lava_tests {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_threads::configure_allocator();
|
||||
horizon_client::telemetry::mark_start();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
use crate::{hud::Rect, mesher::Vertex};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Test the flame's own envelope, which is taller and wider than the entity's box.
|
||||
/// This only rejects camera-invisible geometry; callers keep other-view shader/mod paths.
|
||||
pub fn in_view(
|
||||
view: &crate::gfx::Frustum,
|
||||
camera: glam::DVec3,
|
||||
position: [f64; 3],
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> bool {
|
||||
let origin = (glam::DVec3::from(position) - camera).as_vec3();
|
||||
bounds(origin, width, height).is_none_or(|(lo, hi)| view.intersects(lo, hi))
|
||||
}
|
||||
|
||||
fn bounds(origin: glam::Vec3, width: f32, height: f32) -> Option<(glam::Vec3, glam::Vec3)> {
|
||||
let scale = width * 1.4;
|
||||
let remaining = height / scale;
|
||||
// Unusual/invalid dimensions keep the original path. The ordinary aspect ratios also
|
||||
// bound accumulated float error in the 0.45-step loop well below the spare 0.1 scale.
|
||||
if !origin.is_finite()
|
||||
|| !scale.is_finite()
|
||||
|| !remaining.is_finite()
|
||||
|| scale <= 0.0
|
||||
|| !(0.0..=64.0).contains(&remaining)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// half starts at 0.5 and shrinks. z starts at 0.3 - trunc(remaining)*0.02 and
|
||||
// recedes by 0.03 per layer. Two extra steps cover float rounding at layer boundaries.
|
||||
let layers = (remaining / 0.45).ceil() + 2.0;
|
||||
let z = 0.3 - remaining.trunc() * 0.02;
|
||||
let depth = z.abs().max((z - layers * 0.03).abs());
|
||||
// |rotated x/z| <= |x| + |z|, for every billboard yaw. The last layer starts
|
||||
// below height and reaches 1.4*scale above its start; keep extra numerical slack.
|
||||
let radius = (0.5 + depth) * scale;
|
||||
let padding = glam::Vec3::splat(0.05 + origin.abs().max_element() * f32::EPSILON * 4.0);
|
||||
Some((
|
||||
origin - glam::Vec3::new(radius, 0.0, radius) - padding,
|
||||
origin + glam::Vec3::new(radius, height + 1.5 * scale, radius) + padding,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn flames(
|
||||
position: [f64; 3],
|
||||
@@ -62,6 +103,74 @@ pub fn flames(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn culling_envelope_contains_emitted_flames_at_every_billboard_yaw() {
|
||||
let rect = super::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites =
|
||||
super::HashMap::from([("block/fire_0".into(), rect), ("block/fire_1".into(), rect)]);
|
||||
for (width, height) in [
|
||||
(0.25, 0.25),
|
||||
(0.5, 1.975),
|
||||
(0.25, 0.9875),
|
||||
(0.6, 1.8),
|
||||
(0.9, 1.4),
|
||||
(2.0, 1.9),
|
||||
(5.0, 448.0),
|
||||
] {
|
||||
for scale in [0.0625, 1.0, 16.0] {
|
||||
let (width, height) = (width * scale, height * scale);
|
||||
for camera in [
|
||||
glam::DVec3::ZERO,
|
||||
glam::DVec3::new(30_000_000.0, -64.0, -30_000_000.0),
|
||||
] {
|
||||
let position = camera + glam::DVec3::new(7.25, -2.5, -12.0);
|
||||
let (lo, hi) =
|
||||
super::bounds((position - camera).as_vec3(), width, height).unwrap();
|
||||
for yaw in (0..360).step_by(15) {
|
||||
let mut out = Vec::new();
|
||||
super::flames(
|
||||
position.to_array(),
|
||||
width,
|
||||
height,
|
||||
camera,
|
||||
yaw as f32,
|
||||
&sprites,
|
||||
0,
|
||||
&mut out,
|
||||
);
|
||||
assert!(!out.is_empty());
|
||||
for v in out {
|
||||
let p = glam::Vec3::from_array(v.pos);
|
||||
assert!(
|
||||
p.cmpge(lo).all() && p.cmple(hi).all(),
|
||||
"{width}x{height}, yaw {yaw}: {p:?} outside {lo:?}..{hi:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_dimensions_keep_the_unculled_path() {
|
||||
for (width, height) in [
|
||||
(0.0, 1.0),
|
||||
(f32::NAN, 1.0),
|
||||
(1.0, f32::INFINITY),
|
||||
(0.001, 100.0),
|
||||
] {
|
||||
assert!(super::bounds(glam::Vec3::ZERO, width, height).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flames_cover_entity_height_and_alternate_animated_sprites() {
|
||||
let rect = |u0| super::Rect {
|
||||
|
||||
@@ -85,7 +85,8 @@ impl FurnaceCooking for Furnace {
|
||||
let can_burn = match &self.items[2] {
|
||||
None => true,
|
||||
Some(out) => {
|
||||
out.item == recipe.result.item && out.count + recipe.result.count <= max
|
||||
out.same_item_same_components(&recipe.result)
|
||||
&& out.count + recipe.result.count <= max
|
||||
}
|
||||
};
|
||||
if can_burn {
|
||||
@@ -249,6 +250,66 @@ mod tests {
|
||||
(items, recipes, providers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooking_requires_output_components_and_capacity() {
|
||||
let (items, recipes, providers) = data("output-components");
|
||||
let mut different = ItemStack::new("test:ingot", 1);
|
||||
different.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Cold".into()));
|
||||
for (output, eligible) in [
|
||||
(Some(different), false),
|
||||
(None, true),
|
||||
(Some(ItemStack::new("test:ingot", 1)), true),
|
||||
(Some(ItemStack::new("test:ingot", 64)), false),
|
||||
(Some(ItemStack::new("test:ingot", 63)), true),
|
||||
] {
|
||||
let before = output.clone();
|
||||
let mut f = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
Some(ItemStack::new("test:ore", 3)),
|
||||
Some(ItemStack::new("test:coal", 1)),
|
||||
output,
|
||||
],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 200,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let recipe = recipes
|
||||
.cook(f.kind.recipe_type(), f.items[0].as_ref().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(&*recipe.result.item, recipe.result.count, recipe.time),
|
||||
("test:ingot", 1, 200)
|
||||
);
|
||||
let mut drops = Vec::new();
|
||||
assert_eq!(
|
||||
f.tick(&recipes, &items, &providers, &mut drops),
|
||||
eligible.then_some(true),
|
||||
"output {before:?}"
|
||||
);
|
||||
assert!(drops.is_empty());
|
||||
if eligible {
|
||||
assert!(f.items[1].is_none());
|
||||
for _ in 1..200 {
|
||||
f.tick(&recipes, &items, &providers, &mut drops);
|
||||
}
|
||||
assert_eq!(f.items[0].as_ref().unwrap().count, 2);
|
||||
assert_eq!(
|
||||
f.items[2].as_ref().unwrap().count,
|
||||
before.as_ref().map_or(0, |s| s.count) + 1
|
||||
);
|
||||
} else {
|
||||
assert_eq!(f.items[0].as_ref().unwrap().count, 3);
|
||||
assert_eq!(f.items[1].as_ref().unwrap().count, 1);
|
||||
assert_eq!(f.items[2], before);
|
||||
assert_eq!((f.lit_remaining, f.cooking_timer), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
|
||||
/// real tick on every idle case and compare, and check a furnace that can smelt
|
||||
/// isn't idle and does change.
|
||||
|
||||
+336
-106
@@ -71,6 +71,7 @@ mod boarding;
|
||||
mod boat_authority_fixture;
|
||||
mod boat_leads;
|
||||
mod boat_passengers;
|
||||
mod boat_rider_fixture;
|
||||
mod boats;
|
||||
mod books;
|
||||
mod bossbars;
|
||||
@@ -173,6 +174,7 @@ mod lan_chat;
|
||||
mod lan_crafting;
|
||||
mod lan_entities;
|
||||
mod lan_far;
|
||||
mod lan_idle;
|
||||
mod lan_mobs;
|
||||
mod lan_mount_menu;
|
||||
mod lan_particles;
|
||||
@@ -1909,9 +1911,12 @@ impl LevelAccess for GameLevel<'_> {
|
||||
for (k, v) in [("x", pos.x), ("y", pos.y), ("z", pos.z)] {
|
||||
nbt.insert(k.into(), Tag::Int(v));
|
||||
}
|
||||
if let Some((key, entity)) = horizon_client::save::decode_block_entity(&Tag::Compound(nbt))
|
||||
{
|
||||
let tag = Tag::Compound(nbt);
|
||||
if let Some((key, entity)) = horizon_client::save::decode_block_entity(&tag) {
|
||||
self.world.block_entities.insert(key, entity);
|
||||
if tag.get("LootTable").is_some() {
|
||||
self.emit(LevelEvent::ContainerLootTable { pos, tag });
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set_command_block_powered(&mut self, pos: BlockPos, powered: bool) -> Option<(bool, bool)> {
|
||||
@@ -2951,6 +2956,7 @@ struct Game<'a> {
|
||||
dragon_model: Option<horizon_client::mobs::Part>,
|
||||
/// Map textures waiting for the next HUD batch to upload them.
|
||||
map_patches: Vec<horizon_client::font::Patch>,
|
||||
name_tag_layouts: names::NameTagLayouts,
|
||||
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
|
||||
interactions: std::collections::HashMap<i32, interactions::RemoteInteraction>,
|
||||
/// The fence knots a server tracks to this client, by entity id, at their
|
||||
@@ -3037,6 +3043,9 @@ struct Game<'a> {
|
||||
/// Where item entities are, while the block entities tick (hoppers).
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
/// `CreativeModeInventoryScreen.ignoreTextInput`: the chat key opened the
|
||||
/// search tab, so the letter it carries is not typed into the box.
|
||||
creative_ignore_text: bool,
|
||||
control: Option<horizon_client::control::Server>,
|
||||
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
|
||||
agent_control_on: bool,
|
||||
@@ -3109,6 +3118,9 @@ struct Game<'a> {
|
||||
water_plane_memo: std::cell::Cell<Option<f32>>,
|
||||
/// The camera's block and the second `water_plane` last scanned at.
|
||||
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
|
||||
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
|
||||
/// worked out for (`vehicle_mob_reach`).
|
||||
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
|
||||
persist: persist::EntityMemory,
|
||||
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
|
||||
/// Connected to a server: the world comes from the network (game/remote.rs).
|
||||
@@ -3165,6 +3177,10 @@ struct Game<'a> {
|
||||
pause_menu: bool,
|
||||
pause_focus: Option<u8>,
|
||||
death_focus: Option<u8>,
|
||||
/// The screen ([`Game::key_screen`]) the menu focus belongs to. Another
|
||||
/// screen in front drops it, as `Gui.setScreen` drops the focus of the
|
||||
/// screen it leaves (`Screen.clearFocus`).
|
||||
focus_screen: u64,
|
||||
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
|
||||
previous_mode: Option<horizon_client::command::args::GameMode>,
|
||||
gamemode_switcher: Option<horizon_client::game_mode_switcher::GameModeSwitcher>,
|
||||
@@ -5064,10 +5080,12 @@ impl Game<'_> {
|
||||
/// (`ServerChunkCache.tick`'s broadcast), before the block events and the
|
||||
/// block entities ran.
|
||||
fn tick_blocks(&mut self) -> Result<Option<usize>> {
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
// ServerChunkCache.tick: the tickets that time out.
|
||||
self.tickets.tick_portal();
|
||||
self.sync_tick_chunks();
|
||||
self.start_ticking_chunks();
|
||||
laps.lap("tb_tickets");
|
||||
let levels = self.tickets.levels();
|
||||
let bounds = levels.random_tick_bounds();
|
||||
// Keep the world map's chunk order, including empty chunks
|
||||
@@ -5086,6 +5104,7 @@ impl Game<'_> {
|
||||
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
|
||||
})
|
||||
.collect();
|
||||
laps.lap("tb_chunks");
|
||||
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
|
||||
// buttons under the players (the host and the LAN guests), the minecarts
|
||||
// and the arrows (the mobs and other entities are added in `with_level`
|
||||
@@ -5259,6 +5278,7 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
self.process_level_events()?;
|
||||
laps.lap("tb_entity_inside");
|
||||
// ServerLevel.tick: a Debug world runs no scheduled block or fluid ticks, and
|
||||
// ServerChunkCache.tickChunks no random ticks.
|
||||
let debug = self.world_debug;
|
||||
@@ -5299,12 +5319,17 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
});
|
||||
laps = horizon_client::spans::laps();
|
||||
let sent = self.lan_broadcast_changes();
|
||||
laps.lap("tb_broadcast");
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.run_block_events(level, ticks);
|
||||
});
|
||||
laps.lap("tb_block_events");
|
||||
self.process_level_events()?;
|
||||
laps.lap("tb_level_events");
|
||||
self.sync_moving_pistons();
|
||||
laps.lap("tb_pistons");
|
||||
Ok(sent)
|
||||
}
|
||||
/// `ServerLevel.tick`: moving block entities push after entity travel.
|
||||
@@ -5347,7 +5372,10 @@ impl Game<'_> {
|
||||
/// events) happen before the next tick runs, as they do inline in vanilla.
|
||||
pub(super) fn tick_scheduled(&mut self) -> Result<()> {
|
||||
for fluids in [false, true] {
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let name = if fluids { "tb_fluids" } else { "tb_scheduled" };
|
||||
if !self.with_level(|b, level, ticks| b.scheduled_begin(level, ticks, fluids)) {
|
||||
laps.lap(name);
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
@@ -5370,6 +5398,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.with_level(|b, _, ticks| b.scheduled_finish(ticks, fluids));
|
||||
laps.lap(name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5677,6 +5706,14 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
LevelEvent::ContainerLootTable { pos, tag } => {
|
||||
let key = (pos.x, pos.y, pos.z);
|
||||
if let Some(mut entity) = self.world.block_entities.remove(&key) {
|
||||
self.take_loot_table(key, &tag, &mut entity);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
|
||||
}
|
||||
}
|
||||
// A prime the mods refused is silent: no fuse sound, no game event.
|
||||
LevelEvent::Sound { pos, ref sound, .. }
|
||||
if self.tnt_refused == Some(pos)
|
||||
@@ -7501,12 +7538,13 @@ impl Game<'_> {
|
||||
/// game event from the player, at its feet.
|
||||
pub(super) fn stop_using_item(&mut self, stack: &ItemStack) {
|
||||
self.using = None;
|
||||
self.use_vibration(stack);
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
}
|
||||
/// `ItemStack.causeUseVibration` for `stack` used by the player: an
|
||||
/// `ITEM_INTERACT_FINISH` game event from the player, at its feet, when the
|
||||
/// stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack) {
|
||||
/// `ItemStack.causeUseVibration` for `stack` used by the player: the
|
||||
/// `event` game event (`ITEM_INTERACT_START` when the use begins,
|
||||
/// `ITEM_INTERACT_FINISH` when it ends) from the player, at its feet, when
|
||||
/// the stack's use effects ask for interaction vibrations (the default).
|
||||
pub(super) fn use_vibration(&mut self, stack: &ItemStack, event: &'static str) {
|
||||
if !self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
@@ -7518,7 +7556,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event("minecraft:item_interact_finish", [p.x, p.y, p.z], ctx);
|
||||
self.post_game_event(event, [p.x, p.y, p.z], ctx);
|
||||
}
|
||||
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
|
||||
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
|
||||
@@ -7572,10 +7610,8 @@ impl Game<'_> {
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
let block_break_speed = self.block_break_speed();
|
||||
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let facts = self.res.facts().get(state);
|
||||
if facts.hardness < 0.0 {
|
||||
if self.res.facts().get(state).hardness < 0.0 {
|
||||
// The server still counts its destroy ticks on an unbreakable block.
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.ticks += 1;
|
||||
@@ -7583,36 +7619,7 @@ impl Game<'_> {
|
||||
self.destroy_progress_event(pos, hit.face, ticks);
|
||||
return Ok(());
|
||||
}
|
||||
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
|
||||
let held = self.held();
|
||||
let mut speed = self.items().mining_speed(held, &facts.name);
|
||||
let hardness = facts.hardness;
|
||||
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
|
||||
speed = self.modify_destroy_speed(speed);
|
||||
speed *= block_break_speed;
|
||||
let player = self.player.as_ref().ok_or("no player")?;
|
||||
if !player.ground.on_ground {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
if self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(
|
||||
eye[0].floor() as i32,
|
||||
eye[1].floor() as i32,
|
||||
eye[2].floor() as i32,
|
||||
))
|
||||
.fluid
|
||||
== Fluid::Water
|
||||
{
|
||||
speed *= self.submerged_mining_speed();
|
||||
}
|
||||
let progress = if hardness == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
speed / hardness / if harvest { 30.0 } else { 100.0 }
|
||||
};
|
||||
let progress = self.destroy_progress(hit.pos);
|
||||
self.swing_arm();
|
||||
let m = self.mining.as_mut().ok_or("mining")?;
|
||||
m.progress += progress;
|
||||
@@ -7789,18 +7796,29 @@ impl Game<'_> {
|
||||
/// farmland breaks. The local player's and every server player's landings.
|
||||
/// The level events they send go out too: a turtle egg's break particles
|
||||
/// and sound (`decreaseEggs`' 2001, `Level.destroyBlock`'s for the last).
|
||||
fn landing_block_updates(&mut self, event: &horizon_client::landing::FallEvent) {
|
||||
///
|
||||
/// A mod hears the change first (`native_entity_change_block_v2`, Bukkit's
|
||||
/// `EntityChangeBlockEvent`): cancelling puts the block back and skips the
|
||||
/// updates and the events, so nothing the change would have caused happens -
|
||||
/// the crop on trampled farmland stays, because breaking it is the
|
||||
/// neighbour update below and not a write of its own.
|
||||
fn landing_block_updates(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
event: &horizon_client::landing::FallEvent,
|
||||
) {
|
||||
use horizon_client::landing::FallEvent;
|
||||
let (position, flags) = match *event {
|
||||
// The block the landing changed, what it replaced, and what it became.
|
||||
let (position, from, to, flags) = match *event {
|
||||
FallEvent::Write {
|
||||
position, flags, ..
|
||||
} => (position, flags as i32),
|
||||
position,
|
||||
from,
|
||||
state,
|
||||
flags,
|
||||
} => (position, from, state, flags as i32),
|
||||
FallEvent::Destroy {
|
||||
position, state, ..
|
||||
} => {
|
||||
self.level_event_at(2001, [position.x, position.y, position.z], state.0 as i32);
|
||||
(position, level_flags::UPDATE_ALL)
|
||||
}
|
||||
} => (position, state, self.air_state(), level_flags::UPDATE_ALL),
|
||||
FallEvent::LevelEvent {
|
||||
event,
|
||||
position,
|
||||
@@ -7811,8 +7829,49 @@ impl Game<'_> {
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if self.landing_change_cancelled(player, position, from, to) {
|
||||
self.world
|
||||
.set(position.x, position.y, position.z, from)
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
if matches!(event, FallEvent::Destroy { .. }) {
|
||||
self.level_event_at(2001, [position.x, position.y, position.z], from.0 as i32);
|
||||
}
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
/// `Blocks.AIR.defaultBlockState`: what a landing's destroy leaves.
|
||||
fn air_state(&self) -> BlockStateId {
|
||||
self.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:air")
|
||||
.unwrap_or(BlockStateId(0))
|
||||
}
|
||||
/// Whether a mod cancelled a landing's block change
|
||||
/// (`native_entity_change_block_v2`): asked with the two blocks' names and
|
||||
/// the player whose landing it was (`None` when no player did it).
|
||||
fn landing_change_cancelled(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
position: BlockPos,
|
||||
from: BlockStateId,
|
||||
to: BlockStateId,
|
||||
) -> bool {
|
||||
let name = |s: BlockStateId| self.res.facts().get(s).name.to_string();
|
||||
let (from, to) = (name(from), name(to));
|
||||
let mut cancel = false;
|
||||
self.native_entity_change_block_v2(
|
||||
player,
|
||||
None,
|
||||
"minecraft:player",
|
||||
[position.x, position.y, position.z],
|
||||
&from,
|
||||
&to,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
@@ -8052,8 +8111,6 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
// Everything that has joined has had its spawn packet.
|
||||
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
@@ -8072,6 +8129,10 @@ impl Game<'_> {
|
||||
self.run_ordered_ticks(horizon_client::join_seq::JoinSeq::MAX, true)?;
|
||||
self.apply_late_wind_pushes();
|
||||
}
|
||||
// Everything that has joined has had its spawn packet and, if it joined
|
||||
// before the entity passes (TNT a piston or repeater primed), its first
|
||||
// tick in the late pass above (#10063).
|
||||
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
|
||||
if running {
|
||||
horizon_client::spans::time("tick_moving_pistons", || self.tick_moving_pistons())?;
|
||||
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
|
||||
@@ -8622,7 +8683,7 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
pub(super) fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
|
||||
// `GuiGraphics.renderItem`: the resolved item model (tints, foil and
|
||||
// special renderers included), then the decorations.
|
||||
self.draw_item_model(batch, stack, x, y);
|
||||
@@ -8712,7 +8773,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`)
|
||||
/// and damage bar.
|
||||
fn draw_item_decorations(
|
||||
pub(super) fn draw_item_decorations(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
stack: &ItemStack,
|
||||
@@ -10798,6 +10859,10 @@ impl Game<'_> {
|
||||
pub(super) fn has_control_down(&self) -> bool {
|
||||
self.modifiers.control_key()
|
||||
}
|
||||
/// The same live word for Alt (`InputWithModifiers.hasAltDown`).
|
||||
pub(super) fn has_alt_down(&self) -> bool {
|
||||
self.modifiers.alt_key()
|
||||
}
|
||||
/// Real events word the modifiers by their own `ModifiersChanged`; a
|
||||
/// control socket's `key` is synthesized, so it words the bit directly
|
||||
/// and every reader can ask the one live state.
|
||||
@@ -11247,12 +11312,27 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.yaw
|
||||
};
|
||||
// Packs may move vertices or draw from another camera; TAA jitters the
|
||||
// projection beyond this unjittered frustum. Keep their original geometry path.
|
||||
let fire_view = self.view_frustum.as_ref().filter(|_| {
|
||||
self.pack_ids.is_none()
|
||||
&& self.native_shaders.picked.is_empty()
|
||||
&& !self.settings.horizon.taa
|
||||
});
|
||||
let fire_visible = |position, width, height| {
|
||||
fire_view.is_none_or(|view| {
|
||||
horizon_client::fire_render::in_view(view, camera, position, width, height)
|
||||
})
|
||||
};
|
||||
laps.lap("eg_prep");
|
||||
for m in &self.mobs {
|
||||
// Blaze.isOnFire: a charged blaze burns.
|
||||
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
|
||||
if (m.is_on_fire() || blazing) && !m.removed {
|
||||
let at = m.previous.lerp(m.position, alpha);
|
||||
if !fire_visible([at.x, at.y, at.z], m.width(), m.height()) {
|
||||
continue;
|
||||
}
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
m.width(),
|
||||
@@ -11277,20 +11357,26 @@ impl Game<'_> {
|
||||
let feet = self
|
||||
.previous_position
|
||||
.lerp(p.position, self.clock.camera_fraction());
|
||||
horizon_client::fire_render::flames(
|
||||
if fire_visible(
|
||||
[feet.x, feet.y, feet.z],
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
camera,
|
||||
fire_yaw,
|
||||
&self.gui.atlas.sprites,
|
||||
self.world.light(
|
||||
feet.x.floor() as i32,
|
||||
feet.y.floor() as i32,
|
||||
feet.z.floor() as i32,
|
||||
),
|
||||
&mut out,
|
||||
);
|
||||
) {
|
||||
horizon_client::fire_render::flames(
|
||||
[feet.x, feet.y, feet.z],
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
camera,
|
||||
fire_yaw,
|
||||
&self.gui.atlas.sprites,
|
||||
self.world.light(
|
||||
feet.x.floor() as i32,
|
||||
feet.y.floor() as i32,
|
||||
feet.z.floor() as i32,
|
||||
),
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_fire_mobs");
|
||||
for item in &self.dropped {
|
||||
@@ -11301,6 +11387,9 @@ impl Game<'_> {
|
||||
.resists_fire(&item.stack, "minecraft:in_fire")
|
||||
{
|
||||
let at = item.previous.lerp(item.position, alpha);
|
||||
if !fire_visible([at.x, at.y, at.z], 0.25, 0.25) {
|
||||
continue;
|
||||
}
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
0.25,
|
||||
@@ -11322,6 +11411,9 @@ impl Game<'_> {
|
||||
if stand.fire_ticks > 0 && !stand.marker {
|
||||
let scale = if stand.small { 0.5 } else { 1.0 };
|
||||
let at = stand.render_position(alpha);
|
||||
if !fire_visible([at.x, at.y, at.z], 0.5 * scale, 1.975 * scale) {
|
||||
continue;
|
||||
}
|
||||
horizon_client::fire_render::flames(
|
||||
[at.x, at.y, at.z],
|
||||
0.5 * scale,
|
||||
@@ -11425,7 +11517,17 @@ impl Game<'_> {
|
||||
);
|
||||
// Text, block and item displays (DisplayRenderer's shadow radius).
|
||||
casters.extend(self.text_displays.values().filter_map(|d| d.shadow(a)));
|
||||
// Packs may move vertices or draw from another view.
|
||||
let shadow_view = self
|
||||
.view_frustum
|
||||
.as_ref()
|
||||
.filter(|_| self.pack_ids.is_none() && self.native_shaders.picked.is_empty());
|
||||
for (pos, radius, strength) in casters {
|
||||
if let Some(view) = shadow_view
|
||||
&& !horizon_client::shadows::in_view(view, camera, pos, radius, strength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -11478,8 +11580,38 @@ impl Game<'_> {
|
||||
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
|
||||
})
|
||||
};
|
||||
let dynamic_visible = |_: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
|
||||
if off_screen(state) {
|
||||
let camera_list: Vec<&([i32; 3], u32)> = r
|
||||
.visible_block_entities_in_frustum(
|
||||
self.settings.chunk_fade,
|
||||
camera,
|
||||
view,
|
||||
&off_screen,
|
||||
long_range,
|
||||
)
|
||||
.filter(|(block, section_inside)| {
|
||||
let (pos, state) = **block;
|
||||
self.res.block_entity_render().renders(state)
|
||||
&& (off_screen(state) || *section_inside || in_view(&pos))
|
||||
})
|
||||
.map(|(block, _)| block)
|
||||
.collect();
|
||||
// A shader pack's shadow pass draws the block entities of every section it draws,
|
||||
// in the view or not (`Renderer::shadow_pass_block_entities`, #8464): those the
|
||||
// view leaves out go in hidden from the camera.
|
||||
let shadow_only: Vec<&([i32; 3], u32)> =
|
||||
if self.pack_ids.is_some() && self.pack_shadow_casters.0 {
|
||||
let listed: HashSet<[i32; 3]> = camera_list.iter().map(|b| b.0).collect();
|
||||
r.shadow_pass_block_entities(camera)
|
||||
.filter(|(pos, state)| {
|
||||
!listed.contains(pos) && self.res.block_entity_render().renders(*state)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let shadow_only_at: HashSet<[i32; 3]> = shadow_only.iter().map(|b| b.0).collect();
|
||||
let dynamic_visible = |pos: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
|
||||
if off_screen(state) || shadow_only_at.contains(&pos) {
|
||||
return true;
|
||||
}
|
||||
if let Some(view) = view
|
||||
@@ -11534,20 +11666,7 @@ impl Game<'_> {
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
dynamic_visible: Some(&dynamic_visible),
|
||||
};
|
||||
let visible = r
|
||||
.visible_block_entities_in_frustum(
|
||||
self.settings.chunk_fade,
|
||||
camera,
|
||||
view,
|
||||
&off_screen,
|
||||
long_range,
|
||||
)
|
||||
.filter(|(block, section_inside)| {
|
||||
let (pos, state) = **block;
|
||||
self.res.block_entity_render().renders(state)
|
||||
&& (off_screen(state) || *section_inside || in_view(&pos))
|
||||
})
|
||||
.map(|(block, _)| block);
|
||||
let visible = camera_list.into_iter().chain(shadow_only.iter().copied());
|
||||
let block_entity_cache = CacheOptions {
|
||||
shulker_boxes: self.settings.horizon.cache_shulker_boxes,
|
||||
signs: self.settings.horizon.cache_signs,
|
||||
@@ -11575,7 +11694,11 @@ impl Game<'_> {
|
||||
Some(§ion_revision),
|
||||
&mut out,
|
||||
|_, state, start| self.mark_block_entity(start, state),
|
||||
|_, _, _, _| {},
|
||||
|pos, _, start, out| {
|
||||
if shadow_only_at.contains(&pos) {
|
||||
self.hide_spans_from(start, out.len());
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let cache_candidates = r
|
||||
@@ -12210,7 +12333,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
/// `Player.canCriticalAttack` for a living target at attack strength
|
||||
/// `scale`: falling, off the ground, not in water, not blind (mobility
|
||||
/// `scale`: falling, off the ground, not in water, not on a ladder, not blind (mobility
|
||||
/// restricted), not riding, not sprinting.
|
||||
pub(super) fn critical_attack(&self, scale: f32) -> bool {
|
||||
let player = self.player.as_ref();
|
||||
@@ -12218,6 +12341,7 @@ impl Game<'_> {
|
||||
!p.ground.on_ground
|
||||
&& p.fall_distance > 0.0
|
||||
&& !p.fluids.was_touching_water
|
||||
&& !p.on_climbable
|
||||
&& !p.sprinting
|
||||
});
|
||||
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
|
||||
@@ -13814,7 +13938,7 @@ impl Game<'_> {
|
||||
return Ok(UseResult::Pass);
|
||||
};
|
||||
// `BucketItem.use`: `level.mayInteract` and `mayUseItemAt`.
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let mut canceled = false;
|
||||
@@ -13865,7 +13989,7 @@ impl Game<'_> {
|
||||
};
|
||||
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
if let Some((content, sound, mob)) = bucket {
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let target = self.bucket_target(content, &hit);
|
||||
@@ -13981,7 +14105,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if item == "flint_and_steel" || item == "fire_charge" {
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let name = self.res.facts().get(clicked).name.clone();
|
||||
@@ -14432,11 +14556,7 @@ impl Game<'_> {
|
||||
a.fired_from_weapon = weapon;
|
||||
}
|
||||
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
|
||||
if let Some(seconds) = self.bow_projectile_ignite()
|
||||
&& let Some(a) = arrow.arrow.as_mut()
|
||||
{
|
||||
a.ignite_for_seconds(seconds);
|
||||
}
|
||||
self.ignite_spawned_arrow(stack, &mut arrow);
|
||||
let (velocity, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |pl| {
|
||||
(self.known_player_movement(), pl.ground.on_ground)
|
||||
});
|
||||
@@ -16120,6 +16240,8 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
let prepare = spans::guard("prepare");
|
||||
let mut pr = spans::laps();
|
||||
// The window's GPU holds the atlas: its copy in memory is kept
|
||||
// compressed (a no-op once it is).
|
||||
if self.renderer.is_some() {
|
||||
@@ -16137,6 +16259,9 @@ impl Game<'_> {
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
self.observe_screen();
|
||||
// `Gui.setScreen` clears the focus of the screen it leaves: a menu
|
||||
// that comes back starts with no widget highlighted.
|
||||
self.drop_left_screen_focus();
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts, with the server's chunk map and progress.
|
||||
if let Some(starting) = &self.integrated_starting {
|
||||
@@ -16158,6 +16283,8 @@ impl Game<'_> {
|
||||
upgrade,
|
||||
self.cursor,
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16197,6 +16324,8 @@ impl Game<'_> {
|
||||
Some(&status),
|
||||
progress,
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16213,6 +16342,8 @@ impl Game<'_> {
|
||||
loader.progress.line(),
|
||||
loader.progress.fraction(),
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16229,6 +16360,8 @@ impl Game<'_> {
|
||||
reload.progress.fraction(),
|
||||
self.settings.file.bool("darkMojangStudiosBackground"),
|
||||
);
|
||||
pr.lap("pr_loading");
|
||||
drop(prepare);
|
||||
return renderer.render(&loading_frame(&hud));
|
||||
}
|
||||
return Ok(false);
|
||||
@@ -16240,6 +16373,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
pr.lap("pr_setup");
|
||||
let alpha = self.clock.fraction();
|
||||
// Camera.getCameraEntityPartialTicks: the view, the hand and the
|
||||
// player's own model stay smooth while the level is frozen.
|
||||
@@ -16279,8 +16413,10 @@ impl Game<'_> {
|
||||
self.pitch,
|
||||
),
|
||||
};
|
||||
pr.lap("pr_camera");
|
||||
self.audio
|
||||
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
|
||||
pr.lap("pr_listener");
|
||||
let block = [
|
||||
camera.x.floor() as i32,
|
||||
camera.y.floor() as i32,
|
||||
@@ -16302,6 +16438,7 @@ impl Game<'_> {
|
||||
renderer.start_occlusion(camera, (self.streaming.radius * 16) as f32)
|
||||
});
|
||||
}
|
||||
pr.lap("pr_occlusion");
|
||||
let biome = self
|
||||
.world
|
||||
.chunks
|
||||
@@ -16349,12 +16486,14 @@ impl Game<'_> {
|
||||
std::array::from_fn(|i| sky.sky_color[i] + (flash[i] - sky.sky_color[i]) * 0.22);
|
||||
sky.sky_light_factor = 1.0;
|
||||
}
|
||||
pr.lap("pr_environment");
|
||||
// FishingHookRenderer's line, then DebugRenderer: chunk borders and
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
let colored_lights = if self.title.is_none() {
|
||||
self.native_frame_visuals(camera, alpha, &mut lines)
|
||||
} else {
|
||||
@@ -16372,12 +16511,14 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = horizon_client::camera::view_matrix(view_yaw, view_pitch);
|
||||
let world_primitives = if self.title.is_none() {
|
||||
self.world_primitive_geometry(camera, alpha, view)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
pr.lap("pr_primitives");
|
||||
let at = camera.to_array();
|
||||
// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown.
|
||||
let mut crosshair = horizon_client::gizmos::Gizmos::default();
|
||||
@@ -16457,6 +16598,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_outline");
|
||||
let title = self.shows_panorama();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
@@ -16515,11 +16657,13 @@ impl Game<'_> {
|
||||
dimension: self.dim.id().to_string(),
|
||||
}
|
||||
});
|
||||
pr.lap("pr_projection");
|
||||
// GUI hooks project against this frame's camera, including its bob/spin and FOV.
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
self.flush_ime(&mut hud);
|
||||
self.native_gui_render_post(&mut hud, self.ui.hide_gui, self.focused);
|
||||
self.resolve_inline_profiles(&hud.inline_profiles);
|
||||
pr.lap("pr_hud");
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let requested_post_effects = self.requested_post_effects();
|
||||
@@ -16536,6 +16680,7 @@ impl Game<'_> {
|
||||
self.see_through_vertices.clear();
|
||||
self.glowing_vertices.get_mut().clear();
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
pr.lap("pr_entity_setup");
|
||||
if !title {
|
||||
let (built, static_sections, visible_static_sections, static_stats) =
|
||||
spans::time("entity_geometry", || {
|
||||
@@ -16552,6 +16697,7 @@ impl Game<'_> {
|
||||
// be drawn over the title (#8192). Its models stay stored.
|
||||
self.skin_frame.get_mut().clear_draws();
|
||||
}
|
||||
pr.lap("pr_entities");
|
||||
let bolts = if title {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -16566,6 +16712,7 @@ impl Game<'_> {
|
||||
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
|
||||
entities.extend(dragon);
|
||||
}
|
||||
pr.lap("pr_dragon_bolts");
|
||||
let (celestial, stars) = if title {
|
||||
(Vec::new(), None)
|
||||
} else if self.dim == Dim::Overworld
|
||||
@@ -16579,6 +16726,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
pr.lap("pr_celestial");
|
||||
let (mut hand, hand_items) = if title || self.scoping() {
|
||||
Default::default()
|
||||
} else {
|
||||
@@ -16591,6 +16739,7 @@ impl Game<'_> {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
self.item_activation_geometry(camera_alpha as f32, view, aspect, &mut hand);
|
||||
}
|
||||
pr.lap("pr_hand");
|
||||
let screen = if title {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -16605,6 +16754,7 @@ impl Game<'_> {
|
||||
self.end_geometry(camera, &mut entities);
|
||||
}
|
||||
self.outline_fill(&outline, camera, &mut entities);
|
||||
pr.lap("pr_screen_end");
|
||||
let weather = if !title && self.can_have_weather() && self.settings.horizon.weather {
|
||||
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
|
||||
} else {
|
||||
@@ -16614,16 +16764,19 @@ impl Game<'_> {
|
||||
.then(|| self.border_draw(camera.to_array(), alpha as f32))
|
||||
.flatten();
|
||||
let see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
pr.lap("pr_weather_border");
|
||||
if title {
|
||||
self.dynamic_lights.clear();
|
||||
} else {
|
||||
self.gather_dynamic_lights(camera, alpha);
|
||||
}
|
||||
pr.lap("pr_lights");
|
||||
let pack_inputs = if title {
|
||||
Default::default()
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32, &sky)
|
||||
};
|
||||
pr.lap("pr_shader_inputs");
|
||||
// Map textures and font glyphs changed while building this frame's
|
||||
// geometry (game/maps.rs, world text) upload with this batch; draining
|
||||
// map_patches any earlier leaves them a frame behind.
|
||||
@@ -16790,6 +16943,9 @@ impl Game<'_> {
|
||||
},
|
||||
chunk_fade: self.settings.chunk_fade,
|
||||
};
|
||||
pr.lap("pr_frame");
|
||||
// Renderer encoding and GPU waiting belong to render, not prepare.
|
||||
drop(prepare);
|
||||
let result = match self.renderer.as_mut() {
|
||||
Some(renderer) => {
|
||||
renderer.set_pass_timing(self.debug || self.gpu_passes);
|
||||
@@ -16797,6 +16953,7 @@ impl Game<'_> {
|
||||
}
|
||||
None => Err("renderer".into()),
|
||||
};
|
||||
let _cleanup = spans::guard("pr_cleanup");
|
||||
// A frame the surface refused (occluded, lost, timed out) is `Ok(false)`.
|
||||
self.frame_drawn = matches!(result, Ok(true));
|
||||
// Restore storage even when rendering fails (for example, a lost surface).
|
||||
@@ -16946,6 +17103,9 @@ impl Game<'_> {
|
||||
/// `Screen.keyPressed` hears the raw one, and only a raw key settles what
|
||||
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
|
||||
fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
|
||||
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
|
||||
// every key, before it decides anything.
|
||||
self.creative_ignore_text = false;
|
||||
if self.sleeping.is_some()
|
||||
&& self.screen == Screen::Playing
|
||||
&& !matches!(
|
||||
@@ -17049,6 +17209,17 @@ impl Game<'_> {
|
||||
&& self.search_tab_hotbar_key(key) => {}
|
||||
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
|
||||
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat_method(""),
|
||||
// CreativeModeInventoryScreen.keyPressed: the chat key opens the
|
||||
// search tab from another tab, and `ignoreTextInput` drops the
|
||||
// letter it would have typed.
|
||||
KeyCode::KeyT
|
||||
if self.screen == Screen::Creative && !self.creative_screen.tab().search =>
|
||||
{
|
||||
if let Some(tab) = self.creative_screen.search_tab() {
|
||||
select_creative_tab(&mut self.creative_screen, &mut self.inv_clicks, tab);
|
||||
self.creative_ignore_text = true;
|
||||
}
|
||||
}
|
||||
// key.advancements.
|
||||
KeyCode::KeyL if self.screen == Screen::Playing => {
|
||||
self.open_advancements(false);
|
||||
@@ -19217,6 +19388,8 @@ impl Game<'_> {
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
// Minecraft.tick: ClientTickEvent.Pre before any of it.
|
||||
self.native_client_tick_pre();
|
||||
if self.title.is_none() && !self.pause {
|
||||
if self.tick_script.armed() {
|
||||
self.tick_script_step(event_loop);
|
||||
@@ -19239,6 +19412,8 @@ impl Game<'_> {
|
||||
self.tick_chat_queue();
|
||||
self.tick_music();
|
||||
self.tick_sound_engine();
|
||||
// ClientTickEvent.Post, the tick's work done.
|
||||
self.native_client_tick_post();
|
||||
self.clock.record(started.elapsed());
|
||||
if let Some(report) = self.clock.take_sprint_report() {
|
||||
self.sprint_report(&report);
|
||||
@@ -19259,7 +19434,7 @@ impl Game<'_> {
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
self.apply_cursor_icon();
|
||||
horizon_client::spans::time("pr_cursor", || self.apply_cursor_icon());
|
||||
// The frame's spans, taken every frame they are recorded so they never
|
||||
// pile up: the slow-frame line's, and the session's (`perf.frame`).
|
||||
let frame_spans = if horizon_client::spans::recording_here() {
|
||||
@@ -19267,6 +19442,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if horizon_client::spans::trace::due() {
|
||||
horizon_client::spans::trace::stop();
|
||||
}
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
@@ -19920,6 +20098,7 @@ impl<'a> Game<'a> {
|
||||
breath_clouds: Vec::new(),
|
||||
dragon_model: prepared.dragon_model,
|
||||
map_patches: Vec::new(),
|
||||
name_tag_layouts: Default::default(),
|
||||
text_displays: Default::default(),
|
||||
interactions: Default::default(),
|
||||
remote_knots: Default::default(),
|
||||
@@ -19973,6 +20152,7 @@ impl<'a> Game<'a> {
|
||||
mod_chunk_tickets: HashMap::new(),
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
creative_ignore_text: false,
|
||||
control: None,
|
||||
agent_control_on: false,
|
||||
agent_last: None,
|
||||
@@ -20012,6 +20192,7 @@ impl<'a> Game<'a> {
|
||||
sky_darken_memo: Default::default(),
|
||||
water_plane_memo: Default::default(),
|
||||
water_plane_scan: Default::default(),
|
||||
vehicle_mob_reach: Default::default(),
|
||||
persist: Default::default(),
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
@@ -20043,6 +20224,7 @@ impl<'a> Game<'a> {
|
||||
pause_menu: true,
|
||||
pause_focus: None,
|
||||
death_focus: None,
|
||||
focus_screen: 0,
|
||||
previous_mode: None,
|
||||
gamemode_switcher: None,
|
||||
clipboard: Default::default(),
|
||||
@@ -20943,11 +21125,9 @@ fn gameplay_tables(
|
||||
// pulls through a `BufReader` and is measurably slower on a file this
|
||||
// size than one read plus `from_slice`, which is how `BlockFacts` next
|
||||
// door already reads its own. Same value either way.
|
||||
let behaviour_bytes =
|
||||
std::fs::read(root.join("block-behaviours.json")).map_err(|e| e.to_string())?;
|
||||
let behaviour_json: serde_json::Value =
|
||||
serde_json::from_slice(&behaviour_bytes).map_err(|e| e.to_string())?;
|
||||
drop(behaviour_bytes);
|
||||
horizon_world::json_file::read(&root.join("block-behaviours.json"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (facts, gameplay_projection) =
|
||||
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -21759,6 +21939,10 @@ fn start_game<'a>(
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_RIDER_FIXTURE") {
|
||||
game.run_boat_rider_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
|
||||
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
|
||||
return Ok(None);
|
||||
@@ -22311,18 +22495,48 @@ impl ApplicationHandler for App {
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
if root.join("horizon-resources.json").is_file() {
|
||||
return None;
|
||||
}
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
))
|
||||
if !root.join("horizon-resources.json").is_file() {
|
||||
return Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Horizon is started from its launcher: download it at {} (Download). It needs your \
|
||||
own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\
|
||||
Developers: horizon-prepare --prepare, or pass --resources <folder>",
|
||||
shown.display(),
|
||||
horizon_client::no_resources::DOWNLOAD_URL
|
||||
));
|
||||
}
|
||||
damaged_prepared(root).map(|problem| {
|
||||
format!(
|
||||
"Horizon's prepared game files are damaged: {problem} in {}\n\
|
||||
Prepare them again with the launcher, or: horizon-prepare --prepare",
|
||||
shown.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which top-level prepared file a killed preparation or an interrupted copy
|
||||
/// left unusable. The loader parses these before it can start; naming one
|
||||
/// beats serde's bare "expected value at line 1 column 1".
|
||||
fn damaged_prepared(root: &std::path::Path) -> Option<String> {
|
||||
let manifest = root.join("horizon-resources.json");
|
||||
let bytes = std::fs::read(&manifest).ok()?;
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Some("horizon-resources.json is empty".into());
|
||||
}
|
||||
if serde_json::from_slice::<serde_json::Value>(&bytes).is_err() {
|
||||
return Some("horizon-resources.json is not valid JSON".into());
|
||||
}
|
||||
for entry in std::fs::read_dir(root).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension() != Some(std::ffi::OsStr::new("json")) {
|
||||
continue;
|
||||
}
|
||||
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
|
||||
return Some(format!("{} is empty", entry.file_name().to_string_lossy()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `Minecraft.<init>`: `startedCleanly` left false means the previous start
|
||||
@@ -22419,6 +22633,8 @@ pub fn run_with() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
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);
|
||||
// Before any worker starts: on Linux they go behind the server thread
|
||||
@@ -22485,6 +22701,7 @@ pub fn run_with() -> Result<()> {
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
mods::register_layer_definitions();
|
||||
let mut early_settings = Settings::load(&Game::options_path());
|
||||
// Minecraft.<init>: read the last start's flag, then mark this one as not
|
||||
// finished until onGameLoadFinished. A dedicated server has no options.txt.
|
||||
@@ -22730,6 +22947,19 @@ mod startup_cpu_tests {
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
|
||||
// A killed export leaves an empty file behind; the error names it
|
||||
// instead of serde's "expected value at line 1 column 1".
|
||||
std::fs::write(root.join("gameplay-facts.json"), "").unwrap();
|
||||
let damaged = missing_resources(&root).expect("an empty prepared file is reported");
|
||||
assert!(damaged.contains("gameplay-facts.json"), "{damaged}");
|
||||
assert!(damaged.contains("damaged"), "{damaged}");
|
||||
std::fs::write(root.join("gameplay-facts.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
|
||||
std::fs::write(root.join("horizon-resources.json"), " \n").unwrap();
|
||||
let damaged = missing_resources(&root).expect("an empty manifest is reported");
|
||||
assert!(damaged.contains("horizon-resources.json"), "{damaged}");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -692,7 +692,9 @@ impl Game<'_> {
|
||||
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
|
||||
let raw = self.persist.raw.get(&m.id);
|
||||
let registry = Some(self.pack.assets.registry);
|
||||
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
|
||||
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry, |other| {
|
||||
self.persist.uuid_of(other)
|
||||
});
|
||||
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
|
||||
super::persist::add_passengers(&mut tag, riders);
|
||||
tag
|
||||
|
||||
@@ -55,7 +55,10 @@ impl Game<'_> {
|
||||
// or stops one, and the block entities run into the thousands.
|
||||
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
|
||||
for i in 0..self.mobs.len() {
|
||||
if self.mobs[i].species() != Species::Allay || !self.mobs[i].alive() {
|
||||
if self.mobs[i].species() != Species::Allay
|
||||
|| !self.mobs[i].alive()
|
||||
|| !self.creatures.ticked.contains(&self.mobs[i].id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let pos = self.mobs[i].position;
|
||||
@@ -199,16 +202,27 @@ impl Game<'_> {
|
||||
let host = self
|
||||
.player_view()
|
||||
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
|
||||
.map(|p| p.position);
|
||||
.map(|p| (p.position, p.eye));
|
||||
let found = host.or_else(|| {
|
||||
self.lan_present()
|
||||
.into_iter()
|
||||
.find(|p| p.uuid == uuid && !p.spectator)
|
||||
.map(|p| {
|
||||
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
|
||||
(
|
||||
horizon_core::geometry::Vec3::new(
|
||||
p.position[0],
|
||||
p.position[1],
|
||||
p.position[2],
|
||||
),
|
||||
p.eye,
|
||||
)
|
||||
})
|
||||
});
|
||||
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
|
||||
// The 64-block search uses the feet; the deposit point is the eyes
|
||||
// (`new EntityTracker(player, true)`).
|
||||
found
|
||||
.filter(|(feet, _)| feet.distance_squared(m.position) < 64.0 * 64.0)
|
||||
.map(|(_, eye)| horizon_core::geometry::Vec3::new(eye[0], eye[1], eye[2]))
|
||||
}
|
||||
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
|
||||
/// the deposit, one item a tick, each restarting the three-second pause
|
||||
@@ -246,13 +260,14 @@ impl Game<'_> {
|
||||
};
|
||||
let from = [
|
||||
m.position.x,
|
||||
m.position.y + m.eye_height() - 0.3,
|
||||
m.position.y + m.eye_height() - 0.2,
|
||||
m.position.z,
|
||||
];
|
||||
// BehaviorUtils.throwItem: the vector runs from the allay's feet.
|
||||
let d = [
|
||||
target.x - from[0],
|
||||
target.y + 1.0 - from[1],
|
||||
target.z - from[2],
|
||||
target.x - m.position.x,
|
||||
target.y + 1.0 - m.position.y,
|
||||
target.z - m.position.z,
|
||||
];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6);
|
||||
let v = [d[0] / len * 0.2, d[1] / len * 0.3, d[2] / len * 0.2];
|
||||
|
||||
@@ -869,6 +869,8 @@ impl Game<'_> {
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
// Entity.baseTick clears firstTick only when the entity actually ticks.
|
||||
a.first_tick = false;
|
||||
// aiStep's freezing step (removeFrost) makes the speed instance.
|
||||
a.speed_instance = true;
|
||||
let (water_height, lava_height) =
|
||||
@@ -908,7 +910,9 @@ impl Game<'_> {
|
||||
if in_water {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
} else {
|
||||
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
|
||||
// Entity.checkFallDamage narrows each movement before adding to the double.
|
||||
a.enchantment_fall_distance +=
|
||||
f64::from((a.previous.y - a.position.y).max(0.0) as f32);
|
||||
}
|
||||
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
|
||||
if a.on_ground {
|
||||
@@ -1106,12 +1110,13 @@ impl Game<'_> {
|
||||
)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
|
||||
let light = self.world.light(
|
||||
at.x.floor() as i32,
|
||||
(at.y + 0.5).floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block
|
||||
// `ArmorStand.getLightProbePosition` gives - the brightest cell of a
|
||||
// marker's own box, else the stand's eye - at 15 block light while
|
||||
// it burns.
|
||||
let probe = a.light_probe(alpha, |x, y, z| self.world.light(x, y, z));
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let packed = a.render_light(light);
|
||||
let skinned = if skinning {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
@@ -1625,8 +1630,8 @@ impl Game<'_> {
|
||||
.extra
|
||||
.as_deref()
|
||||
.and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile"))
|
||||
.map(|(_, t)| horizon_client::scoreboard::tag_to_json(t))
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
.map(|(_, t)| self.skin_profiles.borrow_mut().head_profile_nbt(t))
|
||||
.unwrap_or_else(|| std::sync::Arc::new(serde_json::Value::Null));
|
||||
self.pack.item_renderer.submit_special(
|
||||
&special,
|
||||
&profile,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The native parity harness runs the actual client boat ticker without a
|
||||
//! renderer, on an empty world; movement ownership changes between ticks.
|
||||
//! The native parity harness runs the client boat ticker and authoritative
|
||||
//! boat attacks without a renderer, on an empty world.
|
||||
use super::{Game, boats::Boat};
|
||||
use horizon_client::Result;
|
||||
use horizon_core::geometry::Vec3;
|
||||
@@ -12,7 +12,43 @@ struct Case {
|
||||
name: String,
|
||||
velocity: [f64; 3],
|
||||
spin: f32,
|
||||
#[serde(default)]
|
||||
owned: Vec<bool>,
|
||||
#[serde(default)]
|
||||
paddles: Option<PaddleCase>,
|
||||
#[serde(default)]
|
||||
attack: Option<Attack>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Attack {
|
||||
yaw: f32,
|
||||
sprint: bool,
|
||||
ticks: u32,
|
||||
player_velocity: [f64; 3],
|
||||
#[serde(default)]
|
||||
knockback: u8,
|
||||
#[serde(default)]
|
||||
creative: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PaddleCase {
|
||||
initial: [f32; 2],
|
||||
steps: Vec<PaddleStep>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct PaddleStep {
|
||||
occupied: bool,
|
||||
#[serde(default)]
|
||||
owned: bool,
|
||||
#[serde(default = "is_player")]
|
||||
first_player: bool,
|
||||
flags: [bool; 2],
|
||||
}
|
||||
|
||||
fn is_player() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -27,7 +63,69 @@ impl Game<'_> {
|
||||
boat.velocity = Vec3::new(x, y, z);
|
||||
boat.delta_rotation = case.spin;
|
||||
self.boats.push(boat);
|
||||
if let Some(attack) = case.attack {
|
||||
let mut player =
|
||||
horizon_client::motion::PlayerMotion::new(self.motion, Vec3::ZERO)?;
|
||||
player.velocity = Vec3::new(
|
||||
attack.player_velocity[0],
|
||||
attack.player_velocity[1],
|
||||
attack.player_velocity[2],
|
||||
);
|
||||
player.set_sprinting(attack.sprint)?;
|
||||
self.player = Some(player);
|
||||
self.inventory.fill(None);
|
||||
self.selected = 0;
|
||||
if attack.knockback > 0 {
|
||||
self.inventory[0] = horizon_client::item_data::parse_stack(&format!(
|
||||
r#"{{id:"minecraft:stick",count:1,components:{{"minecraft:enchantments":{{"minecraft:knockback":{}}}}}}}"#,
|
||||
attack.knockback
|
||||
))?;
|
||||
}
|
||||
self.yaw = attack.yaw;
|
||||
self.attack_ticks = attack.ticks;
|
||||
self.creative = attack.creative;
|
||||
self.strike_boat(0);
|
||||
let player = self.player.as_ref().unwrap();
|
||||
output.push(json!({
|
||||
"name": case.name,
|
||||
"velocity": self.boats.first().map(|boat| boat.velocity.components().map(|v| format!("{:016x}", v.to_bits()))),
|
||||
"destroyed": self.boats.is_empty(),
|
||||
"player_velocity": player.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
|
||||
"sprint": player.sprinting,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let mut steps = Vec::new();
|
||||
if let Some(paddles) = case.paddles {
|
||||
self.riding = None;
|
||||
self.boats[0].paddles = paddles.initial;
|
||||
for step in paddles.steps {
|
||||
self.boats[0].paddling = step.flags;
|
||||
self.boats[0].paddle_controller = step.occupied && step.first_player;
|
||||
self.riding = step.owned.then_some(1);
|
||||
if step.owned {
|
||||
self.boats[0].input = [
|
||||
step.flags[1] && !step.flags[0],
|
||||
step.flags[0] && !step.flags[1],
|
||||
step.flags[0] && step.flags[1],
|
||||
false,
|
||||
];
|
||||
let boat = self.tick_boat(self.boats[0].clone(), true);
|
||||
self.boats[0] = boat;
|
||||
}
|
||||
self.tick_remote_boat_bubbles();
|
||||
let boat = &self.boats[0];
|
||||
let rowing = [0.0, 0.25, 0.5, 1.0].map(|a| {
|
||||
[0, 1].map(|side| format!("{:08x}", boat.rowing_time(side, a).to_bits()))
|
||||
});
|
||||
steps.push(json!({
|
||||
"phase": boat.paddles.map(|p| format!("{:08x}", p.to_bits())),
|
||||
"rowing": rowing,
|
||||
}));
|
||||
}
|
||||
output.push(json!({"name": case.name, "steps": steps}));
|
||||
continue;
|
||||
}
|
||||
for owned in case.owned {
|
||||
self.riding = owned.then_some(1);
|
||||
if owned {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
|
||||
use horizon_client::{
|
||||
camera::wrap_degrees,
|
||||
mobs::{Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
|
||||
scoreboard::pushable_by,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
@@ -68,6 +68,23 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
|
||||
impl Game<'_> {
|
||||
/// The players in boat `id`: the window's player (or the server player
|
||||
/// acting as it) and the server's other players.
|
||||
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
|
||||
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
|
||||
/// Nothing a vehicle does resizes a mob, and a mob joining or leaving works it out again.
|
||||
pub(super) fn vehicles_ticking(&self, on: bool) {
|
||||
self.vehicle_mob_reach
|
||||
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
|
||||
}
|
||||
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
|
||||
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
|
||||
let (count, reach) = self.vehicle_mob_reach.get()?;
|
||||
if count == self.mobs.len() {
|
||||
return Some(reach);
|
||||
}
|
||||
let reach = BoxReach::of(&self.mobs);
|
||||
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
|
||||
Some(reach)
|
||||
}
|
||||
pub(super) fn boat_players(&self, id: u32) -> usize {
|
||||
let guests = self.lan.as_ref().map_or(0, |h| {
|
||||
h.hub
|
||||
@@ -150,11 +167,14 @@ impl Game<'_> {
|
||||
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
|
||||
/// shulkers and pushable mobs and players, but not its own passengers.
|
||||
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut out: Vec<Aabb> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
let m = &self.mobs[j];
|
||||
pushable(m)
|
||||
// The box first: it turns away nearly every mob of the level.
|
||||
reach.is_none_or(|r| r.may_meet(m, area))
|
||||
&& m.bounds().intersects(area)
|
||||
&& pushable(m)
|
||||
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
|
||||
})
|
||||
.map(|j| self.mobs[j].bounds())
|
||||
@@ -203,9 +223,32 @@ impl Game<'_> {
|
||||
let pick_up = self.boat_players(id) == 0;
|
||||
// canAddPassenger, which startRiding asks after the checks below.
|
||||
let room = !self.boats[i].under_water();
|
||||
// The box first: it turns away nearly every mob of the level, and the
|
||||
// teams are only worked out when something touches the boat.
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let sweep = area.union_bounds(bb);
|
||||
let near: Vec<usize> = (0..self.mobs.len())
|
||||
.filter(|&j| {
|
||||
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
|
||||
return false;
|
||||
}
|
||||
let mb = self.mobs[j].bounds();
|
||||
mb.intersects(area) || mb.intersects(bb)
|
||||
})
|
||||
.collect();
|
||||
let outside = self.players_outside(id);
|
||||
let touching = !near.is_empty()
|
||||
|| outside
|
||||
.iter()
|
||||
.any(|(pb, _)| pb.intersects(area) || pb.intersects(bb));
|
||||
// (mob, touched by the boat's sweep, its own pushEntities reaches the boat)
|
||||
let teams = self.mob_teams();
|
||||
let mobs: Vec<(u32, bool, bool)> = (0..self.mobs.len())
|
||||
let teams = if touching {
|
||||
self.mob_teams()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mobs: Vec<(u32, bool, bool)> = near
|
||||
.into_iter()
|
||||
.filter_map(|j| {
|
||||
let m = &self.mobs[j];
|
||||
if !pushable(m) || m.extra.mount.is_some() && self.mob_root_vehicle(j) == Some(id) {
|
||||
@@ -222,8 +265,7 @@ impl Game<'_> {
|
||||
})
|
||||
.collect();
|
||||
let player_team = teams.of(Who::Player);
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = self
|
||||
.players_outside(id)
|
||||
let players: Vec<(Aabb, bool, bool, bool)> = outside
|
||||
.into_iter()
|
||||
.map(|(pb, own)| {
|
||||
let team = if own { player_team } else { None };
|
||||
@@ -354,10 +396,17 @@ impl Game<'_> {
|
||||
/// `positionRider` and `clampRotation` for the mobs in boats: each sits
|
||||
/// in its seat facing the way the boat does, an animal in a full boat
|
||||
/// sideways, its head kept within its reach of its body.
|
||||
pub(super) fn seat_boat_mobs(&mut self) {
|
||||
pub(super) fn seat_boat_mobs(&mut self, riding: &super::passengers::Riders) {
|
||||
for i in 0..self.boats.len() {
|
||||
let id = self.boats[i].id;
|
||||
let mobs = self.boat_mobs(id);
|
||||
// `boat_mobs`, from the riders found in one pass.
|
||||
let mobs: Vec<usize> = riding
|
||||
.get(&id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.filter(|&j| !self.mobs[j].removed && self.mobs[j].alive())
|
||||
.collect();
|
||||
if mobs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Headless passenger-order regression through the real remote client paths.
|
||||
use super::{Game, Keys, boats::Boat};
|
||||
use horizon_client::{Result, held::Source, motion::PlayerMotion};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{connection::Pipe, packets::play::Serverbound};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Case {
|
||||
name: String,
|
||||
first: Vec<bool>,
|
||||
velocity: [f64; 3],
|
||||
spin: f32,
|
||||
keys: [bool; 4],
|
||||
event: i8,
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_boat_rider_fixture(&mut self, directory: &Path) -> Result<()> {
|
||||
let cases: Vec<Case> =
|
||||
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
|
||||
let (client, peer) = Pipe::pair();
|
||||
self.connect_local(client, "BoatSeatFixture")?;
|
||||
self.remote.as_mut().unwrap().entity_id = 1;
|
||||
self.player = Some(PlayerMotion::new(self.motion, Vec3::new(0.0, 100.0, 0.0))?);
|
||||
|
||||
let mut output = Vec::new();
|
||||
for case in cases {
|
||||
self.boats.clear();
|
||||
let mut b = Boat::new(3, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
|
||||
let [x, y, z] = case.velocity;
|
||||
b.velocity = Vec3::new(x, y, z);
|
||||
b.delta_rotation = case.spin;
|
||||
self.keys.release_source(Source::Agent);
|
||||
for (key, held) in [KeyCode::KeyA, KeyCode::KeyD, KeyCode::KeyW, KeyCode::KeyS]
|
||||
.into_iter()
|
||||
.zip(case.keys)
|
||||
{
|
||||
if held {
|
||||
self.keys.press(key, Source::Agent);
|
||||
}
|
||||
}
|
||||
self.boats.push(b);
|
||||
let mut steps = Vec::new();
|
||||
for first in case.first {
|
||||
self.remote_set_passengers(3, if first { &[1, 2] } else { &[2, 1] });
|
||||
let mut accepts_server_moves = false;
|
||||
self.tick_remote_objects(
|
||||
|_, p| {
|
||||
accepts_server_moves = true;
|
||||
p
|
||||
},
|
||||
|_, yaw| yaw,
|
||||
|_| false,
|
||||
);
|
||||
self.remote_ride(
|
||||
Keys {
|
||||
left: case.keys[0],
|
||||
right: case.keys[1],
|
||||
forward: case.keys[2],
|
||||
backward: case.keys[3],
|
||||
..Keys::default()
|
||||
},
|
||||
7,
|
||||
)?;
|
||||
let packets = self.remote_vehicle_packets();
|
||||
if case.event != 0 {
|
||||
self.remote_boat_event(3, case.event);
|
||||
}
|
||||
let boat = &self.boats[0];
|
||||
steps.push(json!({
|
||||
"controlled": self.remote.as_ref().unwrap().first_passenger,
|
||||
"accepts_server_moves": accepts_server_moves,
|
||||
"hands_busy": self.hands_busy(),
|
||||
"moves": packets.iter().filter(|p| matches!(p, Serverbound::MoveVehicle(_))).count(),
|
||||
"paddles": packets.iter().filter(|p| matches!(p, Serverbound::PaddleBoat(_))).count(),
|
||||
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
|
||||
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
|
||||
}));
|
||||
}
|
||||
output.push(json!({"name": case.name, "steps": steps}));
|
||||
}
|
||||
peer.shutdown();
|
||||
self.remote = None;
|
||||
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,8 @@ pub(super) struct Boat {
|
||||
pub no_gravity: bool,
|
||||
pub paddles: [f32; 2],
|
||||
pub paddling: [bool; 2],
|
||||
/// Whether this boat has a player controlling its paddles.
|
||||
pub paddle_controller: bool,
|
||||
/// left, right, up, down
|
||||
pub input: [bool; 4],
|
||||
pub on_ground: bool,
|
||||
@@ -192,6 +194,7 @@ impl Boat {
|
||||
no_gravity: false,
|
||||
paddles: [0.0; 2],
|
||||
paddling: [false; 2],
|
||||
paddle_controller: false,
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
stuck: Vec3::ZERO,
|
||||
@@ -324,9 +327,14 @@ impl Boat {
|
||||
fired
|
||||
}
|
||||
/// `getRowingTime`.
|
||||
pub fn paddle_state(&self, side: usize) -> bool {
|
||||
self.paddling[side] && self.paddle_controller
|
||||
}
|
||||
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
|
||||
if self.paddling[side] {
|
||||
self.paddles[side] - PI / 8.0 + a * PI / 8.0
|
||||
if self.paddle_state(side) {
|
||||
let current = self.paddles[side];
|
||||
let previous = current - PI / 8.0;
|
||||
previous + a.clamp(0.0, 1.0) * (current - previous)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
@@ -642,6 +650,11 @@ impl Game<'_> {
|
||||
b.tick_submersion();
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// A remote client gets the controller from the passenger packet, even when
|
||||
// this window occupies the second seat. Local worlds derive it here.
|
||||
if self.remote.is_none() {
|
||||
b.paddle_controller = controlled;
|
||||
}
|
||||
if !controlled {
|
||||
b.paddling = [false; 2];
|
||||
b.input = [false; 4];
|
||||
@@ -776,9 +789,18 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
b.velocity = horizon_world::entity_move::scale_by_block_speed(b.velocity, factor);
|
||||
Self::tick_boat_paddles(&mut b, &mut self.audio, &mut self.world.random);
|
||||
b
|
||||
}
|
||||
/// `AbstractBoat.tick` advances the oars even when this client does not move the hull.
|
||||
pub(super) fn tick_boat_paddles(
|
||||
b: &mut Boat,
|
||||
audio: &mut super::Audio,
|
||||
random: &mut horizon_core::random::LegacyRandom,
|
||||
) {
|
||||
// Paddle sounds at the top of each stroke.
|
||||
for i in 0..2 {
|
||||
if b.paddling[i] {
|
||||
if b.paddle_state(i) {
|
||||
let p = b.paddles[i] % (2.0 * PI);
|
||||
if p <= PI / 4.0 && (b.paddles[i] + PI / 8.0) % (2.0 * PI) >= PI / 4.0 {
|
||||
let sound = match b.status {
|
||||
@@ -791,8 +813,8 @@ impl Game<'_> {
|
||||
if let Some(sound) = sound {
|
||||
let v = view_vector(b.yaw, 0.0);
|
||||
let (dx, dz) = if i == 1 { (-v[2], v[0]) } else { (v[2], -v[0]) };
|
||||
let pitch = 0.8 + 0.4 * self.world.random.next_float();
|
||||
self.audio.play(
|
||||
let pitch = 0.8 + 0.4 * random.next_float();
|
||||
audio.play(
|
||||
sound,
|
||||
Some([b.position.x + dx, b.position.y, b.position.z + dz]),
|
||||
1.0,
|
||||
@@ -805,10 +827,14 @@ impl Game<'_> {
|
||||
b.paddles[i] = 0.0;
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
/// Every boat but the ridden one (which ticks with its rider).
|
||||
pub(super) fn tick_boats(&mut self) {
|
||||
self.vehicles_ticking(true);
|
||||
self.tick_boats_now();
|
||||
self.vehicles_ticking(false);
|
||||
}
|
||||
fn tick_boats_now(&mut self) {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut round = self.native_tick_round();
|
||||
@@ -908,7 +934,7 @@ impl Game<'_> {
|
||||
/// client moves only the boat its player drives.
|
||||
fn boat_can_simulate_movement(&self, id: u32) -> bool {
|
||||
if self.remote.is_some() {
|
||||
self.riding == Some(id)
|
||||
self.controls_boat(id)
|
||||
} else {
|
||||
!self.guest_vehicles().contains(&id)
|
||||
}
|
||||
@@ -1418,16 +1444,40 @@ impl Game<'_> {
|
||||
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
|
||||
return false;
|
||||
}
|
||||
self.strike_boat(i);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// The accepted boat target, shared by aimed attacks and headless parity.
|
||||
pub(super) fn strike_boat(&mut self, i: usize) {
|
||||
let area = self.boats[i].bounds();
|
||||
// Player.attack's full damage against the boat's entity type.
|
||||
let (kind, id) = (self.boats[i].entity_type(), self.boats[i].id as i32);
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
let (damage, scale, _, magic) = self.melee_damage(&kind, false);
|
||||
let hurt = self.hurt_boat_result(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
if damage > 0.0
|
||||
&& let Some(destroyed) = hurt
|
||||
{
|
||||
let (knockback, _) = self.extra_knockback(&kind, scale);
|
||||
if knockback > 0.0 {
|
||||
// Player.causeExtraKnockback uses Entity.push for a boat,
|
||||
// not LivingEntity.knockback (no resistance or velocity halving).
|
||||
if !destroyed {
|
||||
let (x, z) = self.extra_knockback_direction();
|
||||
self.boats[i].velocity.x += f64::from(-(x as f32) * knockback);
|
||||
self.boats[i].velocity.y += 0.1;
|
||||
self.boats[i].velocity.z += f64::from(-(z as f32) * knockback);
|
||||
}
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.velocity.x *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
|
||||
player.velocity.z *= horizon_client::attack_strength::ATTACKER_SLOWDOWN;
|
||||
}
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
|
||||
/// (`creative`: a creative player's hit discards it straight away).
|
||||
@@ -1435,11 +1485,15 @@ impl Game<'_> {
|
||||
/// reason that destroys the boat (DISCARDED included), while the boat item
|
||||
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
|
||||
/// a creative hit discards it. True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
self.hurt_boat_result(i, damage, creative).unwrap_or(false)
|
||||
}
|
||||
/// None means a canceled hit; Some records whether an accepted hit destroyed it.
|
||||
fn hurt_boat_result(&mut self, i: usize, mut damage: f32, creative: bool) -> Option<bool> {
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
@@ -1478,7 +1532,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
destroyed
|
||||
Some(destroyed)
|
||||
}
|
||||
/// AbstractBoatRenderer.
|
||||
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
|
||||
|
||||
@@ -294,6 +294,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"raid_state" => {
|
||||
let state = self.harness_owner(|g| g.raid_fixture_state(&op))?;
|
||||
let row = json!({"cmd":format!("#raid_state {} {}",op["label"].as_str().unwrap_or(""),op["id"]),
|
||||
"messages":[],"results":[],"state":state});
|
||||
out.push_str(&row.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"mob_anger" => {
|
||||
// Metadata-state equality through the production writer, not packet delivery.
|
||||
let uuid = u128::from_str_radix(
|
||||
|
||||
@@ -2799,6 +2799,18 @@ impl Game<'_> {
|
||||
horizon_client::save::consume_container_loot(&mut extras, key);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
// `ChestBlock.getMenuProvider` unpacks both halves of a double chest:
|
||||
// the other half's table, if it still holds one, rolls too. The own roll
|
||||
// is consumed above, so the partner's call does not come back here.
|
||||
if let Some((other, _)) = self.chest_partner(key) {
|
||||
let other_name = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(other.0, other.1, other.2))
|
||||
.name
|
||||
.to_string();
|
||||
self.unpack_opened_loot(other, &other_name);
|
||||
}
|
||||
}
|
||||
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
|
||||
/// container (`setblock`, `data`): an empty container with a `LootTable`
|
||||
|
||||
@@ -163,8 +163,8 @@ impl Game<'_> {
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(id) => {
|
||||
let tag =
|
||||
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid());
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, uuid);
|
||||
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
@@ -201,19 +201,8 @@ impl Game<'_> {
|
||||
crate::game::persist::with_raw(tag, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Tnt(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = self.tnts.iter().find(|t| t.id == id)?;
|
||||
entity_nbt::tnt_to_nbt(
|
||||
t.position,
|
||||
t.velocity,
|
||||
t.on_ground,
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
let i = self.tnts.iter().position(|t| t.id == id)?;
|
||||
self.tnt_nbt(i)
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
let f = self.falling.iter().find(|f| f.id == id)?.clone();
|
||||
@@ -394,8 +383,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Target::Stand(id) => {
|
||||
let new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
|
||||
let mut new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?;
|
||||
if let Some(slot) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
// Entity.load keeps the existing entity's firstTick state.
|
||||
new.first_tick = slot.first_tick;
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
@@ -1208,6 +1199,84 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orb_uuid_tests {
|
||||
use super::Target;
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn saved_orb_uuid_comes_from_registered_identity_without_or_despite_raw() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
let uuid = [0, 0, 0, 2];
|
||||
let tag = Tag::compound([
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
Tag::Double(200.0_f64.to_bits()),
|
||||
Tag::Double(4.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("Value", Tag::Short(1)),
|
||||
("Age", Tag::Short(0)),
|
||||
("Health", Tag::Short(5)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
game.load_orb(tag, Some(uuid));
|
||||
assert_eq!(game.orbs.len(), 1);
|
||||
let id = game.orbs[0].id;
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
let time = game.world.game_time;
|
||||
for raw in [
|
||||
None,
|
||||
Some(Tag::compound([("UUID", Tag::IntArray(vec![9, 9, 9, 9]))])),
|
||||
] {
|
||||
if let Some(raw) = &raw {
|
||||
game.persist.raw.insert(id, raw.clone());
|
||||
} else {
|
||||
game.persist.raw.remove(&id);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
let saved = game.entity_nbt_in_level(Target::Orb(id)).unwrap();
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
assert_eq!(game.persist.uuids.get(&id), Some(&uuid));
|
||||
assert_eq!(game.persist.raw.get(&id), raw.as_ref());
|
||||
assert_eq!(game.world.game_time, time);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wolf_live_anger_tests {
|
||||
use super::Target;
|
||||
|
||||
@@ -592,21 +592,52 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
Slot::Stand(id, i) => match self
|
||||
.armor_stands
|
||||
.iter_mut()
|
||||
.find(|a| a.id == *id)
|
||||
.and_then(|a| a.equipment.get_mut(*i))
|
||||
{
|
||||
Some(slot) => {
|
||||
*slot = stack;
|
||||
true
|
||||
Slot::Stand(id, i) => {
|
||||
// LivingEntity.onEquipItem: all stand slots emit; the new
|
||||
// stack's EQUIPPABLE presence chooses equip versus unequip.
|
||||
let equippable = stack.as_ref().is_some_and(|s| {
|
||||
let data = s.data();
|
||||
!data.is_some_and(|d| d.other("!minecraft:equippable").is_some())
|
||||
&& (data.is_some_and(|d| d.other("minecraft:equippable").is_some())
|
||||
|| self
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| i.equip_slot.is_some()))
|
||||
});
|
||||
let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == *id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(slot) = a.equipment.get_mut(*i) else {
|
||||
return false;
|
||||
};
|
||||
let same = match (slot.as_ref(), stack.as_ref()) {
|
||||
(None, None) => true,
|
||||
(Some(old), Some(new)) => old.same_item_same_components(new),
|
||||
_ => false,
|
||||
};
|
||||
let emit = !a.first_tick && !same;
|
||||
let at = a.position;
|
||||
*slot = stack;
|
||||
if emit && self.remote.is_none() {
|
||||
self.post_game_event(
|
||||
if equippable {
|
||||
"minecraft:equip"
|
||||
} else {
|
||||
"minecraft:unequip"
|
||||
},
|
||||
[at.x, at.y, at.z],
|
||||
horizon_client::sculk::Context {
|
||||
source: Some(horizon_client::mobs::Who::Mob(*id)),
|
||||
living: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Slot::Frame(id) => match self.item_frames.iter_mut().find(|f| f.id == *id) {
|
||||
Some(f) => {
|
||||
f.item = stack;
|
||||
true
|
||||
}
|
||||
Slot::Frame(id) => match self.item_frames.iter().position(|f| f.id == *id) {
|
||||
Some(i) => {
|
||||
self.set_frame_item(i, stack);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -1390,3 +1421,105 @@ fn list_operation(m: &serde_json::Value, old: Vec<Tag>, new: Vec<Tag>) -> Vec<Ta
|
||||
out.truncate(256);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stand_equip_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, containers::BlockEntity, mobs::Who, sculk::Info};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn candidate(game: &Game<'_>) -> Option<Info> {
|
||||
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get(&(9, 200, 10)) else {
|
||||
panic!("actual sensor must be registered")
|
||||
};
|
||||
sensor.vibration.selector.current.map(|(info, _)| info)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn commands_post_stand_equipment_events_after_its_first_tick() {
|
||||
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-h130-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 registry = game.pack.assets.registry;
|
||||
let sensor = registry.default_state("minecraft:sculk_sensor").unwrap();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(9, 200, 10, sensor.0);
|
||||
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 armor_stand 6.5 200 10.5 {UUID:[I;130,0,0,1],NoGravity:1b,attributes:[{id:\"minecraft:movement_speed\",base:0.1d}]}"));
|
||||
let id = game.armor_stands[0].id;
|
||||
assert_eq!(game.uuid_for(id), [130, 0, 0, 1]);
|
||||
assert!(
|
||||
game.armor_stands[0].speed_instance,
|
||||
"a loaded attribute is not firstTick"
|
||||
);
|
||||
game.sculk_prepare();
|
||||
game.sculk_sync_index();
|
||||
assert!(candidate(&game).is_none());
|
||||
let prefix = "item replace entity 00000082-0000-0000-0000-000000000001 ";
|
||||
for item in ["iron_helmet", "air"] {
|
||||
assert!(game.run_command(&format!("{prefix}armor.head with {item}")));
|
||||
assert!(candidate(&game).is_none(), "first-tick suppression");
|
||||
}
|
||||
game.tick_armor_stands().unwrap();
|
||||
assert!(
|
||||
game.run_command(
|
||||
"data merge entity 00000082-0000-0000-0000-000000000001 {ShowArms:1b}"
|
||||
)
|
||||
);
|
||||
for (slot, item, event) in [
|
||||
("armor.head", "iron_helmet", Some("minecraft:equip")),
|
||||
("armor.head", "iron_helmet", None),
|
||||
("armor.head", "air", Some("minecraft:unequip")),
|
||||
("armor.head", "air", None),
|
||||
("weapon.mainhand", "stick", Some("minecraft:unequip")),
|
||||
] {
|
||||
let Some(BlockEntity::Sculk(sensor)) = game.world.block_entities.get_mut(&(9, 200, 10))
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
sensor.vibration = Default::default();
|
||||
assert!(game.run_command(&format!("{prefix}{slot} with {item}")));
|
||||
let heard = candidate(&game);
|
||||
assert_eq!(heard.map(|info| info.event), event, "{slot} -> {item}");
|
||||
if let Some(info) = heard {
|
||||
assert_eq!(info.source, Some(Who::Mob(id)));
|
||||
assert_eq!(info.pos, [6.5, 200.0, 10.5]);
|
||||
assert!(info.owner.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,26 +30,30 @@ impl Game<'_> {
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
/// A template's tree by id, as `StructureTemplateManager` reads it from
|
||||
/// disk.
|
||||
/// A template's tree by id, as `StructureTemplateManager` reads it: the
|
||||
/// world's saved structures, the enabled data packs from the highest, and
|
||||
/// the game's own.
|
||||
pub(in crate::game) fn read_template_tag(&self, id: &str) -> Option<Tag> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let generated = self
|
||||
.world_root
|
||||
.join("generated")
|
||||
.join(ns)
|
||||
.join("structures")
|
||||
.join(format!("{path}.nbt"));
|
||||
let in_pack = format!("data/{ns}/structure/{path}.nbt");
|
||||
let prepared = self
|
||||
.options
|
||||
.resources
|
||||
.join("worldgen/structure")
|
||||
.join(ns)
|
||||
.join(format!("{path}.nbt"));
|
||||
let candidates = [
|
||||
self.world_root
|
||||
.join("generated")
|
||||
.join(ns)
|
||||
.join("structures")
|
||||
.join(format!("{path}.nbt")),
|
||||
self.options
|
||||
.resources
|
||||
.join("worldgen/structure")
|
||||
.join(ns)
|
||||
.join(format!("{path}.nbt")),
|
||||
std::fs::read(&generated).ok(),
|
||||
self.fns.pack_file(&in_pack),
|
||||
std::fs::read(&prepared).ok(),
|
||||
];
|
||||
for file in candidates {
|
||||
let Ok(bytes) = std::fs::read(&file) else {
|
||||
continue;
|
||||
};
|
||||
for bytes in candidates.into_iter().flatten() {
|
||||
let Ok(raw) = Compression::Gzip.decode(&bytes, 1 << 28) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -9,6 +9,9 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{position::ChunkPos, random::RandomSource};
|
||||
|
||||
/// How long `/forceload add` waits for its chunks, as the game tests' loads do.
|
||||
const FORCED_CHUNK_WAIT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
fn num(n: impl std::fmt::Display) -> Text {
|
||||
Text::literal(n.to_string())
|
||||
}
|
||||
@@ -42,6 +45,23 @@ impl Game<'_> {
|
||||
let p = self.resolve_coords(&c);
|
||||
Ok([p[0].floor() as i32, p[2].floor() as i32])
|
||||
}
|
||||
/// `ServerLevel.setChunkForced`'s `getChunk`: a command right after
|
||||
/// `/forceload add` (`/place`, `/setblock`) finds the chunks loaded.
|
||||
fn wait_for_forced_chunks(&mut self, min: [i32; 2], max: [i32; 2]) {
|
||||
let missing = |g: &Self| {
|
||||
(min[0]..=max[0])
|
||||
.any(|x| (min[1]..=max[1]).any(|z| !g.world.chunks.contains_key(&(x, z))))
|
||||
};
|
||||
let deadline = std::time::Instant::now() + FORCED_CHUNK_WAIT;
|
||||
while missing(self) {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
log::warn!("Horizon: /forceload: chunks {min:?}..{max:?} are not loaded yet");
|
||||
return;
|
||||
}
|
||||
let _ = self.streaming.update(&mut self.world);
|
||||
self.streaming.wait(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
pub(super) fn cmd_forceload(&mut self, id: &str, ctx: &Context) -> R<i32> {
|
||||
let dim = Text::literal(self.dim.id());
|
||||
match id {
|
||||
@@ -128,6 +148,7 @@ impl Game<'_> {
|
||||
}
|
||||
if add {
|
||||
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
|
||||
self.wait_for_forced_chunks([cx0, cz0], [cx1, cz1]);
|
||||
}
|
||||
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
|
||||
let verb = if add { "added" } else { "removed" };
|
||||
@@ -518,12 +539,28 @@ impl Game<'_> {
|
||||
let (tag, _) = horizon_world::nbt::parse_snbt(&table)
|
||||
.map_err(|e| err("argument.nbt.invalid", &[&e]))?;
|
||||
let json = horizon_client::scoreboard::tag_to_json(&tag);
|
||||
// LootCommand's omitted fishing tool is EMPTY, not mainhand.
|
||||
let held = if id == "loot.fish"
|
||||
&& (ctx.get("tool").is_some() || ctx.has("mainhand") || ctx.has("offhand"))
|
||||
{
|
||||
self.tool_stack(ctx)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let drops = match json["random_sequence"].as_str() {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(sequence, self.level.seed);
|
||||
world_items!(self).inline_loot_table(&json, random)
|
||||
world_items!(self).inline_loot_table_with_tool(
|
||||
&json,
|
||||
held.as_ref(),
|
||||
random,
|
||||
)
|
||||
}
|
||||
None => world_items!(self).inline_loot_table(&json, &mut self.world.random),
|
||||
None => world_items!(self).inline_loot_table_with_tool(
|
||||
&json,
|
||||
held.as_ref(),
|
||||
&mut self.world.random,
|
||||
),
|
||||
};
|
||||
(drops, None)
|
||||
} else {
|
||||
|
||||
@@ -294,6 +294,11 @@ impl Game<'_> {
|
||||
// The anvil's rename box, while it has an input to name.
|
||||
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
|
||||
Screen::Creative if self.creative_screen.tab().search => {
|
||||
// CreativeModeInventoryScreen.charTyped: the chat key that
|
||||
// opened the tab does not type its letter.
|
||||
if std::mem::take(&mut self.creative_ignore_text) {
|
||||
return Ok(());
|
||||
}
|
||||
// As typed: replaces a selected query, keeps the length limit.
|
||||
self.creative_search_edit(Some(text))
|
||||
}
|
||||
@@ -435,6 +440,27 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "gpu_passes": self.gpu_passes })))
|
||||
}
|
||||
// Every thread's spans as a timeline Perfetto opens (`spans::trace`).
|
||||
"trace" => match words.get(1).copied() {
|
||||
None => Ok(Some(
|
||||
json!({ "ok": true, "tracing": horizon_client::spans::trace::on() }),
|
||||
)),
|
||||
Some("stop") => Ok(Some(
|
||||
json!({ "ok": true, "events": horizon_client::spans::trace::stop() }),
|
||||
)),
|
||||
Some(file) => {
|
||||
let seconds = match words.get(2) {
|
||||
None => None,
|
||||
Some(s) => Some(
|
||||
s.parse::<u32>()
|
||||
.map_err(|_| "trace [FILE [SECONDS] | stop]")?,
|
||||
),
|
||||
};
|
||||
horizon_client::spans::trace::start(std::path::Path::new(file), seconds)
|
||||
.map_err(|e| format!("trace {file}: {e}"))?;
|
||||
Ok(Some(json!({ "ok": true, "tracing": true, "file": file })))
|
||||
}
|
||||
},
|
||||
"render-capture" => match words.get(1).copied() {
|
||||
None => {
|
||||
let r = self.renderer.as_ref().ok_or("no renderer")?;
|
||||
|
||||
@@ -105,6 +105,14 @@ pub(super) struct Creatures {
|
||||
pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>,
|
||||
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
|
||||
pub elder_apparition: i32,
|
||||
/// 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 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
|
||||
/// (pickups, item searches) is for these alone.
|
||||
pub ticked: horizon_client::fast_hash::FxHashSet<u32>,
|
||||
/// The wandering trader spawner's clock.
|
||||
pub trader: super::wandering_trader::Spawner,
|
||||
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
|
||||
@@ -136,15 +144,18 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
binding: self.w.collision,
|
||||
subject: None,
|
||||
};
|
||||
let context = horizon_world::collision_context::CollisionContext::position(from.y);
|
||||
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
if self.w.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
// ClipContext hands CollisionContext.of(projectile): scaffolding
|
||||
// is open for a projectile below it.
|
||||
let shape = self
|
||||
.w
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.collision_shape_with_context(&mut scene, state, p, &context)
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
});
|
||||
@@ -702,6 +713,9 @@ impl Game<'_> {
|
||||
rider.extra.mount = Some(id);
|
||||
}
|
||||
}
|
||||
if let Some(raw) = horizon_client::entity_nbt::conversion_tags(self.persist_raw(from.id)) {
|
||||
self.persist.raw.insert(id, raw);
|
||||
}
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
let transformed_kind = self
|
||||
@@ -964,6 +978,8 @@ impl Game<'_> {
|
||||
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
|
||||
/// projectiles and effects that follow from them.
|
||||
pub(super) fn tick_creature_entities(&mut self) -> Result<()> {
|
||||
// Its steps as spans for `horizon-report` (nothing is read while spans are off).
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
// LivingEntity.die runs when the killing blow lands: mobs killed
|
||||
// since the last tick (by the player, explosions...) die first.
|
||||
let killed: Vec<Mob> = self
|
||||
@@ -996,15 +1012,21 @@ impl Game<'_> {
|
||||
}
|
||||
let uuid = self.level.player_uuid;
|
||||
let level_id = self.dim.id();
|
||||
let mut snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
let mut snapshot = std::mem::take(&mut self.creatures.seen_buf);
|
||||
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
|
||||
let mut events = Vec::new();
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let mut dead = Vec::new();
|
||||
let mut corpses = Vec::new();
|
||||
let mut died = std::mem::take(&mut self.died);
|
||||
let mut ticked = std::mem::take(&mut self.creatures.ticked);
|
||||
ticked.clear();
|
||||
let ticking = self.entity_ticking_area();
|
||||
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
|
||||
let guests = self.lan_guest_views();
|
||||
let mut guests = self.lan_guest_views();
|
||||
// What stands in a guest's slot while a mob holds that guest's view for
|
||||
// its tick (see `lent` below).
|
||||
let mut spare = guests.first().map(|g| g.1.clone());
|
||||
let players = self.level_players();
|
||||
let nearest = super::natural_spawning::NearestPlayers::new(&players);
|
||||
let mut bindings = match self.lan.as_mut() {
|
||||
@@ -1059,6 +1081,7 @@ impl Game<'_> {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
laps.lap("creature_prepare");
|
||||
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
|
||||
for i in 0..mobs.len() {
|
||||
let m = &mut mobs[i];
|
||||
@@ -1085,6 +1108,29 @@ impl Game<'_> {
|
||||
let nearest_player = nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position);
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick. The despawn check reads the nearest player
|
||||
// and the difficulty, not the player the mob sees.
|
||||
if !ticking(bx, bz) {
|
||||
let w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
nearest_player,
|
||||
player: None,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
@@ -1099,36 +1145,6 @@ impl Game<'_> {
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick.
|
||||
if !ticking(bx, bz) {
|
||||
let active_player = match seen_guest {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
|
||||
None => base.player.take(),
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
}
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
}
|
||||
keep.push(kept);
|
||||
continue;
|
||||
}
|
||||
// A mod canceled its tick: it stays as it is.
|
||||
if round.canceled(m.id) {
|
||||
keep.push(true);
|
||||
@@ -1149,9 +1165,16 @@ impl Game<'_> {
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
let active_player = match seen_guest {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
|
||||
None => base.player.take(),
|
||||
// The guest's view is lent to the mob for its tick, not copied:
|
||||
// the tick only reads it (`&MobWorld`), and it goes back to the
|
||||
// guest's slot straight after.
|
||||
let lent = seen_guest.and_then(|conn| guests.iter().position(|g| g.0 == conn));
|
||||
let active_player = match (seen_guest, lent) {
|
||||
(None, _) => base.player.take(),
|
||||
(Some(_), Some(k)) => spare
|
||||
.take()
|
||||
.map(|stand_in| std::mem::replace(&mut guests[k].1, stand_in)),
|
||||
(Some(_), None) => None,
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
@@ -1165,6 +1188,7 @@ impl Game<'_> {
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
ticked.insert(mobs[i].id);
|
||||
// `horizon-report`'s cost by mob type (tick_profile.rs).
|
||||
let sampled = super::tick_profile::start().map(|t| (t, mobs[i].kind.clone()));
|
||||
let kept =
|
||||
@@ -1175,6 +1199,8 @@ impl Game<'_> {
|
||||
let m = &mut mobs[i];
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
} else if let Some(k) = lent {
|
||||
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
@@ -1225,6 +1251,7 @@ impl Game<'_> {
|
||||
grid.update(i, snapshot[i].bounds);
|
||||
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
|
||||
}
|
||||
laps.lap("creature_loop");
|
||||
let mut flags = keep.into_iter();
|
||||
mobs.retain(|_| flags.next().unwrap_or(true));
|
||||
for m in &mut mobs {
|
||||
@@ -1252,6 +1279,9 @@ impl Game<'_> {
|
||||
self.mobs.append(&mut mobs);
|
||||
}
|
||||
self.died = died;
|
||||
snapshot.clear();
|
||||
self.creatures.seen_buf = snapshot;
|
||||
self.creatures.ticked = ticked;
|
||||
self.native_tick_post_batch(round);
|
||||
// ... and entity event 60 goes out as it is: the poof.
|
||||
for (id, at, width, height) in gone {
|
||||
@@ -1259,6 +1289,7 @@ impl Game<'_> {
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
}
|
||||
laps.lap("creature_retain");
|
||||
self.tick_boarding();
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
@@ -1267,6 +1298,7 @@ impl Game<'_> {
|
||||
self.villager_reputation_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
laps.lap("creature_villagers");
|
||||
for m in dead {
|
||||
self.mob_died(&m);
|
||||
}
|
||||
@@ -1282,9 +1314,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.handle_mob_events(events)?;
|
||||
self.lan_guest_mob_events(guest_events);
|
||||
laps.lap("creature_events");
|
||||
self.ordered_before(super::ordered_tick::Batch::Projectiles)?;
|
||||
self.tick_projectiles()?;
|
||||
self.tick_fangs();
|
||||
laps.lap("creature_projectiles");
|
||||
self.tick_allays();
|
||||
self.tick_pandas();
|
||||
self.tick_foxes();
|
||||
@@ -1295,12 +1329,14 @@ impl Game<'_> {
|
||||
self.piglin_pickups();
|
||||
self.sulfur_cube_pickups();
|
||||
self.sulfur_cube_power_priming();
|
||||
laps.lap("creature_systems");
|
||||
// Hurt and death sounds of damage dealt outside the mob ticks.
|
||||
let mut queued = Vec::new();
|
||||
for m in &mut self.mobs {
|
||||
queued.append(&mut m.outbox);
|
||||
}
|
||||
self.handle_mob_events(queued)?;
|
||||
laps.lap("creature_outbox");
|
||||
Ok(())
|
||||
}
|
||||
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
|
||||
@@ -2086,11 +2122,15 @@ impl Game<'_> {
|
||||
None,
|
||||
);
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
MobEvent::Sound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
// Level.playLocalSound: play locally without broadcasting.
|
||||
self.audio
|
||||
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch)
|
||||
}
|
||||
MobEvent::LevelEventSound {
|
||||
sound,
|
||||
at,
|
||||
@@ -2128,7 +2168,7 @@ impl Game<'_> {
|
||||
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
|
||||
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
|
||||
// (ENTITY_PLACE).
|
||||
// (ENTITY_PLACE), Enderman.teleport's TELEPORT where it was.
|
||||
MobEvent::GameEvent {
|
||||
event, at, source, ..
|
||||
} if matches!(
|
||||
@@ -2139,6 +2179,7 @@ impl Game<'_> {
|
||||
| "minecraft:container_open"
|
||||
| "minecraft:container_close"
|
||||
| "minecraft:entity_place"
|
||||
| "minecraft:teleport"
|
||||
) =>
|
||||
{
|
||||
let ctx = self.who_context(Some(Who::Mob(source)), None);
|
||||
@@ -7104,11 +7145,11 @@ impl Game<'_> {
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
self.mark_entity(entity_start, &mob.kind.id);
|
||||
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
|
||||
let light = self.world.light(
|
||||
mob.position.x.floor() as i32,
|
||||
(mob.position.y + mob.eye_height()).floor() as i32,
|
||||
mob.position.z.floor() as i32,
|
||||
);
|
||||
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
|
||||
// probe (its eye) is in, at the position this frame draws rather
|
||||
// than this tick's.
|
||||
let probe = mob.light_probe(alpha);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
|
||||
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
|
||||
@@ -7365,11 +7406,12 @@ impl Game<'_> {
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
// EntityRenderer.getPackedLightCoords: the block its light probe
|
||||
// (the eye) is in, not the one under its feet.
|
||||
// (the eye) is in, at the position this frame draws rather than
|
||||
// this tick's, and not the one under its feet.
|
||||
let light = self.world.light(
|
||||
a.position.x.floor() as i32,
|
||||
a.light_probe_y().floor() as i32,
|
||||
a.position.z.floor() as i32,
|
||||
at[0].floor() as i32,
|
||||
a.light_probe_y(alpha).floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
|
||||
@@ -7903,6 +7945,86 @@ fn mob_effect_instance(
|
||||
Some(instance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_sound_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::mobs::MobEvent;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn local_mob_events_play_without_entering_the_shared_capture() {
|
||||
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-h069-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.audio.capture = Some(Vec::new());
|
||||
game.handle_mob_events(vec![
|
||||
MobEvent::Sound("entity.cow.hurt", [1.0, 100.0, 2.0], 0.5, 1.0),
|
||||
MobEvent::LocalSound("entity.breeze.whirl", [3.0, 100.0, 4.0], 0.8, 0.7),
|
||||
MobEvent::Sound("entity.zombie.hurt", [5.0, 100.0, 6.0], 1.0, 0.9),
|
||||
])
|
||||
.unwrap();
|
||||
// A positive LocalSound must still take the playback path. An absent
|
||||
// producer cannot satisfy this actual Game adapter regression.
|
||||
assert_eq!(
|
||||
game.audio.heard.get("minecraft:entity.breeze.whirl"),
|
||||
Some(&1)
|
||||
);
|
||||
assert_eq!(game.audio.heard.get("minecraft:entity.cow.hurt"), Some(&1));
|
||||
assert_eq!(
|
||||
game.audio.heard.get("minecraft:entity.zombie.hurt"),
|
||||
Some(&1)
|
||||
);
|
||||
let shared = game.audio.capture.as_ref().unwrap();
|
||||
assert_eq!(shared.len(), 2, "LocalSound must not become a shared sound");
|
||||
assert_eq!(shared[0].event, "minecraft:entity.cow.hurt");
|
||||
assert_eq!(shared[1].event, "minecraft:entity.zombie.hurt");
|
||||
assert_eq!(
|
||||
(
|
||||
shared[0].position,
|
||||
shared[0].volume,
|
||||
shared[0].pitch,
|
||||
shared[0].source
|
||||
),
|
||||
([1.0, 100.0, 2.0], 0.5, 1.0, 6)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
shared[1].position,
|
||||
shared[1].volume,
|
||||
shared[1].pitch,
|
||||
shared[1].source
|
||||
),
|
||||
([5.0, 100.0, 6.0], 1.0, 0.9, 5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::perspective::CameraType;
|
||||
@@ -8454,3 +8576,104 @@ mod dye_mix_tests {
|
||||
assert!(kept == orange || kept == cyan, "kept {kept}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conversion_tag_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn converted_mobs_keep_command_tags_at_the_new_id_and_on_save() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
for (number, (from, to)) in [
|
||||
("husk", "zombie"),
|
||||
("zombie", "drowned"),
|
||||
("skeleton", "stray"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let uuid = [10374, 1, 0, number as i32];
|
||||
let tags = Tag::List(vec![
|
||||
Tag::String("subject".into()),
|
||||
Tag::String(from.into()),
|
||||
]);
|
||||
let raw = Tag::compound([
|
||||
(
|
||||
"id",
|
||||
Tag::String(format!("minecraft:{from}").as_str().into()),
|
||||
),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(0.5_f64.to_bits()),
|
||||
Tag::Double(70.0_f64.to_bits()),
|
||||
Tag::Double(0.5_f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Tags", tags.clone()),
|
||||
("horizon:old_entity_only", Tag::Byte(1)),
|
||||
]);
|
||||
let old_id = game.load_mob(raw, None).unwrap();
|
||||
assert_eq!(game.tags_of(Target::Mob(old_id)), ["subject", from]);
|
||||
let source = game.mobs.iter().find(|m| m.id == old_id).unwrap().clone();
|
||||
let new_id = game
|
||||
.convert_to(
|
||||
&source,
|
||||
&format!("minecraft:{to}"),
|
||||
horizon_client::mobs::conversion::Params::KEEP_ALL,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(old_id, new_id);
|
||||
assert_eq!(
|
||||
game.tags_of(Target::Mob(new_id)),
|
||||
["subject", from],
|
||||
"converted ID must keep tags"
|
||||
);
|
||||
let index = game.mobs.iter().position(|m| m.id == new_id).unwrap();
|
||||
let saved = game.mob_tree_nbt(index);
|
||||
assert_eq!(saved.get("Tags"), Some(&tags));
|
||||
assert_eq!(
|
||||
horizon_client::entity_nbt::kind(&saved).as_deref(),
|
||||
Some(format!("minecraft:{to}").as_str())
|
||||
);
|
||||
assert_ne!(horizon_client::entity_nbt::uuid(&saved), Some(uuid));
|
||||
assert!(saved.get("horizon:old_entity_only").is_none());
|
||||
game.mobs.remove(index);
|
||||
let reloaded = game.load_mob(saved, None).unwrap();
|
||||
assert_eq!(game.tags_of(Target::Mob(reloaded)), ["subject", from]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +371,8 @@ impl Game<'_> {
|
||||
a.fired_from_weapon = Some(weapon.clone());
|
||||
a.pierce = pierce;
|
||||
}
|
||||
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
|
||||
self.ignite_spawned_arrow(&weapon, &mut arrow);
|
||||
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
|
||||
self.add_projectile(arrow);
|
||||
let (player, name, projectile) = (
|
||||
@@ -488,6 +490,39 @@ mod tests {
|
||||
assert_eq!(at, vec![feet]);
|
||||
}
|
||||
|
||||
/// `AbstractArrow`'s constructor runs `EnchantmentHelper.onProjectileSpawned`
|
||||
/// for the weapon the arrow is fired from, bow or crossbow, so a Flame
|
||||
/// crossbow shoots burning arrows: Flame's 5 second ignite, 100 ticks, on
|
||||
/// every arrow of a Multishot volley (#10482).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_flame_crossbow_shoots_burning_arrows() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let (mut game, _) = game_with_player(&root);
|
||||
// Shoots a crossbow with `enchantments` and one arrow loaded per shot,
|
||||
// and returns how long each new arrow burns for.
|
||||
let mut shoot = |enchantments: &[&str], shots: usize| -> Vec<i32> {
|
||||
let mut crossbow = ItemStack::new(CROSSBOW, 1);
|
||||
for id in enchantments {
|
||||
crossbow.enchant(id, 1);
|
||||
}
|
||||
crossbow.data_mut().charged = vec![ItemStack::new(ARROW, 1); shots];
|
||||
*game.held_mut() = Some(crossbow);
|
||||
let before = game.creatures.projectiles.len();
|
||||
game.shoot_crossbow();
|
||||
game.creatures.projectiles[before..]
|
||||
.iter()
|
||||
.map(|p| p.arrow.as_ref().expect("an arrow").fire_ticks)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(shoot(&[], 1), vec![0]);
|
||||
assert_eq!(shoot(&["minecraft:flame"], 1), vec![100]);
|
||||
assert_eq!(
|
||||
shoot(&["minecraft:flame", "minecraft:multishot"], 3),
|
||||
vec![100; 3]
|
||||
);
|
||||
}
|
||||
|
||||
/// A charge that crosses a point between two ticks plays that point's
|
||||
/// sound, and one a mod's tick jumps over (`native_living_entity_use_item_tick`
|
||||
/// cutting the ticks left) still plays every point it passed, as
|
||||
|
||||
@@ -67,9 +67,6 @@ impl Game<'_> {
|
||||
/// own handling. Returns true when it is used up (a debug combination,
|
||||
/// F3 itself, F3+Escape).
|
||||
pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool {
|
||||
if self.title.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
@@ -713,6 +710,9 @@ impl Game<'_> {
|
||||
self.debug_charts.toggle_lightmap(&mut self.debug);
|
||||
true
|
||||
}
|
||||
// The debug options screen is not wired over the title screen yet:
|
||||
// leave F6 unhandled there rather than half-open it.
|
||||
DebugAction::DebugOptions if self.title.is_some() => false,
|
||||
DebugAction::DebugOptions => {
|
||||
self.toggle_debug_options();
|
||||
true
|
||||
|
||||
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
horizon_client::command::vanilla::set_horizon_commands(true);
|
||||
}
|
||||
props.store(&settings)?;
|
||||
// `RegionFileVersion.configure`: the compression every region file this
|
||||
// server writes uses, from `region-file-compression`. An unknown name is
|
||||
// logged with the ones there are, and the default (deflate) stays.
|
||||
if !horizon_world::compression::Compression::configure(&props.region_file_compression) {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!(
|
||||
"Invalid `region-file-compression` value `{}` in server.properties. Please use one of: {}",
|
||||
props.region_file_compression,
|
||||
horizon_world::compression::REGION_FILE_NAMES.join(",")
|
||||
),
|
||||
);
|
||||
}
|
||||
let eula_file = PathBuf::from("eula.txt");
|
||||
let agreed = match eula::agreed(&eula_file) {
|
||||
Ok(a) => a,
|
||||
@@ -1119,6 +1132,11 @@ impl Game<'_> {
|
||||
.raw
|
||||
.get(horizon_client::lan::HORIZON_CLIENTS_ONLY)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
accept_older_clients: props
|
||||
.raw
|
||||
.get(horizon_client::lan::ACCEPT_OLDER_CLIENTS)
|
||||
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")),
|
||||
older_clients_play: false,
|
||||
lod_distance: horizon_client::lod_net::distance_property(
|
||||
props.raw.get(horizon_client::lod_net::PROPERTY),
|
||||
),
|
||||
@@ -1338,6 +1356,14 @@ impl Game<'_> {
|
||||
{
|
||||
self.finish_report(recording, state.view, state.max_players);
|
||||
}
|
||||
if horizon_client::spans::trace::due()
|
||||
&& let Some(events) = horizon_client::spans::trace::stop()
|
||||
{
|
||||
log(
|
||||
"INFO",
|
||||
&format!("Trace written: {events} events (reports/)"),
|
||||
);
|
||||
}
|
||||
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
|
||||
// (no window drains the toasts here).
|
||||
if horizon_client::chunk_io::take().low_disk_space
|
||||
@@ -1640,6 +1666,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Horizon's own, on the console only: every thread's spans as a timeline (report.rs).
|
||||
"horizon-trace" if rest.first() == Some(&"stop") => {
|
||||
match horizon_client::spans::trace::stop() {
|
||||
Some(events) => log("INFO", &format!("Trace stopped: {events} events written")),
|
||||
None => log("INFO", "No trace is being written"),
|
||||
}
|
||||
}
|
||||
"horizon-trace" => {
|
||||
let seconds = match rest.first().map(|s| s.parse::<u32>()) {
|
||||
None => Some(30),
|
||||
Some(Ok(s)) if (1..=report::TRACE_MAX_SECONDS).contains(&s) => Some(s),
|
||||
Some(_) => None,
|
||||
};
|
||||
match seconds.map(report::start_trace) {
|
||||
None => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"horizon-trace [SECONDS | stop]: 1 to {} seconds, 30 by default",
|
||||
report::TRACE_MAX_SECONDS
|
||||
),
|
||||
),
|
||||
Some(Ok(path)) => log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Writing a trace for {} s to {} (open it in ui.perfetto.dev)",
|
||||
seconds.unwrap_or_default(),
|
||||
path.display()
|
||||
),
|
||||
),
|
||||
Some(Err(e)) => log("ERROR", &format!("Could not start a trace: {e}")),
|
||||
}
|
||||
}
|
||||
"save-all" => {
|
||||
self.console_reply(Text::translate("commands.save.saving", &[]));
|
||||
let saved = self.save_game();
|
||||
|
||||
@@ -560,6 +560,20 @@ fn process_limits() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The longest `horizon-trace` writes for.
|
||||
pub(super) const TRACE_MAX_SECONDS: u32 = 300;
|
||||
|
||||
/// `horizon-trace SECONDS`: every thread's spans for `seconds`, to `reports/trace-<time>.json`
|
||||
/// (Perfetto opens it).
|
||||
pub(super) fn start_trace(seconds: u32) -> std::io::Result<PathBuf> {
|
||||
let dir = Path::new("reports");
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
|
||||
let path = dir.join(format!("trace-{stamp}.json"));
|
||||
spans::trace::start(&path, Some(seconds))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
|
||||
|
||||
@@ -657,13 +657,13 @@ impl Dragon {
|
||||
if damage <= self.last_hurt {
|
||||
return Hurt::Landed { full: false };
|
||||
}
|
||||
self.health -= damage - self.last_hurt;
|
||||
self.hurt_health(damage - self.last_hurt, source);
|
||||
self.last_hurt = damage;
|
||||
false
|
||||
} else {
|
||||
self.last_hurt = damage;
|
||||
self.invulnerable = 20;
|
||||
self.health -= damage;
|
||||
self.hurt_health(damage, source);
|
||||
self.hurt_time = 10;
|
||||
true
|
||||
};
|
||||
@@ -705,6 +705,16 @@ impl Dragon {
|
||||
}
|
||||
Hurt::Landed { full }
|
||||
}
|
||||
/// `LivingEntity.actuallyHurt` for the dragon, which has no armour or
|
||||
/// absorption: a native mod's `LivingDamageEvent.Pre` may change what
|
||||
/// comes off the health, and `.Post` hears what did.
|
||||
fn hurt_health(&mut self, damage: f32, source: HurtSource<'_>) {
|
||||
use horizon_client::health::{Hurting, damage_post, damage_pre};
|
||||
let mut damage = damage;
|
||||
damage_pre(Hurting::Dragon(self.id as i32, source), &mut damage);
|
||||
self.health -= damage;
|
||||
damage_post(Hurting::Dragon(self.id as i32, source), damage);
|
||||
}
|
||||
/// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase
|
||||
/// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`:
|
||||
/// the source position minus this position). Knockback resistance is the
|
||||
@@ -3580,6 +3590,8 @@ impl Game<'_> {
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
@@ -5135,6 +5147,69 @@ mod tests {
|
||||
assert!(matches!(d.phase, Phase::Dying { .. }));
|
||||
}
|
||||
|
||||
/// What the `LivingDamageEvent` hooks of these tests were handed: the
|
||||
/// dragon, Post (true) or Pre, and the amount.
|
||||
static TAKEN: std::sync::Mutex<Vec<(i32, bool, f32)>> = std::sync::Mutex::new(Vec::new());
|
||||
/// Sets the native `LivingDamageEvent.Pre` and `.Post` hooks for dragons
|
||||
/// 9201 and 9202 (global, as `set_hook`'s): they record what they are
|
||||
/// handed, and Pre halves 9202's amount.
|
||||
fn set_damage_hooks() {
|
||||
use horizon_client::health::{DAMAGE_POST, DAMAGE_PRE, Hurting};
|
||||
fn pre(hurting: Hurting<'_>, amount: &mut f32) {
|
||||
let Hurting::Dragon(entity @ 9201..=9202, _) = hurting else {
|
||||
return;
|
||||
};
|
||||
TAKEN.lock().unwrap().push((entity, false, *amount));
|
||||
if entity == 9202 {
|
||||
*amount /= 2.0;
|
||||
}
|
||||
}
|
||||
fn post(hurting: Hurting<'_>, amount: f32) {
|
||||
if let Hurting::Dragon(entity @ 9201..=9202, _) = hurting {
|
||||
TAKEN.lock().unwrap().push((entity, true, amount));
|
||||
}
|
||||
}
|
||||
let installed = (
|
||||
*DAMAGE_PRE.get_or_init(|| pre),
|
||||
*DAMAGE_POST.get_or_init(|| post),
|
||||
);
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed.0,
|
||||
pre as fn(Hurting<'_>, &mut f32)
|
||||
));
|
||||
assert!(std::ptr::fn_addr_eq(
|
||||
installed.1,
|
||||
post as fn(Hurting<'_>, f32)
|
||||
));
|
||||
}
|
||||
fn taken(entity: i32) -> Vec<(bool, f32)> {
|
||||
let taken = TAKEN.lock().unwrap();
|
||||
taken
|
||||
.iter()
|
||||
.filter(|t| t.0 == entity)
|
||||
.map(|t| (t.1, t.2))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_native_damage_hooks_see_and_change_what_a_dragons_health_takes() {
|
||||
set_damage_hooks();
|
||||
// The hit after the part's cut (20 / 4 + 1), then, within the
|
||||
// cooldown, what a stronger one exceeds it by.
|
||||
let mut d = dragon_as(9201);
|
||||
assert_eq!(d.hurt(2, 20.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(d.hurt(0, 10.0, HIT, false), Hurt::Landed { full: false });
|
||||
assert_eq!(
|
||||
taken(9201),
|
||||
[(false, 6.0), (true, 6.0), (false, 4.0), (true, 4.0)]
|
||||
);
|
||||
assert_eq!(d.health, MAX_HEALTH - 10.0);
|
||||
let mut d = dragon_as(9202);
|
||||
assert_eq!(d.hurt(0, 9.0, HIT, false), Hurt::Landed { full: true });
|
||||
assert_eq!(taken(9202), [(false, 9.0), (true, 4.5)]);
|
||||
assert_eq!(d.health, MAX_HEALTH - 4.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hit_names_the_player_behind_it() {
|
||||
let source = |kind, by, direct| {
|
||||
|
||||
@@ -94,6 +94,8 @@ impl Game<'_> {
|
||||
/// Fills `dynamic_lights` for the frame from [`Self::dynamic_light_sources`], the nearest
|
||||
/// [`dynamic_light::MAX`] of them.
|
||||
pub(super) fn gather_dynamic_lights(&mut self, camera: DVec3, alpha: f64) {
|
||||
let _span = horizon_client::spans::guard("pr_light_gather");
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let mut out = std::mem::take(&mut self.dynamic_lights);
|
||||
out.clear();
|
||||
let sources: Vec<Source> = self
|
||||
@@ -101,7 +103,9 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.map(|(s, _)| s)
|
||||
.collect();
|
||||
laps.lap("pr_light_sources");
|
||||
dynamic_light::select(&sources, camera, &mut out);
|
||||
self.dynamic_lights = out;
|
||||
laps.lap("pr_light_select");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
enchant::{EffectContext, Enchantments, level_value},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
projectiles::Projectile,
|
||||
};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
|
||||
@@ -654,17 +655,27 @@ impl Game<'_> {
|
||||
) == 0.0;
|
||||
(f64::from(power), infinity)
|
||||
}
|
||||
/// `EnchantmentHelper.onProjectileSpawned` ignite effects of the held bow
|
||||
/// (flame): the seconds the new arrow burns for.
|
||||
pub(super) fn bow_projectile_ignite(&mut self) -> Option<f32> {
|
||||
let bow = self.held().cloned()?;
|
||||
/// `EnchantmentHelper.onProjectileSpawned`, which `AbstractArrow`'s
|
||||
/// constructor runs for every arrow fired from a weapon, bow or crossbow:
|
||||
/// the weapon's `projectile_spawned` ignite effects (Flame) spawn the arrow
|
||||
/// burning.
|
||||
pub(super) fn ignite_spawned_arrow(&mut self, weapon: &ItemStack, arrow: &mut Projectile) {
|
||||
if let Some(seconds) = self.projectile_spawned_ignite(weapon)
|
||||
&& let Some(a) = arrow.arrow.as_mut()
|
||||
{
|
||||
a.ignite_for_seconds(seconds);
|
||||
}
|
||||
}
|
||||
/// The `projectile_spawned` ignite effects of `weapon` (Flame): the seconds
|
||||
/// the new arrow burns for, the longest when several apply.
|
||||
fn projectile_spawned_ignite(&mut self, weapon: &ItemStack) -> Option<f32> {
|
||||
let ctx = EffectContext {
|
||||
this: Some("minecraft:arrow"),
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut ignite: Option<f32> = None;
|
||||
for (id, level) in bow.enchantments() {
|
||||
for (id, level) in weapon.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -187,28 +187,51 @@ impl Game<'_> {
|
||||
};
|
||||
bite(self);
|
||||
self.each_guest_hit(bite);
|
||||
// `AbstractIllager.considersEntityAsAlly`: with no teams, the illager
|
||||
// tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose
|
||||
// owner is allied (itself or another illager). Teams are not modelled.
|
||||
let illager = |s: Species| {
|
||||
matches!(
|
||||
s,
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
|
||||
)
|
||||
};
|
||||
// `Entity.isAlliedTo`: the owner's or the target's
|
||||
// `considersEntityAsAlly`, teams included.
|
||||
let teams = self.mob_teams();
|
||||
let illagers: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| illager(m.species()))
|
||||
.filter(|m| is_illager(m.species()))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
// A missing owner hurts everyone (`currentOwner == null`).
|
||||
let owner_known = illagers.contains(&f.owner);
|
||||
let team_of = |id: u32| teams.of(horizon_client::mobs::Who::Mob(id));
|
||||
let allies: Vec<u32> = if owner_known {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let other = Party {
|
||||
illager: is_illager(m.species()),
|
||||
team: team_of(m.id),
|
||||
};
|
||||
let root = (m.species() == Species::Vex)
|
||||
.then_some(m.extra.counter2 as u32)
|
||||
.filter(|r| illagers.contains(r))
|
||||
.map(|r| {
|
||||
(
|
||||
r == f.owner,
|
||||
Party {
|
||||
illager: true,
|
||||
team: team_of(r),
|
||||
},
|
||||
)
|
||||
});
|
||||
let owner = Party {
|
||||
illager: true,
|
||||
team: team_of(f.owner),
|
||||
};
|
||||
fangs_allied(owner, other, root)
|
||||
})
|
||||
.map(|m| m.id)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for m in &mut self.mobs {
|
||||
let ally = owner_known
|
||||
&& (illager(m.species())
|
||||
|| m.species() == Species::Vex
|
||||
&& illagers.contains(&(m.extra.counter2 as u32)));
|
||||
let ally = allies.contains(&m.id);
|
||||
if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) {
|
||||
m.damage(
|
||||
6.0,
|
||||
@@ -293,12 +316,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EntityTypeTags.ILLAGER` members, the types of `ILLAGER_FRIENDS`.
|
||||
fn is_illager(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator
|
||||
)
|
||||
}
|
||||
/// One side of an ally check: whether it is an illager and its team.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Party<'a> {
|
||||
illager: bool,
|
||||
team: Option<&'a horizon_client::scoreboard::Team>,
|
||||
}
|
||||
/// `AbstractIllager.considersEntityAsAlly(other)`: the base rule (the teams
|
||||
/// are allied), or an illager friend while both have no team.
|
||||
fn illager_considers(me: Party, other: Party) -> bool {
|
||||
horizon_client::scoreboard::teams_allied(me.team, other.team)
|
||||
|| other.illager && me.team.is_none() && other.team.is_none()
|
||||
}
|
||||
/// `owner.isAlliedTo(other)` for an evoker owner: `Evoker.considersEntityAsAlly`
|
||||
/// either way round. `vex_root` is the root owner of a vex target (whether it
|
||||
/// is the owner itself, and its party); the owner treats such a vex as an ally
|
||||
/// when its root owner is.
|
||||
fn fangs_allied(owner: Party, other: Party, vex_root: Option<(bool, Party)>) -> bool {
|
||||
illager_considers(owner, other)
|
||||
|| other.illager && illager_considers(other, owner)
|
||||
|| !other.illager && horizon_client::scoreboard::teams_allied(other.team, owner.team)
|
||||
|| vex_root.is_some_and(|(is_owner, root)| is_owner || illager_considers(owner, root))
|
||||
}
|
||||
fn p_zrot(p: &mut Poses, part: &str, v: f32) {
|
||||
p.zrot(part, v);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fang_attack_pitch, fang_crit};
|
||||
use super::{Party, fang_attack_pitch, fang_crit, fangs_allied};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
#[test]
|
||||
@@ -334,4 +386,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn party(illager: bool, team: Option<&horizon_client::scoreboard::Team>) -> Party<'_> {
|
||||
Party { illager, team }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fangs_allies_follow_teams_and_the_illager_tag() {
|
||||
let mut board = horizon_client::scoreboard::Scoreboard::default();
|
||||
board.add_team("red");
|
||||
board.add_team("blue");
|
||||
let red = board.team("red").unwrap();
|
||||
let blue = board.team("blue").unwrap();
|
||||
let evoker = |t| party(true, t);
|
||||
// Teamless illagers are friends.
|
||||
assert!(fangs_allied(evoker(None), party(true, None), None));
|
||||
// Different teams: the illager tag no longer applies.
|
||||
assert!(!fangs_allied(
|
||||
evoker(Some(red)),
|
||||
party(true, Some(blue)),
|
||||
None
|
||||
));
|
||||
// One side with a team: still not allied.
|
||||
assert!(!fangs_allied(evoker(Some(red)), party(true, None), None));
|
||||
// The same team allies a non-illager mob.
|
||||
assert!(fangs_allied(
|
||||
evoker(Some(red)),
|
||||
party(false, Some(red)),
|
||||
None
|
||||
));
|
||||
assert!(!fangs_allied(evoker(None), party(false, None), None));
|
||||
// A vex follows its root owner.
|
||||
assert!(fangs_allied(
|
||||
evoker(None),
|
||||
party(false, None),
|
||||
Some((true, evoker(None)))
|
||||
));
|
||||
assert!(!fangs_allied(
|
||||
evoker(Some(red)),
|
||||
party(false, None),
|
||||
Some((false, party(true, Some(blue))))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,34 @@ impl Game<'_> {
|
||||
"vz": hook.velocity.z.to_bits() as i64, "nibble": hook.nibble, "next": hook.random.next_long(),
|
||||
}));
|
||||
}
|
||||
// The client must take the same block hit before moving as the server;
|
||||
// otherwise it lands farther away and slides back on the next update.
|
||||
let stone = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
self.level_set(block, stone).unwrap();
|
||||
for (case, motion) in [
|
||||
Vec3::new(0.2, -0.4, 0.1),
|
||||
Vec3::new(-0.2, -0.4, 0.1),
|
||||
Vec3::new(0.0, -0.6, -0.2),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut state = (Vec3::new(x, 301.1, z), motion, State::Flying, false);
|
||||
let mut random = LegacyRandom::new(0);
|
||||
for tick in 0..5 {
|
||||
state = self.client_hook_step(state, &mut random);
|
||||
rows.push(serde_json::json!({
|
||||
"case": case, "tick": tick,
|
||||
"position": [state.0.x - f64::from(block[0]), state.0.y, state.0.z - f64::from(block[2])],
|
||||
"motion": [state.1.x, state.1.y, state.1.z],
|
||||
}));
|
||||
}
|
||||
}
|
||||
self.hook = old_hook;
|
||||
self.offhand = old_offhand;
|
||||
self.player.as_mut().unwrap().position = old_position;
|
||||
@@ -874,27 +902,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 = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
let entity_end = block_hit.map_or(to, |hit| hit.location);
|
||||
let block_hit = self.hook_block_hit(h.position, to);
|
||||
let entity_end = block_hit.unwrap_or(to);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
let hit = first_entity_hit(
|
||||
@@ -968,9 +977,9 @@ impl Game<'_> {
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[hit.location.x, hit.location.y, hit.location.z],
|
||||
[hit.x, hit.y, hit.z],
|
||||
);
|
||||
h.velocity = rebound(h.velocity, hit.location, h.position);
|
||||
h.velocity = rebound(h.velocity, hit, h.position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
@@ -1060,7 +1069,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !in_water && !h.on_ground {
|
||||
h.velocity.y -= 0.03;
|
||||
h.velocity.y -= f64::from(0.03_f32);
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
@@ -1584,6 +1593,28 @@ impl Game<'_> {
|
||||
self.remote_hooks = hooks;
|
||||
}
|
||||
|
||||
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
|
||||
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
self.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?
|
||||
.clip(from, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|hit| hit.location)
|
||||
}
|
||||
|
||||
/// `FishingHook.tick`: one tick of movement without the fishing (the
|
||||
/// server's): position, motion, state and whether it rests on the ground.
|
||||
fn client_hook_step(
|
||||
@@ -1608,7 +1639,11 @@ impl Game<'_> {
|
||||
State::Flying if velocity == Vec3::ZERO && on_ground => {
|
||||
return (position, velocity, state, on_ground);
|
||||
}
|
||||
State::Flying => {}
|
||||
State::Flying => {
|
||||
if let Some(hit) = self.hook_block_hit(position, position.add_vector(velocity)) {
|
||||
velocity = rebound(velocity, hit, position);
|
||||
}
|
||||
}
|
||||
State::Hooked(id) => {
|
||||
// updateHookedEntity: at 0.8 of the hooked entity's height.
|
||||
let hooked = if self
|
||||
@@ -1661,7 +1696,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if !in_water && !on_ground {
|
||||
velocity.y -= 0.03;
|
||||
velocity.y -= f64::from(0.03_f32);
|
||||
}
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
|
||||
@@ -20,7 +20,11 @@ impl Game<'_> {
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Fox || !m.alive() || m.removed {
|
||||
if m.species() != Species::Fox
|
||||
|| !m.alive()
|
||||
|| m.removed
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1), in order.
|
||||
@@ -82,7 +86,11 @@ impl Game<'_> {
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Dolphin || !m.alive() || m.removed {
|
||||
if m.species() != Species::Dolphin
|
||||
|| !m.alive()
|
||||
|| m.removed
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `Mob.aiStep`: the items in the box grown by (1, 0, 1); `Dolphin.pickUpItem`
|
||||
|
||||
@@ -379,6 +379,21 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
|
||||
/// for a non-empty stack and updates the comparators.
|
||||
pub(super) fn set_frame_item(&mut self, i: usize, stack: Option<ItemStack>) {
|
||||
let f = &mut self.item_frames[i];
|
||||
f.item = stack.map(|s| ItemStack { count: 1, ..s });
|
||||
let (pos, filled) = (f.pos, f.item.is_some());
|
||||
if filled {
|
||||
let (name, at) = (
|
||||
sound(&self.item_frames[i], "add_item"),
|
||||
center(&self.item_frames[i]),
|
||||
);
|
||||
self.audio.play(&name, Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.frame_output_changed(pos);
|
||||
}
|
||||
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
|
||||
/// the comparators reading the frame take its new signal.
|
||||
fn frame_output_changed(&mut self, pos: [i32; 3]) {
|
||||
|
||||
@@ -24,6 +24,16 @@ pub(super) struct Scheduled {
|
||||
pub time: i64,
|
||||
pub seq: u64,
|
||||
}
|
||||
impl FnState {
|
||||
/// The file `path` of the highest enabled pack that has it.
|
||||
pub(super) fn pack_file(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.enabled
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|id| self.packs.iter().find(|p| &p.id == id))
|
||||
.find_map(|pack| pack.read(path))
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct FnState {
|
||||
pub store: DataStore,
|
||||
|
||||
@@ -383,7 +383,9 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`, whose
|
||||
/// end asks the mods' `native_break_speed`. Every dig's progress comes from
|
||||
/// here: single player's, a host's for each guest, a remote client's own.
|
||||
pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
|
||||
let block_break_speed = self.block_break_speed();
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
@@ -414,6 +416,8 @@ impl Game<'_> {
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
// NeoForge's PlayerEvent.BreakSpeed, where Player.getDestroySpeed ends.
|
||||
self.native_break_speed(pos, state, speed, &mut speed);
|
||||
speed / hardness / if harvest { 30.0 } else { 100.0 }
|
||||
}
|
||||
/// A key for the main-hand stack (`destroyingItem`): its item and components.
|
||||
@@ -439,7 +443,7 @@ impl Game<'_> {
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
pub(super) fn hands_busy(&self) -> bool {
|
||||
self.riding
|
||||
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
|
||||
.is_some_and(|id| self.controls_boat(id) && self.boats.iter().any(|b| b.id == id))
|
||||
&& [
|
||||
winit::keyboard::KeyCode::KeyW,
|
||||
winit::keyboard::KeyCode::KeyS,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! test each mob's reach against every item (`Mob.aiStep`'s looting, the
|
||||
//! foxes' and dolphins' item searches). A reach that meets no cell holding an
|
||||
//! item meets no item, so its scan, which would find nothing, is skipped; a
|
||||
//! reach near an item scans as before, in the same order, with the same tests.
|
||||
use horizon_client::{entities::ItemEntity, fast_hash::FxHashSet, join_seq::JoinSeq};
|
||||
//! reach near an item scans as before, in the same order, with the same tests,
|
||||
//! jumping over the items of the cells it doesn't meet ([`ItemScan`]).
|
||||
use horizon_client::{entities::ItemEntity, fast_hash::FxHashMap, join_seq::JoinSeq};
|
||||
use horizon_core::geometry::Aabb;
|
||||
|
||||
/// Cells of 8 blocks on each axis.
|
||||
@@ -11,7 +12,8 @@ const SHIFT: i32 = 3;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ItemAreas {
|
||||
cells: FxHashSet<[i32; 3]>,
|
||||
/// The items in each cell, by index, in the list's order.
|
||||
cells: FxHashMap<[i32; 3], Vec<u32>>,
|
||||
/// What the cells were built from: the list's length, the last entity id
|
||||
/// the game handed out and the next join number. An item spawned or
|
||||
/// removed since changes one of them, and the cells are built again.
|
||||
@@ -21,40 +23,137 @@ pub(super) struct ItemAreas {
|
||||
impl ItemAreas {
|
||||
/// Whether an item of `items` may meet `area`: false only when none can.
|
||||
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
|
||||
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
|
||||
if self.built != Some(key) {
|
||||
self.cells.clear();
|
||||
self.cells.extend(items.iter().map(|e| {
|
||||
let p = e.position;
|
||||
[cell(p.x), cell(p.y), cell(p.z)]
|
||||
}));
|
||||
self.built = Some(key);
|
||||
}
|
||||
self.build(items, next_entity);
|
||||
if self.cells.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (area.min_position(), area.max_position());
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
let Some((from, to)) = cell_span(area) else {
|
||||
return true;
|
||||
}
|
||||
// An item's box spans its width sideways and upwards from its
|
||||
// position; a margin of a whole width keeps rounding on the safe side.
|
||||
let m = ItemEntity::WIDTH;
|
||||
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
|
||||
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
|
||||
let span = (0..3)
|
||||
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
|
||||
.product::<i64>();
|
||||
if span > self.cells.len() as i64 {
|
||||
};
|
||||
if span(from, to) > self.cells.len() as i64 {
|
||||
return self
|
||||
.cells
|
||||
.iter()
|
||||
.keys()
|
||||
.any(|c| (0..3).all(|a| (from[a]..=to[a]).contains(&c[a])));
|
||||
}
|
||||
(from[2]..=to[2]).any(|z| {
|
||||
(from[1]..=to[1]).any(|y| (from[0]..=to[0]).any(|x| self.cells.contains(&[x, y, z])))
|
||||
(from[1]..=to[1])
|
||||
.any(|y| (from[0]..=to[0]).any(|x| self.cells.contains_key(&[x, y, z])))
|
||||
})
|
||||
}
|
||||
|
||||
/// A scan of `items` for `area`, which jumps over the items that can't
|
||||
/// meet it; empty when none can ([`Self::may_meet`] false).
|
||||
pub(super) fn scan(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> ItemScan {
|
||||
let all = ItemScan {
|
||||
near: None,
|
||||
passed: 0,
|
||||
len: items.len(),
|
||||
};
|
||||
if !self.may_meet(items, next_entity, area) {
|
||||
return ItemScan {
|
||||
near: Some(Vec::new()),
|
||||
..all
|
||||
};
|
||||
}
|
||||
let Some((from, to)) = cell_span(area) else {
|
||||
return all;
|
||||
};
|
||||
if span(from, to) > self.cells.len() as i64 {
|
||||
return all;
|
||||
}
|
||||
let mut near = Vec::new();
|
||||
for z in from[2]..=to[2] {
|
||||
for y in from[1]..=to[1] {
|
||||
for x in from[0]..=to[0] {
|
||||
if let Some(list) = self.cells.get(&[x, y, z]) {
|
||||
near.extend(list.iter().map(|&i| i as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
near.sort_unstable();
|
||||
ItemScan {
|
||||
near: Some(near),
|
||||
..all
|
||||
}
|
||||
}
|
||||
|
||||
fn build(&mut self, items: &[ItemEntity], next_entity: u32) {
|
||||
let key = (items.len(), next_entity, JoinSeq::upcoming().get());
|
||||
if self.built == Some(key) {
|
||||
return;
|
||||
}
|
||||
self.cells.clear();
|
||||
for (i, e) in items.iter().enumerate() {
|
||||
let p = e.position;
|
||||
let at = [cell(p.x), cell(p.y), cell(p.z)];
|
||||
self.cells.entry(at).or_default().push(i as u32);
|
||||
}
|
||||
self.built = Some(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// One reach's walk over the items, in the list's order: [`Self::next_from`] gives
|
||||
/// the next index to test, past the items that lie in cells the reach doesn't
|
||||
/// meet. Told of each item the walk removes, it keeps up; if the list changes
|
||||
/// any other way (an item joins), the walk tests every item from there on.
|
||||
pub(super) struct ItemScan {
|
||||
/// The items in the cells the reach meets, by index; `None`: every item.
|
||||
near: Option<Vec<usize>>,
|
||||
/// The first of `near` not yet passed.
|
||||
passed: usize,
|
||||
/// The list's length as the walk knows it.
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl ItemScan {
|
||||
/// Whether no item can meet the reach.
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.near.as_ref().is_some_and(Vec::is_empty)
|
||||
}
|
||||
/// The first index from `j` the walk must test (`len` or more: done).
|
||||
pub(super) fn next_from(&mut self, j: usize, len: usize) -> usize {
|
||||
if len != self.len {
|
||||
self.near = None;
|
||||
}
|
||||
let Some(near) = &self.near else {
|
||||
return j;
|
||||
};
|
||||
while near.get(self.passed).is_some_and(|&i| i < j) {
|
||||
self.passed += 1;
|
||||
}
|
||||
near.get(self.passed).copied().unwrap_or(len).max(j)
|
||||
}
|
||||
/// The walk removed item `j`: the items after it moved down one.
|
||||
pub(super) fn removed(&mut self, j: usize) {
|
||||
self.len -= 1;
|
||||
if let Some(near) = &mut self.near {
|
||||
for i in near.iter_mut().filter(|i| **i > j) {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells an item meeting `area` may lie in: an item's box spans its width
|
||||
/// sideways and upwards from its position, and a margin of a whole width
|
||||
/// keeps rounding on the safe side. `None` for an area that isn't finite.
|
||||
fn cell_span(area: Aabb) -> Option<([i32; 3], [i32; 3])> {
|
||||
let (lo, hi) = (area.min_position(), area.max_position());
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let m = ItemEntity::WIDTH;
|
||||
let from = [cell(lo.x - m), cell(lo.y - 2.0 * m), cell(lo.z - m)];
|
||||
let to = [cell(hi.x + m), cell(hi.y + m), cell(hi.z + m)];
|
||||
Some((from, to))
|
||||
}
|
||||
|
||||
fn span(from: [i32; 3], to: [i32; 3]) -> i64 {
|
||||
(0..3)
|
||||
.map(|a| i64::from(to[a]) - i64::from(from[a]) + 1)
|
||||
.product()
|
||||
}
|
||||
|
||||
fn cell(v: f64) -> i32 {
|
||||
@@ -136,6 +235,77 @@ mod tests {
|
||||
assert!(!cells.may_meet(&[], 1, near));
|
||||
}
|
||||
|
||||
/// The items a walk tests that meet `area`, by position: what the scans
|
||||
/// act on. `take` removes the item it names when the walk meets it.
|
||||
fn walk(items: &mut Vec<ItemEntity>, area: Aabb, take: Option<f64>) -> Vec<f64> {
|
||||
let mut cells = ItemAreas::default();
|
||||
let mut scan = cells.scan(items, 1, area);
|
||||
let (mut met, mut j) = (Vec::new(), 0);
|
||||
loop {
|
||||
j = scan.next_from(j, items.len());
|
||||
let Some(e) = items.get(j) else {
|
||||
break;
|
||||
};
|
||||
if !area.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
met.push(e.position.x);
|
||||
if take == Some(e.position.x) {
|
||||
items.remove(j);
|
||||
scan.removed(j);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
met
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_meets_what_a_full_scan_meets_in_the_same_order() {
|
||||
let mut items = Vec::new();
|
||||
for i in 0..60 {
|
||||
let x = -40.0 + f64::from(i) * 1.37;
|
||||
items.push(item_at(
|
||||
x,
|
||||
64.0 + f64::from(i % 3) * 0.6,
|
||||
(x * 0.7).sin() * 9.0,
|
||||
));
|
||||
}
|
||||
for k in 0..40 {
|
||||
let x = -42.0 + f64::from(k) * 2.11;
|
||||
let area = Aabb::new(Vec3::new(x, 63.5, -10.0), Vec3::new(x + 3.3, 65.5, 10.0));
|
||||
let full: Vec<f64> = items
|
||||
.iter()
|
||||
.filter(|e| area.intersects(e.bounds()))
|
||||
.map(|e| e.position.x)
|
||||
.collect();
|
||||
assert_eq!(walk(&mut items.clone(), area, None), full, "{area:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_keeps_up_with_the_items_it_removes() {
|
||||
let xs = [1.0, 30.0, 1.5, 31.0, 2.0, 2.5];
|
||||
let mut items: Vec<ItemEntity> = xs.iter().map(|&x| item_at(x, 64.0, 1.0)).collect();
|
||||
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(3.0, 65.0, 2.0));
|
||||
assert_eq!(walk(&mut items, area, Some(1.5)), vec![1.0, 1.5, 2.0, 2.5]);
|
||||
assert_eq!(items.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_tests_an_item_that_joins_during_it() {
|
||||
let mut items = vec![item_at(1.0, 64.0, 1.0), item_at(40.0, 64.0, 1.0)];
|
||||
let area = Aabb::new(Vec3::new(0.0, 63.0, 0.0), Vec3::new(3.0, 65.0, 2.0));
|
||||
let mut cells = ItemAreas::default();
|
||||
let mut scan = cells.scan(&items, 1, area);
|
||||
assert_eq!(scan.next_from(0, items.len()), 0);
|
||||
// One joins at the end, inside the reach, after the walk began.
|
||||
items.push(item_at(2.0, 64.0, 1.0));
|
||||
assert_eq!(scan.next_from(1, items.len()), 1);
|
||||
assert_eq!(scan.next_from(2, items.len()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_spawned_after_the_cells_were_built_is_seen() {
|
||||
let mut items = vec![item_at(100.5, 64.0, 100.5)];
|
||||
|
||||
@@ -620,11 +620,24 @@ impl Game<'_> {
|
||||
let pose = glam::Mat4::from_translation(pos.as_vec3())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, bob + min_offset_y, 0.0))
|
||||
* glam::Mat4::from_rotation_y(spin);
|
||||
let packed = self.world.light(
|
||||
q.x.floor() as i32,
|
||||
(q.y + 0.1).floor() as i32,
|
||||
q.z.floor() as i32,
|
||||
);
|
||||
// `EntityRenderer.getPackedLightCoords`: an item entity is lit from
|
||||
// the block its light probe (its eye, 0.2125 up) is in, at 15 block
|
||||
// light while it burns. A pickup particle is not an entity and keeps
|
||||
// the cell it is drawn in.
|
||||
let packed = if entity {
|
||||
let probe = item.light_probe(alpha);
|
||||
item.render_light(self.world.light(
|
||||
probe[0].floor() as i32,
|
||||
probe[1].floor() as i32,
|
||||
probe[2].floor() as i32,
|
||||
))
|
||||
} else {
|
||||
vertex_light(self.world.light(
|
||||
q.x.floor() as i32,
|
||||
(q.y + 0.1).floor() as i32,
|
||||
q.z.floor() as i32,
|
||||
))
|
||||
};
|
||||
let seed = i64::from(self.pack.item_renderer.item_id(&item.stack.item))
|
||||
+ i64::from(item.stack.damage);
|
||||
let copies = item_render::cluster_poses(
|
||||
@@ -664,11 +677,11 @@ impl Game<'_> {
|
||||
let outline = self.thing_outline(item.id, "minecraft:item");
|
||||
// Posed on the GPU when every layer can be, its silhouette too when it glows.
|
||||
let skinned = skinning
|
||||
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
|
||||
.then(|| self.submit_item_skinned(&state, &copies, packed, lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
for © in &copies {
|
||||
self.submit_item_world(&state, copy, vertex_light(packed), out);
|
||||
self.submit_item_world(&state, copy, packed, out);
|
||||
}
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
|
||||
@@ -32,6 +32,9 @@ impl Game<'_> {
|
||||
self.using = Some((self.held_slot(), 0));
|
||||
self.use_duration = duration;
|
||||
self.use_ticks_set = ticks_set;
|
||||
// `LivingEntity.startUsingItem`: `causeUseVibration(ITEM_INTERACT_START)`
|
||||
// when the use effects of the stack ask for interaction vibrations.
|
||||
self.use_vibration(&stack, "minecraft:item_interact_start");
|
||||
// A new charge hears the crossbow's loading sounds again
|
||||
// (`game/crossbow.rs`).
|
||||
self.crossbow_sounds = [false; 2];
|
||||
@@ -100,7 +103,7 @@ impl Game<'_> {
|
||||
// a potion is drunk (no last tick of its own: a mod's tick ran it out).
|
||||
if self.finish_drinking(slot, stack) {
|
||||
self.use_item_finished(stack);
|
||||
self.use_vibration(stack);
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
return;
|
||||
}
|
||||
self.using = None;
|
||||
@@ -113,7 +116,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.use_item_finished(stack);
|
||||
// `LivingEntity.completeUsingItem` ends with `stopUsingItem`.
|
||||
self.use_vibration(stack);
|
||||
self.use_vibration(stack, "minecraft:item_interact_finish");
|
||||
}
|
||||
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
|
||||
/// (no ticks left): the hand holds what a mod made the result.
|
||||
@@ -449,3 +452,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod interact_vibration_tests {
|
||||
use super::*;
|
||||
|
||||
/// Runs `f` on a windowless game built from the prepared resources with
|
||||
/// `held` in the main hand and returns the game events it posted;
|
||||
/// `None` without the resources.
|
||||
fn with_game(held: &str, f: impl FnOnce(&mut Game<'_>)) -> Option<Vec<String>> {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.ok()?;
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(
|
||||
game.motion,
|
||||
horizon_core::geometry::Vec3::new(10.5, 64.0, -3.25),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
*game.held_mut() = Some(ItemStack::new(held, 1));
|
||||
crate::game::dispense_fixture::start_recording();
|
||||
f(&mut game);
|
||||
Some(crate::game::dispense_fixture::stop_recording())
|
||||
}
|
||||
|
||||
/// `LivingEntity.startUsingItem` ends with
|
||||
/// `this.useItem.causeUseVibration(this, GameEvent.ITEM_INTERACT_START)`,
|
||||
/// and `ItemStack.causeUseVibration` posts only when the stack's use effects
|
||||
/// ask for interaction vibrations (the default). `stopUsingItem` posts the
|
||||
/// finish the same way, which Horizon already did, so a use is a pair of
|
||||
/// vibrations and an item that turns them off is neither.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_use_start_is_an_item_interact_start_vibration() {
|
||||
let bow = || ItemStack::new("minecraft:bow", 1);
|
||||
let Some(lines) = with_game("minecraft:bow", |game| {
|
||||
assert!(game.start_using_item(), "the bow's use starts");
|
||||
game.stop_using_item(&bow());
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"G 10,64,-4 minecraft:item_interact_start".to_owned(),
|
||||
"G 10,64,-4 minecraft:item_interact_finish".to_owned(),
|
||||
]
|
||||
);
|
||||
// A spear's use effects set `interact_vibrations` false: no vibration.
|
||||
let spear = || ItemStack::new("minecraft:iron_spear", 1);
|
||||
let Some(lines) = with_game("minecraft:iron_spear", |game| {
|
||||
assert!(game.start_using_item(), "the spear's use starts");
|
||||
game.stop_using_item(&spear());
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -325,6 +325,11 @@ impl Game<'_> {
|
||||
self.start_song(pos, &song);
|
||||
}
|
||||
if ticks % 20 == 0 {
|
||||
// `JukeboxSongPlayer.tick`: `gameEvent(JUKEBOX_PLAY, pos,
|
||||
// Context.of(state))` every twenty ticks while a song plays, for
|
||||
// the sculk listeners and the allays around it.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.post_block_game_event("minecraft:jukebox_play", pos, None, Some(state));
|
||||
let color = f64::from(self.world.random.next_int_bound(4).unwrap_or(0)) / 24.0;
|
||||
let at = [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
@@ -431,3 +436,100 @@ mod tests {
|
||||
assert!(!keeps_song(true, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod jukebox_play_tests {
|
||||
use super::*;
|
||||
|
||||
/// A windowless game from the prepared resources with a jukebox at
|
||||
/// (0, 64, 0), a disc in hand and its song started, ticked `ticks` times:
|
||||
/// the game events it posted, in order. `None` without the resources.
|
||||
fn playing(ticks: i32) -> Option<Vec<String>> {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES")?);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.ok()?;
|
||||
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();
|
||||
// `World::set` writes into a resident chunk only.
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let jukebox = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:jukebox")?;
|
||||
game.world.set(0, 64, 0, jukebox).unwrap();
|
||||
let disc = ItemStack::new("minecraft:music_disc_cat", 1);
|
||||
crate::game::dispense_fixture::start_recording();
|
||||
assert!(
|
||||
game.use_jukebox([0, 64, 0], Some(&disc)),
|
||||
"the disc goes in"
|
||||
);
|
||||
for _ in 0..ticks {
|
||||
game.tick_jukebox((0, 64, 0));
|
||||
}
|
||||
Some(crate::game::dispense_fixture::stop_recording())
|
||||
}
|
||||
|
||||
/// `JukeboxSongPlayer.tick` posts `JUKEBOX_PLAY` at the jukebox every
|
||||
/// `PLAY_EVENT_INTERVAL_TICKS` (20) ticks of a song, next to the note
|
||||
/// particle it already drew. Horizon drew the particle and kept the
|
||||
/// listeners deaf: a sculk sensor beside a playing jukebox never clicked.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_playing_jukebox_posts_jukebox_play_every_twenty_ticks() {
|
||||
let Some(lines) = playing(41) else { return };
|
||||
let plays: Vec<&String> = lines
|
||||
.iter()
|
||||
.filter(|l| l.ends_with("minecraft:jukebox_play"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
plays,
|
||||
vec![
|
||||
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
|
||||
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
|
||||
&"G 0,64,0 minecraft:jukebox_play".to_owned(),
|
||||
],
|
||||
"{lines:?}"
|
||||
);
|
||||
// The song is still playing after 41 ticks.
|
||||
assert!(
|
||||
lines.iter().all(|l| l.ends_with("minecraft:jukebox_play")),
|
||||
"{lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A jukebox whose song is stopped posts no play event: only the twenty
|
||||
/// ticks of a playing song do.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_stopped_jukebox_posts_no_play_event() {
|
||||
let Some(lines) = playing(0) else { return };
|
||||
assert!(lines.is_empty(), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -268,6 +268,8 @@ pub(super) struct LanHost {
|
||||
/// The entity id counter when this tick's states were taken for guests
|
||||
/// (`lan_note_joined`), None when no guest tracked them.
|
||||
pub(super) states_next_entity: Option<u32>,
|
||||
/// Last tick's states of the idle mobs and items (game/lan_idle.rs).
|
||||
pub(super) idle_states: super::lan_idle::IdleStates,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -1448,6 +1450,7 @@ impl Game<'_> {
|
||||
sleep_ignored: Default::default(),
|
||||
states: Default::default(),
|
||||
states_next_entity: None,
|
||||
idle_states: Default::default(),
|
||||
});
|
||||
self.audio.capture = Some(Vec::new());
|
||||
Ok(())
|
||||
@@ -1896,7 +1899,9 @@ impl Game<'_> {
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
/// Only the entities whose id is above `after` (0: all of them).
|
||||
/// Only the entities whose id is above `after` (0: all of them). With
|
||||
/// `kept`, idle mobs and items no client can pair with are described bare
|
||||
/// (game/lan_idle.rs).
|
||||
fn lan_entities(
|
||||
&mut self,
|
||||
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
@@ -1904,16 +1909,24 @@ impl Game<'_> {
|
||||
out: &mut States,
|
||||
drain_minecart_steps: bool,
|
||||
after: u32,
|
||||
mut kept: Option<&mut super::lan_idle::IdlePass<'_>>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut speeds = super::lan_attributes::SpeedByType::default();
|
||||
// ChunkMap.tick sends the changes of an entity outside the
|
||||
// entity-ticking range only when it changed section or needs sync.
|
||||
let levels = self.tickets.levels();
|
||||
let idle_at = |p: horizon_core::geometry::Vec3| {
|
||||
!levels.in_entity_ticking_range((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
};
|
||||
for m in self.mobs.iter().filter(|m| !m.removed && m.id > after) {
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id);
|
||||
let player_entity = || {
|
||||
(mob_players.get(&m.id))
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
};
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => match m.extra.leash_player {
|
||||
Some(u) => hub
|
||||
@@ -1921,21 +1934,37 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|g| playerdata::uuid_ints(g.uuid) == u)
|
||||
.map(|g| g.entity_id),
|
||||
None => player_entity,
|
||||
None => player_entity(),
|
||||
},
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
});
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
|
||||
e.position = [m.position.x, m.position.y, m.position.z];
|
||||
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
|
||||
e.on_ground = m.on_ground;
|
||||
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
|
||||
let idle = idle_at(m.position);
|
||||
let place = super::lan_idle::Place {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
rotation: (m.yaw, m.head_pitch, m.head_yaw),
|
||||
on_ground: m.on_ground,
|
||||
velocity: [m.velocity.x, m.velocity.y, m.velocity.z],
|
||||
};
|
||||
let kind = m.kind.id.as_str();
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut()).is_some_and(|k| k.bare(hub, m.id as i32, kind, &place))
|
||||
&& !m.needs_sync;
|
||||
let e = out.add(m.id as i32, entity_uuid(m.id), kind);
|
||||
e.position = place.position;
|
||||
(e.yaw, e.pitch, e.head_yaw) = place.rotation;
|
||||
e.on_ground = place.on_ground;
|
||||
e.velocity = place.velocity;
|
||||
e.needs_sync = m.needs_sync;
|
||||
(e.idle, e.bare) = (idle, bare);
|
||||
e.leash = leash;
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
mob_meta(
|
||||
m,
|
||||
hub,
|
||||
player_entity,
|
||||
player_entity(),
|
||||
self.world.game_time,
|
||||
&mut e.meta,
|
||||
&mut e.defaults,
|
||||
@@ -1946,14 +1975,28 @@ impl Game<'_> {
|
||||
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
|
||||
mob_equipment(m, hub, &mut e.equipment);
|
||||
self.mob_attributes(m, &mut speeds, &mut e.attributes);
|
||||
e.leash = leash;
|
||||
}
|
||||
for d in self.dropped.iter().filter(|d| d.id > after) {
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
let idle = idle_at(d.position);
|
||||
let place = super::lan_idle::Place {
|
||||
position: [d.position.x, d.position.y, d.position.z],
|
||||
rotation: (0.0, 0.0, 0.0),
|
||||
on_ground: d.on_ground,
|
||||
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
|
||||
};
|
||||
let bare = idle
|
||||
&& (kept.as_deref_mut())
|
||||
.is_some_and(|k| k.bare(hub, d.id as i32, "minecraft:item", &place))
|
||||
&& !d.needs_sync;
|
||||
let e = out.add(d.id as i32, entity_uuid(d.id), "minecraft:item");
|
||||
e.position = [d.position.x, d.position.y, d.position.z];
|
||||
e.on_ground = d.on_ground;
|
||||
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
|
||||
e.position = place.position;
|
||||
e.on_ground = place.on_ground;
|
||||
e.velocity = place.velocity;
|
||||
(e.idle, e.bare) = (idle, bare);
|
||||
if bare {
|
||||
continue;
|
||||
}
|
||||
let stack = hub.stack(&Some(d.stack.clone()));
|
||||
// `ServerEntity.getRequiresPrecisePosition` is false for an item:
|
||||
// `needs_sync` asks for its data again, not for an exact position.
|
||||
// The collision case is `full_precision_required` in the tracker.
|
||||
@@ -1970,6 +2013,7 @@ impl Game<'_> {
|
||||
for o in self.orbs.iter().filter(|o| o.id > after) {
|
||||
let e = out.add(o.id as i32, entity_uuid(o.id), "minecraft:experience_orb");
|
||||
e.position = [o.position.x, o.position.y, o.position.z];
|
||||
e.idle = idle_at(o.position);
|
||||
e.on_ground = o.on_ground;
|
||||
e.velocity = [o.velocity.x, o.velocity.y, o.velocity.z];
|
||||
// ExperienceOrb.DATA_VALUE.
|
||||
@@ -2460,7 +2504,14 @@ impl Game<'_> {
|
||||
self.lan_assign_ids();
|
||||
if self.next_entity != next {
|
||||
let mut states = States::default();
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, false, next);
|
||||
self.lan_entities(
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut states,
|
||||
false,
|
||||
next,
|
||||
None,
|
||||
);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
host.hub.note_joined(states.as_slice());
|
||||
@@ -2537,8 +2588,31 @@ impl Game<'_> {
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(&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.
|
||||
let mut kept = if self.level_pass.packets_drained {
|
||||
let percent = if host.hub.config.integrated {
|
||||
(self.settings.entity_distance * 100.0).round() as i32
|
||||
} else {
|
||||
host.hub.entity_range_percent
|
||||
};
|
||||
host.idle_states.pass(&host.hub, percent)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
horizon_client::spans::time("lan_entity_states", || {
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states, true, 0);
|
||||
self.lan_entities(
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut states,
|
||||
true,
|
||||
0,
|
||||
kept.as_mut(),
|
||||
);
|
||||
if let Some(kept) = kept {
|
||||
kept.finish();
|
||||
}
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
});
|
||||
@@ -2803,7 +2877,14 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs, false, 0);
|
||||
self.lan_entities(
|
||||
&host.mob_players,
|
||||
&mut host.hub,
|
||||
&mut theirs,
|
||||
false,
|
||||
0,
|
||||
None,
|
||||
);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
|
||||
@@ -1278,6 +1278,17 @@ impl Game<'_> {
|
||||
if pos.1 == super::minecarts::CART_KEY_Y {
|
||||
return self.lan_open_vehicle_container(host, conn, pos);
|
||||
}
|
||||
// `RandomizableContainer.unpackLootTable`: a guest opening a block
|
||||
// container rolls the table it still holds, as the host's own open does
|
||||
// (`unpack_opened_loot` no-ops when there is no roll), and the stat
|
||||
// fires for the acting guest.
|
||||
let opened = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name
|
||||
.to_string();
|
||||
self.unpack_opened_loot(pos, &opened);
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
@@ -1499,6 +1510,17 @@ impl Game<'_> {
|
||||
// loot table is still to roll, and the table stays.
|
||||
let spectator = host.hub.guest(conn).is_some_and(|g| g.spectator());
|
||||
if spectator && self.boat_loot_pending(self.boats[i].id) {
|
||||
// ServerPlayer.openMenu: the refused spectator's action bar, red.
|
||||
let message =
|
||||
horizon_client::text::Text::translate("container.spectatorCantOpen", &[])
|
||||
.with_color(horizon_client::text::RED);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: horizon_client::lan::data::component_nbt(&message.component()),
|
||||
overlay: true,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
self.unpack_boat_loot(i);
|
||||
@@ -3333,7 +3355,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
|
||||
if self.look_at_entity(host, gi, id, None).is_none() {
|
||||
let boat = self.boats.iter().position(|boat| boat.id as i32 == id);
|
||||
// A packet names its target already. Keep the attacker's actual yaw
|
||||
// for the boat's knockback instead of aiming a ray at its center.
|
||||
if boat.is_none() && self.look_at_entity(host, gi, id, None).is_none() {
|
||||
return;
|
||||
}
|
||||
// A mace's smash sets the attacker's velocity and sends it at once, before the
|
||||
@@ -3344,7 +3369,12 @@ impl Game<'_> {
|
||||
.player
|
||||
.as_mut()
|
||||
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
|
||||
g.attack();
|
||||
if let Some(index) = boat {
|
||||
g.strike_boat(index);
|
||||
g.swing_arm();
|
||||
} else {
|
||||
g.attack();
|
||||
}
|
||||
let p = g.player.as_mut()?;
|
||||
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
|
||||
sent.then_some(p.velocity)
|
||||
@@ -3680,12 +3710,10 @@ impl Game<'_> {
|
||||
let world = self
|
||||
.level
|
||||
.world_spawn
|
||||
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
|
||||
let spawn = host.hub.guests[i]
|
||||
.spawn
|
||||
.or(world)
|
||||
.map(|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5])
|
||||
.or(self.spawn.map(|s| [s.x, s.y, s.z]))
|
||||
.unwrap_or([0.5, 80.0, 0.5]);
|
||||
let spawn = host.hub.guests[i].spawn.unwrap_or(world);
|
||||
if !host.hub.guests[i].dead {
|
||||
return;
|
||||
}
|
||||
@@ -3780,7 +3808,9 @@ impl Game<'_> {
|
||||
world: &mut self.world,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
// PlayerList.respawn / sendLevelInfo send level respawn data,
|
||||
// independently of the personal destination passed to Hub::respawn.
|
||||
spawn: world,
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
difficulty_locked: self.level.difficulty_locked,
|
||||
clock_rate,
|
||||
@@ -4718,8 +4748,10 @@ impl Game<'_> {
|
||||
let mut events = std::mem::take(&mut w.player.events);
|
||||
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
|
||||
for event in &events {
|
||||
if let MotionEvent::CauldronExtinguish { pos } = event {
|
||||
game.cauldron_extinguish(*pos);
|
||||
match event {
|
||||
MotionEvent::CauldronExtinguish { pos } => game.cauldron_extinguish(*pos),
|
||||
MotionEvent::PowderSnowMelt { pos } => game.powder_snow_melt(*pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Entity.baseTick: rain puts out a burning player.
|
||||
@@ -4873,7 +4905,10 @@ impl Game<'_> {
|
||||
host.hub.entity_event(id, event as i8, at);
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
MotionEvent::Fall(event) => {
|
||||
let player = host.hub.guest(conn).map(|g| g.uuid);
|
||||
self.landing_block_updates(player, &event)
|
||||
}
|
||||
// PowderSnowBlock.entityInside: every player around sees the
|
||||
// snowflake (`ServerLevel.sendParticles`).
|
||||
MotionEvent::Snowflake { position, velocity } => {
|
||||
@@ -4892,6 +4927,11 @@ impl Game<'_> {
|
||||
}
|
||||
self.drown_particles(at, [0.0; 3]);
|
||||
}
|
||||
// ServerPlayer.onInsideBlock: the enter_block trigger, for the
|
||||
// player who moved.
|
||||
MotionEvent::EnterBlock { state } => {
|
||||
self.as_guest(host, conn, |game| game.trigger_enter_block(state));
|
||||
}
|
||||
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
|
||||
// others (Player.playSound excludes the player, whose client
|
||||
// plays it), the entity event 53 everyone around.
|
||||
@@ -6002,3 +6042,149 @@ mod spin_target_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod respawn_world_point_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::{configuration::ClientInformation, play::Clientbound};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
/// PlayerList.respawn sends world respawn data, independently of the personal landing.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn guest_respawn_keeps_world_information_distinct_from_personal_landing() {
|
||||
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-respawn-point-{}-{}",
|
||||
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();
|
||||
let world = [0, 100, 0];
|
||||
let personal = [5.5, 101.0, 5.5];
|
||||
game.level.world_spawn = Some(world);
|
||||
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, "respawn-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: 330,
|
||||
name: "RespawnGuest".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,
|
||||
},
|
||||
2,
|
||||
[0.5, 100.0, 0.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.spawn = Some(personal);
|
||||
guest.health = 0.0;
|
||||
guest.dead = true;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
game.guest_respawn(&mut host, 2);
|
||||
let guest = &host.hub.guests[0];
|
||||
assert_eq!(guest.uuid, 330);
|
||||
assert_eq!(guest.position, personal);
|
||||
assert_eq!(guest.health, 20.0);
|
||||
assert!(!guest.dead);
|
||||
let mut packets = Vec::new();
|
||||
for out in rx.try_iter() {
|
||||
match out {
|
||||
Out::Packet(body) => packets.push(body),
|
||||
Out::Packets(bodies) => packets.extend(bodies),
|
||||
Out::Disconnect(_) => panic!("unexpected disconnect"),
|
||||
}
|
||||
}
|
||||
let mut sequence = Vec::new();
|
||||
let mut points = Vec::new();
|
||||
for body in packets {
|
||||
let mut r = horizon_protocol::buf::Reader::new(&body);
|
||||
let id = r.varint().unwrap();
|
||||
match Clientbound::decode(id, r.rest()).unwrap() {
|
||||
Clientbound::Respawn(_) => sequence.push(84),
|
||||
Clientbound::SetDefaultSpawnPosition(p) => {
|
||||
sequence.push(99);
|
||||
points.push((
|
||||
p.pos.dimension,
|
||||
p.pos.pos,
|
||||
p.yaw.to_bits(),
|
||||
p.pitch.to_bits(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(sequence, [84, 99, 99]);
|
||||
assert_eq!(points, vec![("minecraft:overworld".into(), world, 0, 0); 2]);
|
||||
drop(host);
|
||||
drop(game);
|
||||
std::fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
needs_sync: false,
|
||||
idle: false,
|
||||
bare: false,
|
||||
leash: None,
|
||||
spectator: false,
|
||||
watching: false,
|
||||
@@ -525,7 +527,8 @@ impl Game<'_> {
|
||||
/// selectors use, so a client can name it back (the crosshair UUID that
|
||||
/// `ClientSuggestionProvider.getSelectedEntities` suggests).
|
||||
pub(super) fn lan_entity_uuids(&mut self, states: &mut States) {
|
||||
for e in states.as_mut_slice() {
|
||||
// A bare state is never paired: its UUID is never read.
|
||||
for e in states.as_mut_slice().iter_mut().filter(|e| !e.bare) {
|
||||
e.uuid = ints_uuid(self.uuid_for(e.id as u32));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Idle mobs and items that need no new description this tick (#10253).
|
||||
//!
|
||||
//! An entity outside the entity-ticking range does not tick, and `ChunkMap.tick` does not send
|
||||
//! its changes (`EntityState::idle`) unless it changed section or needs sync. While it stays
|
||||
//! where it was, its state is read only for where it is, and to pair it with a client that
|
||||
//! starts tracking it. So such an entity is described bare (`EntityState::bare`: where it is,
|
||||
//! none of its data) when no client could pair with it this tick: it was in the tracker's last
|
||||
//! pass, has not moved, carries no passengers, and every client within its tracking range
|
||||
//! already tracks it. Otherwise it is described in full, so a pairing always sends the entity
|
||||
//! as it is.
|
||||
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::lan::{hub::Hub, tracking};
|
||||
|
||||
/// Per level, each idle entity's place and the tracking pass it was last described in.
|
||||
#[derive(Default)]
|
||||
pub(super) struct IdleStates {
|
||||
levels: FxHashMap<String, FxHashMap<i32, (u32, Place)>>,
|
||||
}
|
||||
|
||||
/// Where an entity is: its position, rotation (yaw, pitch, head yaw), whether
|
||||
/// it is on the ground, and its velocity.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) struct Place {
|
||||
pub position: [f64; 3],
|
||||
pub rotation: (f32, f32, f32),
|
||||
pub on_ground: bool,
|
||||
pub velocity: [f64; 3],
|
||||
}
|
||||
|
||||
/// The idle entities of the level in place, for one pass.
|
||||
pub(super) struct IdlePass<'a> {
|
||||
kept: &'a mut FxHashMap<i32, (u32, Place)>,
|
||||
/// The level's tracking passes so far (`Hub::tracking_ticks`). An entity
|
||||
/// described one pass earlier was in the tracker's last pass.
|
||||
now: u32,
|
||||
/// The players the level's tracker runs for (`Hub::watches`: their
|
||||
/// index), where they are horizontally, and their view distance in blocks:
|
||||
/// the clients that could pair.
|
||||
viewers: Vec<(usize, [f64; 2], i32)>,
|
||||
/// `entity-broadcast-range-percentage`, as the tracker scales ranges.
|
||||
percent: i32,
|
||||
}
|
||||
|
||||
impl IdleStates {
|
||||
/// The level in place's idle entities, or None before its tracking ran.
|
||||
pub(super) fn pass(&mut self, hub: &Hub, percent: i32) -> Option<IdlePass<'_>> {
|
||||
let now = hub.tracking_ticks()?;
|
||||
let viewers = (hub.guests.iter().enumerate())
|
||||
.filter(|(_, g)| hub.watches(g))
|
||||
.map(|(i, g)| (i, [g.position[0], g.position[2]], g.view_distance * 16))
|
||||
.collect();
|
||||
let kept = self.levels.entry(hub.config.dimension.clone()).or_default();
|
||||
Some(IdlePass {
|
||||
kept,
|
||||
now,
|
||||
viewers,
|
||||
percent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IdlePass<'_> {
|
||||
/// Whether the idle entity `id` of type `kind`, now at `at`, can be
|
||||
/// described bare: it was in the tracker's last pass at the same place,
|
||||
/// carries no passengers (whose ranges widen its own), and every client
|
||||
/// within its tracking range already tracks it. Notes it either way.
|
||||
pub(super) fn bare(&mut self, hub: &Hub, id: i32, kind: &str, at: &Place) -> bool {
|
||||
self.unchanged(id, kind, at, |i| hub.guests[i].tracker.is_tracking(id))
|
||||
&& hub.riding.passengers(id).is_empty()
|
||||
}
|
||||
|
||||
/// [`Self::bare`] but for the passengers, with whether the player `i`
|
||||
/// tracks the entity.
|
||||
fn unchanged(
|
||||
&mut self,
|
||||
id: i32,
|
||||
kind: &str,
|
||||
at: &Place,
|
||||
tracks: impl Fn(usize) -> bool,
|
||||
) -> bool {
|
||||
// A new one reads as noted two passes ago.
|
||||
let now = self.now;
|
||||
let noted = self.kept.entry(id).or_insert((now.wrapping_sub(2), *at));
|
||||
let still = noted.0.wrapping_add(1) == now && noted.1 == *at;
|
||||
*noted = (now, *at);
|
||||
if !still {
|
||||
return false;
|
||||
}
|
||||
// The tracker's `sees`, but for the chunk it also asks the client to have.
|
||||
let range = self.percent * (i32::from(tracking::settings(kind).0) * 16) / 100;
|
||||
!self.viewers.iter().any(|&(i, [x, z], view)| {
|
||||
let range = f64::from(range.min(view));
|
||||
let (dx, dz) = (x - at.position[0], z - at.position[2]);
|
||||
dx * dx + dz * dz <= range * range && !tracks(i)
|
||||
})
|
||||
}
|
||||
|
||||
/// Forget the entities this pass did not note: they left, or tick again.
|
||||
pub(super) fn finish(self) {
|
||||
let now = self.now;
|
||||
self.kept.retain(|_, (stamp, _)| *stamp == now);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn place(x: f64) -> Place {
|
||||
Place {
|
||||
position: [x, 64.0, 0.0],
|
||||
rotation: (0.0, 0.0, 0.0),
|
||||
on_ground: true,
|
||||
velocity: [0.0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
fn pass(kept: &mut FxHashMap<i32, (u32, Place)>, now: u32) -> IdlePass<'_> {
|
||||
// One client at the origin, one 400 blocks away; both see 10 chunks.
|
||||
IdlePass {
|
||||
kept,
|
||||
now,
|
||||
viewers: vec![(0, [0.0, 0.0], 160), (1, [400.0, 0.0], 160)],
|
||||
percent: 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// An idle cow (tracked 10 chunks away) is described bare while it stays
|
||||
/// put and every client in its range tracks it; in full when a client
|
||||
/// there could pair with it, when it moved, and after a pass it was not in.
|
||||
#[test]
|
||||
fn an_idle_entity_is_bare_until_a_client_could_pair_or_it_moves() {
|
||||
let mut kept = FxHashMap::default();
|
||||
let cow = "minecraft:cow";
|
||||
let at = place(100.0);
|
||||
// Its first pass describes it in full.
|
||||
assert!(!pass(&mut kept, 5).unchanged(7, cow, &at, |_| true));
|
||||
// Tracked by the client in range; the far one can't pair.
|
||||
let tracked = |i| i == 0;
|
||||
assert!(pass(&mut kept, 6).unchanged(7, cow, &at, tracked));
|
||||
// The client in range doesn't track it yet: it could pair.
|
||||
assert!(!pass(&mut kept, 7).unchanged(7, cow, &at, |_| false));
|
||||
// 100 blocks is past an item's 6 chunks: no client could pair.
|
||||
assert!(pass(&mut kept, 8).unchanged(7, "minecraft:item", &at, |_| false));
|
||||
// It moved.
|
||||
assert!(!pass(&mut kept, 9).unchanged(7, cow, &place(100.5), tracked));
|
||||
// A pass went by without it.
|
||||
assert!(!pass(&mut kept, 11).unchanged(7, cow, &place(100.5), tracked));
|
||||
// Only what a pass noted stays.
|
||||
pass(&mut kept, 12).finish();
|
||||
assert!(kept.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -68,13 +68,10 @@ pub(super) fn player_for(
|
||||
if m.extra.leash == Some(horizon_client::mobs::leash::Holder::Player)
|
||||
&& let Some(holder) = m.extra.leash_player
|
||||
{
|
||||
if host.is_some_and(|v| v.alive && !v.spectator && v.uuid == holder) {
|
||||
if host.is_some_and(|v| v.uuid == holder) {
|
||||
return None;
|
||||
}
|
||||
if let Some((conn, _)) = guests
|
||||
.iter()
|
||||
.find(|(_, v)| v.alive && !v.spectator && v.uuid == holder)
|
||||
{
|
||||
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.uuid == holder) {
|
||||
return Some(*conn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,9 +492,9 @@ impl Game<'_> {
|
||||
}
|
||||
gs.last_sound = self.ticks;
|
||||
let at = gs.pos.map(|v| f64::from(v) + 0.5);
|
||||
for sound in sounds {
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
// The menu's `lastSoundTime` lets one sound through per tick, however
|
||||
// many results a shift-click took.
|
||||
self.audio.play(sounds[0], Some(at), 1.0, 1.0);
|
||||
}
|
||||
|
||||
/// A click on the guest's station window; false for other windows.
|
||||
|
||||
@@ -561,6 +561,7 @@ impl Game<'_> {
|
||||
};
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == vehicle) {
|
||||
b.paddling = paddles;
|
||||
b.paddle_controller = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,8 +430,11 @@ impl Game<'_> {
|
||||
self.mobs[index].extra.leash = Some(Holder::Player);
|
||||
self.mobs[index].extra.leash_player = Some(holder);
|
||||
self.lead_sound("tied", at);
|
||||
// The lead is always used up.
|
||||
if let Some(stack) = self.held_mut().as_mut() {
|
||||
// `Player.interactOn` restores the count of a player with
|
||||
// infinite materials (creative); others spend the lead.
|
||||
if !self.creative
|
||||
&& let Some(stack) = self.held_mut().as_mut()
|
||||
{
|
||||
stack.count -= 1;
|
||||
if stack.count == 0 {
|
||||
*self.held_mut() = None;
|
||||
|
||||
@@ -1275,6 +1275,8 @@ impl Game<'_> {
|
||||
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
|
||||
/// hopper minecart: one item from the container above, else the item
|
||||
/// entities over it or touching it, each tick without a cooldown.
|
||||
/// `consumedItemThisFrame` in 26.3 caps a tick at one item: the tick tries
|
||||
/// once, and `makeStepAlongTrack` may try again, but only one succeeds.
|
||||
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
|
||||
// The cart's slots are edited as a container under the placeholder key
|
||||
// its menu uses; when that menu is open the placeholder is the live copy.
|
||||
@@ -1702,3 +1704,80 @@ mod mod_container_tests {
|
||||
assert_eq!(view.slots_for_face(None), [0, 1, 2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hopper_cart_transfer_tests {
|
||||
use super::super::{
|
||||
minecarts::{CartKind, Minecart},
|
||||
native_machines::tests::server_game,
|
||||
};
|
||||
use super::Game;
|
||||
use horizon_client::{containers::BlockEntity, items::ItemStack};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// Issue #8668: a hopper minecart takes one item a tick from the container
|
||||
/// above. `MinecartHopper.tick` has no transfer cooldown in 26.3: it calls
|
||||
/// `tryConsumeItems` every tick, and `consumedItemThisFrame` caps a tick at
|
||||
/// one item even though `makeStepAlongTrack` may try too. A whole stack a
|
||||
/// tick (the old behaviour) or a block hopper's eight-tick cooldown both
|
||||
/// fail this.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn hopper_cart_takes_one_item_a_tick() {
|
||||
let mut game = server_game();
|
||||
let rail = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:rail")
|
||||
.unwrap();
|
||||
let chest = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:chest")
|
||||
.unwrap();
|
||||
// A rail holds the cart still; the chest sits in the block the cart
|
||||
// pulls from (`getLevelY` is y + 0.5, so the block above is y + 1.5).
|
||||
game.world.set(8, 64, 8, rail).unwrap();
|
||||
game.world.set(8, 65, 8, chest).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
(8, 65, 8),
|
||||
BlockEntity::Chest(vec![Some(ItemStack::new("minecraft:stone", 64)); 27]),
|
||||
);
|
||||
game.next_entity += 1;
|
||||
let id = game.next_entity;
|
||||
let mut cart = Minecart::new(id, CartKind::Hopper, Vec3::new(8.5, 64.0625, 8.5));
|
||||
cart.enabled = true;
|
||||
game.carts.push(cart);
|
||||
let count = |game: &Game<'_>| {
|
||||
let in_chest: u32 = match game.world.block_entities.get(&(8, 65, 8)) {
|
||||
Some(BlockEntity::Chest(s)) => s.iter().flatten().map(|s| s.count).sum(),
|
||||
_ => 0,
|
||||
};
|
||||
let in_cart: u32 = game
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|s| s.count)
|
||||
.sum();
|
||||
(in_chest, in_cart)
|
||||
};
|
||||
let (before_chest, before_cart) = count(&game);
|
||||
for _ in 0..20 {
|
||||
game.tick_carts();
|
||||
}
|
||||
let (after_chest, after_cart) = count(&game);
|
||||
assert_eq!(before_chest, 27 * 64);
|
||||
assert_eq!(before_cart, 0);
|
||||
assert_eq!(before_chest - after_chest, after_cart - before_cart);
|
||||
assert_eq!(
|
||||
after_cart, 20,
|
||||
"one item a tick, not a stack nor eight ticks"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1171,9 +1171,7 @@ impl Game<'_> {
|
||||
let Some(id) = map_id(stack) else {
|
||||
return false;
|
||||
};
|
||||
// ItemStack.useOn passes in adventure mode (its `can_place_on`
|
||||
// exception is not ported).
|
||||
if !self.may_build() {
|
||||
if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = hit.pos;
|
||||
@@ -2012,4 +2010,107 @@ mod tests {
|
||||
assert_eq!(clamp_map_coordinate(-70.0), -128);
|
||||
assert_eq!(clamp_map_coordinate(10.25), 21);
|
||||
}
|
||||
|
||||
/// `ItemStack.useOn` / `MapItem.useOn`: in adventure mode, a map whose
|
||||
/// `can_place_on` component includes the banner block toggles that banner's
|
||||
/// marker, while one without does not.
|
||||
#[test]
|
||||
fn map_on_banner_in_adventure_mode_honours_can_place_on() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, command::args::GameMode};
|
||||
use std::sync::Arc;
|
||||
|
||||
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
|
||||
return;
|
||||
};
|
||||
let root = std::path::PathBuf::from(root);
|
||||
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 pos = [0, 64, 0];
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let banner_state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:red_banner")
|
||||
.unwrap();
|
||||
game.world
|
||||
.set(pos[0], pos[1], pos[2], banner_state)
|
||||
.unwrap();
|
||||
game.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
horizon_client::containers::BlockEntity::Banner(horizon_client::containers::Banner {
|
||||
layers: Vec::new(),
|
||||
name: None,
|
||||
components: Default::default(),
|
||||
}),
|
||||
);
|
||||
|
||||
let map_id = 42;
|
||||
game.maps
|
||||
.data
|
||||
.insert(map_id, MapData::fresh(0.0, 0.0, 0, "minecraft:overworld"));
|
||||
game.set_mode(GameMode::Adventure);
|
||||
let hit = super::super::Hit {
|
||||
pos,
|
||||
face: [0, 1, 0],
|
||||
point: [
|
||||
pos[0] as f64 + 0.5,
|
||||
pos[1] as f64 + 0.5,
|
||||
pos[2] as f64 + 0.5,
|
||||
],
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:filled_map", 1);
|
||||
stack.data_mut().map_id = Some(map_id);
|
||||
|
||||
// Without can_place_on, use_map_on_banner fails in adventure mode.
|
||||
*game.held_mut() = Some(stack.clone());
|
||||
assert!(!game.use_map_on_banner(&hit, &stack));
|
||||
assert!(game.map(map_id).unwrap().banners.is_empty());
|
||||
|
||||
// With can_place_on listing the banner block, the marker toggles.
|
||||
stack.data_mut().other.push((
|
||||
"minecraft:can_place_on".into(),
|
||||
Tag::compound([(
|
||||
"blocks",
|
||||
Tag::List(vec![Tag::String("minecraft:red_banner".into())]),
|
||||
)]),
|
||||
));
|
||||
*game.held_mut() = Some(stack.clone());
|
||||
assert!(game.use_map_on_banner(&hit, &stack));
|
||||
assert_eq!(game.map(map_id).unwrap().banners.len(), 1);
|
||||
assert_eq!(game.map(map_id).unwrap().banners[0].pos, pos);
|
||||
assert_eq!(game.map(map_id).unwrap().banners[0].color, "red");
|
||||
|
||||
// Second use toggles the marker off.
|
||||
assert!(game.use_map_on_banner(&hit, &stack));
|
||||
assert!(game.map(map_id).unwrap().banners.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,7 @@ impl Game<'_> {
|
||||
// turns nearly all of them away: it is tested first, before a mob's
|
||||
// pushable state, and only the boxes kept are collected.
|
||||
let close = |b: &Aabb| b.intersects(near);
|
||||
let reach = self.vehicle_mob_reach();
|
||||
let mut boxes: Vec<Aabb> = self
|
||||
.carts
|
||||
.iter()
|
||||
@@ -600,10 +601,12 @@ impl Game<'_> {
|
||||
.chain(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| !m.removed && m.extra.mount != Some(c.id))
|
||||
.filter(|m| reach.is_none_or(|r| r.may_meet(m, near)))
|
||||
.map(|m| (m, m.bounds()))
|
||||
.filter(|(m, b)| {
|
||||
close(b)
|
||||
&& !m.removed
|
||||
&& m.extra.mount != Some(c.id)
|
||||
&& (m.pushable()
|
||||
|| m.species() == horizon_client::mobs::Species::Shulker
|
||||
&& m.alive())
|
||||
@@ -1752,17 +1755,17 @@ impl Game<'_> {
|
||||
}
|
||||
/// `positionRider` for the mobs in minecarts: each sits on the cart's
|
||||
/// passenger attachment, less its own vehicle attachment.
|
||||
pub(super) fn seat_cart_mobs(&mut self) {
|
||||
pub(super) fn seat_cart_mobs(&mut self, riding: &super::passengers::Riders) {
|
||||
for i in 0..self.carts.len() {
|
||||
let id = self.carts[i].id;
|
||||
let Some(js) = riding.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let (pos, prev) = (self.carts[i].position, self.carts[i].previous);
|
||||
let seats: Vec<(usize, f64)> = self
|
||||
.mobs
|
||||
let seats: Vec<(usize, f64)> = js
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.extra.mount == Some(id))
|
||||
// Minecart's box is 0.98 wide.
|
||||
.map(|(j, m)| (j, riders::vehicle_attachment(m, 0.98)))
|
||||
.map(|&j| (j, riders::vehicle_attachment(&self.mobs[j], 0.98)))
|
||||
.collect();
|
||||
for (j, offset) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
@@ -1793,6 +1796,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// Every cart but the ridden one (which ticks with its rider).
|
||||
pub(super) fn tick_carts(&mut self) {
|
||||
self.vehicles_ticking(true);
|
||||
self.tick_carts_now();
|
||||
self.vehicles_ticking(false);
|
||||
}
|
||||
fn tick_carts_now(&mut self) {
|
||||
self.push_carts();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
|
||||
@@ -28,18 +28,26 @@ impl Game<'_> {
|
||||
let mut near = super::item_areas::ItemAreas::default();
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.can_pick_up_loot() || !m.alive() || m.removed {
|
||||
if !m.can_pick_up_loot()
|
||||
|| !m.alive()
|
||||
|| m.removed
|
||||
|| !self.creatures.ticked.contains(&m.id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// `Mob.ITEM_PICKUP_REACH`.
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
if !near.may_meet(&self.dropped, self.next_entity, reach) {
|
||||
let mut scan = near.scan(&self.dropped, self.next_entity, reach);
|
||||
if scan.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (species, baby, passenger) = (m.species(), m.is_baby(), m.extra.passenger);
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
loop {
|
||||
j = scan.next_from(j, self.dropped.len());
|
||||
let Some(e) = self.dropped.get(j) else {
|
||||
break;
|
||||
};
|
||||
if e.pickup_delay > 0 || !reach.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
@@ -70,6 +78,7 @@ impl Game<'_> {
|
||||
e.stack.count -= taken;
|
||||
if e.stack.count == 0 {
|
||||
self.remove_dropped(j);
|
||||
scan.removed(j);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
@@ -132,8 +141,22 @@ impl Game<'_> {
|
||||
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
|
||||
return false;
|
||||
}
|
||||
let new = self.stack_facts(m, stack, slot);
|
||||
let worn = self.stack_facts(m, &worn_stack, slot);
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
self.items().item_in_tag(tag, &stack.item),
|
||||
self.items().item_in_tag(tag, ¤t),
|
||||
)
|
||||
});
|
||||
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
|
||||
}
|
||||
|
||||
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
|
||||
/// in `slot` for the mob `m`.
|
||||
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
|
||||
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
|
||||
let new = Facts {
|
||||
Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
@@ -143,24 +166,7 @@ impl Game<'_> {
|
||||
// `EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE` is the Curse of
|
||||
// Binding's, the only enchantment that carries one.
|
||||
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let (armor, toughness, attack) = self.attributes(m, ¤t, slot);
|
||||
let worn = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: worn_stack.enchantments().len(),
|
||||
damage: worn_stack.damage as i32,
|
||||
custom_name: worn_stack.custom_name().is_some(),
|
||||
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
self.items().item_in_tag(tag, &stack.item),
|
||||
self.items().item_in_tag(tag, ¤t),
|
||||
)
|
||||
});
|
||||
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
|
||||
|
||||
@@ -79,6 +79,13 @@ pub(super) fn register_standalone_models() {
|
||||
Game::native_standalone_models(&mut names);
|
||||
horizon_client::models::register_standalone(names);
|
||||
}
|
||||
/// The native mods' entity model layers (`native_layer_definitions`), kept
|
||||
/// once at start for `native_bake_layer`.
|
||||
pub(super) fn register_layer_definitions() {
|
||||
let mut layers = Vec::new();
|
||||
Game::native_layer_definitions(&mut layers);
|
||||
horizon_client::mobs::model_layers::register(layers);
|
||||
}
|
||||
|
||||
/// `root` with the content mods that are on layered over it
|
||||
/// (docs/modpacks.md): their blocks, items, data and assets. Read once at
|
||||
@@ -667,7 +674,8 @@ mod tests {
|
||||
Mob::new(2, ghoul.clone(), at, 0.0),
|
||||
);
|
||||
assert_eq!(ghoul.health, 40.0);
|
||||
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
|
||||
let saved =
|
||||
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
|
||||
let keys = |tag: &horizon_world::nbt::Tag| -> Vec<String> {
|
||||
let horizon_world::nbt::Tag::Compound(map) = tag else {
|
||||
panic!("a mob saves a compound");
|
||||
@@ -718,7 +726,8 @@ mod tests {
|
||||
let trader = Mob::new(1, types["minecraft:wandering_trader"].clone(), at, 0.0);
|
||||
let merchant = Mob::new(2, merchant.clone(), at, 0.0);
|
||||
assert!(merchant.ai.goals.has("Float") && trader.ai.goals.has("Float"));
|
||||
let saved = |m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None);
|
||||
let saved =
|
||||
|m: &Mob| horizon_client::entity_nbt::mob_to_nbt(m, [0, 0, 0, 2], None, None, |_| None);
|
||||
let keys = |tag: &Tag| -> Vec<String> {
|
||||
let Tag::Compound(map) = tag else {
|
||||
panic!("a mob saves a compound");
|
||||
|
||||
@@ -31,7 +31,7 @@ fn stick_on_break(stack: &ItemStack, wear: u32, max: u32) -> Option<ItemStack> {
|
||||
Some(rod)
|
||||
}
|
||||
|
||||
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
|
||||
pub(super) fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
|
||||
let item = match species {
|
||||
Species::Pig => "minecraft:carrot_on_a_stick",
|
||||
Species::Strider => "minecraft:warped_fungus_on_a_stick",
|
||||
|
||||
@@ -8,13 +8,112 @@
|
||||
//! and the player's own in third person with the `ownNameTag` extra.
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, mod_api,
|
||||
save::uuid_text, scoreboard as sb, text::Text,
|
||||
block_entity_render::vertex_light,
|
||||
hud::{HudAtlas, HudBatch, HudVertex},
|
||||
mesher::Vertex,
|
||||
mobs::Mob,
|
||||
mod_api,
|
||||
save::uuid_text,
|
||||
scoreboard as sb,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
|
||||
pub(super) const NAME_TAG_UNIT: f32 = 0.025;
|
||||
|
||||
/// Local glyph quads only: camera transforms, lighting and cosmetic marks stay per frame.
|
||||
/// Slots follow submission order; comparing the whole styled text also handles entity reorder,
|
||||
/// team/score changes and native name hooks without keeping entity IDs alive.
|
||||
#[derive(Default)]
|
||||
pub(super) struct NameTagLayouts {
|
||||
epoch: (u64, u64, bool),
|
||||
next: usize,
|
||||
lines: Vec<Option<NameTagLine>>,
|
||||
}
|
||||
|
||||
struct NameTagLine {
|
||||
text: Text,
|
||||
alpha: u32,
|
||||
background: bool,
|
||||
y: f32,
|
||||
behind: usize,
|
||||
vertices: Vec<HudVertex>,
|
||||
}
|
||||
|
||||
impl NameTagLayouts {
|
||||
fn begin_frame(&mut self, atlas: &HudAtlas) {
|
||||
let epoch = (
|
||||
atlas.revision,
|
||||
atlas.font.epoch(),
|
||||
horizon_client::lang::bidirectional(),
|
||||
);
|
||||
if self.epoch != epoch {
|
||||
self.lines.clear();
|
||||
self.epoch = epoch;
|
||||
}
|
||||
self.next = 0;
|
||||
}
|
||||
|
||||
fn end_frame(&mut self) {
|
||||
self.lines.truncate(self.next);
|
||||
}
|
||||
|
||||
fn name_tag(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
y: f32,
|
||||
alpha: u32,
|
||||
background: bool,
|
||||
) -> usize {
|
||||
// At most 512 * 512 HUD vertices (~10 MiB), plus short text keys. Longer tags and
|
||||
// excess lines still render normally. Animated/random glyphs and live inline skins
|
||||
// must go through the font each time, including legacy formatting supplied by hooks.
|
||||
const MAX_LINES: usize = 512;
|
||||
const MAX_VERTICES: usize = 512;
|
||||
let slot = self.next;
|
||||
self.next += 1;
|
||||
let stable =
|
||||
text.resolved().spans.iter().all(|span| {
|
||||
span.style.obfuscated != Some(true) && span.style.inline_object.is_none()
|
||||
}) && text.spans.iter().map(|s| s.text.len()).sum::<usize>() <= 1024;
|
||||
if slot < MAX_LINES
|
||||
&& stable
|
||||
&& let Some(Some(line)) = self.lines.get(slot)
|
||||
&& line.text == *text
|
||||
&& line.alpha == alpha
|
||||
&& line.background == background
|
||||
&& line.y == y
|
||||
{
|
||||
batch.vertices.extend_from_slice(&line.vertices);
|
||||
return line.behind;
|
||||
}
|
||||
let behind = batch.name_tag(
|
||||
atlas,
|
||||
text,
|
||||
y,
|
||||
alpha << 24 | 0xFF_FFFF,
|
||||
if background { 0x3F00_0000 } else { 0 },
|
||||
);
|
||||
if slot < MAX_LINES {
|
||||
if self.lines.len() <= slot {
|
||||
self.lines.resize_with(slot + 1, || None);
|
||||
}
|
||||
self.lines[slot] =
|
||||
(stable && batch.vertices.len() <= MAX_VERTICES).then(|| NameTagLine {
|
||||
text: text.clone(),
|
||||
alpha,
|
||||
background,
|
||||
y,
|
||||
behind,
|
||||
vertices: batch.vertices.clone(),
|
||||
});
|
||||
}
|
||||
behind
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A named name tag names the mob under the crosshair.
|
||||
pub(super) fn name_mob(&mut self, index: usize) -> bool {
|
||||
@@ -158,9 +257,7 @@ impl Game<'_> {
|
||||
let local_entity_id = self.remote.as_ref().map(|r| r.entity_id as u32);
|
||||
let ridden = collect_vehicle_ids(
|
||||
&self.mobs,
|
||||
self.text_displays.values().filter_map(|d| d.vehicle),
|
||||
self.armor_stands.iter().filter_map(|a| a.vehicle),
|
||||
self.interactions.values().filter_map(|i| i.vehicle),
|
||||
self.remote_vehicle_ids(),
|
||||
self.riding,
|
||||
local_entity_id,
|
||||
);
|
||||
@@ -471,6 +568,8 @@ impl Game<'_> {
|
||||
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
|
||||
const FADED: u32 = 0x7F;
|
||||
let mut patches = Vec::new();
|
||||
let mut layouts = std::mem::take(&mut self.name_tag_layouts);
|
||||
layouts.begin_frame(&self.gui.atlas);
|
||||
for tag in self.name_tags(camera, alpha) {
|
||||
let o = tag.at - camera;
|
||||
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
|
||||
@@ -488,6 +587,7 @@ impl Game<'_> {
|
||||
NAME_TAG_UNIT,
|
||||
out,
|
||||
tag.marks,
|
||||
Some(&mut layouts),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -505,6 +605,7 @@ impl Game<'_> {
|
||||
NAME_TAG_UNIT,
|
||||
out,
|
||||
tag.marks,
|
||||
Some(&mut layouts),
|
||||
));
|
||||
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
|
||||
patches.extend(self.styled_name_tag_quads(
|
||||
@@ -517,8 +618,11 @@ impl Game<'_> {
|
||||
NAME_TAG_UNIT,
|
||||
see_through,
|
||||
tag.marks,
|
||||
Some(&mut layouts),
|
||||
));
|
||||
}
|
||||
layouts.end_frame();
|
||||
self.name_tag_layouts = layouts;
|
||||
self.map_patches.extend(patches);
|
||||
}
|
||||
/// The lines of one name tag, `o` from the camera: the text at
|
||||
@@ -547,6 +651,7 @@ impl Game<'_> {
|
||||
unit,
|
||||
out,
|
||||
Marks::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -565,6 +670,7 @@ impl Game<'_> {
|
||||
unit: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
marks: Marks,
|
||||
mut layouts: Option<&mut NameTagLayouts>,
|
||||
) -> Vec<horizon_client::font::Patch> {
|
||||
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
|
||||
const BACKGROUND: u32 = 0x3F00_0000;
|
||||
@@ -597,13 +703,23 @@ impl Game<'_> {
|
||||
batch.vertices.clear();
|
||||
// Font.prepareText, as any text: bold, italic, fonts and each
|
||||
// glyph's own box (accented.png's are 12 pixels tall).
|
||||
let behind = batch.name_tag(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha << 24 | 0xFF_FFFF,
|
||||
if background { BACKGROUND } else { 0 },
|
||||
);
|
||||
let behind = match layouts.as_deref_mut() {
|
||||
Some(layouts) => layouts.name_tag(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha,
|
||||
background,
|
||||
),
|
||||
None => batch.name_tag(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
y0,
|
||||
text_alpha << 24 | 0xFF_FFFF,
|
||||
if background { BACKGROUND } else { 0 },
|
||||
),
|
||||
};
|
||||
if n == last && marks.horizon {
|
||||
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
|
||||
// 8 text pixels square, a pixel left of the background.
|
||||
@@ -662,7 +778,9 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Collect entity IDs of all vehicles: entities that have passengers
|
||||
/// (mobs, players, text displays, armor stands, or interactions riding them).
|
||||
/// (`passenger_vehicles`: what every entity the server sent rides, text displays,
|
||||
/// armor stands, item displays and interactions included; vanilla's `isVehicle` is
|
||||
/// any passenger at all).
|
||||
///
|
||||
/// On multiplayer servers (e.g. `mcpvp.club`), server plugins hang custom nametags
|
||||
/// (ranks, prefixes, stats) over players using `TextDisplay` or `ArmorStand` passengers.
|
||||
@@ -674,9 +792,7 @@ impl Game<'_> {
|
||||
/// is ridden and suppresses the duplicate tag (#9477).
|
||||
fn collect_vehicle_ids(
|
||||
mobs: &[Mob],
|
||||
text_display_vehicles: impl IntoIterator<Item = u32>,
|
||||
armor_stand_vehicles: impl IntoIterator<Item = u32>,
|
||||
interaction_vehicles: impl IntoIterator<Item = u32>,
|
||||
passenger_vehicles: impl IntoIterator<Item = u32>,
|
||||
local_riding: Option<u32>,
|
||||
local_entity_id: Option<u32>,
|
||||
) -> std::collections::HashSet<u32> {
|
||||
@@ -684,9 +800,7 @@ fn collect_vehicle_ids(
|
||||
.iter()
|
||||
.filter_map(|o| o.extra.mount)
|
||||
.chain(mobs.iter().filter_map(|m| m.extra.rider.map(|_| m.id)))
|
||||
.chain(text_display_vehicles)
|
||||
.chain(armor_stand_vehicles)
|
||||
.chain(interaction_vehicles)
|
||||
.chain(passenger_vehicles)
|
||||
.collect();
|
||||
if let Some(riding) = local_riding {
|
||||
ridden.insert(riding);
|
||||
@@ -781,6 +895,135 @@ mod tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn tag_font() -> HudAtlas {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-tag-cache-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
));
|
||||
let textures = root.join("assets/minecraft/textures/font");
|
||||
std::fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
|
||||
std::fs::create_dir_all(&textures).unwrap();
|
||||
std::fs::write(root.join("assets/minecraft/font/default.json"),
|
||||
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/cache.png","ascent":7,"chars":["ab"]}]}"#).unwrap();
|
||||
let file = std::fs::File::create(textures.join("cache.png")).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, 16, 8);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let pixels: Vec<u8> = (0..128)
|
||||
.flat_map(|i| [255, 255, 255, if i % 8 < 4 { 255 } else { 0 }])
|
||||
.collect();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
drop(writer);
|
||||
let mut atlas = HudAtlas::boot();
|
||||
atlas.font = horizon_client::font::Font::load(
|
||||
&root,
|
||||
&Default::default(),
|
||||
&[],
|
||||
(1024, 1024),
|
||||
(512, 512),
|
||||
atlas.sprites["horizon/white"],
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
atlas
|
||||
}
|
||||
|
||||
fn tag_bytes(
|
||||
cache: &mut NameTagLayouts,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
alpha: u32,
|
||||
background: bool,
|
||||
) -> Vec<u8> {
|
||||
cache.begin_frame(atlas);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let behind = cache.name_tag(&mut batch, atlas, text, 0.0, alpha, background);
|
||||
cache.end_frame();
|
||||
let mut fresh = HudBatch::new([1, 1]);
|
||||
assert_eq!(
|
||||
behind,
|
||||
fresh.name_tag(
|
||||
atlas,
|
||||
text,
|
||||
0.0,
|
||||
alpha << 24 | 0xFF_FFFF,
|
||||
if background { 0x3F00_0000 } else { 0 }
|
||||
)
|
||||
);
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&batch.vertices);
|
||||
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
|
||||
bytes.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_tag_quads_follow_text_style_pass_and_font_changes() {
|
||||
let mut atlas = tag_font();
|
||||
let mut cache = NameTagLayouts::default();
|
||||
let a = Text::literal("a");
|
||||
let first = tag_bytes(&mut cache, &atlas, &a, 255, false);
|
||||
assert!(!first.is_empty());
|
||||
assert_eq!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
|
||||
for text in [
|
||||
Text::literal("b"),
|
||||
a.clone().bold(),
|
||||
Text::colored("a", 0x12AB34),
|
||||
] {
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
|
||||
tag_bytes(&mut cache, &atlas, &text, 255, false);
|
||||
}
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 127, true), first);
|
||||
tag_bytes(&mut cache, &atlas, &a, 127, true);
|
||||
tag_bytes(&mut cache, &atlas, &a, 255, false);
|
||||
// Reset and occupy the first glyph slot with b: a's old UV is now wrong.
|
||||
atlas.font.reset_cache();
|
||||
atlas.font.glyph('b');
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
|
||||
// Font options may change without replacing the atlas.
|
||||
atlas.font.uniform = true;
|
||||
tag_bytes(&mut cache, &atlas, &a, 255, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obfuscated_tag_glyphs_keep_animating_after_cache_warmup() {
|
||||
let atlas = tag_font();
|
||||
let mut cache = NameTagLayouts::default();
|
||||
let text = Text::legacy("§kaaaaaaaaaaaaaaaa");
|
||||
let mut images = std::collections::HashSet::new();
|
||||
for seed in 0..8 {
|
||||
cache.begin_frame(&atlas);
|
||||
atlas.font.reseed(seed);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
cache.name_tag(&mut batch, &atlas, &text, 0.0, 255, false);
|
||||
cache.end_frame();
|
||||
let bytes = bytemuck::cast_slice::<_, u8>(&batch.vertices).to_vec();
|
||||
atlas.font.reseed(seed);
|
||||
let mut fresh = HudBatch::new([1, 1]);
|
||||
fresh.name_tag(&atlas, &text, 0.0, u32::MAX, 0);
|
||||
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
|
||||
images.insert(bytes);
|
||||
}
|
||||
assert!(images.len() > 1, "the fixture must actually animate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_tag_objects_follow_live_sprite_updates() {
|
||||
let mut atlas = HudAtlas::boot();
|
||||
let mut sprite = atlas.sprites["horizon/white"];
|
||||
atlas.sprites.insert("item/probe".into(), sprite);
|
||||
let text = horizon_client::component::to_text(
|
||||
&serde_json::json!({"object":"atlas", "sprite":"minecraft:item/probe"}),
|
||||
&mut |_| Text::empty(),
|
||||
);
|
||||
let mut cache = NameTagLayouts::default();
|
||||
let first = tag_bytes(&mut cache, &atlas, &text, 255, false);
|
||||
assert!(!first.is_empty());
|
||||
sprite.u0 += 0.1;
|
||||
atlas.sprites.insert("item/probe".into(), sprite);
|
||||
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
|
||||
}
|
||||
|
||||
/// The player model `perspective::player_type` builds from the prepared
|
||||
/// resources, without them: its box only needs the id and the pointer.
|
||||
fn player_mob() -> Mob {
|
||||
@@ -903,14 +1146,7 @@ mod tests {
|
||||
let mobs = vec![remote_player];
|
||||
|
||||
// 1. TextDisplay riding remote player 42
|
||||
let ridden = collect_vehicle_ids(
|
||||
&mobs,
|
||||
vec![42],
|
||||
std::iter::empty(),
|
||||
std::iter::empty(),
|
||||
None,
|
||||
Some(100),
|
||||
);
|
||||
let ridden = collect_vehicle_ids(&mobs, vec![42], None, Some(100));
|
||||
assert!(
|
||||
ridden.contains(&42),
|
||||
"remote player 42 is ridden by text display"
|
||||
@@ -919,14 +1155,7 @@ mod tests {
|
||||
assert!(!ridden.contains(&0), "local model 0 has no passenger");
|
||||
|
||||
// 2. ArmorStand riding local player 100 -> model 0 must be in ridden
|
||||
let ridden_local = collect_vehicle_ids(
|
||||
&mobs,
|
||||
std::iter::empty(),
|
||||
vec![100],
|
||||
std::iter::empty(),
|
||||
None,
|
||||
Some(100),
|
||||
);
|
||||
let ridden_local = collect_vehicle_ids(&mobs, vec![100], None, Some(100));
|
||||
assert!(
|
||||
ridden_local.contains(&100),
|
||||
"local player 100 is ridden by armor stand"
|
||||
|
||||
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user