mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
677
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42ee9ad644 | ||
|
|
a21f8a3947 | ||
|
|
f0811d3363 | ||
|
|
c543cb8689 | ||
|
|
4b65ec8a0d | ||
|
|
bb8a15db1e | ||
|
|
555b885429 | ||
|
|
722e63500a | ||
|
|
f1708966d5 | ||
|
|
7b45eb9614 | ||
|
|
911aa6ddc6 | ||
|
|
5816e84e56 | ||
|
|
1cd6c70899 | ||
|
|
eb084c5152 | ||
|
|
aa3483bf5a | ||
|
|
23678db4f7 | ||
|
|
a97725d63c | ||
|
|
0ce3cf8624 | ||
|
|
aa548b0a41 | ||
|
|
cd4e73e270 | ||
|
|
d946b838fe | ||
|
|
b2d30693b8 | ||
|
|
8b13f108ef | ||
|
|
a15a3ea50c | ||
|
|
5ddbaaab62 | ||
|
|
6c34352b0f | ||
|
|
52540d4ff2 | ||
|
|
f671c935c2 | ||
|
|
11e85c0411 | ||
|
|
2dabcba7dc | ||
|
|
c94cdb0c76 | ||
|
|
8182a33000 | ||
|
|
dd90805814 | ||
|
|
0c1a839a02 | ||
|
|
00f230d640 | ||
|
|
535211eb7e | ||
|
|
d4c59f8c7a | ||
|
|
82f042de20 | ||
|
|
c351efbab6 | ||
|
|
3054963dec | ||
|
|
25a8fab320 | ||
|
|
194c5c2c6c | ||
|
|
bab03f04db | ||
|
|
9a71098242 | ||
|
|
6d76e89de2 | ||
|
|
609981c352 | ||
|
|
3aeb206813 | ||
|
|
9bd670d3d3 | ||
|
|
63bd6c914d | ||
|
|
ea787a658c | ||
|
|
14e27946ca | ||
|
|
9639526d6b | ||
|
|
d9b9996a4d | ||
|
|
a71f9d3bd3 | ||
|
|
91e8f91fee | ||
|
|
c6544a2311 | ||
|
|
63ad77a1da | ||
|
|
440a7f0144 | ||
|
|
96bb424a7f | ||
|
|
5cdcbf9812 | ||
|
|
3cc15c8ba0 | ||
|
|
e983ba70a5 | ||
|
|
1a5bea076d | ||
|
|
672b1cae9a | ||
|
|
9a2135d7b2 | ||
|
|
beb77a48e0 | ||
|
|
2abe71ca92 | ||
|
|
d7a25b8aec | ||
|
|
c6683ed9a2 | ||
|
|
0a484606c9 | ||
|
|
98961ccd14 | ||
|
|
f787306fce | ||
|
|
d8bf54d1e6 | ||
|
|
f165e7fa29 | ||
|
|
9d73f6a26a | ||
|
|
c2bead90c4 | ||
|
|
e470dac100 | ||
|
|
4a60f12112 | ||
|
|
192be7194f | ||
|
|
5f3caf9be4 | ||
|
|
571ef142c5 | ||
|
|
21845f7949 | ||
|
|
27a4dc65c9 | ||
|
|
deaab60ba9 | ||
|
|
c2c3cea8e3 | ||
|
|
a5dbd2c5c1 | ||
|
|
4c4de96fa5 | ||
|
|
c19aaf98e5 | ||
|
|
30a7b86bc8 | ||
|
|
0e0f95efa6 | ||
|
|
483497ef1c | ||
|
|
f7ba023dc6 | ||
|
|
cad46c5ff5 | ||
|
|
654bed3a7f | ||
|
|
6154ce2282 | ||
|
|
5e331b277a | ||
|
|
96712da3cb | ||
|
|
755ba0b9a5 | ||
|
|
29223492fc | ||
|
|
d5f9e06dac | ||
|
|
26d4f3f273 | ||
|
|
fc71cbfeed | ||
|
|
fcf7c70c83 | ||
|
|
be34bdfd3c | ||
|
|
09d748f19c | ||
|
|
0d55f3075b | ||
|
|
e72b69caca | ||
|
|
166e09f55e | ||
|
|
24b778c3b4 | ||
|
|
e1895229b8 | ||
|
|
cf79b811ac | ||
|
|
2b86defaa7 | ||
|
|
0da40f990f | ||
|
|
d63ae80019 | ||
|
|
cd5c829460 | ||
|
|
14ff11b96b | ||
|
|
12bbd34fe5 | ||
|
|
4a015bfb7c | ||
|
|
aaad7c57c1 | ||
|
|
61c43e5109 | ||
|
|
10721b288e | ||
|
|
be9a3c000f | ||
|
|
82a17c59ae | ||
|
|
646722c466 | ||
|
|
02f9ee4629 | ||
|
|
902c147f8e | ||
|
|
92166e6236 | ||
|
|
eb6536a832 | ||
|
|
cf26b92d45 | ||
|
|
bb9d4c2467 | ||
|
|
d9d89df9d8 | ||
|
|
5a5d9f780c | ||
|
|
687caa60c4 | ||
|
|
9503016e93 | ||
|
|
344bcdec9a | ||
|
|
cce82d001c | ||
|
|
f9d2eddb83 | ||
|
|
d66d48d9a3 | ||
|
|
18415b2fbd | ||
|
|
aab654d4e8 | ||
|
|
b7eeb79a33 | ||
|
|
db7b03ffda | ||
|
|
e27a62210c | ||
|
|
06e9cfe6da | ||
|
|
b6941b78f2 | ||
|
|
d64883cef3 | ||
|
|
7ab3a23f95 | ||
|
|
07050a34bd | ||
|
|
9054341b05 | ||
|
|
30db6aa7fd | ||
|
|
663e7457ab | ||
|
|
2b375bf38c | ||
|
|
172cec105a | ||
|
|
45f36d84e1 | ||
|
|
5b28083ef3 | ||
|
|
3072b90817 | ||
|
|
8368def6fa | ||
|
|
4c29ce57c7 | ||
|
|
291b2c2b03 | ||
|
|
10004fcd77 | ||
|
|
9fa96cab6f | ||
|
|
892c64cc69 | ||
|
|
f8e59c3e3b | ||
|
|
6fad4fb984 | ||
|
|
32ed49b6d8 | ||
|
|
35992267b9 | ||
|
|
95d025f4c2 | ||
|
|
40b9d160c9 | ||
|
|
984872a0dd | ||
|
|
4d00052e33 | ||
|
|
94077f9e66 | ||
|
|
1dcb48e1ec | ||
|
|
80570541d0 | ||
|
|
475ea2f6a7 | ||
|
|
cc15269fb6 | ||
|
|
1177119180 | ||
|
|
b0918fb06c | ||
|
|
702b9e5a6e | ||
|
|
0bdbffccf7 | ||
|
|
e3d11137f6 | ||
|
|
2bfb755693 | ||
|
|
783c053601 | ||
|
|
1416eb94b9 | ||
|
|
76a0ded489 | ||
|
|
44699b4c51 | ||
|
|
22b2093962 | ||
|
|
7a67cce201 | ||
|
|
11bda3ecc3 | ||
|
|
7a725b44e8 | ||
|
|
c9fbcbc9c0 | ||
|
|
3f287372f4 | ||
|
|
d3e0272348 | ||
|
|
e3d38ae5bd | ||
|
|
58cdf1b551 | ||
|
|
818e0a229e | ||
|
|
09fa6403c5 | ||
|
|
cad3d55f76 | ||
|
|
0c03fe844b | ||
|
|
0d85f3f65f | ||
|
|
ede16abf28 | ||
|
|
39046b18e8 | ||
|
|
02b5729038 | ||
|
|
370854a657 | ||
|
|
46d26691cf | ||
|
|
713fe9176d | ||
|
|
2e23fd1dd1 | ||
|
|
66319c12e6 | ||
|
|
31b0e68219 | ||
|
|
8bf7dd6dfe | ||
|
|
5814b1cb5f | ||
|
|
59cd7697be | ||
|
|
063632d5f5 | ||
|
|
8424af6894 | ||
|
|
a0d2e7d935 | ||
|
|
90827741ae | ||
|
|
7e779ceb8b | ||
|
|
5481445402 | ||
|
|
2c62ea5aaa | ||
|
|
0bd5635d74 | ||
|
|
53ef669745 | ||
|
|
44d13ad5d4 | ||
|
|
f66e1a8d91 | ||
|
|
83703c28b2 | ||
|
|
634df8744a | ||
|
|
e8b23d06ed | ||
|
|
c1894da34b | ||
|
|
1b016ba22e | ||
|
|
0482839c1b | ||
|
|
023cef202f | ||
|
|
fdc97df507 | ||
|
|
09fdaa392a | ||
|
|
6e5270105b | ||
|
|
7c9db3e706 | ||
|
|
e8b01b7282 | ||
|
|
f22a814607 | ||
|
|
fd7c423476 | ||
|
|
731699144c | ||
|
|
b14027d065 | ||
|
|
5133e20195 | ||
|
|
1fc67d4d10 | ||
|
|
59f63a251a | ||
|
|
e745db772e | ||
|
|
7573ae6a3e | ||
|
|
3e75afe983 | ||
|
|
29df2938b1 | ||
|
|
0808d1f189 | ||
|
|
014462436f | ||
|
|
7dffe8983b | ||
|
|
5a4d8c11e6 | ||
|
|
789be0131d | ||
|
|
54ea398de4 | ||
|
|
219d6b6b4e | ||
|
|
0c07b18c76 | ||
|
|
71d150b028 | ||
|
|
f8cf08849e | ||
|
|
d78f353156 | ||
|
|
251c800717 | ||
|
|
6c58b77fed | ||
|
|
02b3da3eec | ||
|
|
0bd65042f2 | ||
|
|
245d97a312 | ||
|
|
eb1fbb151d | ||
|
|
4676650694 | ||
|
|
9aae4a6671 | ||
|
|
660288e50b | ||
|
|
2497c21f48 | ||
|
|
5cbded2bd9 | ||
|
|
53ffc05840 | ||
|
|
5b275cd324 | ||
|
|
05c0a3942f | ||
|
|
fa34419e4b | ||
|
|
a640a5a91a | ||
|
|
7475a27b7b | ||
|
|
721a16e17b | ||
|
|
20c3b24dab | ||
|
|
8d60a65c6c | ||
|
|
72d6a5b383 | ||
|
|
b1104143ef | ||
|
|
1c98b67b44 | ||
|
|
1299432e7a | ||
|
|
271bace8e1 | ||
|
|
d07604cd8e | ||
|
|
195dceeb94 | ||
|
|
dec1f7bd51 | ||
|
|
3943570b53 | ||
|
|
a5b58a242b | ||
|
|
8dc176f4e6 | ||
|
|
6494dc92e0 | ||
|
|
13fe0905ba | ||
|
|
7cae9ff3c4 | ||
|
|
d9d0c7ad13 | ||
|
|
d60a13d0d3 | ||
|
|
38e2c2522a | ||
|
|
8c46ceea46 | ||
|
|
7d7fa98a44 | ||
|
|
fd2f881a32 | ||
|
|
0f209ac875 | ||
|
|
de7ceb78d7 | ||
|
|
c3325b01f3 | ||
|
|
59dbb4703d | ||
|
|
fe78b81e88 | ||
|
|
28f0c9802e | ||
|
|
e1a97b5783 | ||
|
|
4e86da4a7a | ||
|
|
98df3549d2 | ||
|
|
7d256e1766 | ||
|
|
1ec82f9a9f | ||
|
|
d62d4787ab | ||
|
|
012f70508a | ||
|
|
40a5a99ca8 | ||
|
|
c0cfcda90e | ||
|
|
4a90452d73 | ||
|
|
68d5cd7114 | ||
|
|
b1fb8a4048 | ||
|
|
37d6df3219 | ||
|
|
59b2aed66c | ||
|
|
b9abf293aa | ||
|
|
cb8761a18c | ||
|
|
f5dbdcadba | ||
|
|
d361eaf394 | ||
|
|
fe4d3a2e94 | ||
|
|
1b5be38573 | ||
|
|
54909bf033 | ||
|
|
2106e9b21e | ||
|
|
f5a3990fbf | ||
|
|
b1cdc91bf4 | ||
|
|
e7cdaa120a | ||
|
|
58a9cbddca | ||
|
|
56d067048f | ||
|
|
68b16245aa | ||
|
|
32a43ad647 | ||
|
|
b5c00e8040 | ||
|
|
23ae7d42c2 | ||
|
|
46179fd42e | ||
|
|
1aa3ad9441 | ||
|
|
fc67c07589 | ||
|
|
190aa00a63 | ||
|
|
a4266531f2 | ||
|
|
bb6b34d530 | ||
|
|
b2258c35e7 | ||
|
|
90fed51ab1 | ||
|
|
5de4e1ee74 | ||
|
|
be2f80a4cf | ||
|
|
12e1d88d71 | ||
|
|
6b1f808b13 | ||
|
|
81336ce7a9 | ||
|
|
47e22facbb | ||
|
|
f2a97a7329 | ||
|
|
1d2b14b01d | ||
|
|
1a9bf76f69 | ||
|
|
715402eb80 | ||
|
|
18e26a3e4d | ||
|
|
95756af921 | ||
|
|
72e4d61b57 | ||
|
|
4c2306d61d | ||
|
|
ff08319e96 | ||
|
|
6604778b58 | ||
|
|
194ddaa041 | ||
|
|
289674508b | ||
|
|
ce25b504c8 | ||
|
|
5a281cec36 | ||
|
|
f2ea985b8d | ||
|
|
33a70e3fa4 | ||
|
|
f5ea4bde3a | ||
|
|
251976843a | ||
|
|
8e17b81335 | ||
|
|
1bcc5ec3f9 | ||
|
|
9ac80b72b7 | ||
|
|
1c5f7c2d3c | ||
|
|
a8d3ffaf2e | ||
|
|
e7922700f0 | ||
|
|
7ad2c3b0bc | ||
|
|
f2b07381ab | ||
|
|
983bd56fb5 | ||
|
|
bee546c085 | ||
|
|
80392718df | ||
|
|
122a7650ad | ||
|
|
c4c5649ce9 | ||
|
|
da7c9d05b3 | ||
|
|
b61cff5625 | ||
|
|
938c04c32e | ||
|
|
37f6d169d7 | ||
|
|
ebcd7beedb | ||
|
|
1e45ee6ed3 | ||
|
|
8bf3aa3982 | ||
|
|
7b98f70c85 | ||
|
|
7c723354eb | ||
|
|
c9675c1ba5 | ||
|
|
debbbf6585 | ||
|
|
ac4204f395 | ||
|
|
7714b1d37a | ||
|
|
b045dddfa1 | ||
|
|
9e701221fd | ||
|
|
5a76ab5f51 | ||
|
|
ef218f6ced | ||
|
|
8e4d76ef04 | ||
|
|
a8f543b1cc | ||
|
|
5f61926401 | ||
|
|
ad8dd2f57e | ||
|
|
98b40e8e5e | ||
|
|
018ca6a76e | ||
|
|
242fba5b5a | ||
|
|
c93c23248a | ||
|
|
fbfa055eff | ||
|
|
b610bc7618 | ||
|
|
b082e400c6 | ||
|
|
9a267cf4bf | ||
|
|
f19afb61ad | ||
|
|
d9eab08f7f | ||
|
|
c6f61bbcc4 | ||
|
|
70865d77e8 | ||
|
|
09b8b5b94b | ||
|
|
d526f569a0 | ||
|
|
957ba89eac | ||
|
|
c4146a25af | ||
|
|
dc064e4bb1 | ||
|
|
c59deaa19b | ||
|
|
c741fd4d39 | ||
|
|
fb6b8c54e2 | ||
|
|
d1a243289c | ||
|
|
eb18b83652 | ||
|
|
c4889805f3 | ||
|
|
64fc971ebc | ||
|
|
b52d83f71e | ||
|
|
c6e83d5f09 | ||
|
|
72c066552e | ||
|
|
3f254035ec | ||
|
|
545b8f9e8d | ||
|
|
8c893c55f0 | ||
|
|
c2e172a8a3 | ||
|
|
9147b0893b | ||
|
|
004628230c | ||
|
|
75cfb4cc28 | ||
|
|
dd60b729ee | ||
|
|
651abefe2b | ||
|
|
8858d0c7e0 | ||
|
|
df589fa039 | ||
|
|
2c8c04518d | ||
|
|
08e01dc3da | ||
|
|
c289d208e6 | ||
|
|
84d6e3d97a | ||
|
|
226d535e77 | ||
|
|
7f0f9337f3 | ||
|
|
7d011d64aa | ||
|
|
4ea481cb2f | ||
|
|
2aeaeb2495 | ||
|
|
782331b499 | ||
|
|
30e4e488b2 | ||
|
|
b2095cc1ca | ||
|
|
e2ee8ad7d9 | ||
|
|
c59a4d6ffd | ||
|
|
4256dec408 | ||
|
|
959c3b6180 | ||
|
|
e00864ba59 | ||
|
|
866057db34 | ||
|
|
ee8d4c4a72 | ||
|
|
fb05c1acfc | ||
|
|
8e4e46fdd6 | ||
|
|
9351cd381f | ||
|
|
72e3ec1924 | ||
|
|
a2cdad2a10 | ||
|
|
53e8715d83 | ||
|
|
278dee9871 | ||
|
|
7a8364a0f7 | ||
|
|
c96f20ac6c | ||
|
|
fb6707ec92 | ||
|
|
dfc7fdd786 | ||
|
|
306dca0a21 | ||
|
|
541bc4b519 | ||
|
|
6352b7468c | ||
|
|
26d32147ea | ||
|
|
b5c4b2f84b | ||
|
|
0b2ed50a68 | ||
|
|
75ef2801f5 | ||
|
|
699cf1101f | ||
|
|
386d0560a1 | ||
|
|
f90e164b4b | ||
|
|
49873c3371 | ||
|
|
359388eea7 | ||
|
|
07af125581 | ||
|
|
4d6923514e | ||
|
|
5e5258f410 | ||
|
|
d9672a3587 | ||
|
|
15e02ce312 | ||
|
|
1c15680e9a | ||
|
|
7cdd21f24d | ||
|
|
27b18c07f0 | ||
|
|
61d8dea2a7 | ||
|
|
a06a1853e2 | ||
|
|
0afcabe0ec | ||
|
|
67e0a30ea8 | ||
|
|
02d449ced2 | ||
|
|
2e8cc55201 | ||
|
|
d13e35bb8f | ||
|
|
e24554895d | ||
|
|
c4ec5dfd57 | ||
|
|
7d37a291c2 | ||
|
|
d2bc41cd4e | ||
|
|
61ffa23382 | ||
|
|
fca27e4c63 | ||
|
|
c44aa01ebb | ||
|
|
f61130508b | ||
|
|
7ab5bfd238 | ||
|
|
e347c6f99f | ||
|
|
f03a2ffe21 | ||
|
|
935faca944 | ||
|
|
70ee24e9ef | ||
|
|
267c3838b6 | ||
|
|
dfbdf94740 | ||
|
|
afb34764ec | ||
|
|
479276f66d | ||
|
|
17cdf49055 | ||
|
|
b49f625199 | ||
|
|
c1fad2fb18 | ||
|
|
9b087a4560 | ||
|
|
4d3d579464 | ||
|
|
d46bea7851 | ||
|
|
a25182804a | ||
|
|
dd178e638f | ||
|
|
01731db683 | ||
|
|
7b9fca2a49 | ||
|
|
4b8f61ed0e | ||
|
|
1f7d2adcbe | ||
|
|
1258ec339a | ||
|
|
7cbe3d49b0 | ||
|
|
83122825e5 | ||
|
|
c3af4fadeb | ||
|
|
fbf4ddd296 | ||
|
|
a8c16eb5db | ||
|
|
ce66fd40bd | ||
|
|
22e92450e4 | ||
|
|
ee1dfabaa6 | ||
|
|
5e1eab0e05 | ||
|
|
fc83f106f2 | ||
|
|
7f8e095514 | ||
|
|
d1d35697a1 | ||
|
|
6a9154801f | ||
|
|
72c06622be | ||
|
|
793565a33f | ||
|
|
4bed23dc4c | ||
|
|
b820331574 | ||
|
|
ca8cc5a43b | ||
|
|
ac2a95ab1d | ||
|
|
faef0581d1 | ||
|
|
4df6ddc3f3 | ||
|
|
9f1fb2c77c | ||
|
|
46fe2c7f3d | ||
|
|
f23a03f6c7 | ||
|
|
644d598685 | ||
|
|
afd4df1cd9 | ||
|
|
fa7424ca31 | ||
|
|
37249efd34 | ||
|
|
98498da9aa | ||
|
|
a2b63b3e3b | ||
|
|
1f3f7cebae | ||
|
|
316bef4484 | ||
|
|
4cea8c2ee6 | ||
|
|
0694db7dcb | ||
|
|
f3fdefa0ec | ||
|
|
b21058f361 | ||
|
|
58f80819d9 | ||
|
|
f606efbf36 | ||
|
|
e8397cd8eb | ||
|
|
dd2d560901 | ||
|
|
7837d91790 | ||
|
|
53b7a6768d | ||
|
|
9d025d7e3c | ||
|
|
a03902b64d | ||
|
|
30637c0820 | ||
|
|
b63d7491d2 | ||
|
|
e64e477126 | ||
|
|
dc94adc898 | ||
|
|
f914bcd4a9 | ||
|
|
0a6d0dd257 | ||
|
|
c719e4ac0e | ||
|
|
7049d0e811 | ||
|
|
78e094e614 | ||
|
|
f81f6de46b | ||
|
|
ff42a99d98 | ||
|
|
aced4bb6b9 | ||
|
|
97a323b499 | ||
|
|
227333eacd | ||
|
|
9367ed7710 | ||
|
|
e15589bbbb | ||
|
|
84fe5b4f12 | ||
|
|
7ee566f6b7 | ||
|
|
3774ca6edd | ||
|
|
c73321876c | ||
|
|
19d083fa26 | ||
|
|
8d35e7fc0a | ||
|
|
46f0546de6 | ||
|
|
7c173c30b1 | ||
|
|
715a969071 | ||
|
|
5097c53509 | ||
|
|
a89477a4b9 | ||
|
|
430b6dc4c5 | ||
|
|
e591b36a8d | ||
|
|
701eaa6f9c | ||
|
|
018a51c27f | ||
|
|
4db980e5de | ||
|
|
d40ab7df1a | ||
|
|
54578041bc | ||
|
|
945c667281 | ||
|
|
bdec76846e | ||
|
|
93ebb80b92 | ||
|
|
8d7267f7f7 | ||
|
|
345041401e | ||
|
|
0eb2196f99 | ||
|
|
18c03d23d8 | ||
|
|
dceee849cb | ||
|
|
10b318cd6b | ||
|
|
79cfecbdb9 | ||
|
|
298d3f12e9 | ||
|
|
73a745339e | ||
|
|
70287510ab | ||
|
|
26afbb999c | ||
|
|
d906242b0e | ||
|
|
dbf99fe456 | ||
|
|
7842fca4da | ||
|
|
ef01959fb1 | ||
|
|
ec2cd929ab | ||
|
|
6f79c55c2a | ||
|
|
529cd743a0 | ||
|
|
3877ae52d8 | ||
|
|
8e46f3971d | ||
|
|
ebe1a5d52b | ||
|
|
61a0362859 | ||
|
|
166073d4bc | ||
|
|
92742bef1b | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b | ||
|
|
e632dda2b7 | ||
|
|
d22f313e9e | ||
|
|
16237bb2b7 | ||
|
|
71e2401edc | ||
|
|
e957a4bd85 | ||
|
|
6c5f62af35 | ||
|
|
d029364bd4 | ||
|
|
89f90f0b65 | ||
|
|
f965ae85f3 | ||
|
|
983d9a3235 | ||
|
|
87c473666a | ||
|
|
1c9f8a4359 | ||
|
|
d0850614ba | ||
|
|
f4d10e7099 | ||
|
|
1b029a08f6 | ||
|
|
8a5211f260 | ||
|
|
0831968204 | ||
|
|
1e8745416d | ||
|
|
9fc9722501 | ||
|
|
adfd8e43c8 | ||
|
|
f7267efa1b | ||
|
|
d288db6b6e | ||
|
|
35fc116bd3 | ||
|
|
ede2ada183 | ||
|
|
cc100b4470 | ||
|
|
2a95475e8f | ||
|
|
47894e831f | ||
|
|
5e735bd030 | ||
|
|
da4591bc88 | ||
|
|
dbcbe4fe87 | ||
|
|
8423eabc57 | ||
|
|
fd46aa03c5 | ||
|
|
87f2c1314e | ||
|
|
4c2a2c42d2 | ||
|
|
4c246e24f2 | ||
|
|
3ff75a8ecb | ||
|
|
6ca76d9e9c | ||
|
|
c58a3eef6f | ||
|
|
ca57097ae8 | ||
|
|
507006a08d | ||
|
|
b98cb1a611 | ||
|
|
b7eb967776 | ||
|
|
c74851a64b | ||
|
|
6b6a9577c7 | ||
|
|
316f9680d5 | ||
|
|
5a60e2252b |
No files matched your search
+32
-18
@@ -1,5 +1,5 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
@@ -7,11 +7,15 @@ name: checks
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
|
||||
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,7 +23,8 @@ concurrency:
|
||||
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
|
||||
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
|
||||
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
|
||||
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
|
||||
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
@@ -34,11 +39,14 @@ jobs:
|
||||
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
|
||||
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
|
||||
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
|
||||
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
|
||||
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
|
||||
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
|
||||
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. On ours the build
|
||||
# folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are
|
||||
# GitHub's only.
|
||||
verify:
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
@@ -52,22 +60,24 @@ jobs:
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
|
||||
# read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
@@ -84,21 +94,22 @@ jobs:
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
|
||||
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
|
||||
# changes since then would count as the pull request's (#13874).
|
||||
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
|
||||
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
@@ -139,10 +150,13 @@ jobs:
|
||||
cargo +stable test --locked --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26). Not in the merge queue: no check of
|
||||
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
|
||||
launcher:
|
||||
if: github.event_name != 'merge_group'
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
|
||||
Generated
+2
-1
@@ -1486,6 +1486,7 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1849,8 +1850,8 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
|
||||
|
||||
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
|
||||
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
|
||||
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
|
||||
let found = if haystack.len() < 16 {
|
||||
haystack
|
||||
let found = if entries.len() < 16 {
|
||||
entries
|
||||
.iter()
|
||||
.position(|h| *h > needle)
|
||||
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
|
||||
.position(|e| e.0 > needle)
|
||||
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
|
||||
} else {
|
||||
match binary_search(&haystack, needle) {
|
||||
match binary_search(entries, needle) {
|
||||
Ok(index) => index as i64,
|
||||
Err(insertion) => insertion as i64 - 1,
|
||||
}
|
||||
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
|
||||
}
|
||||
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
|
||||
/// values (-0.0 below 0.0, NaN above everything).
|
||||
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
|
||||
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
|
||||
let bits = |v: f32| -> i32 {
|
||||
if v.is_nan() {
|
||||
0x7fc0_0000
|
||||
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
};
|
||||
while low <= high {
|
||||
let mid = (low + high) >> 1;
|
||||
let value = a[mid as usize];
|
||||
let value = entries[mid as usize].0;
|
||||
if value < key {
|
||||
low = mid + 1;
|
||||
} else if value > key {
|
||||
|
||||
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_index_less_or_equal_linear_and_binary() {
|
||||
use super::last_index_less_or_equal;
|
||||
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
|
||||
thresholds
|
||||
.iter()
|
||||
.map(|&t| (t, ItemModelNode::Empty))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Empty
|
||||
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
|
||||
|
||||
// Short (< 16): linear search
|
||||
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
|
||||
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
|
||||
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
|
||||
|
||||
// Long (>= 16): binary search
|
||||
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
|
||||
let long = make(&thresholds);
|
||||
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
|
||||
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
|
||||
|
||||
// Short lists use upper-bound tie behavior, including signed zero.
|
||||
let duplicates = make(&[1.0, 1.0, 2.0]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
|
||||
|
||||
let zeroes = make(&[-0.0, 0.0]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
|
||||
|
||||
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
|
||||
// Expected indices were checked on the JVM.
|
||||
let duplicates = make(&[
|
||||
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
|
||||
|
||||
let zeroes = make(&[
|
||||
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -68,12 +69,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -109,84 +110,16 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +132,6 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -228,39 +145,11 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,9 +163,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -568,6 +568,8 @@ impl Chat {
|
||||
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
|
||||
if self.qol != qol {
|
||||
self.qol = qol;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
}
|
||||
@@ -593,20 +595,25 @@ impl Chat {
|
||||
.filter(|q| !q.trim().is_empty());
|
||||
if self.search != query {
|
||||
self.search = query;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
pub fn searching(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
|
||||
/// `resetChatScroll`, then the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
|
||||
/// where the reader was (`chatScrollbarPos` survives a filter change);
|
||||
/// `rescale` resets the scroll itself, as `rescaleChat` does.
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.lines.clear();
|
||||
let mut messages = std::mem::take(&mut self.messages);
|
||||
for m in messages.iter_mut().rev() {
|
||||
|
||||
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
|
||||
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
|
||||
/// `rescaleChat` resets them, after `resetChatScroll`.
|
||||
#[test]
|
||||
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
for i in 0..60 {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(format!("line {i}")),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
chat.scroll = 3;
|
||||
chat.new_since_scroll = true;
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(chat.scroll, 3);
|
||||
assert!(chat.new_since_scroll);
|
||||
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
chat.rescale(&atlas);
|
||||
assert_eq!(chat.scroll, 0);
|
||||
assert!(!chat.new_since_scroll);
|
||||
}
|
||||
|
||||
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
|
||||
/// pointing hand; outside it the cursor stays the arrow.
|
||||
#[test]
|
||||
|
||||
@@ -196,7 +196,7 @@ impl ColumnLayout {
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
@@ -700,7 +700,7 @@ impl Chunk {
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
@@ -984,6 +984,24 @@ pub struct ChunkExtras {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
|
||||
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
chunk.set(0, layout.min_y(), 0, 7);
|
||||
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
|
||||
assert_eq!(layout.section_index(y), None);
|
||||
assert_eq!(chunk.section_index(y), None);
|
||||
assert_eq!(chunk.get(0, y, 0), 0);
|
||||
assert_eq!(
|
||||
chunk.light(0, y, 0),
|
||||
if y < layout.min_y() { 0 } else { 0xf0 }
|
||||
);
|
||||
chunk.set(0, y, 0, 9);
|
||||
}
|
||||
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn each_light_form_keeps_every_value_and_run() {
|
||||
// Sky varies alone, block varies alone, both, neither.
|
||||
let cases: [(fn(usize) -> u8, &str); 4] = [
|
||||
|
||||
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
|
||||
.into_iter()
|
||||
.try_fold(1i64, |n, size| n.checked_mul(size));
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
|
||||
return false;
|
||||
}
|
||||
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
|
||||
@@ -1111,6 +1113,13 @@ mod section_tests {
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// The fast path must decline an enormous volume without overflowing
|
||||
// its size calculation or beginning to walk it.
|
||||
let enormous = Aabb::new(
|
||||
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
|
||||
Vec3::new(1.0e9, 1.0e9, 1.0e9),
|
||||
);
|
||||
assert!(!scene.cells_without_collision(enormous));
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct Overlay {
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
|
||||
chain: Vec<(u32, u32)>,
|
||||
overlay: OverlayJoin,
|
||||
}
|
||||
|
||||
@@ -188,6 +190,7 @@ impl ConnectedTextures {
|
||||
Ok(tiles) => Some(LoadedRule {
|
||||
rule,
|
||||
tiles,
|
||||
chain: Vec::new(),
|
||||
overlay: OverlayJoin::default(),
|
||||
}),
|
||||
Err(e) => {
|
||||
@@ -292,10 +295,23 @@ impl ConnectedTextures {
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
|
||||
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let name = sprite_name(tile);
|
||||
let list = by_name.entry(name.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
|
||||
// name. By layer it would match every face whose sprite has the same pixels
|
||||
// (identical stills share a layer: Conquest's blank placeholders, dozens).
|
||||
if !name.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(l) = layer(&name) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
@@ -316,6 +332,26 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let by_name: HashMap<String, (u32, u32)> = by_name
|
||||
.into_iter()
|
||||
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
|
||||
.collect();
|
||||
// Set at the end: `by_block` borrows the rules' names.
|
||||
let chains: Vec<Vec<(u32, u32)>> = out
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|t| match t {
|
||||
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
|
||||
_ => None,
|
||||
})
|
||||
.map(Option::unwrap_or_default)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
@@ -355,6 +391,9 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
for (r, chain) in out.rules.iter_mut().zip(chains) {
|
||||
r.chain = chain;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -392,9 +431,11 @@ impl ConnectedTextures {
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
ranges.get(i).map_or(&[], |&range| self.range(range))
|
||||
}
|
||||
|
||||
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
@@ -441,7 +482,7 @@ impl ConnectedTextures {
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(
|
||||
&& let Some((pick, index)) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
@@ -454,7 +495,7 @@ impl ConnectedTextures {
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile (and this block, if they name blocks).
|
||||
// tile rules naming the picked tile by name (and this block, if they name blocks).
|
||||
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
|
||||
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
|
||||
return pick;
|
||||
@@ -467,7 +508,7 @@ impl ConnectedTextures {
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.range(r.chain.get(index).copied().unwrap_or_default())
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
@@ -488,8 +529,8 @@ impl ConnectedTextures {
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
Some((Pick::Keep, _)) => return pick,
|
||||
Some((chained, _)) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -512,7 +553,7 @@ impl ConnectedTextures {
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
) -> Option<(Pick, usize)> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
@@ -602,7 +643,7 @@ impl ConnectedTextures {
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
Some((Pick::Quarters(quarters), 0))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -617,7 +658,7 @@ impl ConnectedTextures {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
return Some((Pick::Keep, 0));
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -644,8 +685,8 @@ impl ConnectedTextures {
|
||||
}
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
TileLayer::Default => Some((Pick::Keep, index)),
|
||||
t => Some((Pick::Tile(t), index)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,6 +716,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
overlay: OverlayJoin::default(),
|
||||
chain: Vec::new(),
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
@@ -722,6 +764,10 @@ mod tests {
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
|
||||
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
|
||||
"assets/minecraft/optifine/ctm/grass/1.png"
|
||||
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
@@ -1176,6 +1222,31 @@ mod tests {
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chaining_follows_the_tile_not_its_pixels() {
|
||||
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
|
||||
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
|
||||
// first; it must not draw on the grass.
|
||||
let ctm = world(&[
|
||||
(
|
||||
"grass/top.properties",
|
||||
"matchTiles=glass\nmethod=fixed\ntiles=1",
|
||||
),
|
||||
(
|
||||
"grass/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
|
||||
),
|
||||
(
|
||||
"aaa/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
|
||||
),
|
||||
]);
|
||||
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// A face whose own sprite has a placeholder's pixels is no placeholder.
|
||||
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
|
||||
@@ -56,6 +56,15 @@ pub struct Glue<'a> {
|
||||
pub struct CoreProgram {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
|
||||
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
|
||||
/// binding unused).
|
||||
pub other: Vec<String>,
|
||||
}
|
||||
|
||||
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
|
||||
pub fn other_binding(index: usize) -> u32 {
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
}
|
||||
|
||||
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
|
||||
@@ -66,6 +75,20 @@ pub fn translate_core(
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
) -> Result<CoreProgram, String> {
|
||||
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
|
||||
}
|
||||
|
||||
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
|
||||
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
|
||||
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
|
||||
pub fn translate_core_with(
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<CoreProgram, String> {
|
||||
const CORE: &str = "minecraft:shaders/core/";
|
||||
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
|
||||
@@ -75,10 +98,16 @@ pub fn translate_core(
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let fragment = compile(
|
||||
ShaderStage::Fragment,
|
||||
&fragment_code,
|
||||
defines,
|
||||
&names,
|
||||
provided,
|
||||
)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let given = locations(&vertex, false);
|
||||
if let Some(missing) = locations(&fragment, true)
|
||||
.into_iter()
|
||||
@@ -88,7 +117,11 @@ pub fn translate_core(
|
||||
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
|
||||
));
|
||||
}
|
||||
Ok(CoreProgram { vertex, fragment })
|
||||
Ok(CoreProgram {
|
||||
vertex,
|
||||
fragment,
|
||||
other: names.other,
|
||||
})
|
||||
}
|
||||
|
||||
/// A bound on a pack's includes, which are pasted every time.
|
||||
@@ -309,7 +342,9 @@ fn block_name(line: &str) -> Option<&str> {
|
||||
let rest = line[at + "uniform".len()..].trim();
|
||||
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(name_end);
|
||||
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
|
||||
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
|
||||
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
|
||||
.then_some(name)
|
||||
}
|
||||
|
||||
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
|
||||
@@ -337,7 +372,7 @@ impl Names {
|
||||
self.other.len() - 1
|
||||
}
|
||||
};
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
other_binding(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +586,13 @@ fn rename_main(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Parses and validates one stage; errors name the line of the rewritten code.
|
||||
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
|
||||
fn compile(
|
||||
stage: ShaderStage,
|
||||
code: &str,
|
||||
defines: &[&str],
|
||||
names: &Names,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let mut options = Options::from(stage);
|
||||
for define in defines {
|
||||
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
|
||||
@@ -583,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
|
||||
})?;
|
||||
let entry = info.get_entry_point(0);
|
||||
for (handle, global) in module.global_variables.iter() {
|
||||
let other = global
|
||||
.binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
|
||||
if other && !entry[handle].is_empty() {
|
||||
let name = global.name.as_deref().unwrap_or("?");
|
||||
let name = name
|
||||
.strip_prefix("hz_tex_")
|
||||
.or_else(|| name.strip_prefix("hz_smp_"))
|
||||
.unwrap_or(name);
|
||||
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
|
||||
continue;
|
||||
};
|
||||
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
|
||||
// The name it was declared with (a block's instance may have none).
|
||||
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
|
||||
let name = names.other.get(index).map_or("?", String::as_str);
|
||||
if provided(name) {
|
||||
continue;
|
||||
}
|
||||
return Err(format!("{name} is not a block or sampler of this pipeline"));
|
||||
}
|
||||
}
|
||||
@@ -868,6 +909,63 @@ mod tests {
|
||||
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
|
||||
}
|
||||
|
||||
/// A block declared on one line, as packs write a one-float uniform, is bound like one
|
||||
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
|
||||
/// order, and one it doesn't provide is not.
|
||||
#[test]
|
||||
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
|
||||
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
|
||||
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
|
||||
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
|
||||
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
|
||||
let both = |name: &str| name == "Day" || name == "Bounds";
|
||||
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
|
||||
assert_eq!(program.other, ["Day", "Bounds"]);
|
||||
// The bindings the caller gives them at.
|
||||
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
|
||||
module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
bindings(&program.vertex)
|
||||
.iter()
|
||||
.any(|(_, b)| *b == other_binding(0)),
|
||||
"{:?}",
|
||||
bindings(&program.vertex)
|
||||
);
|
||||
let fragment = bindings(&program.fragment);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
|
||||
"{fragment:?}"
|
||||
);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
|
||||
"{fragment:?}"
|
||||
);
|
||||
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
|
||||
assert!(error.contains("Day is not a block or sampler"), "{error}");
|
||||
let only_day = |name: &str| name == "Day";
|
||||
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
|
||||
assert!(
|
||||
error.contains("Bounds is not a block or sampler"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a uniform block's declaration names a block, written over several lines or on one.
|
||||
#[test]
|
||||
fn block_names_are_read_from_block_declarations_only() {
|
||||
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
|
||||
assert_eq!(block_name("uniform Globals"), Some("Globals"));
|
||||
assert_eq!(block_name("uniform float x;"), None);
|
||||
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_horizon_does_not_provide_is_an_error() {
|
||||
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
|
||||
|
||||
@@ -36,7 +36,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
|
||||
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
|
||||
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
return frame_stored(color);
|
||||
}
|
||||
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// or it would come out darker than the sky around it.
|
||||
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
// Premultiplied after the decode: the light the texel adds, at its coverage.
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
|
||||
|
||||
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
|
||||
pub(crate) mod animate;
|
||||
mod arena;
|
||||
pub mod capture;
|
||||
mod census;
|
||||
pub mod compositor;
|
||||
pub(crate) mod core_lines;
|
||||
pub(crate) mod core_sky;
|
||||
@@ -427,10 +428,10 @@ struct Globals {
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
|
||||
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
|
||||
/// (`Hdr::draw_params`).
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -2547,160 +2548,6 @@ enum FirstFrame {
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "on" } else { "off" }
|
||||
}
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
window: Arc<Window>,
|
||||
@@ -8291,14 +8138,14 @@ impl Renderer {
|
||||
self.shader_pack_kind().is_some(),
|
||||
!frame.title,
|
||||
);
|
||||
let (hdr_settings, hdr_shares) = self
|
||||
let (hdr_settings, hdr_gains) = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
|
||||
self.world_post.baked_light(
|
||||
hdr_gain,
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
hdr_gains,
|
||||
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
);
|
||||
let jitter = self.world_post.jitter(
|
||||
@@ -8458,7 +8305,7 @@ impl Renderer {
|
||||
f32::from(u8::from(hdr_gain)),
|
||||
],
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
hdr_gains,
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
|
||||
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
|
||||
//! GPU resources and of its per-section bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct Uniform {
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ impl CustomSky {
|
||||
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
|
||||
let mark =
|
||||
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
|
||||
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
|
||||
if globals.hdr_settings[2] > 0.5 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
@@ -428,7 +428,7 @@ impl CustomSky {
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
@@ -702,7 +702,7 @@ mod tests {
|
||||
mode: mode_index as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_gains: [1.6, 2.4, 2.8, 4.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Layer {
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
var color = t.rgb;
|
||||
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
|
||||
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
|
||||
}
|
||||
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
|
||||
switch layer.mode {
|
||||
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
|
||||
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = custom_sky_color(in);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
//! or scRGB swapchain at the player's paper white, expanding the world's
|
||||
//! highlights towards their peak brightness. The world is copied aside before
|
||||
//! the hand and HUD so only it is expanded.
|
||||
//!
|
||||
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
|
||||
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
|
||||
//! peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -99,38 +103,53 @@ struct Layered {
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
|
||||
/// texel's own brightness before lighting.
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
|
||||
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
|
||||
/// `full` (all of it), the texel's own brightness before lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub gain: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
|
||||
/// glowstone and campfires, the sun.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
gain: 1.6,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
gain: 2.4,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
gain: 2.8,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
gain: 4.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// The scene's peak, in multiples of paper white: just above the brightest light the world
|
||||
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
|
||||
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
|
||||
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
|
||||
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
|
||||
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
|
||||
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
|
||||
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
|
||||
pub const SUNLIT_GAIN: f32 = 3.0;
|
||||
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
|
||||
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
|
||||
/// changes once, when the float frame replaces it.
|
||||
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
|
||||
|
||||
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
|
||||
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
|
||||
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
|
||||
@@ -331,7 +350,7 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
@@ -944,16 +963,19 @@ impl Hdr {
|
||||
})
|
||||
}
|
||||
|
||||
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
|
||||
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
|
||||
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
|
||||
let p = self.params(world, false);
|
||||
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
|
||||
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
|
||||
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 16];
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -968,12 +990,17 @@ impl Hdr {
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
params[8..12].copy_from_slice(&COMPAT_SHARES);
|
||||
params[12] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[13] = c.map_or(0.0, |c| c.nits);
|
||||
params[14] = c.map_or(0.0, |c| c.band);
|
||||
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
|
||||
// Lighting off, a pack's own light, the title) the curve's source follows the display.
|
||||
params[16] = if world && self.lighting {
|
||||
SCENE_PEAK
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
params
|
||||
}
|
||||
|
||||
@@ -1050,8 +1077,9 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
|
||||
/// surface by, 4, which looked right across biomes; less only where there is little headroom
|
||||
/// to ease into. The float frame's is `SUNLIT_GAIN`.
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
@@ -1069,7 +1097,8 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
|
||||
// writers take the gain that a share gives.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@@ -1088,19 +1117,20 @@ var<private> params: ReferenceParams;
|
||||
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
|
||||
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
|
||||
let expected = reference_lit(color, mark, shows, eased);
|
||||
let gains = 1.0 + (params.peak - 1.0) * params.share;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
let quantized = round(saturate(stored) * 255.0) / 255.0;
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
|
||||
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
|
||||
}
|
||||
}
|
||||
@@ -1319,10 +1349,12 @@ var<private> params: ReferenceParams;
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
|
||||
// of the headroom up to that peak (`writers_lift`, `display_lift`).
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
hdr: &Hdr,
|
||||
lift: [[f32; 4]; 2],
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
@@ -1345,7 +1377,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
@@ -1373,10 +1405,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
contents: bytemuck::cast_slice(&lift),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
@@ -1404,6 +1435,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
);
|
||||
}
|
||||
|
||||
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
|
||||
/// of the headroom up to the scene's peak.
|
||||
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
|
||||
}
|
||||
|
||||
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
|
||||
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let p = hdr.params(true, false);
|
||||
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
@@ -1416,8 +1460,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD:
|
||||
// the frozen oracle turns the marks into the light that display gives them, and the
|
||||
// layered display, whose curve is the identity at 1000 nits, must show it the same.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
@@ -1441,7 +1486,14 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
// The light the compatibility frame shows: its own lift, not the writers'.
|
||||
legacy_light(
|
||||
&device,
|
||||
&mut encoder,
|
||||
display_lift(&legacy),
|
||||
&marked,
|
||||
&frame,
|
||||
);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
@@ -1480,78 +1532,206 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
/// The world's writers read nothing of the display: the same gains and the same scene peak
|
||||
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
#[test]
|
||||
fn the_tone_curve_eases_light_into_the_peak() {
|
||||
fn the_writers_light_is_the_same_on_every_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let headroom = 1000.0 / 203.0;
|
||||
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
|
||||
let after =
|
||||
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
passes(hdr, &mut encoder);
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
|
||||
let written = hdr.draw_params(true);
|
||||
assert_eq!(
|
||||
written,
|
||||
(
|
||||
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
|
||||
[1.6, 2.4, 2.8, 4.0]
|
||||
)
|
||||
);
|
||||
for (paper, peak) in [
|
||||
(203.0, 300.0),
|
||||
(203.0, 600.0),
|
||||
(203.0, 1500.0),
|
||||
(400.0, 600.0),
|
||||
(480.0, 200.0),
|
||||
(80.0, 10000.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
|
||||
assert_eq!(
|
||||
hdr.params(true, false)[16],
|
||||
super::SCENE_PEAK,
|
||||
"{paper}/{peak}"
|
||||
);
|
||||
}
|
||||
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
|
||||
// nothing says where their light ends, so the curve's source follows the display.
|
||||
assert_eq!(hdr.params(false, false)[16], 0.0);
|
||||
hdr.lighting = false;
|
||||
assert_eq!(hdr.draw_params(true).0[1], 1.0);
|
||||
assert_eq!(hdr.params(true, false)[16], 0.0);
|
||||
}
|
||||
|
||||
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
|
||||
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
|
||||
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
|
||||
#[test]
|
||||
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let scene_peak = super::SCENE_PEAK;
|
||||
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
|
||||
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
if curve {
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
|
||||
// light just above S that the curve holds at S, and whether that comes back to the same
|
||||
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
|
||||
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
|
||||
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
|
||||
(0..4).all(|c| {
|
||||
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
|
||||
})
|
||||
};
|
||||
// Linear light in and out, in units of paper white.
|
||||
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
|
||||
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
|
||||
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
|
||||
assert_eq!(a, 1.0);
|
||||
[r, g, b].map(|c| c.powf(2.2))
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
|
||||
// Up to the knee (60% of the peak) the light stays exactly as stored.
|
||||
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
|
||||
// nits (13.7 times paper white), worked out apart from the shader.
|
||||
for (light, want) in [
|
||||
(3.5, 3.43791),
|
||||
(4.0, 3.78351),
|
||||
(headroom, 4.23556),
|
||||
(8.0, 4.82043),
|
||||
(12.0, 4.92462),
|
||||
(50.0, headroom),
|
||||
// A display at or above the mastering one (T >= S): every stored value up to S comes
|
||||
// out exactly as it went in, grey or coloured, and light above S is held at S with its
|
||||
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
|
||||
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
|
||||
for (paper, peak) in [
|
||||
(203.0, 1000.0),
|
||||
(203.0, 1500.0),
|
||||
(203.0, 10000.0),
|
||||
(480.0, 10000.0),
|
||||
(10.0, 200.0),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for i in 0..=64 {
|
||||
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
|
||||
let exact = i < 64;
|
||||
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
|
||||
"{peak}: {i}"
|
||||
);
|
||||
}
|
||||
for light in [8.0f32, 50.0] {
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
|
||||
"{peak}: {light}: {r} {g} {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others: the
|
||||
// light keeps its hue.
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 2.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
|
||||
last = r;
|
||||
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
|
||||
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
|
||||
// where the gates' curve, whose source followed the display, kept it at 203; the
|
||||
// highlights above it keep more of their steps.
|
||||
for (paper, peak, knee, checkpoints) in [
|
||||
(
|
||||
203.0,
|
||||
600.0,
|
||||
2.0,
|
||||
&[
|
||||
(2.5, 2.47516),
|
||||
(3.0, 2.75699),
|
||||
(4.0, 2.9404),
|
||||
(scene_peak, 600.0 / 203.0),
|
||||
(8.0, 600.0 / 203.0),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
203.0,
|
||||
300.0,
|
||||
0.75,
|
||||
&[
|
||||
(0.9, 0.89289),
|
||||
(1.0, 0.97365),
|
||||
(1.2, 1.10406),
|
||||
(1.4, 1.20134),
|
||||
(2.0, 1.36833),
|
||||
(4.0, 1.47641),
|
||||
(8.0, 300.0 / 203.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
400.0,
|
||||
600.0,
|
||||
0.8,
|
||||
&[
|
||||
(1.0, 0.97973),
|
||||
(1.2, 1.11501),
|
||||
(1.5, 1.25607),
|
||||
(2.0, 1.38818),
|
||||
(scene_peak, 1.5),
|
||||
],
|
||||
),
|
||||
// No headroom (a peak under paper white): the scene is compressed into white.
|
||||
(
|
||||
203.0,
|
||||
150.0,
|
||||
0.4,
|
||||
&[
|
||||
(1.0, 0.78346),
|
||||
(1.5, 0.90244),
|
||||
(2.0, 0.95589),
|
||||
(4.0, 0.99944),
|
||||
(scene_peak, 1.0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for light in [0.0f32, 0.3, knee] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
|
||||
"{paper}/{peak}: {light}"
|
||||
);
|
||||
}
|
||||
for &(light, want) in checkpoints {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others; a
|
||||
// half float's step in gamma is 0.1% of the light near white.
|
||||
let headroom = (peak / paper).max(1.0);
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 0.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
r >= last * 0.998 && r <= headroom * 1.002,
|
||||
"{peak}: {light}: {r}"
|
||||
);
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
|
||||
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
|
||||
hdr.peak = 300.0;
|
||||
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
|
||||
// the curve's source follows the display, the knee at 60% of its peak and at least
|
||||
// white, as before one peak. At 300 nits the source's peak is 645 nits.
|
||||
hdr.lighting = false;
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.5f32, 0.9, 1.0] {
|
||||
for light in [0.0f32, 0.5, 0.9, 1.0] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
|
||||
}
|
||||
for (light, want) in [
|
||||
(1.2, 1.16839),
|
||||
@@ -1560,12 +1740,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(2.0, 1.44024),
|
||||
(4.0, headroom),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// No headroom (a peak below paper white): a clip at white, channel by channel.
|
||||
// With no headroom there, a clip at white, channel by channel.
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
@@ -1582,7 +1762,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
|
||||
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
|
||||
hdr.masks.parts = 0.4;
|
||||
hdr.tiers[1].share = 0.7;
|
||||
hdr.tiers[1].gain = 3.0;
|
||||
hdr.debug = true;
|
||||
hdr.calibration = Some(super::Calibration {
|
||||
step: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, legacy_light, texel},
|
||||
tests::{gpu, legacy_light, texel, writers_lift},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
|
||||
}
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
@fragment fn reference() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[24..28].copy_from_slice(&gains);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let mut count = 0;
|
||||
for fog in [0.0, 0.5, 1.0] {
|
||||
for visibility in [1.0, 0.4] {
|
||||
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
|
||||
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 255);
|
||||
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 16);
|
||||
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
}))
|
||||
.unwrap();
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for &alpha in alphas {
|
||||
|
||||
@@ -2770,6 +2770,8 @@ file = \"b.wgsl\"
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
// As the renderer has it under a pack that draws: Horizon lights none of it.
|
||||
display.lighting = false;
|
||||
if switched {
|
||||
use super::super::hdr::Output::{Pq, ScRgb};
|
||||
display = display.retargeted(&device, &queue, ScRgb);
|
||||
|
||||
@@ -47,7 +47,7 @@ struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
color: [f32; 4],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ impl Stars {
|
||||
clip_from_sky: clip_from_sky.to_cols_array_2d(),
|
||||
color: [b; 4],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
|
||||
@@ -5,7 +5,7 @@ struct Stars {
|
||||
// starBrightness in every channel (RenderSystem.setShaderColor).
|
||||
color: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> stars: Stars;
|
||||
@@ -20,7 +20,7 @@ struct Stars {
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
|
||||
@@ -360,13 +360,13 @@ impl Post {
|
||||
&mut self,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
gains: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, shares, windows)
|
||||
(true, settings, gains, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -20,15 +20,20 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
|
||||
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
|
||||
// none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
|
||||
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
|
||||
scene_peak: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@@ -155,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
@@ -251,33 +256,52 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
return display(light, pos);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
|
||||
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
|
||||
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
|
||||
// textures keep their detail; at least paper white.
|
||||
fn tone_knee(headroom: f32) -> f32 {
|
||||
return max(0.6 * headroom, 1.0);
|
||||
}
|
||||
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
|
||||
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
|
||||
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
|
||||
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
|
||||
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
|
||||
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
|
||||
// and no channel passes the peak.
|
||||
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
|
||||
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
|
||||
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
|
||||
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
|
||||
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
|
||||
//
|
||||
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
|
||||
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
|
||||
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
|
||||
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
|
||||
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
|
||||
// `tone_knee`, PQ's 10 000 nits at most.
|
||||
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
|
||||
let peak = pq(vec3(headroom * paper)).x;
|
||||
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
let e = pq(vec3(top * paper)).x / source;
|
||||
var source: f32;
|
||||
var e: f32;
|
||||
var held = 1.0;
|
||||
if scene_peak > 0.0 {
|
||||
held = min(1.0, scene_peak / max(top, 1e-6));
|
||||
if scene_peak <= headroom {
|
||||
return held;
|
||||
}
|
||||
source = pq(vec3(scene_peak * paper)).x;
|
||||
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
|
||||
} else {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
e = pq(vec3(top * paper)).x / source;
|
||||
}
|
||||
let max_lum = peak / source;
|
||||
let ks = 1.5 * max_lum - 0.5;
|
||||
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
|
||||
let ks = max(1.5 * max_lum - 0.5, 0.0);
|
||||
if e >= 1.0 {
|
||||
return headroom / top;
|
||||
}
|
||||
if e <= ks {
|
||||
return 1.0;
|
||||
return held;
|
||||
}
|
||||
let t = (e - ks) / (1.0 - ks);
|
||||
let t2 = t * t;
|
||||
@@ -287,16 +311,16 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
|
||||
// knee stays exactly as stored. With no headroom, a clip at white.
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
|
||||
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
|
||||
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
|
||||
if params.peak <= 1.0 {
|
||||
if params.scene_peak == 0.0 && params.peak <= 1.0 {
|
||||
return vec4(min(g, vec3(1.0)), 1.0);
|
||||
}
|
||||
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
|
||||
let s = tone_scale(top, params.peak, params.paper * 80.0);
|
||||
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
|
||||
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
|
||||
}
|
||||
|
||||
@@ -331,7 +355,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
}
|
||||
|
||||
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
|
||||
// paper white in linear light. Unlike the display curve (whose knee is at least white),
|
||||
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
|
||||
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
|
||||
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
|
||||
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -27,7 +27,8 @@ struct Lit {
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
|
||||
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
|
||||
var lifted = false;
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
@@ -60,7 +61,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
|
||||
var gain = 1.0 + lifts[tier] * lift;
|
||||
if eased {
|
||||
gain = min(gain, settings.x / max(top, 1e-4));
|
||||
}
|
||||
@@ -88,7 +89,8 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
|
||||
return round(saturate(mark) * 255.0) / 255.0;
|
||||
}
|
||||
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
|
||||
let shows = settings.z > 0.5 && settings.x > 1.0;
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
|
||||
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
|
||||
// display; the tone curve fits it to the display afterwards (fs_tonemap).
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
|
||||
}
|
||||
@@ -396,6 +396,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_some());
|
||||
@@ -417,6 +418,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_none());
|
||||
|
||||
@@ -2259,22 +2259,14 @@ impl Mesher<'_> {
|
||||
translucent: face.translucent,
|
||||
opaque: face.opaque,
|
||||
};
|
||||
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
|
||||
let tile = |t: TileLayer| match t {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
let own_translucent = self.translucent(own);
|
||||
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
|
||||
match pick {
|
||||
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
|
||||
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
|
||||
Pick::Tile(t) => {
|
||||
let v = corners.map(|c| c.0);
|
||||
self.quad_as(out, force_solid, tile(t), v, false, tint);
|
||||
}
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
@@ -2283,7 +2275,7 @@ impl Mesher<'_> {
|
||||
.map(|(s, t)| bilinear(&corners, s, t));
|
||||
let quarter = quarter_of(&piece);
|
||||
let piece = piece.map(|c| c.0);
|
||||
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
|
||||
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2296,12 +2288,7 @@ impl Mesher<'_> {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let face_translucent = match pick {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
Pick::Nothing => false,
|
||||
};
|
||||
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
// rotations).
|
||||
@@ -2450,6 +2437,21 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
|
||||
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
|
||||
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
|
||||
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
|
||||
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
|
||||
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
|
||||
match t {
|
||||
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: own_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
}
|
||||
}
|
||||
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
|
||||
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
|
||||
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
|
||||
@@ -2574,6 +2576,38 @@ mod quarter_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_draws_in_its_faces_pass() {
|
||||
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
|
||||
let blade = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: true,
|
||||
opaque: false,
|
||||
};
|
||||
let face = |translucent| TileLayer::Layer {
|
||||
layer: 1,
|
||||
translucent,
|
||||
opaque: !translucent,
|
||||
};
|
||||
let cut = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(in_face_pass(blade, face(false), false), cut);
|
||||
// Over stained glass the tile blends, as the face does.
|
||||
let blend = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: true,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(in_face_pass(blade, face(true), true), blend);
|
||||
assert_eq!(
|
||||
in_face_pass(TileLayer::Default, face(false), false),
|
||||
face(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_take_the_cubes_own_uvs() {
|
||||
use horizon_assets::model::Direction as D;
|
||||
|
||||
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
var color = oit_cloud_color(in, front, true);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
|
||||
globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
var color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
globals.hdr_light.y, f * globals.fog_color.a));
|
||||
// Convert the surface before weighting; its mark never belongs to the light behind it.
|
||||
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
|
||||
}
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
@@ -1100,6 +1100,9 @@ impl PackScreen {
|
||||
0,
|
||||
);
|
||||
self.search.draw(&mut out, m, blink_ms, None);
|
||||
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
for side in 0..2 {
|
||||
self.draw_list(&mut out, m, side, mouse);
|
||||
}
|
||||
@@ -1302,6 +1305,9 @@ impl PackScreen {
|
||||
field.x = qx;
|
||||
field.y = qy;
|
||||
field.draw(out, m, 0, None);
|
||||
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
let (sbx, sby) = self.browser_search_button();
|
||||
d::button(
|
||||
out,
|
||||
|
||||
@@ -4693,20 +4693,19 @@ impl ParticleEngine {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`), turned by
|
||||
/// `Camera.rotation()`: `rotationYXZ(PI - yRot, -xRot, 0)`.
|
||||
pub fn render(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
base,
|
||||
partial,
|
||||
0.0,
|
||||
None,
|
||||
@@ -4721,8 +4720,7 @@ impl ParticleEngine {
|
||||
pub fn render_culled(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
culling_enabled: bool,
|
||||
@@ -4732,13 +4730,12 @@ impl ParticleEngine {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !culling_enabled {
|
||||
self.render(camera, yaw, pitch, partial, light_at, out);
|
||||
self.render(camera, base, partial, light_at, out);
|
||||
return;
|
||||
}
|
||||
self.render_inner::<true>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
base,
|
||||
partial,
|
||||
pixel_focal_length,
|
||||
frustum,
|
||||
@@ -4751,8 +4748,7 @@ impl ParticleEngine {
|
||||
fn render_inner<const CULL: bool>(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
frustum: Option<&crate::gfx::Frustum>,
|
||||
@@ -4760,9 +4756,6 @@ impl ParticleEngine {
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
@@ -5173,12 +5166,11 @@ mod tests {
|
||||
|
||||
let light_at = |_: [i32; 3]| 0xf0;
|
||||
let mut regular = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &light_at, &mut regular);
|
||||
let mut disabled = Vec::new();
|
||||
engine.render_culled(
|
||||
DVec3::ZERO,
|
||||
0.0,
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
0.5,
|
||||
900.0,
|
||||
false,
|
||||
@@ -5207,6 +5199,36 @@ mod tests {
|
||||
assert_eq!(attributes(&disabled), attributes(®ular));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_face_the_rotation_they_are_given() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let sprite = "test:smoke".to_owned();
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.atlas.insert(sprite.clone(), (rect, false));
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut particle =
|
||||
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
|
||||
particle.sprite = Sprite::Named(sprite);
|
||||
engine.particles.push(particle);
|
||||
let draw = |rotation: Quat| {
|
||||
let mut out = Vec::new();
|
||||
engine.render(DVec3::ZERO, rotation, 0.5, &|_| 0xf0, &mut out);
|
||||
out.iter().map(|v| v.pos).collect::<Vec<_>>()
|
||||
};
|
||||
// A bed's view turns the quads as the player's own yaw would not.
|
||||
let ahead = draw(Quat::IDENTITY);
|
||||
let turned = draw(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
|
||||
assert_eq!(ahead.len(), 4);
|
||||
assert_ne!(ahead, turned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
@@ -5600,7 +5622,7 @@ mod tests {
|
||||
particle.sprite = Sprite::Index(0);
|
||||
engine.particles.push(particle);
|
||||
let mut vertices = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.0, &|_| 0, &mut vertices);
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.0, &|_| 0, &mut vertices);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
|
||||
@@ -143,21 +143,39 @@ mod tests {
|
||||
p.size = 0.1;
|
||||
e.particles.push(p.clone());
|
||||
let mut vertices = Vec::new();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
));
|
||||
e.particles[0].set_visible(false);
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert!(vertices.is_empty());
|
||||
assert_eq!(e.particles[0].age, 2);
|
||||
e.pending.push(copy);
|
||||
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
assert!(e.pending.is_empty());
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert!(vertices.is_empty());
|
||||
e.tick_particle(&mut p, &level);
|
||||
assert!(p.removed);
|
||||
|
||||
@@ -1971,7 +1971,7 @@ mod tests {
|
||||
// The camera 5 blocks south, at the particle's height.
|
||||
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
|
||||
let mut out = Vec::new();
|
||||
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
|
||||
engine.render(camera, glam::Quat::IDENTITY, 0.0, &|_| 0xF0, &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
for v in &out {
|
||||
// The texture's top row (v 0) under the centre, its bottom row over it.
|
||||
@@ -2023,8 +2023,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 64.0, 6.0),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
@@ -2138,8 +2137,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 70.0, 1.0),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
|
||||
@@ -580,6 +580,15 @@ pub fn button(
|
||||
"widget/button"
|
||||
};
|
||||
sprite(out, sprite_id, x, y, w, h);
|
||||
// `AbstractWidget.WithInactiveMessage.defaultInactiveMessage`: an inactive
|
||||
// widget's message is -6250336 (0xFFA0A0A0), not the active white.
|
||||
let inactive;
|
||||
let label = if active {
|
||||
label
|
||||
} else {
|
||||
inactive = label.clone().with_color(0xFFA0_A0A0);
|
||||
&inactive
|
||||
};
|
||||
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
|
||||
}
|
||||
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
|
||||
@@ -1150,6 +1159,33 @@ mod button_label_tests {
|
||||
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
|
||||
out
|
||||
}
|
||||
/// `WithInactiveMessage`: a disabled button's message is grey, not white.
|
||||
#[test]
|
||||
fn an_inactive_buttons_label_is_grey() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut out = Vec::new();
|
||||
button(
|
||||
&mut out,
|
||||
&m,
|
||||
&Text::literal("Go"),
|
||||
10,
|
||||
20,
|
||||
100,
|
||||
20,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
let color = out
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
Draw::Text { text, .. } => text.spans.iter().find_map(|s| s.style.color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the label is drawn with its inactive style");
|
||||
assert_eq!(color & 0xFF_FFFF, 0xA0_A0A0);
|
||||
}
|
||||
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
|
||||
draws.iter().find_map(|d| match d {
|
||||
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
|
||||
|
||||
@@ -310,6 +310,9 @@ pub(crate) fn search_field(
|
||||
let frame = [x, y, clear[0] - x, h];
|
||||
search.frame = Some(frame);
|
||||
search.draw(batch, atlas, None);
|
||||
if ui::hovered(cursor, x, y, frame[2], h) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
|
||||
@@ -506,8 +506,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, the extra areas, and the forced chunks.
|
||||
queue_basis: forced_ring::Basis,
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -1071,6 +1071,7 @@ pub struct WorkerContext {
|
||||
}
|
||||
mod bounded;
|
||||
mod forced_cover;
|
||||
mod forced_ring;
|
||||
mod relight_now;
|
||||
use bounded::nearest_first;
|
||||
use forced_cover::ForcedCover;
|
||||
@@ -1354,11 +1355,9 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
if !forced_ring::basis_changed(self) {
|
||||
return;
|
||||
}
|
||||
self.queue_basis = (basis, self.extra.clone());
|
||||
let mut queue = self.shared.queue.lock().unwrap();
|
||||
rank_again(&mut queue, |x, z| self.distance(x, z));
|
||||
}
|
||||
@@ -2862,7 +2861,7 @@ impl Ranges<'_> {
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
fn player_distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
dx * dx + dz * dz
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! How a forced chunk's ring is ranked (#13175).
|
||||
use super::{Band, FORCED_TERRAIN_RING, Ranges, Streaming};
|
||||
use std::collections::HashSet;
|
||||
|
||||
type Chunks = HashSet<(i32, i32)>;
|
||||
|
||||
/// What queued jobs were ranked from (`Streaming::requeue`): the centre, the
|
||||
/// band, the extra areas, and the forced chunks.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Basis {
|
||||
player: ((i32, i32), Option<Band>),
|
||||
extra: Vec<((i32, i32), i32)>,
|
||||
forced: (Chunks, Chunks),
|
||||
}
|
||||
|
||||
/// Whether the queued jobs' ranking is out of date; it is brought up to date.
|
||||
pub(super) fn basis_changed(s: &mut Streaming) -> bool {
|
||||
let b = &mut s.queue_basis;
|
||||
let same = b.player == (s.center, s.band)
|
||||
&& b.extra == s.extra
|
||||
&& b.forced.0 == s.forced
|
||||
&& b.forced.1 == s.mod_forced;
|
||||
if !same {
|
||||
b.player = (s.center, s.band);
|
||||
b.extra.clone_from(&s.extra);
|
||||
b.forced.0.clone_from(&s.forced);
|
||||
b.forced.1.clone_from(&s.mod_forced);
|
||||
}
|
||||
!same
|
||||
}
|
||||
|
||||
impl Ranges<'_> {
|
||||
/// `player_distance`, or the nearer one to a forced chunk whose ring reaches
|
||||
/// (x, z): see `forced_distance`.
|
||||
pub(super) fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.player_distance(x, z).min(self.forced_distance(x, z))
|
||||
}
|
||||
|
||||
/// Distance (squared) to the nearest forced chunk whose ring reaches
|
||||
/// (x, z), or `i64::MAX`. A forced chunk's ring is loaded with its ticket
|
||||
/// (levels 32 to 35 around the FORCED ticket's 31), so the 3x3 it needs to
|
||||
/// tick is ranked around it, not by how far it lies from the player: vanilla
|
||||
/// ticks the chunk on the first tick (`ChunkMap.prepareTickingChunk`).
|
||||
pub(super) fn forced_distance(&self, x: i32, z: i32) -> i64 {
|
||||
const SIDE: usize = (2 * FORCED_TERRAIN_RING + 1) as usize;
|
||||
let held = self.forced.len() + self.mod_forced.len();
|
||||
if held == 0 {
|
||||
return i64::MAX;
|
||||
}
|
||||
let d = |f: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - f.0), i64::from(z - f.1));
|
||||
dx * dx + dz * dz
|
||||
};
|
||||
if held <= SIDE * SIDE {
|
||||
return self
|
||||
.forced_chunks()
|
||||
.filter(|f| (x - f.0).abs().max((z - f.1).abs()) <= FORCED_TERRAIN_RING)
|
||||
.map(|&f| d(f))
|
||||
.min()
|
||||
.unwrap_or(i64::MAX);
|
||||
}
|
||||
// Many forced chunks: look at the columns around (x, z) instead.
|
||||
(-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING)
|
||||
.flat_map(|dz| (-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING).map(move |dx| (dx, dz)))
|
||||
.map(|(dx, dz)| (x + dx, z + dz))
|
||||
.filter(|f| self.is_forced(f.0, f.1))
|
||||
.map(d)
|
||||
.min()
|
||||
.unwrap_or(i64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{GenerationInputs, GenerationScan, NO_CHUNKS};
|
||||
use super::*;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::world::World;
|
||||
|
||||
/// A forced chunk's ring is loaded with its ticket, so it ranks around
|
||||
/// the forced chunk and not by how far it lies from the player. Vanilla
|
||||
/// then ticks the chunk on the first tick after a reload
|
||||
/// (`ChunkMap.prepareTickingChunk`: its 3x3 is full at once); ranked from
|
||||
/// the player at the origin, a ring 4096 blocks out arrived west/north
|
||||
/// first, over tens of ticks.
|
||||
#[test]
|
||||
fn a_forced_chunks_ring_is_ranked_around_it() {
|
||||
let ranges = |forced| Ranges {
|
||||
center: (0, 0),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let forced: HashSet<(i32, i32)> = [(256, 256)].into();
|
||||
let r = ranges(&forced);
|
||||
// Squared distance to the forced chunk, by hand: (255, 254) is
|
||||
// one west and two north of it.
|
||||
assert_eq!(r.priority(256, 256), 0);
|
||||
assert_eq!(r.priority(255, 254), 1 + 4);
|
||||
assert_eq!(r.priority(260, 252), 16 + 16);
|
||||
// Past the fourth ring (level 36 and up) the player's distance rules,
|
||||
// as does the whole map without a forced chunk.
|
||||
assert_eq!(r.priority(261, 256), 261 * 261 + 256 * 256);
|
||||
let none = HashSet::new();
|
||||
assert_eq!(ranges(&none).priority(255, 254), 255 * 255 + 254 * 254);
|
||||
// Many forced chunks (the neighbour lookups) agree with few.
|
||||
let many: HashSet<(i32, i32)> = (0..100).map(|i| (256 + 20 * i, 256)).collect();
|
||||
assert_eq!(ranges(&many).priority(255, 254), 5);
|
||||
assert_eq!(ranges(&many).priority(276, 258), 4);
|
||||
// The generation plan fetches the 3x3 the chunk ticks with first.
|
||||
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let (generating, finishing) = (FxHashSet::default(), FxHashSet::default());
|
||||
let protos = FxHashMap::default();
|
||||
let input = GenerationInputs {
|
||||
ranges: ranges(&forced),
|
||||
world: &world,
|
||||
generating: &generating,
|
||||
protos: &protos,
|
||||
finishing: &finishing,
|
||||
decorates: true,
|
||||
remote: false,
|
||||
};
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(input, usize::MAX)
|
||||
.expect("a scan");
|
||||
let ring: Vec<(i32, i32)> = candidates
|
||||
.iter()
|
||||
.map(|&(_, x, z)| (x, z))
|
||||
.filter(|&(x, z)| (x - 256).abs().max((z - 256).abs()) <= FORCED_TERRAIN_RING)
|
||||
.collect();
|
||||
assert_eq!(ring.len(), 81);
|
||||
assert_eq!(ring[0], (256, 256));
|
||||
let d = |(x, z): (i32, i32)| (x - 256).pow(2) + (z - 256).pow(2);
|
||||
for pair in ring.windows(2) {
|
||||
assert!(
|
||||
d(pair[0]) <= d(pair[1]),
|
||||
"{:?} before {:?}",
|
||||
pair[0],
|
||||
pair[1]
|
||||
);
|
||||
}
|
||||
let first_nine: HashSet<(i32, i32)> = ring[..9].iter().copied().collect();
|
||||
let expected: HashSet<(i32, i32)> = (-1..=1)
|
||||
.flat_map(|dx| (-1..=1).map(move |dz| (256 + dx, 256 + dz)))
|
||||
.collect();
|
||||
assert_eq!(first_nine, expected);
|
||||
}
|
||||
|
||||
/// Jobs queued before a chunk was forced follow it once it is
|
||||
/// (`Streaming::requeue` ranks them again).
|
||||
#[test]
|
||||
fn queued_jobs_follow_a_forced_chunk() {
|
||||
use super::super::{Job, Queued, rank_again};
|
||||
use std::collections::BinaryHeap;
|
||||
let job = |x, z, order| Queued {
|
||||
priority: 0,
|
||||
at: Some((x, z)),
|
||||
bias: 0,
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let first_after = |forced: &HashSet<(i32, i32)>| {
|
||||
let ranges = Ranges {
|
||||
center: (0, 0),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
// (255, 254) lies nearer the origin than (258, 256), the
|
||||
// forced chunk's neighbour two chunks east.
|
||||
let mut queue = BinaryHeap::from(vec![job(255, 254, 1), job(258, 256, 2)]);
|
||||
rank_again(&mut queue, |x, z| ranges.priority(x, z));
|
||||
queue.pop().and_then(|q| q.at)
|
||||
};
|
||||
assert_eq!(first_after(&HashSet::new()), Some((255, 254)));
|
||||
assert_eq!(first_after(&[(256, 256)].into()), Some((258, 256)));
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(finished.rgb, 1.0));
|
||||
}
|
||||
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(color, 1.0));
|
||||
}
|
||||
|
||||
@@ -1104,6 +1104,34 @@ pub fn tooltip_menu(
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: a keyboard-focused widget's tooltip.
|
||||
pub fn tooltip_focused(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let (x, y) = focused_tooltip_position(
|
||||
(batch.width as i32, batch.height as i32),
|
||||
rect.map(|v| v as i32),
|
||||
(w.ceil() as i32, h.ceil() as i32),
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
|
||||
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: below, or above when it does not fit.
|
||||
fn focused_tooltip_position(screen: (i32, i32), rect: [i32; 4], size: (i32, i32)) -> (i32, i32) {
|
||||
let [left, top, width, height] = rect;
|
||||
let mut x = left + 3;
|
||||
let mut y = top + height + 4;
|
||||
if y + size.1 + 3 > screen.1 {
|
||||
y = top - size.1 - 4;
|
||||
}
|
||||
if x + size.0 > screen.0 {
|
||||
x = (left + width - size.0 - 3).max(4);
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// A widget's tooltip while the controller's focus is on it (Horizon's: vanilla has no gamepad
|
||||
/// input, and the hidden pointer mid-widget would put it over the widget).
|
||||
pub fn tooltip_under(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
|
||||
@@ -2614,6 +2642,33 @@ mod tests {
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
#[test]
|
||||
fn a_keyboard_focused_tooltip_uses_vanillas_widget_edges() {
|
||||
let screen = (320, 240);
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 40)),
|
||||
(10, 184)
|
||||
);
|
||||
// Exact fit below; one pixel taller goes above.
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 53)),
|
||||
(10, 184)
|
||||
);
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 54)),
|
||||
(10, 102)
|
||||
);
|
||||
// Right overflow aligns against the widget's right edge, not the screen's.
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [250, 100, 60, 20], (170, 40)),
|
||||
(137, 124)
|
||||
);
|
||||
assert_eq!(
|
||||
focused_tooltip_position((120, 240), [7, 160, 98, 20], (170, 40)),
|
||||
(4, 184)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_focused_tooltip_hangs_from_its_widgets_bottom_left() {
|
||||
let screen = (427.0, 240.0);
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum Cursor {
|
||||
#[default]
|
||||
Arrow,
|
||||
PointingHand,
|
||||
/// `CursorTypes.IBEAM`: over a text field that takes input.
|
||||
Ibeam,
|
||||
NotAllowed,
|
||||
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
|
||||
ResizeNs,
|
||||
@@ -55,6 +57,18 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
|
||||
/// forbidden sign when it takes no input.
|
||||
pub fn text_cursor(hovered: bool, editable: bool) {
|
||||
if hovered {
|
||||
request_cursor(if editable {
|
||||
Cursor::Ibeam
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor the frame asked for, and back to the arrow for the next one
|
||||
/// (the window shows the arrow unless a widget asks).
|
||||
pub fn take_cursor() -> Cursor {
|
||||
@@ -429,6 +443,16 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
|
||||
mod cursor_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hovered_text_field_asks_for_the_i_beam() {
|
||||
let _ = take_cursor();
|
||||
text_cursor(false, true);
|
||||
assert_eq!(take_cursor(), Cursor::Arrow);
|
||||
text_cursor(true, true);
|
||||
assert_eq!(take_cursor(), Cursor::Ibeam);
|
||||
text_cursor(true, false);
|
||||
assert_eq!(take_cursor(), Cursor::NotAllowed);
|
||||
}
|
||||
#[test]
|
||||
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
|
||||
let _ = take_cursor();
|
||||
|
||||
@@ -319,6 +319,12 @@ impl World {
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// `Entity.checkBelowWorld`'s floor, `Level.getMinY() - 64`: the level's own
|
||||
/// lowest Y (0 in the Nether and the End), not the chunks' widened column.
|
||||
/// An entity below it is out of the world.
|
||||
pub fn below_world_y(&self) -> f64 {
|
||||
f64::from(self.shape.min_y - 64)
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
|
||||
@@ -73,6 +73,8 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -862,7 +862,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
|
||||
self.st.fall_distance *= 0.5;
|
||||
}
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
if self.position().y < self.scene.world.below_world_y() {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
@@ -30,6 +31,8 @@ pub struct Banners {
|
||||
dyes: HashMap<Arc<str>, Arc<str>>,
|
||||
/// Item -> `provides_banner_patterns`, in tag order.
|
||||
provided: HashMap<Arc<str>, Vec<Arc<str>>>,
|
||||
/// Banner-pattern tag id -> its members, for a component that names a tag.
|
||||
pattern_tags: HashMap<String, Vec<Arc<str>>>,
|
||||
}
|
||||
|
||||
fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
|
||||
@@ -98,6 +101,31 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
// Every tag, `pattern_item/globe` included, for a component naming one.
|
||||
let tags = root.join("data/minecraft/tags/banner_pattern");
|
||||
let mut dirs = vec![tags.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for path in fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
{
|
||||
if path.is_dir() {
|
||||
dirs.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json")
|
||||
&& let Some(name) = path
|
||||
.strip_prefix(&tags)
|
||||
.ok()
|
||||
.and_then(|p| p.with_extension("").to_str().map(|s| s.replace('\\', "/")))
|
||||
{
|
||||
let members = read_tag(root, &name, 0);
|
||||
banners
|
||||
.pattern_tags
|
||||
.insert(format!("minecraft:{name}"), members);
|
||||
}
|
||||
}
|
||||
}
|
||||
let owned_defaults;
|
||||
let defaults = match defaults {
|
||||
Some(defaults) => Some(defaults),
|
||||
@@ -159,27 +187,88 @@ impl Banners {
|
||||
}
|
||||
/// `LoomMenu.isDyeItem`: the dye colour of a loom dye.
|
||||
pub fn loom_dye(&self, items: &Items, stack: &ItemStack) -> Option<Arc<str>> {
|
||||
let tagged = items
|
||||
if !items
|
||||
.item_tags
|
||||
.get("minecraft:loom_dyes")
|
||||
.is_some_and(|t| t.contains(&*stack.item));
|
||||
tagged
|
||||
.then(|| self.dyes.get(&stack.item).cloned())
|
||||
.flatten()
|
||||
.is_some_and(|t| t.contains(&*stack.item))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:dye").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(Tag::String(color)) = data.and_then(|d| d.other("minecraft:dye")) {
|
||||
return Some(Arc::from(
|
||||
color.to_string_lossy().trim_start_matches("minecraft:"),
|
||||
));
|
||||
}
|
||||
self.dyes.get(&stack.item).cloned()
|
||||
}
|
||||
/// `LoomMenu.isPatternItem`.
|
||||
/// `LoomMenu.isPatternItem`: the item tag and a present
|
||||
/// `provides_banner_patterns` component (the stack's, else the item's).
|
||||
pub fn is_pattern_item(&self, items: &Items, stack: &ItemStack) -> bool {
|
||||
items
|
||||
if !items
|
||||
.item_tags
|
||||
.get("minecraft:loom_patterns")
|
||||
.is_some_and(|t| t.contains(&*stack.item))
|
||||
&& self.provided.contains_key(&stack.item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
|
||||
return false;
|
||||
}
|
||||
data.is_some_and(|d| d.other("minecraft:provides_banner_patterns").is_some())
|
||||
|| self.provided.contains_key(&stack.item)
|
||||
}
|
||||
/// `LoomMenu.getSelectablePatterns`.
|
||||
pub fn selectable(&self, pattern_item: Option<&ItemStack>) -> Vec<Arc<str>> {
|
||||
match pattern_item {
|
||||
None => self.no_item_required.clone(),
|
||||
Some(stack) => self.provided.get(&stack.item).cloned().unwrap_or_default(),
|
||||
Some(stack) => {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
|
||||
return Vec::new();
|
||||
}
|
||||
match data.and_then(|d| d.other("minecraft:provides_banner_patterns")) {
|
||||
Some(tag) => self.provides(tag),
|
||||
None => self.provided.get(&stack.item).cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A `provides_banner_patterns` value: a pattern id, a tag, or a list of
|
||||
/// either (`HolderSet`'s saved forms).
|
||||
fn provides(&self, tag: &Tag) -> Vec<Arc<str>> {
|
||||
match tag {
|
||||
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.flat_map(|value| match value {
|
||||
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn provides_one(&self, value: &str) -> Vec<Arc<str>> {
|
||||
// `Identifier.parse`: an id or tag without a namespace is `minecraft`'s.
|
||||
let namespaced = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
match value.strip_prefix('#') {
|
||||
Some(tag) => self
|
||||
.pattern_tags
|
||||
.get(&namespaced(tag))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
None => vec![Arc::from(namespaced(value))],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +338,81 @@ mod tests {
|
||||
assert_eq!(&*b.selectable(Some(&item))[0], "minecraft:flower");
|
||||
}
|
||||
#[test]
|
||||
fn prepared_dye_component() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
let b = Banners::load(&root);
|
||||
let mut dye = ItemStack::new("minecraft:white_dye", 1);
|
||||
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "white");
|
||||
dye.data_mut()
|
||||
.set_other("minecraft:dye", Some(Tag::String("red".into())));
|
||||
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "red");
|
||||
dye.data_mut().set_other(
|
||||
"!minecraft:dye",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(b.loom_dye(&items, &dye).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn prepared_pattern_component_presence() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
let b = Banners::load(&root);
|
||||
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
|
||||
assert!(b.is_pattern_item(&items, &pattern));
|
||||
pattern.data_mut().set_other(
|
||||
"!minecraft:provides_banner_patterns",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!b.is_pattern_item(&items, &pattern));
|
||||
}
|
||||
#[test]
|
||||
fn prepared_pattern_component_selection() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let b = Banners::load(&root);
|
||||
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
|
||||
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:globe");
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::List(vec![Tag::String("minecraft:creeper".into())])),
|
||||
);
|
||||
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:creeper");
|
||||
// One id without its namespace, or a tag, as `HolderSet`'s codec reads them.
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::String("creeper".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
b.selectable(Some(&pattern)),
|
||||
[Arc::from("minecraft:creeper")]
|
||||
);
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::String("#pattern_item/globe".into())),
|
||||
);
|
||||
assert_eq!(b.selectable(Some(&pattern)), [Arc::from("minecraft:globe")]);
|
||||
pattern.data_mut().set_other(
|
||||
"!minecraft:provides_banner_patterns",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(b.selectable(Some(&pattern)).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn banner_colour_comes_from_the_item() {
|
||||
assert_eq!(
|
||||
banner_color("minecraft:light_gray_banner"),
|
||||
|
||||
@@ -40,6 +40,17 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
|
||||
.is_some_and(|data| data.get("RecordItem").is_some())
|
||||
}
|
||||
|
||||
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
|
||||
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
|
||||
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
|
||||
/// not the block entity the placement just filled.
|
||||
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
|
||||
.is_some_and(|data| data.get("Book").is_some())
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
|
||||
@@ -139,6 +139,8 @@ pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod telemetry;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_fixtures;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -18,13 +18,28 @@ impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
self.enter_with_delay(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// Enter with the traveller's `Entity.getDimensionChangingDelay`.
|
||||
pub fn enter_with_delay(&mut self, gateway: BlockPos, delay: i32) {
|
||||
self.state.set_inside(gateway, delay);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
self.travel_with_delay(allowed, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Travel with the entity-specific portal cooldown.
|
||||
pub fn travel_with_delay(&mut self, allowed: bool, delay: i32) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, delay)
|
||||
}
|
||||
|
||||
/// `Entity.load` updates its counter without replacing the portal process.
|
||||
pub fn set_cooldown(&mut self, cooldown: i32) {
|
||||
self.state.cooldown = cooldown;
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
@@ -171,6 +186,27 @@ mod tests {
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_specific_cooldowns_refresh_then_expire_outside_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
for delay in [2, 10, 300] {
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter_with_delay(gateway, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
|
||||
assert_eq!(traveller.cooldown(), delay);
|
||||
// Continuing contact refreshes the counter, without another trip.
|
||||
traveller.enter_with_delay(gateway, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), None);
|
||||
assert_eq!(traveller.cooldown(), delay - 1);
|
||||
for _ in 1..delay {
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), None);
|
||||
}
|
||||
assert!(traveller.is_done());
|
||||
traveller.enter_with_delay(gateway, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), Some(gateway));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
|
||||
@@ -84,6 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
pub mod nameplate;
|
||||
mod obstacles;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use obstacles::ObstacleGrid;
|
||||
@@ -948,7 +949,7 @@ impl ItemEntity {
|
||||
if self.age != i16::MIN as i32 {
|
||||
self.age += 1;
|
||||
}
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
self.age < 6000 && self.position.y >= f64::from(world.shape.min_y - 64)
|
||||
}
|
||||
/// `Entity.makeStuckInBlock` as `WebBlock.entityInside` and
|
||||
/// `PowderSnowBlock.entityInside` call it for an item (which, unlike a
|
||||
@@ -1289,8 +1290,41 @@ pub struct ExperienceOrb {
|
||||
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
|
||||
/// until they are gone or further than 8 blocks. Never saved.
|
||||
pub following: Option<u32>,
|
||||
/// `Entity.fallDistance` (`fall_distance` in its save).
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
/// An orb's `Entity.move` this tick, for the fall bookkeeping its caller
|
||||
/// finishes (the long-move cast, `checkFallDamage`'s HIT_GROUND).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OrbMove {
|
||||
pub from: Vec3,
|
||||
pub moved: Vec3,
|
||||
/// `Entity.isInWater` from this tick's fluid step.
|
||||
pub in_water: bool,
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// A fresh orb (`new ExperienceOrb(level, pos, motion, value)`): full
|
||||
/// health, no age, fall or followed player, not yet ticked.
|
||||
pub fn new(id: u32, position: Vec3, velocity: Vec3, value: i32) -> Self {
|
||||
Self {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id,
|
||||
position,
|
||||
previous: position,
|
||||
velocity,
|
||||
value,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
no_gravity: false,
|
||||
following: None,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
|
||||
/// `int`; an orb at 0 or below is discarded. True when it is gone.
|
||||
pub fn hurt(&mut self, damage: f32) -> bool {
|
||||
@@ -1414,7 +1448,7 @@ impl ExperienceOrb {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
self.tick_among(
|
||||
let (stays, _) = self.tick_among(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
@@ -1425,10 +1459,12 @@ impl ExperienceOrb {
|
||||
fluids,
|
||||
on_sound,
|
||||
&[],
|
||||
)
|
||||
);
|
||||
stays
|
||||
}
|
||||
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
|
||||
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
|
||||
/// Also gives the move it made, whose fall bookkeeping the caller finishes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick_among(
|
||||
&mut self,
|
||||
@@ -1442,12 +1478,12 @@ impl ExperienceOrb {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
entities: &[Aabb],
|
||||
) -> bool {
|
||||
) -> (bool, OrbMove) {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
|
||||
// water branch below reads: the orb's eye, not its feet, decides.
|
||||
let eye_in_water = self.update_fluids(world, fluids);
|
||||
let (eye_in_water, in_water) = self.update_fluids(world, fluids);
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
@@ -1514,6 +1550,7 @@ impl ExperienceOrb {
|
||||
let moved = scene
|
||||
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
let from = self.position;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
@@ -1561,7 +1598,12 @@ impl ExperienceOrb {
|
||||
self.velocity.y = -wanted.y * 0.4;
|
||||
}
|
||||
self.age += 1;
|
||||
!discarded && self.age < 6000
|
||||
let step = OrbMove {
|
||||
from,
|
||||
moved,
|
||||
in_water,
|
||||
};
|
||||
(!discarded && self.age < 6000, step)
|
||||
}
|
||||
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
|
||||
/// server, through the orb's existing hurt/discard policy, playing the generic
|
||||
@@ -2020,6 +2062,8 @@ pub struct ArmorStand {
|
||||
/// (`getAttribute`) on the stand's first server tick, and a save that lists
|
||||
/// it loads it back, so a stand summoned this tick has none yet.
|
||||
pub speed_instance: bool,
|
||||
/// `NAME_TAG_DISTANCE` and `BELOW_NAME_DISTANCE` from server attribute updates.
|
||||
pub nameplate: crate::entities::nameplate::NameplateDistances,
|
||||
}
|
||||
/// The fluid a stand travels in (`LivingEntity.travelInFluid`: water first,
|
||||
/// then lava), from the heights `Entity::push_by_fluid` measured.
|
||||
@@ -2103,20 +2147,6 @@ impl ArmorStand {
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
|
||||
/// only a living target changes. Stack consumption belongs to the caller.
|
||||
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:name_tag" {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
if self.health > 0.0 {
|
||||
self.custom_name = Some(crate::component::name_text(name));
|
||||
}
|
||||
true
|
||||
}
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
/// The `Pose` compound's keys.
|
||||
pub const POSE_KEYS: [&'static str; 6] =
|
||||
@@ -2273,6 +2303,7 @@ impl ArmorStand {
|
||||
enchantment_equipment: Default::default(),
|
||||
support: Default::default(),
|
||||
speed_instance: false,
|
||||
nameplate: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
|
||||
@@ -3346,6 +3377,7 @@ mod world_border_tests {
|
||||
None,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4757,6 +4789,7 @@ mod orb_underwater_tests {
|
||||
.iter()
|
||||
.map(|(v, _)| {
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4792,6 +4825,7 @@ mod render_light_tests {
|
||||
|
||||
fn orb(y: f64) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4994,6 +5028,7 @@ mod orb_lava_tests {
|
||||
|
||||
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -159,19 +159,28 @@ impl ItemEntity {
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
|
||||
/// orb's box and pushes its velocity by any water or lava current.
|
||||
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
|
||||
/// `0.425` above the orb's feet.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
|
||||
update_entity_fluids(
|
||||
/// orb's box and pushes its velocity by any water or lava current; water
|
||||
/// resets its fall, and `Entity.baseTick` halves it in lava (`isInLava`
|
||||
/// is false on the orb's first tick, `Entity.firstTick`). Answers
|
||||
/// `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads, `0.425`
|
||||
/// above the orb's feet, and `isInWater`.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (bool, bool) {
|
||||
let (water, lava, eye) = update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + f64::from(0.425f32),
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
)
|
||||
.2
|
||||
);
|
||||
if water > 0.0 {
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
// `tick_among` counts this tick first: `tickCount` is 1 on the first.
|
||||
if lava > 0.0 && self.ticks > 1 {
|
||||
self.fall_distance *= 0.5;
|
||||
}
|
||||
(eye, water > 0.0)
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
@@ -471,6 +480,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -562,6 +572,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.6, 20.5);
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The nameplate attributes carried by armor stands and mobs.
|
||||
use super::ArmorStand;
|
||||
use crate::items::ItemStack;
|
||||
use crate::mobs::Mob;
|
||||
|
||||
/// The two synced attributes that control name tags and below-name lines.
|
||||
/// `None` means the server has not sent a sync for that attribute yet.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct NameplateDistances {
|
||||
/// `minecraft:name_tag_distance` from the server's attribute updates.
|
||||
pub name_tag: Option<f64>,
|
||||
/// `minecraft:below_name_distance` from the server's attribute updates.
|
||||
pub below_name: Option<f64>,
|
||||
}
|
||||
|
||||
impl NameplateDistances {
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`: the
|
||||
/// default is 64 and vanilla's maximum is 512.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
name_tag_distance_of(self.name_tag)
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`: the
|
||||
/// default is 10 and vanilla's maximum is 512.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
below_name_distance_of(self.below_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mob {
|
||||
/// Stores both distances, from a model's own live attributes.
|
||||
pub fn set_nameplate(&mut self, distances: NameplateDistances) {
|
||||
let attributes = self.extra.attributes.get_or_insert_with(Default::default);
|
||||
attributes.name_tag_distance = distances.name_tag;
|
||||
attributes.below_name_distance = distances.below_name;
|
||||
}
|
||||
|
||||
/// Stores `NAME_TAG_DISTANCE` from a server attribute update.
|
||||
pub fn set_name_tag_distance(&mut self, value: f64) {
|
||||
self.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Default::default)
|
||||
.name_tag_distance = Some(value);
|
||||
}
|
||||
|
||||
/// Stores `BELOW_NAME_DISTANCE` from a server attribute update.
|
||||
pub fn set_below_name_distance(&mut self, value: f64) {
|
||||
self.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Default::default)
|
||||
.below_name_distance = Some(value);
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
name_tag_distance_of(
|
||||
self.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
.and_then(|attributes| attributes.name_tag_distance),
|
||||
)
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
below_name_distance_of(
|
||||
self.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
.and_then(|attributes| attributes.below_name_distance),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArmorStand {
|
||||
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
|
||||
/// only a living target changes. Stack consumption belongs to the caller.
|
||||
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:name_tag" {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
if self.health > 0.0 {
|
||||
self.custom_name = Some(crate::component::name_text(name));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
self.nameplate.name_tag_distance()
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
self.nameplate.below_name_distance()
|
||||
}
|
||||
}
|
||||
|
||||
/// The vanilla default (64) and maximum (512) for `NAME_TAG_DISTANCE`.
|
||||
pub fn name_tag_distance_of(value: Option<f64>) -> f64 {
|
||||
value
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(64.0)
|
||||
.clamp(0.0, 512.0)
|
||||
}
|
||||
|
||||
/// The vanilla default (10) and maximum (512) for `BELOW_NAME_DISTANCE`.
|
||||
pub fn below_name_distance_of(value: Option<f64>) -> f64 {
|
||||
value
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(10.0)
|
||||
.clamp(0.0, 512.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
fn nameplate_distances_use_vanilla_defaults_and_clamp() {
|
||||
assert_eq!(name_tag_distance_of(None), 64.0);
|
||||
assert_eq!(name_tag_distance_of(Some(5.0)), 5.0);
|
||||
assert_eq!(name_tag_distance_of(Some(600.0)), 512.0);
|
||||
assert_eq!(name_tag_distance_of(Some(-4.0)), 0.0);
|
||||
assert_eq!(below_name_distance_of(None), 10.0);
|
||||
assert_eq!(below_name_distance_of(Some(2.0)), 2.0);
|
||||
assert_eq!(below_name_distance_of(Some(-1.0)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_nameplate_values_override_defaults_and_clamp() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
assert_eq!(stand.name_tag_distance(), 64.0);
|
||||
assert_eq!(stand.below_name_distance(), 10.0);
|
||||
stand.nameplate = NameplateDistances {
|
||||
name_tag: Some(5.0),
|
||||
below_name: Some(2.0),
|
||||
};
|
||||
assert_eq!(
|
||||
(stand.name_tag_distance(), stand.below_name_distance()),
|
||||
(5.0, 2.0)
|
||||
);
|
||||
stand.nameplate.name_tag = Some(600.0);
|
||||
assert_eq!(stand.name_tag_distance(), 512.0);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
mod boat_load;
|
||||
pub use boat_load::boat_from_nbt;
|
||||
mod projectile_fields;
|
||||
use projectile_fields::{load_projectile_entity_fields, put_projectile_entity_fields};
|
||||
mod saved_attributes;
|
||||
@@ -914,15 +916,7 @@ pub fn mob_to_nbt(
|
||||
Tag::Int(m.extra.more_carrot_ticks),
|
||||
);
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
}
|
||||
"minecraft:goat" => crate::mobs::goat::write_nbt(m, map),
|
||||
"minecraft:creeper" => {
|
||||
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
|
||||
map.insert(
|
||||
@@ -2351,11 +2345,7 @@ pub fn mob_from_nbt_with_random(
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
crate::mobs::Species::Goat => crate::mobs::goat::read_nbt(&mut m, tag),
|
||||
_ => {}
|
||||
}
|
||||
// `Rabbit.readAdditionalSaveData`.
|
||||
@@ -2370,8 +2360,8 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
crate::mobs::tame::restore_sitting(&mut m, flag(tag, "Sitting"));
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
|
||||
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Panda {
|
||||
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
|
||||
@@ -2396,8 +2386,8 @@ pub fn mob_from_nbt_with_random(
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
|
||||
m.extra.body_extra = armor.and_then(|s| s.extra);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
|
||||
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2650,7 +2640,7 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) as i8 % 6).unsigned_abs();
|
||||
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
|
||||
m.anim.v[0] = m.extra.counter as f32 * 0.01;
|
||||
@@ -2911,7 +2901,7 @@ pub fn entity_base_tag(
|
||||
motion,
|
||||
rotation,
|
||||
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
|
||||
fall_distance: 0.0,
|
||||
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
|
||||
}
|
||||
.tag(raw)
|
||||
}
|
||||
@@ -3047,6 +3037,7 @@ pub fn arrow_to_nbt(
|
||||
}
|
||||
for (key, value) in [
|
||||
("Fire", Tag::Short(a.fire_ticks as i16)),
|
||||
("Air", Tag::Short(p.air_supply as i16)),
|
||||
("life", Tag::Short(a.life as i16)),
|
||||
("shake", Tag::Byte(a.shake as i8)),
|
||||
("inGround", Tag::Byte(i8::from(a.in_ground))),
|
||||
@@ -3125,6 +3116,7 @@ pub fn arrow_from_nbt(
|
||||
p.owner_uuid = owner;
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
p.on_ground = flag(tag, "OnGround");
|
||||
projectile_fields::load_arrow_entity_fields(&mut p, tag);
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
@@ -3144,8 +3136,6 @@ pub fn arrow_from_nbt(
|
||||
_ => false,
|
||||
})
|
||||
.cloned();
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
a.spectral = spectral;
|
||||
// `SpectralArrow.readAdditionalSaveData`: absent Duration defaults to 200.
|
||||
if spectral {
|
||||
@@ -3472,7 +3462,7 @@ pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
power: i32::from(number(tag.get("ExplosionPower")).unwrap_or(1) as i8),
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
"minecraft:dragon_fireball" => ProjectileKind::DragonFireball,
|
||||
@@ -3702,9 +3692,6 @@ pub fn vehicle_to_nbt(
|
||||
}
|
||||
.tag(raw)
|
||||
}
|
||||
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
|
||||
Some((position(tag)?, motion(tag), rotation(tag)[0]))
|
||||
}
|
||||
/// `Entity.load`'s `OnGround` (`getBooleanOr`: `byteValue() != 0`) and
|
||||
/// `fall_distance` (`getDoubleOr`: any number's `doubleValue`), defaulting to
|
||||
/// false and 0.
|
||||
@@ -4175,6 +4162,30 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// `Shulker` reads `AttachFace` as a byte before
|
||||
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
|
||||
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
|
||||
#[test]
|
||||
fn a_shulkers_attach_face_is_narrowed_to_a_byte_first() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:shulker".into();
|
||||
ty.species = crate::mobs::Species::Shulker;
|
||||
let types = [("minecraft:shulker".into(), kind)].into();
|
||||
let face = |saved: Tag| {
|
||||
let raw = Tag::compound([
|
||||
("id", string("minecraft:shulker")),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
("AttachFace", saved),
|
||||
]);
|
||||
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant3
|
||||
};
|
||||
assert_eq!(face(Tag::Byte(3)), 3);
|
||||
assert_eq!(face(Tag::Byte(-2)), 2);
|
||||
assert_eq!(face(Tag::Int(258)), 2);
|
||||
assert_eq!(face(Tag::Int(250)), 0);
|
||||
}
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
@@ -4222,6 +4233,33 @@ mod tests {
|
||||
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
|
||||
}
|
||||
|
||||
/// An entity that never moves (an `AreaEffectCloud`) saves the
|
||||
/// `fall_distance` `Entity.load` read, and 0 when it was made in play.
|
||||
#[test]
|
||||
fn a_base_tag_keeps_the_loaded_fall_distance() {
|
||||
let raw = horizon_world::nbt::parse_snbt(
|
||||
"{id:\"minecraft:area_effect_cloud\",Pos:[1d,200d,1d],fall_distance:5.0d}",
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let fall = |raw: Option<&super::Tag>| {
|
||||
let tag = super::entity_base_tag(
|
||||
"minecraft:area_effect_cloud",
|
||||
super::Vec3::new(1.0, 200.0, 1.0),
|
||||
super::Vec3::ZERO,
|
||||
[0.0, 0.0],
|
||||
[1; 4],
|
||||
raw,
|
||||
);
|
||||
match tag.get("fall_distance") {
|
||||
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
|
||||
other => panic!("no fall_distance: {other:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(fall(Some(&raw)), 5.0);
|
||||
assert_eq!(fall(None), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
|
||||
let base = Base {
|
||||
@@ -4435,6 +4473,30 @@ mod tests {
|
||||
assert_eq!(load(Tag::Short(7), Tag::Short(19)), (7, 19));
|
||||
}
|
||||
|
||||
/// `Axolotl.Variant.BY_ID` is `OutOfBoundsStrategy.ZERO`: an id outside
|
||||
/// 0..=4 is lucy (0). `Parrot.Variant.BY_ID` is `CLAMP`: it is the nearest
|
||||
/// end.
|
||||
#[test]
|
||||
fn an_unknown_axolotl_variant_is_lucy_and_a_parrots_is_clamped() {
|
||||
let variant = |species, id: &str, saved: i32| {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
let types = [(id.into(), kind)].into();
|
||||
let raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
("Variant", Tag::Int(saved)),
|
||||
]);
|
||||
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant
|
||||
};
|
||||
let axolotl = |saved| variant(crate::mobs::Species::Axolotl, "minecraft:axolotl", saved);
|
||||
let parrot = |saved| variant(crate::mobs::Species::Parrot, "minecraft:parrot", saved);
|
||||
assert_eq!([0, 3, 4, 5, 7, -1].map(axolotl), [0, 3, 4, 0, 0, 0]);
|
||||
assert_eq!([0, 3, 4, 5, 7, -1].map(parrot), [0, 3, 4, 4, 4, 0]);
|
||||
}
|
||||
|
||||
/// A mount keeps its body armour and a helmet (say
|
||||
/// `/item replace entity <horse> armor.head with minecraft:iron_helmet`)
|
||||
/// in their own slots through a save: vanilla holds both (`equipment.body`
|
||||
@@ -4528,6 +4590,35 @@ mod tests {
|
||||
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
|
||||
}
|
||||
|
||||
/// `Wolf` and `Cat` read `CollarColor` with `DyeColor.LEGACY_ID_CODEC`: a
|
||||
/// byte through `DyeColor.byId`, whose out-of-range answer is the first
|
||||
/// colour, white (`OutOfBoundsStrategy.ZERO`). Nothing wraps.
|
||||
#[test]
|
||||
fn a_collar_colour_outside_the_sixteen_reads_white() {
|
||||
for (species, id) in [
|
||||
(crate::mobs::Species::Wolf, "minecraft:wolf"),
|
||||
(crate::mobs::Species::Cat, "minecraft:cat"),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
let types = [(id.into(), kind)].into();
|
||||
let colour = |saved: Option<Tag>| {
|
||||
let mut fields = vec![("id", string(id)), ("Pos", doubles(&[4.5, 220.0, 4.5]))];
|
||||
fields.extend(saved.map(|c| ("CollarColor", c)));
|
||||
let m = mob_from_nbt(&Tag::compound(fields), &types, 1, None).unwrap();
|
||||
m.extra.color
|
||||
};
|
||||
assert_eq!(colour(Some(Tag::Byte(11))), 11, "{id}");
|
||||
assert_eq!(colour(Some(Tag::Byte(17))), 0, "{id}: not 17 & 15");
|
||||
assert_eq!(colour(Some(Tag::Byte(-1))), 0, "{id}: not 15");
|
||||
// Codec.BYTE narrows an int first: 259 is byte 3.
|
||||
assert_eq!(colour(Some(Tag::Int(259))), 3, "{id}");
|
||||
assert_eq!(colour(None), crate::mobs::tame::RED, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
@@ -4771,6 +4862,7 @@ mod tests {
|
||||
no_gravity: true,
|
||||
on_ground: true,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: TRIDENT_HIT_GROUND,
|
||||
item: &trident,
|
||||
},
|
||||
@@ -5776,6 +5868,7 @@ mod tests {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: TRIDENT_HIT_GROUND,
|
||||
item: &trident,
|
||||
},
|
||||
@@ -5916,8 +6009,7 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
|
||||
/// medium salmon or index 0; a missing bucket flag is false.
|
||||
fn read_variant(m: &mut Mob, tag: &Tag) {
|
||||
use crate::mobs::Species as S;
|
||||
// AbstractFish/Axolotl.readAdditionalSaveData: false when absent.
|
||||
// Tadpole.setFromBucket ignores the input.
|
||||
// AbstractFish/Axolotl: false when absent. Tadpole.setFromBucket ignores the input.
|
||||
if matches!(
|
||||
m.species(),
|
||||
S::Cod | S::Salmon | S::Pufferfish | S::TropicalFish | S::Axolotl
|
||||
@@ -5926,7 +6018,8 @@ fn read_variant(m: &mut Mob, tag: &Tag) {
|
||||
}
|
||||
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
|
||||
match m.species() {
|
||||
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
|
||||
S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8, // CLAMP
|
||||
S::Axolotl => m.extra.variant = ((int("Variant") as u32).min(5) % 5) as u8, // ZERO: lucy
|
||||
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
|
||||
S::Rabbit => {
|
||||
m.extra.variant = match int("RabbitType") {
|
||||
@@ -9433,6 +9526,25 @@ mod stand_fall_save_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LargeFireball.readAdditionalSaveData` reads `ExplosionPower` with
|
||||
/// `getByteOr`: an int tag is narrowed (`(byte)(value & 255)`), so 300 is
|
||||
/// 44 and 200 is -56.
|
||||
#[test]
|
||||
fn a_fireball_narrows_its_explosion_power_to_a_byte() {
|
||||
for (saved, expected) in [(Tag::Int(300), 44), (Tag::Int(200), -56), (Tag::Byte(4), 4)] {
|
||||
let input = Tag::compound([
|
||||
("id", string("minecraft:fireball")),
|
||||
("Pos", doubles(&[4.5, 205.0, 4.5])),
|
||||
("ExplosionPower", saved),
|
||||
]);
|
||||
let fireball = fireball_from_nbt(&input, None).unwrap();
|
||||
assert!(
|
||||
matches!(fireball.kind, ProjectileKind::LargeFireball { power } if power == expected),
|
||||
"{expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_stand_fall_distance_survives_save_without_raw_or_with_stale_raw() {
|
||||
let stale = Tag::compound([("fall_distance", Tag::Double(99.0_f64.to_bits()))]);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::{loaded_motion, position, rotation};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Entity.load` for a record with a saved position: clamp coordinates before
|
||||
/// rebuilding the box. Reject invalid positions; motion over 10 resets.
|
||||
pub fn boat_from_nbt(tag: &Tag) -> Option<(Vec3, Vec3, f32)> {
|
||||
let p = position(tag)?;
|
||||
let p = Vec3::new(
|
||||
p.x.clamp(-30_000_512.0, 30_000_512.0),
|
||||
p.y.clamp(-20_000_000.0, 20_000_000.0),
|
||||
p.z.clamp(-30_000_512.0, 30_000_512.0),
|
||||
);
|
||||
p.is_finite()
|
||||
.then(|| (p, loaded_motion(tag), rotation(tag)[0]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_clamps_each_position_axis_and_only_motion_over_ten() {
|
||||
let doubles = |v: [f64; 3]| Tag::List(v.map(|v| Tag::Double(v.to_bits())).to_vec());
|
||||
for (raw, expected) in [
|
||||
(
|
||||
[40_000_000.0, 30_000_000.0, -40_000_000.0],
|
||||
[30_000_512.0, 20_000_000.0, -30_000_512.0],
|
||||
),
|
||||
(
|
||||
[-40_000_000.0, -30_000_000.0, 40_000_000.0],
|
||||
[-30_000_512.0, -20_000_000.0, 30_000_512.0],
|
||||
),
|
||||
([1.25, -64.0, -3.5], [1.25, -64.0, -3.5]),
|
||||
] {
|
||||
let tag = Tag::compound([
|
||||
("Pos", doubles(raw)),
|
||||
("Motion", doubles([10.0, -10.0, 10.00001])),
|
||||
]);
|
||||
let (p, m, _) = boat_from_nbt(&tag).unwrap();
|
||||
assert_eq!(p.components(), expected);
|
||||
assert_eq!(m, Vec3::new(10.0, -10.0, 0.0));
|
||||
}
|
||||
let tag = Tag::compound([("Pos", doubles([0.0, f64::NAN, 0.0]))]);
|
||||
assert!(boat_from_nbt(&tag).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Experience orbs: `ExperienceOrb` save data.
|
||||
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
|
||||
use super::{
|
||||
Base, ExperienceOrb, Tag, flag, float, int_value, motion, number, position, put, rotation,
|
||||
};
|
||||
|
||||
/// `ExperienceOrb`: Health, Age, Value, Count.
|
||||
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
@@ -10,7 +12,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
motion: o.velocity,
|
||||
rotation: o.rotation,
|
||||
on_ground: o.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: o.fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
@@ -35,6 +37,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
|
||||
@@ -45,3 +45,17 @@ pub(super) fn load_projectile_flags_and_entity_fields(p: &mut Projectile, tag: &
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
load_projectile_entity_fields(p, tag);
|
||||
}
|
||||
|
||||
/// `Entity.load` for an arrow: fire and fall distance live on its arrow state,
|
||||
/// air on the projectile and defaults to the 300 of `getMaxAirSupply`.
|
||||
pub(super) fn load_arrow_entity_fields(p: &mut Projectile, tag: &Tag) {
|
||||
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(300);
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
a.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "projectile_fields_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::entity_nbt::{arrow_from_nbt, arrow_to_nbt};
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
|
||||
/// `Entity.load` reads `Air` and `saveWithoutId` writes it back.
|
||||
#[test]
|
||||
fn arrow_keeps_its_saved_air() {
|
||||
let parse = |text: &str| parse_snbt(text).unwrap().0;
|
||||
let raw = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d],Air:32767s,Fire:7s}");
|
||||
let (arrow, _) = arrow_from_nbt(&raw, None, |_| None).unwrap();
|
||||
assert_eq!(arrow.air_supply, 32767);
|
||||
let saved = arrow_to_nbt(&arrow, [197, 0, 0, 1], None, None, Some(&raw)).unwrap();
|
||||
assert_eq!(saved.get("Air"), Some(&Tag::Short(32767)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(7)));
|
||||
let absent = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d]}");
|
||||
let (fresh, _) = arrow_from_nbt(&absent, None, |_| None).unwrap();
|
||||
assert_eq!(fresh.air_supply, 300);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Thrown tridents: `AbstractArrow`, `Projectile` and `ThrownTrident` save data.
|
||||
use super::{
|
||||
Base, Pickup, Tag, Vec3, flag, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
|
||||
Base, Pickup, Tag, Vec3, flag, float, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
|
||||
position, put, rotation, string, text,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct TridentSave<'a> {
|
||||
/// `Entity.onGround`: a trident moves by `setPos`, so it keeps the loaded one.
|
||||
pub on_ground: bool,
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.fallDistance`.
|
||||
pub fall_distance: f64,
|
||||
/// `AbstractArrow.soundEvent`: the sound of its next block hit.
|
||||
pub sound_event: &'a str,
|
||||
pub item: &'a crate::items::ItemStack,
|
||||
@@ -38,7 +40,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
|
||||
motion: t.motion,
|
||||
rotation: [t.yaw, t.pitch],
|
||||
on_ground: t.on_ground,
|
||||
fall_distance: 0.0,
|
||||
fall_distance: t.fall_distance,
|
||||
}
|
||||
.tag(raw);
|
||||
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
|
||||
@@ -95,6 +97,8 @@ pub struct TridentNbt {
|
||||
pub no_gravity: bool,
|
||||
pub on_ground: bool,
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.load`: `getDoubleOr("fall_distance", 0.0)`.
|
||||
pub fall_distance: f64,
|
||||
/// `SoundEvent`, or the trident's default when missing or no identifier.
|
||||
pub sound_event: String,
|
||||
pub item: crate::items::ItemStack,
|
||||
@@ -120,6 +124,7 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i16 as i32,
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
sound_event: text(tag.get("SoundEvent"))
|
||||
.and_then(|s| horizon_client_core::identifier::normalize_id(&s))
|
||||
.unwrap_or_else(|| TRIDENT_HIT_GROUND.to_owned()),
|
||||
|
||||
@@ -1499,8 +1499,8 @@ impl LevelAccess for GameLevel<'_> {
|
||||
signal
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
if let Some(signal) = books::lectern_signal(self.world, self.registry, state, pos) {
|
||||
return signal;
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -3357,11 +3357,10 @@ impl Game<'_> {
|
||||
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
// Minecraft.pick: from the camera entity's eyes along its view
|
||||
// (LivingEntity.getViewYRot: the head's yaw).
|
||||
if let Some(m) = self.camera_mob() {
|
||||
return Some((
|
||||
[m.position.x, m.eye_y(), m.position.z],
|
||||
pick_view_vector(m.head_yaw, m.head_pitch),
|
||||
));
|
||||
if let Some(e) = self.camera_entity() {
|
||||
let [x, y, z] = e.position;
|
||||
let eye = [x, y + f64::from(e.eye_height), z];
|
||||
return Some((eye, pick_view_vector(e.y_rot, e.x_rot)));
|
||||
}
|
||||
Some((self.eye_position()?, pick_view_vector(self.yaw, self.pitch)))
|
||||
}
|
||||
@@ -3404,7 +3403,7 @@ impl Game<'_> {
|
||||
Some(b)
|
||||
}
|
||||
fn outline_context_kind(&self) -> horizon_world::collision_context::EntityContextKind {
|
||||
if self.camera_mob().is_some() {
|
||||
if self.camera_entity().is_some() {
|
||||
horizon_world::collision_context::EntityContextKind::Living
|
||||
} else {
|
||||
horizon_world::collision_context::EntityContextKind::Player
|
||||
@@ -5088,11 +5087,11 @@ impl Game<'_> {
|
||||
if removed && !creative {
|
||||
let held = self.held().cloned();
|
||||
self.mine_block_with_held(&facts.name, facts.hardness);
|
||||
let harvest = !facts.requires_tool
|
||||
|| self
|
||||
.res
|
||||
.items()
|
||||
.correct_for_drops(held.as_ref(), &facts.name);
|
||||
let harvest = self.res.items().has_correct_tool_for_drops(
|
||||
held.as_ref(),
|
||||
facts.requires_tool,
|
||||
&facts.name,
|
||||
);
|
||||
if harvest {
|
||||
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
|
||||
self.award_stat("mined", &facts.name, 1);
|
||||
@@ -7206,8 +7205,7 @@ impl Game<'_> {
|
||||
if particle_mode == horizon_client::graphics::ParticleCullingMode::Off {
|
||||
self.particles.render(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.particle_rotation(),
|
||||
alpha as f32,
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
@@ -7226,8 +7224,7 @@ impl Game<'_> {
|
||||
let mut particle_culling = self.particle_occlusion_context(camera);
|
||||
self.particles.render_culled(
|
||||
camera,
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.particle_rotation(),
|
||||
alpha as f32,
|
||||
pixel_focal_length,
|
||||
true,
|
||||
@@ -8323,22 +8320,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.tracked_special_box(id as i32)
|
||||
}
|
||||
/// The renderer for the window, with the current block sprites, GUI
|
||||
/// atlas and world border texture.
|
||||
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
|
||||
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
|
||||
pub(super) fn forget_atlas_slots(&mut self) {
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
self.trim_slots.forget();
|
||||
self.map_patches.clear();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
}
|
||||
|
||||
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
self.renderer_for(window, &self.pack, &self.gui)
|
||||
}
|
||||
/// Rebuild GPU-owned state after the backend reports device loss.
|
||||
fn recover_device(&mut self) -> Result<()> {
|
||||
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
|
||||
@@ -8401,34 +8382,6 @@ impl Game<'_> {
|
||||
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
|
||||
Ok(())
|
||||
}
|
||||
/// A renderer made again from the resources loaded takes over: what named the old one's
|
||||
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
|
||||
/// settings are applied again.
|
||||
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
|
||||
// A new renderer starts with the vanilla column and without the world border's wall.
|
||||
renderer.clear_sections(self.world.layout());
|
||||
self.border_renderer.invalidate();
|
||||
self.renderer = Some(renderer);
|
||||
self.forget_atlas_slots();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
|
||||
/// discarded, then every loaded section is rebuilt.
|
||||
fn all_sections_changed(&mut self) {
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.clear_sections(self.world.layout());
|
||||
}
|
||||
self.streaming.reload_sections(&mut self.world);
|
||||
}
|
||||
/// A translated message for the player the game acts for: `overlay` is
|
||||
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
|
||||
/// A guest gets a packet, so its own client translates it.
|
||||
@@ -9112,7 +9065,7 @@ impl Game<'_> {
|
||||
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
|
||||
// A spectated entity's view is not the local player's yaw and pitch
|
||||
// (`Camera.setup` reads the camera entity), so it is kept too.
|
||||
let spectating = self.camera_mob().is_some();
|
||||
let spectating = self.camera_entity().is_some();
|
||||
self.posed_rotation = view
|
||||
.filter(|posed| spectating || Some(*posed) != vanilla)
|
||||
.map(|(_, yaw, pitch)| (yaw, pitch));
|
||||
|
||||
@@ -533,7 +533,8 @@ impl Game<'_> {
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, a) in self.armor_stands.iter().enumerate() {
|
||||
if a.marker {
|
||||
// Minecraft.pick never hits the camera entity it looks from.
|
||||
if a.marker || self.remote_camera_id() == Some(a.id) {
|
||||
continue;
|
||||
}
|
||||
let b = a.bounds();
|
||||
@@ -1437,7 +1438,11 @@ impl Game<'_> {
|
||||
) {
|
||||
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
|
||||
let skinning = self.skinning();
|
||||
for a in &self.armor_stands {
|
||||
for a in self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|a| !self.hides_as_camera(a.id))
|
||||
{
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
// edge (4 for a marker's empty box), whatever the entity distance.
|
||||
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
|
||||
|
||||
@@ -15,6 +15,7 @@ use horizon_client::{
|
||||
mesher::Vertex,
|
||||
mobs::render::{DYE, DYE_NAMES},
|
||||
test_instance_block::Status,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
@@ -30,12 +31,15 @@ pub(super) const EFFECTS: [(&str, i32); 6] = [
|
||||
("strength", 3),
|
||||
("regeneration", 4),
|
||||
];
|
||||
const PAYMENT: [&str; 5] = [
|
||||
"minecraft:iron_ingot",
|
||||
"minecraft:gold_ingot",
|
||||
"minecraft:emerald",
|
||||
"minecraft:diamond",
|
||||
"minecraft:netherite_ingot",
|
||||
/// `BeaconScreen.extractBackground`'s payment row in drawing order, with each
|
||||
/// item's x: netherite, emerald, diamond, gold, iron. Not by value: the
|
||||
/// emerald is left of the diamond.
|
||||
const PAYMENT_ROW: [(&str, f32); 5] = [
|
||||
("minecraft:netherite_ingot", 20.0),
|
||||
("minecraft:emerald", 41.0),
|
||||
("minecraft:diamond", 63.0),
|
||||
("minecraft:gold_ingot", 86.0),
|
||||
("minecraft:iron_ingot", 108.0),
|
||||
];
|
||||
/// `BeaconMenu.PaymentSlot` and the player inventory, relative to the 176 origin.
|
||||
pub(super) fn slot_position(i: usize) -> Option<(f32, f32)> {
|
||||
@@ -73,6 +77,43 @@ fn upgrade_button() -> (f32, f32) {
|
||||
fn inside(p: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
|
||||
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
|
||||
}
|
||||
/// `BeaconPowerButton.setEffect`'s tooltip: the effect's description, and for
|
||||
/// `BeaconUpgradePowerButton` (`createEffectDescription`) " II" after it.
|
||||
fn effect_tooltip(e: usize, upgraded: bool) -> Text {
|
||||
let effect = Text::translate(&format!("effect.minecraft.{}", EFFECTS[e].0), &[]);
|
||||
if upgraded {
|
||||
effect.append(" II")
|
||||
} else {
|
||||
effect
|
||||
}
|
||||
}
|
||||
/// `BeaconScreen`: every button's tooltip whichever one the cursor is over
|
||||
/// (`BeaconPowerButton`'s its effect, `BeaconSpriteScreenButton`'s its label),
|
||||
/// hovered whether or not it is active.
|
||||
fn hovered_tooltip(
|
||||
local: (f32, f32),
|
||||
primary: Option<usize>,
|
||||
buttons: &[(usize, f32, f32, bool)],
|
||||
) -> Option<Text> {
|
||||
for &(e, x, y, _) in buttons {
|
||||
if inside(local, x, y, 22.0, 22.0) {
|
||||
return Some(effect_tooltip(e, false));
|
||||
}
|
||||
}
|
||||
if let Some(p) = primary {
|
||||
let (x, y) = upgrade_button();
|
||||
if inside(local, x, y, 22.0, 22.0) {
|
||||
return Some(effect_tooltip(p, true));
|
||||
}
|
||||
}
|
||||
if inside(local, 164.0, 107.0, 22.0, 22.0) {
|
||||
return Some(Text::translate("gui.done", &[]));
|
||||
}
|
||||
if inside(local, 190.0, 107.0, 22.0, 22.0) {
|
||||
return Some(Text::translate("gui.cancel", &[]));
|
||||
}
|
||||
None
|
||||
}
|
||||
fn effect_id(name: &str) -> Option<&'static str> {
|
||||
let n = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
EFFECTS.iter().map(|(e, _)| *e).find(|e| *e == n)
|
||||
@@ -502,12 +543,12 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
}
|
||||
for (i, item) in PAYMENT.iter().rev().enumerate() {
|
||||
let x = [20.0, 41.0, 63.0, 86.0, 108.0][i];
|
||||
for (item, x) in PAYMENT_ROW {
|
||||
self.draw_item(batch, &ItemStack::new(item, 1), bx + x, by + 109.0);
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut tooltip = None;
|
||||
let buttons = buttons();
|
||||
let tooltip = hovered_tooltip(local, sel[0], &buttons);
|
||||
let button =
|
||||
|batch: &mut HudBatch, x: f32, y: f32, active: bool, selected: bool, icon: &str| {
|
||||
let hover = inside(local, x, y, 22.0, 22.0);
|
||||
@@ -535,9 +576,8 @@ impl Game<'_> {
|
||||
18.0,
|
||||
u32::MAX,
|
||||
);
|
||||
hover
|
||||
};
|
||||
for (e, x, y, primary) in buttons() {
|
||||
for (e, x, y, primary) in buttons {
|
||||
let tier = EFFECTS[e].1;
|
||||
let active = if primary {
|
||||
tier <= levels && tier < 4
|
||||
@@ -550,16 +590,12 @@ impl Game<'_> {
|
||||
sel[1] == Some(e)
|
||||
};
|
||||
let icon = format!("mob_effect/{}", EFFECTS[e].0);
|
||||
if button(batch, x, y, active, selected, &icon) {
|
||||
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[e].0));
|
||||
}
|
||||
button(batch, x, y, active, selected, &icon);
|
||||
}
|
||||
if let Some(p) = sel[0] {
|
||||
let (x, y) = upgrade_button();
|
||||
let icon = format!("mob_effect/{}", EFFECTS[p].0);
|
||||
if button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon) {
|
||||
tooltip = Some(format!("effect.minecraft.{}", EFFECTS[p].0));
|
||||
}
|
||||
button(batch, x, y, levels >= 4, sel[1] == Some(p), &icon);
|
||||
}
|
||||
button(
|
||||
batch,
|
||||
@@ -597,14 +633,8 @@ impl Game<'_> {
|
||||
}
|
||||
} else if let Some(stack) = stack_tip {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
} else if let Some(key) = tooltip {
|
||||
let text = self.translate(&key, &key);
|
||||
self.draw_tooltip_lines(
|
||||
batch,
|
||||
&[(horizon_client::text::Text::literal(text), u32::MAX)],
|
||||
self.cursor.0,
|
||||
self.cursor.1,
|
||||
);
|
||||
} else if let Some(text) = tooltip {
|
||||
self.draw_tooltip_lines(batch, &[(text, u32::MAX)], self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// `BeaconRenderer`: each section's rotating solid beam and its glow.
|
||||
@@ -707,6 +737,66 @@ fn effect_box_hits(p: [i32; 3], range: f64, height: f64, lo: [f64; 3], hi: [f64;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `BeaconScreen.extractBackground`: netherite 20, emerald 41, diamond
|
||||
/// 41+22, gold 42+44, iron 42+66.
|
||||
#[test]
|
||||
fn the_payment_row_reads_netherite_emerald_diamond_gold_iron() {
|
||||
assert_eq!(
|
||||
PAYMENT_ROW,
|
||||
[
|
||||
("minecraft:netherite_ingot", 20.0),
|
||||
("minecraft:emerald", 41.0),
|
||||
("minecraft:diamond", 63.0),
|
||||
("minecraft:gold_ingot", 86.0),
|
||||
("minecraft:iron_ingot", 108.0),
|
||||
]
|
||||
);
|
||||
}
|
||||
/// `BeaconUpgradePowerButton.createEffectDescription` appends " II"; the
|
||||
/// primary buttons' description is the bare effect.
|
||||
#[test]
|
||||
fn only_the_upgrade_button_names_the_second_level() {
|
||||
for e in 0..EFFECTS.len() {
|
||||
let primary = effect_tooltip(e, false).plain();
|
||||
let upgrade = effect_tooltip(e, true).plain();
|
||||
assert!(!primary.is_empty());
|
||||
assert!(!primary.ends_with(" II"));
|
||||
assert_eq!(upgrade, format!("{primary} II"));
|
||||
}
|
||||
}
|
||||
/// `BeaconScreenButton.setTooltip`: every button has one while hovered,
|
||||
/// and the sprite buttons' is their label whether or not they are active
|
||||
/// (the tick is disabled without a payment and a chosen power).
|
||||
#[test]
|
||||
fn every_hovered_beacon_button_shows_its_tooltip() {
|
||||
let at = |(x, y): (f32, f32)| (x + 11.0, y + 11.0);
|
||||
let name =
|
||||
|e: usize| horizon_client::lang::get(&format!("effect.minecraft.{}", EFFECTS[e].0));
|
||||
let buttons = buttons();
|
||||
for &(e, x, y, _) in &buttons {
|
||||
let tip = hovered_tooltip(at((x, y)), None, &buttons).unwrap_or_default();
|
||||
assert_eq!(tip.plain(), name(e), "button {e}");
|
||||
}
|
||||
let upgrade = at(upgrade_button());
|
||||
assert!(hovered_tooltip(upgrade, None, &buttons).is_none());
|
||||
assert_eq!(
|
||||
hovered_tooltip(upgrade, Some(0), &buttons).unwrap().plain(),
|
||||
format!("{} II", name(0))
|
||||
);
|
||||
assert_eq!(
|
||||
hovered_tooltip(at((164.0, 107.0)), None, &buttons)
|
||||
.unwrap_or_default()
|
||||
.plain(),
|
||||
horizon_client::lang::get("gui.done")
|
||||
);
|
||||
assert_eq!(
|
||||
hovered_tooltip(at((190.0, 107.0)), Some(0), &buttons)
|
||||
.unwrap_or_default()
|
||||
.plain(),
|
||||
horizon_client::lang::get("gui.cancel")
|
||||
);
|
||||
assert!(hovered_tooltip((0.0, 0.0), Some(0), &buttons).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn only_active_beacons_have_beams() {
|
||||
let mut b = Beacon::default();
|
||||
|
||||
@@ -131,6 +131,10 @@ pub(super) trait Editor {
|
||||
fn accepts(&self, _edit: usize, _current: &str, _cursor: usize, _c: char) -> bool {
|
||||
true
|
||||
}
|
||||
/// The box's tooltip (`EditBox.setTooltip`), if it has one.
|
||||
fn edit_tooltip(&self, _edit: usize) -> Option<Text> {
|
||||
None
|
||||
}
|
||||
/// Whether Enter is Done (a screen with its own `keyPressed`).
|
||||
fn enter_confirms(&self) -> bool {
|
||||
true
|
||||
@@ -171,6 +175,41 @@ impl BlockEditScreen {
|
||||
}
|
||||
}
|
||||
|
||||
/// `WidgetTooltipHolder.refreshTooltipForNextRenderPass`: hovering or keyboard focus shows it.
|
||||
fn edit_tooltip(
|
||||
atlas: &horizon_client::hud::HudAtlas,
|
||||
editor: &dyn Editor,
|
||||
items: &[Item],
|
||||
cursor: (f32, f32),
|
||||
focus: Option<usize>,
|
||||
keyboard: bool,
|
||||
virtual_pointer: bool,
|
||||
) -> Option<(Vec<Text>, [f32; 4], bool)> {
|
||||
let mut hovered = None;
|
||||
for item in items {
|
||||
if let Item::Edit { index, x, y, w } = item {
|
||||
let hover = !virtual_pointer && ui::hovered(cursor, *x, *y, *w, 20.0);
|
||||
let focused = keyboard && focus == Some(*index);
|
||||
if (hover || focused)
|
||||
&& let Some(tip) = editor.edit_tooltip(*index)
|
||||
{
|
||||
// `Tooltip.splitTooltip` uses 170 GUI pixels, including translated text.
|
||||
let tooltip = (
|
||||
atlas.font.split(&tip, 170.0),
|
||||
[*x, *y, *w, 20.0],
|
||||
focused && !hover,
|
||||
);
|
||||
// `GuiGraphicsExtractor.setTooltipForNextFrame`: the focused widget wins.
|
||||
if focused {
|
||||
return Some(tooltip);
|
||||
}
|
||||
hovered = Some(tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
hovered
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `LocalPlayer.open*Block`: the edit screen for the block entity at
|
||||
/// `pos`.
|
||||
@@ -243,6 +282,11 @@ impl Game<'_> {
|
||||
match outcome {
|
||||
Outcome::Stay => {}
|
||||
Outcome::Send(packet) => {
|
||||
// The integrated server is this game: vanilla's packet handler
|
||||
// applies the edit there, so a local game applies it here.
|
||||
if self.remote.is_none() {
|
||||
self.apply_local_editor_packet(&packet);
|
||||
}
|
||||
self.send(packet);
|
||||
self.close_block_editor();
|
||||
}
|
||||
@@ -256,6 +300,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server side of an editor's packet, for the game that is its own
|
||||
/// server: `handleSetJigsawBlock` and its siblings run on the server.
|
||||
fn apply_local_editor_packet(&mut self, packet: &S) {
|
||||
if let S::SetJigsawBlock(p) = packet {
|
||||
self.apply_set_jigsaw_block(p);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_block_editor(&mut self, batch: &mut HudBatch) {
|
||||
let cursor = self.cursor;
|
||||
let Some(items) = self.with_block_editor(|s, e| {
|
||||
@@ -354,6 +406,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The focus navigator's pointer is hidden, not an actual mouse hover.
|
||||
let navigation_focus = self.pad_focus_rect();
|
||||
let focus = navigation_focus.map_or(screen.focus, |rect| {
|
||||
items.iter().find_map(|item| match item {
|
||||
Item::Edit { index, x, y, w } if [*x, *y, *w, 20.0] == rect => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some((lines, rect, focused)) = edit_tooltip(
|
||||
atlas,
|
||||
screen.editor.as_ref(),
|
||||
&items,
|
||||
cursor,
|
||||
focus,
|
||||
self.narration.input_type().is_keyboard(),
|
||||
navigation_focus.is_some(),
|
||||
) {
|
||||
if focused {
|
||||
ui::tooltip_focused(batch, atlas, &lines, rect);
|
||||
} else {
|
||||
ui::tooltip_menu(batch, atlas, &lines, cursor, rect);
|
||||
}
|
||||
}
|
||||
self.block_editor = Some(screen);
|
||||
}
|
||||
|
||||
@@ -643,6 +718,107 @@ pub(super) fn on_off(on: bool) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct TooltipEditor;
|
||||
|
||||
impl Editor for TooltipEditor {
|
||||
fn edits(&mut self) -> &mut [EditBox] {
|
||||
&mut []
|
||||
}
|
||||
fn items(&self, _entity: &BlockEntity, _w: f32, _h: f32) -> Vec<Item> {
|
||||
Vec::new()
|
||||
}
|
||||
fn press(
|
||||
&mut self,
|
||||
_entity: &mut BlockEntity,
|
||||
_pos: Pos,
|
||||
_id: u8,
|
||||
_shift: bool,
|
||||
_alt: bool,
|
||||
) -> Outcome {
|
||||
Outcome::Stay
|
||||
}
|
||||
fn done(&mut self, _entity: &mut BlockEntity, _pos: Pos) -> Outcome {
|
||||
Outcome::Stay
|
||||
}
|
||||
fn cancel(&mut self, _entity: &mut BlockEntity) {}
|
||||
fn edit_tooltip(&self, edit: usize) -> Option<Text> {
|
||||
match edit {
|
||||
4 => Some(Text::literal(
|
||||
"A long explanation of the selection priority that must wrap without losing any of its words.",
|
||||
)),
|
||||
5 => Some(Text::literal("Placement priority")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_tooltips_follow_hover_keyboard_and_hidden_navigation_pointer() {
|
||||
let atlas = horizon_client::hud::HudAtlas::boot();
|
||||
let items = [
|
||||
Item::Edit {
|
||||
index: 0,
|
||||
x: 7.0,
|
||||
y: 20.0,
|
||||
w: 300.0,
|
||||
},
|
||||
Item::Edit {
|
||||
index: 5,
|
||||
x: 110.0,
|
||||
y: 160.0,
|
||||
w: 98.0,
|
||||
},
|
||||
Item::Edit {
|
||||
index: 4,
|
||||
x: 7.0,
|
||||
y: 160.0,
|
||||
w: 98.0,
|
||||
},
|
||||
];
|
||||
let get = |cursor, focus, keyboard, virtual_pointer| {
|
||||
edit_tooltip(
|
||||
&atlas,
|
||||
&TooltipEditor,
|
||||
&items,
|
||||
cursor,
|
||||
focus,
|
||||
keyboard,
|
||||
virtual_pointer,
|
||||
)
|
||||
};
|
||||
let (lines, rect, focused) = get((20.0, 165.0), None, false, false).unwrap();
|
||||
assert_eq!(rect, [7.0, 160.0, 98.0, 20.0]);
|
||||
assert!(!focused);
|
||||
assert!(lines.len() > 1);
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|line| atlas.font.width_text(line) <= 170.0)
|
||||
);
|
||||
assert_eq!(
|
||||
lines.iter().map(Text::plain).collect::<Vec<_>>().join(" "),
|
||||
"A long explanation of the selection priority that must wrap without losing any of its words."
|
||||
);
|
||||
assert!(get((0.0, 0.0), Some(4), true, false).unwrap().2);
|
||||
assert!(get((0.0, 0.0), Some(4), false, false).is_none());
|
||||
// A real hover uses the menu positioner, even when the field has keyboard focus.
|
||||
assert!(!get((20.0, 165.0), Some(4), true, false).unwrap().2);
|
||||
// Keyboard/controller navigation puts a hidden pointer in the field's center.
|
||||
assert!(get((56.0, 170.0), Some(4), true, true).unwrap().2);
|
||||
assert!(get((56.0, 170.0), None, true, true).is_none());
|
||||
assert!(get((20.0, 25.0), Some(0), true, false).is_none());
|
||||
assert!(get((105.0, 180.0), None, false, false).is_none());
|
||||
// A keyboard-focused field overrides a different field's real hover.
|
||||
let (focused_lines, focused_rect, focused) =
|
||||
get((120.0, 165.0), Some(4), true, false).unwrap();
|
||||
assert_eq!(
|
||||
focused_lines.iter().map(Text::plain).collect::<Vec<_>>(),
|
||||
lines.iter().map(Text::plain).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(focused_rect, rect);
|
||||
assert!(focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_characters_follow_screen_is_valid_character_for_name() {
|
||||
assert!(valid_character_for_name("", 'a', 0));
|
||||
@@ -667,4 +843,46 @@ mod tests {
|
||||
assert_eq!(cycle(&[1, 2, 3], 9, 1), 1);
|
||||
assert_eq!(cycle(&[1, 2, 3], 9, -1), 2);
|
||||
}
|
||||
|
||||
/// A single-player jigsaw edit is applied by the game that is its own
|
||||
/// server, as `handleSetJigsawBlock` does there, and marks the chunk.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_local_jigsaw_edit_applies_and_marks_the_chunk() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
let pos = (2, 4, 3);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:jigsaw")
|
||||
.unwrap();
|
||||
game.world.set(pos.0, pos.1, pos.2, state).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(pos, BlockEntity::JigsawBlock(Box::default()));
|
||||
game.world.modified.clear();
|
||||
let packet = S::SetJigsawBlock(horizon_protocol::packets::play::SetJigsawBlock {
|
||||
pos: [2, 4, 3],
|
||||
name: "minecraft:test".into(),
|
||||
target: "minecraft:test".into(),
|
||||
pool: "minecraft:empty".into(),
|
||||
final_state: "minecraft:air".into(),
|
||||
joint: "aligned".into(),
|
||||
selection_priority: 1,
|
||||
placement_priority: 2,
|
||||
});
|
||||
|
||||
game.block_editor_outcome(Outcome::Send(packet));
|
||||
|
||||
let Some(BlockEntity::JigsawBlock(b)) = game.world.block_entities.get(&pos) else {
|
||||
panic!("no jigsaw");
|
||||
};
|
||||
assert_eq!(&*b.name, "minecraft:test", "the edit landed");
|
||||
assert_eq!((b.selection_priority, b.placement_priority), (1, 2));
|
||||
assert!(
|
||||
game.world.modified.contains(&(0, 0)),
|
||||
"the edit left the chunk unmarked"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -576,6 +576,10 @@ impl Game<'_> {
|
||||
let full = count == b.max_passengers();
|
||||
let delta = b.delta_rotation;
|
||||
let boat_box = b.bounds();
|
||||
let ridden = horizon_client::motion::RiddenBoat {
|
||||
under_water: b.under_water(),
|
||||
max_y: boat_box.max_position().y,
|
||||
};
|
||||
let seats: Vec<_> = mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -608,6 +612,7 @@ impl Game<'_> {
|
||||
for (j, offset, at, previous, facing) in seats {
|
||||
let m = &mut self.mobs[j];
|
||||
m.seated(id, offset, boat_box);
|
||||
m.fluids.boat = Some((id, ridden));
|
||||
m.position = at;
|
||||
m.previous = previous;
|
||||
m.velocity = Vec3::ZERO;
|
||||
|
||||
@@ -565,7 +565,10 @@ impl Game<'_> {
|
||||
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
|
||||
return None;
|
||||
}
|
||||
let above = self.res.facts().get(self.world.block(x, y + 1, z));
|
||||
let above = self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(x, y.wrapping_add(1), z));
|
||||
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
|
||||
let height = if above.fluid == f.fluid {
|
||||
1.0
|
||||
@@ -597,7 +600,7 @@ impl Game<'_> {
|
||||
return y as f32 + height;
|
||||
}
|
||||
}
|
||||
(y1 + 1) as f32
|
||||
(i64::from(y1) + 1) as f32
|
||||
}
|
||||
fn boat_status(&self, b: &mut Boat) -> Status {
|
||||
let bb = b.bounds();
|
||||
@@ -660,12 +663,12 @@ impl Game<'_> {
|
||||
);
|
||||
let (g_lo, g_hi) = (ground.min_position(), ground.max_position());
|
||||
let (x0, x1, y0, y1, z0, z1) = (
|
||||
g_lo.x.floor() as i32 - 1,
|
||||
g_hi.x.ceil() as i32 + 1,
|
||||
g_lo.y.floor() as i32 - 1,
|
||||
g_hi.y.ceil() as i32 + 1,
|
||||
g_lo.z.floor() as i32 - 1,
|
||||
g_hi.z.ceil() as i32 + 1,
|
||||
(g_lo.x.floor() - 1.0) as i32,
|
||||
(g_hi.x.ceil() + 1.0) as i32,
|
||||
(g_lo.y.floor() - 1.0) as i32,
|
||||
(g_hi.y.ceil() + 1.0) as i32,
|
||||
(g_lo.z.floor() - 1.0) as i32,
|
||||
(g_hi.z.ceil() + 1.0) as i32,
|
||||
);
|
||||
let slab = VoxelShape::from_box(ground).unwrap_or_else(|_| VoxelShape::empty());
|
||||
let mut scene = SceneCollisions {
|
||||
@@ -740,7 +743,7 @@ impl Game<'_> {
|
||||
if moved_y < 0.0 {
|
||||
let below = self.res.facts().get(self.world.block(
|
||||
b.position.x.floor() as i32,
|
||||
b.position.y.floor() as i32 - 1,
|
||||
(b.position.y.floor() as i32).wrapping_sub(1),
|
||||
b.position.z.floor() as i32,
|
||||
));
|
||||
if below.fluid != Fluid::Water {
|
||||
@@ -980,7 +983,7 @@ impl Game<'_> {
|
||||
if driven.contains(&id) {
|
||||
self.boats[i].wear_off_hurt();
|
||||
}
|
||||
if self.boat_fire_tick(i) {
|
||||
if self.boat_fire_tick(i) || self.boat_below_world(i) {
|
||||
break 'boat;
|
||||
}
|
||||
}
|
||||
@@ -1099,9 +1102,12 @@ impl Game<'_> {
|
||||
));
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
let mut columns = Vec::new();
|
||||
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
|
||||
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
|
||||
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
|
||||
// Entity.checkInsideBlocks floors both ends of the deflated box.
|
||||
// Narrowing ceil(max) before subtracting one wraps at i32::MIN,
|
||||
// turning a tiny box into a scan of the entire integer Y domain.
|
||||
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
|
||||
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
if let Some(column) = bubble_column_at(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
|
||||
@@ -107,4 +107,42 @@ mod tests {
|
||||
.sum();
|
||||
assert_eq!(spilled, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn saved_deep_end_boat_loads_then_is_discarded_and_extreme_scans_terminate() {
|
||||
use super::super::Boat;
|
||||
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut g = crate::game::native_machines::tests::server_game();
|
||||
g.world.shape.min_y = 0;
|
||||
let doubles = |v: &[f64]| Tag::List(v.iter().map(|d| Tag::Double(d.to_bits())).collect());
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"Pos",
|
||||
doubles(&[-77.75688596047263, -2146769274.8915758, -55.6734678353711]),
|
||||
),
|
||||
("Motion", doubles(&[-2e-323, -13104.999899947774, -2e-323])),
|
||||
]);
|
||||
g.load_boat(tag, None, "minecraft:oak_boat");
|
||||
assert_eq!(g.boats.len(), 1);
|
||||
assert_eq!(g.boats[0].position.y, -20_000_000.0);
|
||||
assert_eq!(g.boats[0].velocity, Vec3::new(-2e-323, 0.0, -2e-323));
|
||||
g.player = Some(PlayerMotion::new(g.motion, Vec3::new(-77.75, 64.0, -55.67)).unwrap());
|
||||
g.world
|
||||
.chunks
|
||||
.insert((-5, -4), std::sync::Arc::new(Chunk::empty(-5, -4)));
|
||||
g.update_chunk_tickets();
|
||||
assert!(g.entity_ticking_area()(-78, -56));
|
||||
g.tick_boats();
|
||||
assert!(g.boats.is_empty());
|
||||
for y in [f64::from(i32::MIN) - 1.0, f64::from(i32::MAX) + 1.0] {
|
||||
g.boats
|
||||
.push(Boat::new(2, "oak".into(), Vec3::new(0.5, y, 0.5), 0.0));
|
||||
assert!(g.boat_bubble_columns(0).is_empty());
|
||||
assert_eq!(g.boat_ground_friction(g.boats[0].bounds()), 0.0);
|
||||
g.boats.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
|
||||
use horizon_client::{
|
||||
block_entity_item,
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -18,8 +19,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use horizon_world::nbt::Tag;
|
||||
use horizon_world::{level::flags as level_flags, nbt::Tag, registry::BlockStateId};
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -914,6 +914,32 @@ impl Game<'_> {
|
||||
self.set_lectern_powered(key, false);
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getStateForPlacement`: a lectern placed from an item
|
||||
/// whose `block_entity_data` holds a `Book`, by a placer who may use
|
||||
/// game-master blocks, starts with `has_book` on. It runs before the
|
||||
/// item's `block_state` component, which vanilla applies afterwards and
|
||||
/// which may set `has_book` back. Returns the state to go on with.
|
||||
pub(super) fn lectern_placed(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
stack: &ItemStack,
|
||||
game_master: bool,
|
||||
) -> horizon_client::Result<BlockStateId> {
|
||||
if !game_master
|
||||
|| !block_entity_item::carries_book(stack)
|
||||
|| &*self.res.facts().get(state).name != "minecraft:lectern"
|
||||
{
|
||||
return Ok(state);
|
||||
}
|
||||
match self.with_props(state, &[("has_book", "true")]) {
|
||||
Some(new) if new != state => {
|
||||
self.level_set(pos, new)?;
|
||||
Ok(new)
|
||||
}
|
||||
_ => Ok(state),
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.useItemOn`: a book goes onto an empty lectern.
|
||||
pub(super) fn use_lectern(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
@@ -1686,20 +1712,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
|
||||
pub(super) fn has_book_state(
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`.
|
||||
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
|
||||
/// `LecternBlock.getAnalogOutputSignal` and
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: the state's `has_book` gates the
|
||||
/// answer, and an entity with no book reads 14 (a page count of 0 is a page
|
||||
/// progress of 1.0 with no book to add).
|
||||
pub(super) fn lectern_signal(
|
||||
world: &horizon_client::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
) -> Option<i32> {
|
||||
let s = registry.state(state)?;
|
||||
if s.name != "minecraft:lectern" {
|
||||
return None;
|
||||
}
|
||||
if s.properties.get("has_book").is_none_or(|v| v != "true") {
|
||||
return Some(0);
|
||||
}
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(0, |b| {
|
||||
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(14, |b| {
|
||||
let data = b.data();
|
||||
let count = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.len()))
|
||||
@@ -1809,6 +1840,10 @@ mod lectern_books_tag_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lectern_tests.rs"]
|
||||
mod lectern_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lectern_signal_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
@@ -1822,19 +1857,26 @@ mod lectern_signal_tests {
|
||||
use horizon_world::level::LevelAccess;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
|
||||
fn signal(
|
||||
game: &mut super::Game<'_>,
|
||||
has_book: bool,
|
||||
book: Option<ItemStack>,
|
||||
page: i32,
|
||||
) -> i32 {
|
||||
let key = (8, 64, 8);
|
||||
let state: BlockStateId = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:lectern")
|
||||
.unwrap();
|
||||
let registry = game.pack.assets.registry;
|
||||
let default = registry.default_state("minecraft:lectern").unwrap();
|
||||
let mut state = registry.state(default).unwrap().clone();
|
||||
state
|
||||
.properties
|
||||
.insert("has_book".into(), has_book.to_string());
|
||||
let state: BlockStateId = registry.id(&state).unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Lectern(Lectern { book, page }));
|
||||
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
|
||||
let pos = BlockPos::new(key.0, key.1, key.2);
|
||||
lectern_signal(&game.world, registry, state, pos).unwrap()
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
|
||||
@@ -1886,30 +1928,19 @@ mod lectern_signal_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
|
||||
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
|
||||
/// component, so a stack that only sits in the lectern (a non-book item a
|
||||
/// command left in `Book`) reads 14, not the book's 15.
|
||||
/// `LecternBlock.getAnalogOutputSignal` asks for the state's `HAS_BOOK`
|
||||
/// first, so a lectern without it reads 0 whatever its block entity holds;
|
||||
/// with it, `LecternBlockEntity.getRedstoneSignal` is `floor(page /
|
||||
/// (pageCount - 1) * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a
|
||||
/// book-content component, so a stack that only sits in the lectern (a
|
||||
/// non-book item a command left in `Book`) reads 14, not the book's 15.
|
||||
/// An entity with no book at all reads 14 too: a page count of 0 is a page
|
||||
/// progress of 1.0 with no book to add.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lecterns_signal_adds_one_only_for_a_book() {
|
||||
let mut game = server_game();
|
||||
assert_eq!(signal(&mut game, None, 0), 0, "no book");
|
||||
assert_eq!(
|
||||
signal(&mut game, Some(ItemStack::new("minecraft:stone", 1)), 0),
|
||||
14,
|
||||
"a non-book stack has no book content"
|
||||
);
|
||||
// `writable_book_content` is a writable book's default component.
|
||||
assert_eq!(
|
||||
signal(
|
||||
&mut game,
|
||||
Some(ItemStack::new("minecraft:writable_book", 1)),
|
||||
0
|
||||
),
|
||||
15,
|
||||
"a writable book is a book"
|
||||
);
|
||||
let writable = Some(ItemStack::new("minecraft:writable_book", 1));
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.extra = Some(Box::new(StackData {
|
||||
written: Some(WrittenBook {
|
||||
@@ -1920,8 +1951,25 @@ mod lectern_signal_tests {
|
||||
}),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(signal(&mut game, Some(written.clone()), 0), 1, "page 0");
|
||||
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
|
||||
// `has_book=false`: no signal, whatever the block entity holds.
|
||||
assert_eq!(signal(&mut game, false, None, 0), 0);
|
||||
assert_eq!(
|
||||
signal(&mut game, false, writable.clone(), 0),
|
||||
0,
|
||||
"state denies"
|
||||
);
|
||||
// `has_book=true`, an entity with no book: page progress 1.0.
|
||||
assert_eq!(signal(&mut game, true, None, 0), 14, "no book");
|
||||
let stone = Some(ItemStack::new("minecraft:stone", 1));
|
||||
assert_eq!(signal(&mut game, true, stone, 0), 14, "no book content");
|
||||
// `writable_book_content` is a writable book's default component.
|
||||
assert_eq!(signal(&mut game, true, writable, 0), 15, "a book");
|
||||
assert_eq!(
|
||||
signal(&mut game, true, Some(written.clone()), 0),
|
||||
1,
|
||||
"page 0"
|
||||
);
|
||||
assert_eq!(signal(&mut game, true, Some(written), 1), 15, "last page");
|
||||
}
|
||||
}
|
||||
impl BookScreen {
|
||||
|
||||
@@ -109,6 +109,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let file = multiplayer::servers_file(&saves());
|
||||
let _guard = servers::lock();
|
||||
let (mut list, mut hidden) = servers::load_all(&file);
|
||||
let mut found = false;
|
||||
for entry in list.iter_mut().filter(|s| &s.ip == address) {
|
||||
|
||||
@@ -37,6 +37,14 @@ struct Entry {
|
||||
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
|
||||
direct: Option<Text>,
|
||||
}
|
||||
struct PendingAttacker {
|
||||
damage_type: String,
|
||||
attacker: (u32, Text),
|
||||
item: Option<Text>,
|
||||
living: bool,
|
||||
credit: bool,
|
||||
}
|
||||
|
||||
/// What is stuck in a player's body: `LivingEntity.DATA_ARROW_COUNT_ID` and
|
||||
/// `DATA_STINGER_COUNT_ID` with the timers that pull them out
|
||||
/// (`removeArrowTime`, `removeStingerTime`). Kept with the combat record,
|
||||
@@ -87,7 +95,7 @@ pub(super) struct CombatTracker {
|
||||
in_combat: bool,
|
||||
/// Attackers of damage dealt this tick, claimed by the matching
|
||||
/// `RecordedDamage` event.
|
||||
pending: Vec<(String, (u32, Text), Option<Text>)>,
|
||||
pending: Vec<PendingAttacker>,
|
||||
/// `pending` for a direct entity with no causing entity: only its name.
|
||||
pending_direct: Vec<(String, Text)>,
|
||||
/// The lengths of `pending` and `pending_direct` before the first
|
||||
@@ -107,9 +115,15 @@ pub(super) struct CombatTracker {
|
||||
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
|
||||
/// its entity id and display name, the blast's causing entity.
|
||||
pub(super) blast_owner: Option<(u32, Text)>,
|
||||
/// `Some(None)` is an explicitly ownerless crystal/cart DamageSource.
|
||||
pub(super) explicit_explosion_cause: Option<Option<super::explosion::ExplosionCause>>,
|
||||
pub(super) explicit_explosion_direct: Option<Text>,
|
||||
/// The guest behind `blast_owner`, when that player is a LAN guest; the
|
||||
/// host's own blast leaves it None.
|
||||
pub(super) blast_guest: Option<u32>,
|
||||
/// `blast_owner` is a player (`getIndirectSourceEntity() instanceof
|
||||
/// Player`), not a mob whose UUID the TNT's `owner` names.
|
||||
pub(super) blast_owner_player: bool,
|
||||
pub(super) projectile_attacker: Option<(u32, Text)>,
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
@@ -129,6 +143,9 @@ pub(super) struct CombatTracker {
|
||||
/// A firework rocket's burst is hurting the player now: the rocket is
|
||||
/// the direct entity (`rocket_hurt_player`).
|
||||
pub(super) rocket_hit: bool,
|
||||
/// The arrow hurting the player now pierces (`AbstractArrow.getPierceLevel` above 0):
|
||||
/// `LivingEntity.applyItemBlocking` lets it past a raised shield (`arrow_hits_player`).
|
||||
pub(super) piercing_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
@@ -319,9 +336,32 @@ impl Game<'_> {
|
||||
item: Option<Text>,
|
||||
) {
|
||||
self.combat.mark();
|
||||
self.combat
|
||||
.pending
|
||||
.push((damage_type.to_owned(), (id, name), item));
|
||||
self.combat.pending.push(PendingAttacker {
|
||||
damage_type: damage_type.to_owned(),
|
||||
attacker: (id, name),
|
||||
item,
|
||||
living: true,
|
||||
credit: true,
|
||||
});
|
||||
}
|
||||
/// A non-living or already removed cause remains in the damage source,
|
||||
/// but cannot resolve as an EntityReference for kill credit.
|
||||
pub(super) fn note_uncredited_attacker(
|
||||
&mut self,
|
||||
damage_type: &str,
|
||||
id: u32,
|
||||
name: Text,
|
||||
item: Option<Text>,
|
||||
living: bool,
|
||||
) {
|
||||
self.combat.mark();
|
||||
self.combat.pending.push(PendingAttacker {
|
||||
damage_type: damage_type.to_owned(),
|
||||
attacker: (id, name),
|
||||
item,
|
||||
living,
|
||||
credit: false,
|
||||
});
|
||||
}
|
||||
/// The `DamageSource.directEntity` of the next `damage_type` damage when
|
||||
/// nothing caused it (a splash potion nobody threw): the death message
|
||||
@@ -358,14 +398,20 @@ impl Game<'_> {
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let (attacker, item) = self
|
||||
let (attacker, item, living, credit) = self
|
||||
.combat
|
||||
.pending
|
||||
.iter()
|
||||
.position(|(t, _, _)| *t == name)
|
||||
.position(|p| p.damage_type == name)
|
||||
.map(|i| {
|
||||
let (_, attacker, item) = self.combat.pending.remove(i);
|
||||
(Some(attacker), item)
|
||||
let PendingAttacker {
|
||||
attacker,
|
||||
item,
|
||||
living,
|
||||
credit,
|
||||
..
|
||||
} = self.combat.pending.remove(i);
|
||||
(Some(attacker), item, living, credit)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let direct = match attacker {
|
||||
@@ -380,8 +426,10 @@ impl Game<'_> {
|
||||
let (fall_location, fall_distance) = self.fall_location();
|
||||
let now = self.world.game_time;
|
||||
if let Some(a) = &attacker {
|
||||
self.combat.last_hurt_by = Some((a.clone(), now));
|
||||
if !self.combat.in_combat {
|
||||
if living {
|
||||
self.combat.last_hurt_by = credit.then(|| (a.clone(), now));
|
||||
}
|
||||
if living && !self.combat.in_combat {
|
||||
self.combat.in_combat = true;
|
||||
}
|
||||
}
|
||||
@@ -480,7 +528,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// The display component of a custom-named held stack.
|
||||
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
pub(super) fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
let mut stack = m.extra.main_hand_stack()?.clone();
|
||||
if !has_custom_name(&stack) {
|
||||
@@ -500,7 +548,7 @@ impl Game<'_> {
|
||||
/// `main_hand_custom_name` of a player who is not in place: the guest
|
||||
/// `conn` between turns, or none for the host while a guest acts (their
|
||||
/// hotbar slot is kept by the swap).
|
||||
fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
|
||||
pub(super) fn player_main_hand_custom_name(&self, player: Option<u32>) -> Option<Text> {
|
||||
if player == self.lan_acting {
|
||||
return self.main_hand_custom_name();
|
||||
}
|
||||
@@ -895,7 +943,7 @@ mod held_item_tests {
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod noted_tests {
|
||||
use super::CombatTracker;
|
||||
use super::{CombatTracker, PendingAttacker};
|
||||
use horizon_client::text::Text;
|
||||
|
||||
/// A canceled hit forgets what was noted for it, also before its hurt
|
||||
@@ -905,11 +953,13 @@ mod noted_tests {
|
||||
let mut c = CombatTracker::default();
|
||||
let arrow = |c: &mut CombatTracker, id| {
|
||||
c.mark();
|
||||
c.pending.push((
|
||||
"minecraft:arrow".into(),
|
||||
(id, Text::literal("skeleton")),
|
||||
None,
|
||||
));
|
||||
c.pending.push(PendingAttacker {
|
||||
damage_type: "minecraft:arrow".into(),
|
||||
attacker: (id, Text::literal("skeleton")),
|
||||
item: None,
|
||||
living: true,
|
||||
credit: true,
|
||||
});
|
||||
};
|
||||
arrow(&mut c, 1);
|
||||
c.forget_noted(false);
|
||||
@@ -918,7 +968,10 @@ mod noted_tests {
|
||||
c.pending_direct
|
||||
.push(("minecraft:arrow".into(), Text::literal("arrow")));
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
|
||||
assert_eq!(
|
||||
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
|
||||
[1]
|
||||
);
|
||||
assert!(c.pending_direct.is_empty());
|
||||
// A hit noted and landed, then one noted and canceled: the landed
|
||||
// one keeps its attacker.
|
||||
@@ -927,7 +980,10 @@ mod noted_tests {
|
||||
c.forget_noted(false);
|
||||
arrow(&mut c, 4);
|
||||
c.forget_noted(true);
|
||||
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
|
||||
assert_eq!(
|
||||
c.pending.iter().map(|p| p.attacker.0).collect::<Vec<_>>(),
|
||||
[1, 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -1010,7 +1066,7 @@ mod guest_hit_tests {
|
||||
game.combat.pending.clear();
|
||||
game.guest_mob_event(5, hit);
|
||||
let noted: Vec<u32> = (game.combat.pending.iter())
|
||||
.map(|(_, (id, _), _)| *id)
|
||||
.map(|pending| pending.attacker.0)
|
||||
.collect();
|
||||
assert_eq!(noted, [5]);
|
||||
assert_eq!(game.combat.melee_mob, None, "the hit used it up");
|
||||
|
||||
@@ -13,6 +13,7 @@ pub(in crate::game) mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
mod players;
|
||||
mod position_args;
|
||||
pub(super) mod pregen;
|
||||
mod profiles;
|
||||
mod raid;
|
||||
@@ -517,22 +518,6 @@ impl Game<'_> {
|
||||
pub(super) fn position_arg(&self, ctx: &Context, name: &str) -> Option<[f64; 3]> {
|
||||
self.coords(ctx, name).map(|c| self.resolve_coords(&c))
|
||||
}
|
||||
/// BlockPosArgument.getLoadedBlockPos.
|
||||
pub(super) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
// BlockPos.containing(getPosition)
|
||||
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
|
||||
return Err(err("argument.pos.outofworld", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
fn int(ctx: &Context, name: &str) -> Option<i32> {
|
||||
match ctx.get(name) {
|
||||
Some(Arg::Int(v)) => Some(*v),
|
||||
@@ -2628,23 +2613,14 @@ impl Game<'_> {
|
||||
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck like any orb spawn.
|
||||
let at = self.unstuck_orb(pos);
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(horizon_client::entities::ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(at[0], at[1], at[2]),
|
||||
previous: Vec3::new(at[0], at[1], at[2]),
|
||||
velocity: Vec3::new(0.0, 0.0, 0.0),
|
||||
value: 0,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
following: None,
|
||||
});
|
||||
let at = Vec3::new(at[0], at[1], at[2]);
|
||||
let orb = horizon_client::entities::ExperienceOrb::new(
|
||||
self.next_entity,
|
||||
at,
|
||||
Vec3::ZERO,
|
||||
0,
|
||||
);
|
||||
self.orbs.push(orb);
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
@@ -3794,7 +3770,7 @@ impl Game<'_> {
|
||||
vec![Target::Player]
|
||||
};
|
||||
let pos = if ctx.get("pos").is_some() {
|
||||
self.block_pos_arg(ctx, "pos")?
|
||||
self.spawnable_pos_arg(ctx, "pos")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[
|
||||
@@ -3855,7 +3831,7 @@ impl Game<'_> {
|
||||
}
|
||||
fn cmd_setworldspawn(&mut self, ctx: &Context) -> R<i32> {
|
||||
let pos = if ctx.get("pos").is_some() {
|
||||
self.block_pos_arg(ctx, "pos")?
|
||||
self.spawnable_pos_arg(ctx, "pos")?
|
||||
} else {
|
||||
let p = self.source_position();
|
||||
[
|
||||
|
||||
@@ -646,6 +646,7 @@ impl Game<'_> {
|
||||
orb.count = new.count;
|
||||
orb.health = new.health;
|
||||
orb.no_gravity = new.no_gravity;
|
||||
orb.fall_distance = new.fall_distance;
|
||||
// Entity.load reads portalCooldown and leaves the portal
|
||||
// process as it is.
|
||||
if let Some(portal) = self.portals.entities.get_mut(&id) {
|
||||
|
||||
@@ -1039,14 +1039,7 @@ impl Game<'_> {
|
||||
.unwrap_or(Tag::Compound(Default::default())),
|
||||
v => horizon_client::scoreboard::json_to_tag(v),
|
||||
};
|
||||
edit_components(&mut stack, |c| {
|
||||
let mut data = match c.get(&"minecraft:custom_data".into()) {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
merge_compound(&mut data, &tag);
|
||||
c.insert("minecraft:custom_data".into(), data);
|
||||
});
|
||||
set_custom_data(&mut stack, &tag);
|
||||
}
|
||||
"minecraft:set_components" => {
|
||||
let patch = m["components"].clone();
|
||||
@@ -1577,6 +1570,22 @@ fn edit_components(stack: &mut ItemStack, f: impl FnOnce(&mut horizon_world::nbt
|
||||
stack.extra = extra;
|
||||
stack.tidy();
|
||||
}
|
||||
/// `SetCustomDataFunction.run`: `CustomData.update(stack, tag -> tag.merge(value))`.
|
||||
fn set_custom_data(stack: &mut ItemStack, value: &Tag) {
|
||||
edit_components(stack, |c| {
|
||||
let mut data = match c.get(&"minecraft:custom_data".into()) {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
merge_compound(&mut data, value);
|
||||
// Vanilla CustomData.update removes the component when the result is empty.
|
||||
if matches!(&data, Tag::Compound(m) if m.is_empty()) {
|
||||
c.remove(&"minecraft:custom_data".into());
|
||||
} else {
|
||||
c.insert("minecraft:custom_data".into(), data);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `CompoundTag.merge`: nested compounds merge, other values replace.
|
||||
fn merge_compound(into: &mut Tag, from: &Tag) {
|
||||
let (Tag::Compound(a), Tag::Compound(b)) = (into, from) else {
|
||||
@@ -2815,3 +2824,16 @@ mod registered_slot_source_preservation_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod set_custom_data_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merging_an_empty_tag_into_a_stack_without_custom_data_adds_no_component() {
|
||||
let mut stack = ItemStack::new("minecraft:stick", 1);
|
||||
set_custom_data(&mut stack, &Tag::Compound(Default::default()));
|
||||
let saved = horizon_client::item_data::encode_components(&stack);
|
||||
assert!(saved.is_none_or(|c| c.get("minecraft:custom_data").is_none()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Block position argument validation for command execution.
|
||||
use super::{Context, Game, R, err};
|
||||
|
||||
impl Game<'_> {
|
||||
/// BlockPosArgument.getLoadedBlockPos.
|
||||
pub(in crate::game) fn block_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
// BlockPos.containing(getPosition)
|
||||
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
|
||||
return Err(err("argument.pos.unloaded", &[]));
|
||||
}
|
||||
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
|
||||
return Err(err("argument.pos.outofworld", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
/// BlockPosArgument.getSpawnablePos: no loaded-chunk or build-height check,
|
||||
/// only `Level.isInSpawnableBounds` (upper bounds exclusive).
|
||||
pub(in crate::game) fn spawnable_pos_arg(&self, ctx: &Context, name: &str) -> R<[i32; 3]> {
|
||||
let c = self
|
||||
.coords(ctx, name)
|
||||
.ok_or_else(|| err("argument.pos.missing.int", &[]))?;
|
||||
let p = self.resolve_coords(&c).map(|v| v.floor() as i32);
|
||||
if !(-30_000_000..30_000_000).contains(&p[0])
|
||||
|| !(-30_000_000..30_000_000).contains(&p[2])
|
||||
|| !(-20_000_000..20_000_000).contains(&p[1])
|
||||
{
|
||||
return Err(err("argument.pos.outofbounds", &[]));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,15 @@ impl Game<'_> {
|
||||
consumable: &ItemConsumable,
|
||||
stack: &ItemStack,
|
||||
) {
|
||||
// Consumable.onConsume runs on the stack's own component.
|
||||
let consumable = horizon_client::item_sync::stack_consumable(stack, consumable);
|
||||
let consumable = &*consumable;
|
||||
self.emit_consume(consumable, stack, 16);
|
||||
// ServerPlayer: the used statistic and the consume_item trigger.
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.trigger_item("minecraft:consume_item", stack);
|
||||
// FoodProperties.onConsume, the player's one ConsumableListener.
|
||||
if let Some((nutrition, saturation)) = info.food {
|
||||
if let Some((nutrition, saturation)) = horizon_client::item_sync::food(stack, info.food) {
|
||||
let at = self.feet();
|
||||
let pitch = self.world.random.triangle_float(1.0, 0.4);
|
||||
self.audio.play(&consumable.sound, at, 1.0, pitch);
|
||||
@@ -96,10 +99,11 @@ impl Game<'_> {
|
||||
// with infinite materials, the hand when emptied, else the inventory
|
||||
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
|
||||
self.consume_held();
|
||||
if let Some(remainder) = &info.use_remainder
|
||||
if let Some(remainder) =
|
||||
horizon_client::item_sync::use_remainder(stack, info.use_remainder.clone())
|
||||
&& !self.creative
|
||||
{
|
||||
let remainder = ItemStack::new(remainder, 1);
|
||||
let remainder = ItemStack::new(&remainder, 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(remainder);
|
||||
} else if let Some(rest) = self.give(remainder) {
|
||||
|
||||
@@ -247,8 +247,16 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
|
||||
/// death and a natural heart drops experience. `onExplosionHit` does the
|
||||
/// same for the creaking (`by_player` false), without the experience.
|
||||
/// same for the creaking, and awards the experience only when its
|
||||
/// blast's indirect source is a player (`creaking_heart_broken_by`).
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
|
||||
let award = by_player && !self.creative && !self.spectator();
|
||||
self.creaking_heart_broken_by(pos, award);
|
||||
}
|
||||
/// `creaking_heart_broken` once the breaker is known: `award` is
|
||||
/// `tryAwardExperience`'s player check (a player who neither builds
|
||||
/// instantly, `preventsBlockDrops`, nor spectates).
|
||||
pub(super) fn creaking_heart_broken_by(&mut self, pos: [i32; 3], award: bool) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
@@ -271,16 +279,20 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("natural").cloned())
|
||||
.is_some_and(|v| v == "true");
|
||||
if by_player && natural && !self.creative && !self.spectator() {
|
||||
if award && natural {
|
||||
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
// Block.popExperience: the amount is drawn, then nothing pops
|
||||
// with `block_drops` off.
|
||||
if self.ui.rules.bool("block_drops") {
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod bred_draws;
|
||||
mod effect_instance;
|
||||
use effect_instance::mob_effect_instance;
|
||||
mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod mob_islands;
|
||||
mod prep;
|
||||
mod projectile_targets;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
mod spawn_eggs;
|
||||
@@ -796,7 +799,7 @@ impl Game<'_> {
|
||||
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
|
||||
let info = me.res.items().get(&stack.item)?;
|
||||
if info.equip_slot.is_none()
|
||||
|| !info.equip_damage_on_hurt
|
||||
|| !horizon_client::item_sync::damage_on_hurt(stack, info.equip_damage_on_hurt)
|
||||
|| info.max_damage == 0
|
||||
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|
||||
|| (fire && me.res.items().resists_fire(stack, name))
|
||||
@@ -2309,10 +2312,9 @@ impl Game<'_> {
|
||||
self.post_game_event(event, at, ctx);
|
||||
}
|
||||
// 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), Enderman.teleport's TELEPORT,
|
||||
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, a fox's
|
||||
// DRINK, the shulker's lid, an evoker's vexes (ENTITY_PLACE),
|
||||
// Enderman.teleport's TELEPORT, onEquipItem's EQUIP/UNEQUIP.
|
||||
MobEvent::GameEvent {
|
||||
event, at, source, ..
|
||||
} if matches!(
|
||||
@@ -2320,6 +2322,7 @@ impl Game<'_> {
|
||||
"minecraft:entity_action"
|
||||
| "minecraft:prime_fuse"
|
||||
| "minecraft:eat"
|
||||
| "minecraft:drink"
|
||||
| "minecraft:container_open"
|
||||
| "minecraft:container_close"
|
||||
| "minecraft:entity_place"
|
||||
@@ -2531,6 +2534,13 @@ impl Game<'_> {
|
||||
});
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
let portal_proof = species == Species::SulfurCube
|
||||
&& source_mob.is_some_and(|id| {
|
||||
self.portals
|
||||
.entities
|
||||
.get(&id)
|
||||
.is_some_and(|p| p.cooldown > 0)
|
||||
});
|
||||
self.combat.explosion_cause = match species {
|
||||
Species::Creeper => Some("minecraft:creeper"),
|
||||
Species::Wither => Some("minecraft:wither"),
|
||||
@@ -2560,7 +2570,11 @@ impl Game<'_> {
|
||||
Species::Creeper | Species::Wither => {
|
||||
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
|
||||
}
|
||||
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
|
||||
Species::SulfurCube => self.explode_sulfur_cube(
|
||||
at,
|
||||
radius,
|
||||
source.with_portal_proof(portal_proof),
|
||||
),
|
||||
_ => self.explode(at, radius, source),
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
@@ -3969,6 +3983,7 @@ impl Game<'_> {
|
||||
) -> bool {
|
||||
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
let pierce = std::mem::take(&mut self.combat.piercing_hit);
|
||||
let melee = self.combat.melee_mob.take();
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
@@ -3983,7 +3998,7 @@ impl Game<'_> {
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let blocking = self.player_blocking(from, damage_type, pierce);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
let attacker = match known {
|
||||
@@ -4919,38 +4934,7 @@ impl Game<'_> {
|
||||
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
|
||||
}
|
||||
}
|
||||
/// The boats, minecarts and primed TNT a projectile can strike
|
||||
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
|
||||
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
|
||||
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
|
||||
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
|
||||
boats.chain(carts).chain(tnts).collect()
|
||||
}
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs).
|
||||
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
// Boats, minecarts and primed TNT are pickable for every projectile,
|
||||
// so every kind shares this one list.
|
||||
targets.extend(self.pickable_object_targets());
|
||||
targets
|
||||
}
|
||||
/// `Level.isRainingAt` for arrows.
|
||||
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
move |p: BlockPos| self.raining_at(p)
|
||||
}
|
||||
@@ -5201,6 +5185,24 @@ impl Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
&& self.item_frames.iter().any(|f| f.id == id)
|
||||
{
|
||||
// AbstractArrow.onHitEntity on an item frame: spent when the
|
||||
// frame took the hit, turned back when a fixed one refused it.
|
||||
let by_player = p.owner == Some(Who::Player);
|
||||
let hurt = self.projectile_hurts_frame(id, by_player, by_player && self.creative);
|
||||
let outcome = if hurt {
|
||||
EntityHit::Hurt
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
};
|
||||
self.finish_arrow_hit(p.clone(), outcome);
|
||||
return Ok(());
|
||||
}
|
||||
if p.arrow.is_some()
|
||||
&& let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
@@ -5319,11 +5321,7 @@ impl Game<'_> {
|
||||
}
|
||||
let hurt = match who {
|
||||
Who::Player => {
|
||||
let hurt = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
);
|
||||
let hurt = self.arrow_hits_player(amount, p);
|
||||
// AbstractArrow.onHitEntity: a hit that hurt leaves a
|
||||
// non-piercing arrow stuck in the body.
|
||||
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
|
||||
@@ -5587,7 +5585,7 @@ impl Game<'_> {
|
||||
if let Some(id) = self.spawn_mob_raw("minecraft:chicken", atp, true)
|
||||
&& let Some(c) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
c.extra.age = -24000;
|
||||
(c.extra.age, c.extra.variant) = (-24000, p.egg_variant.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7150,12 +7148,12 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
|
||||
// last moved, not a velocity the server kept for a guest.
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower last moved.
|
||||
let on_ground = p.ground.on_ground;
|
||||
let velocity = self.known_player_movement();
|
||||
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
|
||||
proj.set_egg_item(stack);
|
||||
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
|
||||
self.add_projectile(proj);
|
||||
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
|
||||
@@ -7654,24 +7652,6 @@ fn knock_back(
|
||||
);
|
||||
}
|
||||
|
||||
/// The instance a mob effect gives the player; `None` for a creative or
|
||||
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
|
||||
fn mob_effect_instance(
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
immune: bool,
|
||||
) -> Option<horizon_client::effects::EffectInstance> {
|
||||
if immune && !ambient {
|
||||
return None;
|
||||
}
|
||||
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
|
||||
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
|
||||
instance.ambient = ambient;
|
||||
Some(instance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_sound_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
@@ -8832,6 +8812,9 @@ mod chorus_projectile_drop_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_projectile_tests.rs"]
|
||||
mod frame_projectile_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "loaded_arrow_shot_tests.rs"]
|
||||
mod loaded_arrow_shot_tests;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/// The instance a mob effect gives the player; `None` for a creative or
|
||||
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
|
||||
pub(super) fn mob_effect_instance(
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
immune: bool,
|
||||
) -> Option<horizon_client::effects::EffectInstance> {
|
||||
if immune && !ambient {
|
||||
return None;
|
||||
}
|
||||
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
|
||||
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
|
||||
instance.ambient = ambient;
|
||||
Some(instance)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! What a projectile can strike: the mobs, the player, the dragon's parts and
|
||||
//! End crystals, and the non-living entities that are pickable (boats,
|
||||
//! minecarts, item frames).
|
||||
use super::super::Game;
|
||||
use horizon_client::mobs::Who;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The boats, minecarts, primed TNT and item frames a projectile can strike
|
||||
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`,
|
||||
/// `BlockAttachedEntity.isPickable`).
|
||||
pub(in crate::game) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (Who::Mob(b.id), b.bounds()))
|
||||
.collect();
|
||||
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
|
||||
targets.extend(self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds())));
|
||||
targets.extend(self.item_frames.iter().map(|f| {
|
||||
let (lo, hi) = f.bounds();
|
||||
(
|
||||
Who::Mob(f.id),
|
||||
Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
),
|
||||
)
|
||||
}));
|
||||
targets
|
||||
}
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs), and the vehicles
|
||||
/// and item frames.
|
||||
pub(in crate::game) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (Who::Mob(m.id), m.bounds()))
|
||||
.collect();
|
||||
if let Some(p) = self.player_view()
|
||||
&& p.alive
|
||||
{
|
||||
targets.push((Who::Player, p.bounds));
|
||||
}
|
||||
targets.extend(
|
||||
self.special_targets()
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
// Boats, minecarts, primed TNT and item frames are pickable for every
|
||||
// projectile, so every kind shares this one list.
|
||||
targets.extend(self.pickable_object_targets());
|
||||
targets
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
use super::*;
|
||||
|
||||
impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn below_world_y(&self) -> f64 {
|
||||
self.w.world.below_world_y()
|
||||
}
|
||||
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
|
||||
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
|
||||
let state = self.w.world.block(p.x, p.y, p.z);
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Recording {
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
cpu_s: horizon_system::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
@@ -363,7 +363,7 @@ impl Game<'_> {
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
let cpu = horizon_system::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
@@ -382,7 +382,7 @@ impl Game<'_> {
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
@@ -395,9 +395,12 @@ impl Game<'_> {
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cpu": horizon_system::cpu(),
|
||||
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
|
||||
@@ -37,6 +37,10 @@ pub(super) fn aim_deflect(
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
// `WindCharge.deflect`: refused while its `noDeflectTicks` last.
|
||||
if !p.deflectable() {
|
||||
return;
|
||||
}
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
@@ -183,6 +187,27 @@ impl Game<'_> {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
// `WindCharge.deflect` refuses while its `noDeflectTicks` last: the
|
||||
// blow goes on as an attack that hurts nothing.
|
||||
if p.deflectable() {
|
||||
Self::turn_by_player(p, dir, guest, owner_uuid);
|
||||
}
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
/// `AIM_DEFLECT` by a player's blow: along their view, theirs now.
|
||||
fn turn_by_player(
|
||||
p: &mut Projectile,
|
||||
dir: [f64; 3],
|
||||
guest: Option<u32>,
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
) {
|
||||
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
|
||||
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
|
||||
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
|
||||
@@ -196,14 +221,6 @@ impl Game<'_> {
|
||||
// at once, not at the fireball's next update ten ticks later.
|
||||
p.acceleration = 0.1;
|
||||
p.needs_sync = true;
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +262,7 @@ mod tests {
|
||||
assert_eq!(b.max_position().y, 66.0);
|
||||
}
|
||||
|
||||
/// A wind charge past its first five ticks (`noDeflectTicks` run out).
|
||||
fn wind_charge() -> Projectile {
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::WindCharge,
|
||||
@@ -252,9 +270,26 @@ mod tests {
|
||||
Some(Who::Mob(7)),
|
||||
);
|
||||
p.velocity = Vec3::new(0.0, 1.5, 0.0);
|
||||
p.no_deflect_ticks = 0;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_wind_charge_is_not_turned() {
|
||||
let mut p = wind_charge();
|
||||
p.no_deflect_ticks = 5;
|
||||
aim_deflect(
|
||||
&mut p,
|
||||
Some(Vec3::new(0.0, -1.0, 0.0)),
|
||||
Some(Who::Player),
|
||||
Some([4; 4]),
|
||||
Some(3),
|
||||
);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, Some(Who::Mob(7)));
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
|
||||
@@ -319,7 +319,9 @@ impl Game<'_> {
|
||||
);
|
||||
let state = self.world.block(origin.x, origin.y, origin.z);
|
||||
let mut used = Vec::new();
|
||||
if let Some(op) = case.get("special_attack") {
|
||||
if let Some(op) = case.get("shield_hit") {
|
||||
used = self.fixture_shield_hit(op)?;
|
||||
} else if let Some(op) = case.get("special_attack") {
|
||||
used = self.fixture_special_attack(op)?;
|
||||
} else if let Some(op) = case.get("wind_burst") {
|
||||
let center = [
|
||||
@@ -497,6 +499,68 @@ impl Game<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Actual player combat and blocking-item side effects, without a window or connection.
|
||||
fn fixture_shield_hit(&mut self, op: &Value) -> Result<Vec<String>> {
|
||||
let stack = parse_stack(op["item"].as_str().ok_or("shield_hit item")?)?
|
||||
.ok_or("shield_hit requires an item")?;
|
||||
let item = stack.item.clone();
|
||||
self.creative = op["creative"].as_bool().unwrap_or(false);
|
||||
self.mode = if self.creative {
|
||||
GameMode::Creative
|
||||
} else {
|
||||
GameMode::Survival
|
||||
};
|
||||
self.player = Some(PlayerMotion::new(
|
||||
self.motion,
|
||||
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
|
||||
)?);
|
||||
self.yaw = 0.0;
|
||||
self.armor = Default::default();
|
||||
self.offhand = Some(stack);
|
||||
self.using = Some((super::shield::OFFHAND_SLOT, 100));
|
||||
self.combat = Default::default();
|
||||
let before = self.stats.get("used", &item);
|
||||
let from = horizon_core::geometry::Vec3::new(
|
||||
0.0,
|
||||
0.0,
|
||||
if op["behind"].as_bool().unwrap_or(false) {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
);
|
||||
let mut arrow = horizon_client::projectiles::Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: op["damage"].as_f64().unwrap_or(8.0),
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
from,
|
||||
None,
|
||||
);
|
||||
arrow.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 1.0);
|
||||
arrow.arrow.as_mut().unwrap().pierce = u8::from(op["pierce"].as_bool().unwrap_or(false));
|
||||
self.projectile_hit(
|
||||
&arrow,
|
||||
horizon_client::projectiles::Hit::Entity {
|
||||
who: horizon_client::mobs::Who::Player,
|
||||
at: horizon_core::geometry::Vec3::ZERO,
|
||||
},
|
||||
)?;
|
||||
let out = vec![format!(
|
||||
"shield health={} used={} stack={}",
|
||||
num(f64::from(self.player.as_ref().unwrap().health.health())),
|
||||
self.stats.get("used", &item) - before,
|
||||
self.offhand
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_owned(), |s| format!("{}:{}", s.count, s.damage))
|
||||
)];
|
||||
self.player = None;
|
||||
self.offhand = None;
|
||||
self.using = None;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A player holding the case's item right-clicks the block at `pos`
|
||||
/// (`use_item_as_player`'s block steps): the statistics, the result
|
||||
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
|
||||
|
||||
@@ -909,7 +909,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (Some(slot), true) = (
|
||||
info.equip_slot.as_deref().and_then(Slot::from_name),
|
||||
info.equip_dispensable,
|
||||
horizon_client::item_sync::dispensable(stack, info.equip_dispensable),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1240,6 +1240,7 @@ impl Game<'_> {
|
||||
_ => ProjectileKind::Snowball,
|
||||
};
|
||||
let mut shot = Projectile::new(kind, at, None);
|
||||
shot.set_egg_item(&one);
|
||||
// ArrowItem/SpectralArrowItem.asProjectile: dispensed arrows
|
||||
// may be picked up.
|
||||
if let Some(a) = shot.arrow.as_mut() {
|
||||
|
||||
@@ -381,7 +381,7 @@ pub(super) struct Dragon {
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
velocity: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
yaw_accel: f32,
|
||||
@@ -869,14 +869,14 @@ impl Dragon {
|
||||
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
|
||||
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
|
||||
/// its eye to the motion (`Entity.push`); the parts are not pushed.
|
||||
fn whole_box(&self) -> Aabb {
|
||||
pub(super) fn whole_box(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
)
|
||||
}
|
||||
fn explosion_knockback(
|
||||
pub(super) fn explosion_knockback(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
@@ -5831,7 +5831,6 @@ mod dragon_fireball_spawn_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thorns_tests.rs"]
|
||||
mod thorns_tests;
|
||||
@@ -68,17 +68,13 @@ fn block_break_speed_factor(value: Option<f64>) -> f32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
|
||||
/// and mining fatigue.
|
||||
pub(super) fn modify_destroy_speed(&mut self, mut speed: f32) -> f32 {
|
||||
if speed > 1.0 {
|
||||
speed += self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
|
||||
.unwrap_or(0.0) as f32;
|
||||
}
|
||||
speed * self.potions.effects.mining_speed_factor()
|
||||
/// `Attributes.MINING_EFFICIENCY` (efficiency), which `Player.getDestroySpeed`
|
||||
/// adds to a tool speed above 1: 0 without a player or attribute.
|
||||
pub(super) fn mining_efficiency(&mut self) -> f32 {
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
|
||||
.unwrap_or(0.0) as f32
|
||||
}
|
||||
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
|
||||
/// 1.0 by default, so a missing player or attribute changes nothing.
|
||||
|
||||
@@ -1535,8 +1535,15 @@ impl Game<'_> {
|
||||
if self.restart_portal_cooldown(id) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
let delay = self.entity_portal_delay(id);
|
||||
self.end
|
||||
.gateways
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.enter_with_delay(pos, delay);
|
||||
}
|
||||
// ThrowableProjectile applies the gateway's block effect before Entity.tick.
|
||||
self.handle_end_gateway_portals_phase(true);
|
||||
}
|
||||
|
||||
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
|
||||
@@ -1558,7 +1565,11 @@ impl Game<'_> {
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
// Nobody in a gateway (nearly always): nothing to look up.
|
||||
self.handle_end_gateway_portals_phase(false);
|
||||
}
|
||||
|
||||
fn handle_end_gateway_portals_phase(&mut self, after_move: bool) {
|
||||
// Nobody in a gateway: nothing to look up.
|
||||
if self.remote.is_some() || self.dim != Dim::End || self.end.gateways.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -1606,11 +1617,25 @@ impl Game<'_> {
|
||||
if !ticking {
|
||||
continue;
|
||||
}
|
||||
// Only an entity in a gateway has a state: skip the others before
|
||||
// the per-id lookups below, which walk the entity lists.
|
||||
if !self.end.gateways.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
let is_pearl = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.any(|p| p.id == id && matches!(p.kind, ProjectileKind::EnderPearl));
|
||||
if is_pearl != after_move {
|
||||
continue;
|
||||
}
|
||||
let delay = self.entity_portal_delay(id);
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
if let Some(entry) = state.travel_with_delay(allowed, delay) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
@@ -1939,3 +1964,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gateway_cooldown_tests.rs"]
|
||||
mod cooldown_tests;
|
||||
@@ -2274,6 +2274,7 @@ mod set_rotation_tests {
|
||||
#[test]
|
||||
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
Game,
|
||||
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
|
||||
};
|
||||
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
|
||||
};
|
||||
@@ -23,6 +24,16 @@ use horizon_world::{
|
||||
};
|
||||
use std::{cmp::Ordering, io};
|
||||
|
||||
/// The causing entity of an explicit explosion damage source. Minecart ignition
|
||||
/// keeps this in memory only, just like `MinecartTNT.ignitionSource`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct ExplosionCause {
|
||||
pub entity: (u32, Text),
|
||||
pub player: bool,
|
||||
pub guest: Option<u32>,
|
||||
pub species: Option<horizon_client::mobs::Species>,
|
||||
}
|
||||
|
||||
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and the
|
||||
/// collision context of the entity whose exposure is measured.
|
||||
struct ColliderRays<'a, 'r> {
|
||||
@@ -98,6 +109,7 @@ pub(super) enum BlockInteraction {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct ExplosionSource {
|
||||
silent: bool,
|
||||
pub(super) portal_proof: bool,
|
||||
/// The entity that exploded and its kind (`Explosion.getDirectSourceEntity`), when known.
|
||||
entity: Option<(u32, &'static str)>,
|
||||
}
|
||||
@@ -106,6 +118,10 @@ impl ExplosionSource {
|
||||
self.silent = silent;
|
||||
self
|
||||
}
|
||||
pub(super) fn with_portal_proof(mut self, portal_proof: bool) -> Self {
|
||||
self.portal_proof = portal_proof;
|
||||
self
|
||||
}
|
||||
pub(super) fn with_entity(mut self, id: u32, kind: &'static str) -> Self {
|
||||
self.entity = Some((id, kind));
|
||||
self
|
||||
@@ -171,6 +187,10 @@ fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
|
||||
struct ExplosionHookContext<'a> {
|
||||
center: [f64; 3],
|
||||
blocks: &'a [[i32; 3]],
|
||||
explicit_cause: Option<Option<ExplosionCause>>,
|
||||
direct_name: Option<Text>,
|
||||
held_item: Option<Text>,
|
||||
credit: bool,
|
||||
}
|
||||
|
||||
/// An entity in the blast's reach: how far (as a share of the blast's
|
||||
@@ -187,12 +207,10 @@ fn hurt_mob(
|
||||
hurtable: bool,
|
||||
cause: Option<horizon_client::mobs::Species>,
|
||||
(amount, source): (f32, horizon_client::mobs::Damage),
|
||||
) {
|
||||
) -> bool {
|
||||
let shielded =
|
||||
cause.is_some_and(|c| horizon_client::mobs::wither::shielded_from(m.species(), c));
|
||||
if hurtable && !shielded {
|
||||
m.damage(amount, source);
|
||||
}
|
||||
hurtable && !shielded && m.damage(amount, source)
|
||||
}
|
||||
|
||||
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
|
||||
@@ -228,8 +246,101 @@ pub(super) fn client_float() -> f32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `blast_cause` for the blast going off now.
|
||||
pub(super) fn explosion_entity_name(&self, id: u32, kind: &str) -> Text {
|
||||
self.persist_raw(id)
|
||||
.and_then(|r| r.get("CustomName"))
|
||||
.map_or_else(
|
||||
|| Text::translate(&format!("entity.{}", kind.replace(':', ".")), &[]),
|
||||
|name| {
|
||||
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(
|
||||
name,
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn explosion_cause_resolves(&self, cause: &ExplosionCause) -> bool {
|
||||
if !cause.player {
|
||||
return self
|
||||
.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == cause.entity.0 && !m.removed);
|
||||
}
|
||||
let acting = self
|
||||
.lan_acting_entity
|
||||
.or_else(|| self.player.as_ref().map(|p| p.entity_id as u32));
|
||||
acting == Some(cause.entity.0)
|
||||
|| self.lan.as_ref().is_some_and(|host| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| g.entity_id as u32 == cause.entity.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// An explicit DamageSource must not inherit an enclosing blast's cause.
|
||||
pub(super) fn with_explosion_cause<R>(
|
||||
&mut self,
|
||||
cause: Option<ExplosionCause>,
|
||||
direct_source: (u32, Text),
|
||||
run: impl FnOnce(&mut Self) -> R,
|
||||
) -> R {
|
||||
// The DamageSource retains the removed mob's display name. This is
|
||||
// deliberately not added to exploded_kinds: its EntityReference no
|
||||
// longer resolves for kill credit, unlike Horizon's early exploders.
|
||||
if let Some(cause) = &cause
|
||||
&& cause.species.is_some()
|
||||
&& !self.mobs.iter().any(|m| m.id == cause.entity.0)
|
||||
&& !self
|
||||
.combat
|
||||
.exploded
|
||||
.iter()
|
||||
.any(|(id, _)| *id == cause.entity.0)
|
||||
{
|
||||
self.combat.exploded.push(cause.entity.clone());
|
||||
}
|
||||
let source = self.combat.explosion_source.take();
|
||||
let owner = self.combat.explosion_owner.take();
|
||||
let player = self.combat.blast_owner.take();
|
||||
let guest = self.combat.blast_guest.take();
|
||||
let direct = self.combat.explosion_cause.take();
|
||||
self.combat.explosion_source = Some(direct_source.0);
|
||||
let outer_direct = self
|
||||
.combat
|
||||
.explicit_explosion_direct
|
||||
.replace(direct_source.1);
|
||||
if let Some(cause) = &cause {
|
||||
if cause.player {
|
||||
self.combat.blast_owner = Some(cause.entity.clone());
|
||||
self.combat.blast_guest = cause.guest;
|
||||
} else {
|
||||
self.combat.explosion_owner = Some(cause.entity.0);
|
||||
}
|
||||
}
|
||||
let outer = self.combat.explicit_explosion_cause.replace(cause);
|
||||
let result = run(self);
|
||||
self.combat.explicit_explosion_cause = outer;
|
||||
self.combat.explosion_source = source;
|
||||
self.combat.explosion_owner = owner;
|
||||
self.combat.blast_owner = player;
|
||||
self.combat.blast_guest = guest;
|
||||
self.combat.explosion_cause = direct;
|
||||
self.combat.explicit_explosion_direct = outer_direct;
|
||||
result
|
||||
}
|
||||
|
||||
/// The blast damage's cause. An explicit crystal/cart DamageSource may
|
||||
/// differ from the explosion's own indirect source.
|
||||
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
|
||||
if let Some(cause) = &self.combat.explicit_explosion_cause {
|
||||
return cause.as_ref().map(|c| {
|
||||
let name = (!c.player)
|
||||
.then(|| self.mob_display_name(c.entity.0))
|
||||
.flatten()
|
||||
.unwrap_or_else(|| c.entity.1.clone());
|
||||
(c.entity.0, name)
|
||||
});
|
||||
}
|
||||
// `Explosion.getIndirectSourceEntity`: a primed TNT's owner names the
|
||||
// blast before any mob does, which makes the damage type
|
||||
// `minecraft:player_explosion` (`server_explode` asks this).
|
||||
@@ -311,10 +422,15 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
cap: Option<f32>,
|
||||
portal_proof: bool,
|
||||
wither_immune: Option<&std::collections::HashSet<String, S>>,
|
||||
) -> (f32, bool) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
// USED_PORTAL_DAMAGE_CALCULATOR returns no resistance and never explodes a portal.
|
||||
if portal_proof && &*f.name == "minecraft:nether_portal" {
|
||||
return (0.0, true);
|
||||
}
|
||||
let fluid = if f.fluid != Fluid::None {
|
||||
100.0f32
|
||||
} else {
|
||||
@@ -347,6 +463,7 @@ impl Game<'_> {
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
portal_proof: bool,
|
||||
) -> Vec<[i32; 3]> {
|
||||
let (min_y, max_y) = self.with_level(|_, level, _| {
|
||||
use horizon_world::level::LevelAccess;
|
||||
@@ -395,8 +512,13 @@ impl Game<'_> {
|
||||
let (taken, spared, again) = match last {
|
||||
Some((at, taken, spared)) if at == pos => (taken, spared, true),
|
||||
_ => {
|
||||
let (taken, spared) =
|
||||
self.blast_step(pos, resists, cap, wither_immune.as_ref());
|
||||
let (taken, spared) = self.blast_step(
|
||||
pos,
|
||||
resists,
|
||||
cap,
|
||||
portal_proof,
|
||||
wither_immune.as_ref(),
|
||||
);
|
||||
last = Some((pos, taken, spared));
|
||||
(taken, spared, false)
|
||||
}
|
||||
@@ -438,7 +560,7 @@ impl Game<'_> {
|
||||
attacker: Option<&(u32, Text)>,
|
||||
cause: Option<&horizon_client::advancements::EntityView>,
|
||||
) -> Option<[f64; 3]> {
|
||||
let ExplosionHookContext { center, blocks } = *context;
|
||||
let ExplosionHookContext { center, blocks, .. } = *context;
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
|
||||
// piece, so two Blast Protection IV pieces cancel it).
|
||||
@@ -491,10 +613,39 @@ impl Game<'_> {
|
||||
let flying = self.player.as_ref().is_some_and(|p| p.flying);
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
let mut pushed = None;
|
||||
let explicit = context.explicit_cause.as_ref().and_then(|c| c.as_ref());
|
||||
let living_attacker = explicit.is_none_or(|c| c.player || c.species.is_some());
|
||||
let living_non_player = context
|
||||
.explicit_cause
|
||||
.as_ref()
|
||||
.map_or(attacker.is_some(), |c| {
|
||||
c.as_ref().is_some_and(|c| !c.player && c.species.is_some())
|
||||
});
|
||||
// The death message names the blast's causing entity. It goes to this
|
||||
// player's tracker, the host's or a guest's.
|
||||
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
|
||||
self.note_named_attacker(damage_type, *id, name.clone());
|
||||
if living_attacker && context.credit {
|
||||
self.note_player_attacker(
|
||||
damage_type,
|
||||
*id,
|
||||
name.clone(),
|
||||
context.held_item.clone(),
|
||||
);
|
||||
} else {
|
||||
self.note_uncredited_attacker(
|
||||
damage_type,
|
||||
*id,
|
||||
name.clone(),
|
||||
context.held_item.clone(),
|
||||
living_attacker,
|
||||
);
|
||||
}
|
||||
} else if !creative
|
||||
&& !spectator
|
||||
&& hurtable
|
||||
&& let Some(direct) = context.direct_name.clone()
|
||||
{
|
||||
self.note_direct_entity(damage_type, direct);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
@@ -509,7 +660,7 @@ impl Game<'_> {
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
attacker.is_some(),
|
||||
living_non_player,
|
||||
// The blast's causing entity.
|
||||
attacker.map_or(0, |(id, _)| *id as i32),
|
||||
)
|
||||
@@ -617,7 +768,33 @@ impl Game<'_> {
|
||||
// same for all of them (`onExplosionHit(this.source)`).
|
||||
let attacker = self.blast_attacker();
|
||||
let blast_source = self.combat.explosion_cause.clone();
|
||||
let context = ExplosionHookContext { center, blocks };
|
||||
let context = ExplosionHookContext {
|
||||
center,
|
||||
blocks,
|
||||
explicit_cause: self.combat.explicit_explosion_cause.clone(),
|
||||
direct_name: self.combat.explicit_explosion_direct.clone(),
|
||||
credit: self
|
||||
.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.is_none_or(|cause| {
|
||||
cause
|
||||
.as_ref()
|
||||
.is_some_and(|cause| self.explosion_cause_resolves(cause))
|
||||
}),
|
||||
held_item: self
|
||||
.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_ref())
|
||||
.and_then(|c| {
|
||||
if c.player {
|
||||
self.player_main_hand_custom_name(c.guest)
|
||||
} else {
|
||||
self.mob_main_hand_name(c.entity.0)
|
||||
}
|
||||
}),
|
||||
};
|
||||
let own = self.explosion_hurt_player(
|
||||
&context,
|
||||
radius,
|
||||
@@ -655,10 +832,17 @@ impl Game<'_> {
|
||||
// Mobs. `WitherBoss.hurtServer`: a wither shrugs off an undead cause.
|
||||
let cause = self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species());
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.and_then(|c| c.as_ref())
|
||||
.and_then(|c| c.species)
|
||||
.or_else(|| {
|
||||
self.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
|
||||
.map(|m| m.species())
|
||||
});
|
||||
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
|
||||
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
|
||||
// is the same for all, and only what a blast does to a kind differs. The
|
||||
@@ -667,7 +851,8 @@ impl Game<'_> {
|
||||
// (as `retain` does), and a destroyed boat or cart shifts its list, so
|
||||
// `gone` counts how far.
|
||||
let blast = Blast {
|
||||
vehicles_ignore: self.combat.blast_owner.is_none()
|
||||
vehicles_ignore: self.combat.explicit_explosion_cause.is_none()
|
||||
&& self.combat.blast_owner.is_none()
|
||||
&& self.blast_attacker().is_some()
|
||||
&& !self.ui.rules.bool("mob_griefing"),
|
||||
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
|
||||
@@ -704,18 +889,53 @@ impl Game<'_> {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`),
|
||||
// else the exploding mob itself.
|
||||
attacker: attacker
|
||||
attacker: self
|
||||
.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
.and_then(|c| c.as_ref())
|
||||
.filter(|c| c.player)
|
||||
.map(|_| Who::Player)
|
||||
.or_else(|| {
|
||||
attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.filter(|_| {
|
||||
self.combat
|
||||
.explicit_explosion_cause
|
||||
.as_ref()
|
||||
.is_none_or(|c| {
|
||||
c.as_ref().is_some_and(|c| c.species.is_some())
|
||||
})
|
||||
})
|
||||
.map(horizon_client::mobs::Who::Mob)
|
||||
}),
|
||||
from: Some(center),
|
||||
attacker_species: cause,
|
||||
};
|
||||
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
|
||||
// its base is 0 for every mob type, so iron golems and
|
||||
// ravagers are pushed.
|
||||
hurt_mob(m, hurtable, cause, (amount, source));
|
||||
let victim = m.id;
|
||||
let hurt = hurt_mob(m, hurtable, cause, (amount, source));
|
||||
if hurt {
|
||||
if source.attacker == Some(Who::Player) {
|
||||
let guest = self.combat.blast_guest;
|
||||
self.last_hurt_by_player.insert(victim, guest);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
match guest {
|
||||
Some(conn) => {
|
||||
host.mob_players.insert(victim, conn);
|
||||
}
|
||||
None => {
|
||||
host.mob_players.remove(&victim);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.resolve_player_responsible(victim, source);
|
||||
}
|
||||
// Enderman.hurtServer teleports inside the hurt, before
|
||||
// the push (the mob order stays as it was).
|
||||
if let Err(e) = self.settle_hurt_teleports() {
|
||||
@@ -817,8 +1037,15 @@ impl Game<'_> {
|
||||
self.explosion_breaks_decorations(center, diameter, interaction);
|
||||
pushed
|
||||
}
|
||||
/// `ServerExplosion.getIndirectSourceEntity`: the causing living entity,
|
||||
/// including a TNT igniter or the player who broke an end crystal.
|
||||
fn blast_damage_caused_by_mob(&self) -> bool {
|
||||
self.combat.explicit_explosion_cause.as_ref().map_or_else(
|
||||
|| self.blast_attacker().is_some(),
|
||||
|cause| cause.as_ref().is_some_and(|c| c.species.is_some()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Decoration kills credit `DamageSource.getEntity`: the explicit blast
|
||||
/// cause, including a TNT igniter or the player who broke an end crystal.
|
||||
pub(super) fn explosion_kill_credit(&self) -> Option<super::commands::Target> {
|
||||
use super::commands::Target;
|
||||
// A primed TNT's owner (a player) names the blast before any mob does.
|
||||
@@ -834,10 +1061,10 @@ impl Game<'_> {
|
||||
}
|
||||
self.crystal_blast_player()
|
||||
}
|
||||
/// `blast_spares_decorations` for the blast going off now: its causing
|
||||
/// mob (`blast_attacker`) is the source's entity.
|
||||
/// Decoration hurt checks use the damage source's cause (`instanceof Mob`),
|
||||
/// unlike vehicles, which use the explosion's indirect source.
|
||||
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
|
||||
let by_mob = self.blast_attacker().is_some();
|
||||
let by_mob = self.blast_damage_caused_by_mob();
|
||||
blast_spares_decorations(
|
||||
self.ui.rules.bool("mob_griefing"),
|
||||
interaction,
|
||||
@@ -865,7 +1092,7 @@ impl Game<'_> {
|
||||
interaction: BlockInteraction,
|
||||
) {
|
||||
// An armour stand is no `BlockAttachedEntity`: a source in water does not spare it.
|
||||
let by_mob = self.blast_attacker().is_some();
|
||||
let by_mob = self.blast_damage_caused_by_mob();
|
||||
let spares_stands = blast_spares_decorations(
|
||||
self.ui.rules.bool("mob_griefing"),
|
||||
interaction,
|
||||
@@ -961,12 +1188,38 @@ impl Game<'_> {
|
||||
fire: bool,
|
||||
source: ExplosionSource,
|
||||
) -> Result<()> {
|
||||
let damage_type = super::creatures::damage_type(
|
||||
horizon_client::mobs::DamageKind::Explosion,
|
||||
self.blast_attacker().is_some(),
|
||||
);
|
||||
self.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
|
||||
self.server_explode_from_cause(center, radius, interaction, fire, source, None)
|
||||
}
|
||||
/// An explicit `DamageSource` is independent of the blast's indirect owner.
|
||||
/// `Some(None)` is explicitly ownerless; `None` keeps the ordinary source.
|
||||
pub(super) fn server_explode_from_cause(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
interaction: BlockInteraction,
|
||||
fire: bool,
|
||||
source: ExplosionSource,
|
||||
cause: Option<Option<ExplosionCause>>,
|
||||
) -> Result<()> {
|
||||
let blast = |g: &mut Self| {
|
||||
let damage_type = super::creatures::damage_type(
|
||||
horizon_client::mobs::DamageKind::Explosion,
|
||||
g.blast_attacker().is_some(),
|
||||
);
|
||||
g.server_explode_as_source(center, radius, interaction, fire, damage_type, None, source)
|
||||
};
|
||||
match cause {
|
||||
None => blast(self),
|
||||
Some(cause) => {
|
||||
let (id, kind) = source.entity.ok_or_else(|| {
|
||||
io::Error::other("an explicit explosion cause needs its direct entity")
|
||||
})?;
|
||||
let direct = (id, self.explosion_entity_name(id, kind));
|
||||
self.with_explosion_cause(cause, direct, blast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `server_explode` with the damage type the player takes and a block
|
||||
/// whose explosion resistance the damage calculator overrides.
|
||||
pub(super) fn server_explode_as(
|
||||
@@ -1016,7 +1269,7 @@ impl Game<'_> {
|
||||
// Explosion.explode: the blast is heard.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.exploded_positions(center, radius, resists);
|
||||
let mut targets = self.exploded_positions(center, radius, resists, source.portal_proof);
|
||||
let dimension = self.dim.id();
|
||||
self.native_explosion_detonate(dimension, center, source.entity, &mut targets);
|
||||
let pushed =
|
||||
@@ -1104,10 +1357,18 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let is_tnt = &*name == "minecraft:tnt";
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
|
||||
// experience, as Horizon does not track a TNT's owner).
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies, and a blast
|
||||
// whose indirect source is a player (a primed TNT's owner) awards
|
||||
// the heart's experience (`tryAwardExperience`).
|
||||
if &*name == "minecraft:creaking_heart" {
|
||||
self.creaking_heart_broken(pos, false);
|
||||
// tryAwardExperience checks the TNT owner's own abilities.
|
||||
let award = match self.combat.blast_guest {
|
||||
Some(conn) => self
|
||||
.command_guest(conn)
|
||||
.is_some_and(|g| !g.creative() && !g.spectator()),
|
||||
None => self.combat.blast_owner_player && !self.creative && !self.spectator(),
|
||||
};
|
||||
self.creaking_heart_broken_by(pos, award);
|
||||
}
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
@@ -1139,6 +1400,15 @@ impl Game<'_> {
|
||||
if !is_tnt && name.starts_with("minecraft:infested_") {
|
||||
self.spawn_infestation(pos);
|
||||
}
|
||||
// Its dropExperience is whether the blast's indirect source is a
|
||||
// player (a primed TNT's owner, not a mob's): ores, sculk and spawners
|
||||
// sample their experience then, and pop it while blocks drop.
|
||||
if !is_tnt && self.combat.blast_owner_player {
|
||||
let xp = self.block_experience(&name);
|
||||
if xp > 0 && self.ui.rules.bool("block_drops") {
|
||||
self.spawn_orbs(pos.map(|v| f64::from(v) + 0.5), xp);
|
||||
}
|
||||
}
|
||||
if !is_tnt {
|
||||
let dynamic = self.pending.dynamic(pos);
|
||||
// LootContextParams.EXPLOSION_RADIUS under DESTROY_WITH_DECAY: the
|
||||
@@ -1186,10 +1456,18 @@ impl Game<'_> {
|
||||
let rot = r.next_double() * f64::from(std::f32::consts::TAU);
|
||||
let fuse = r.next_int_bound(20).unwrap_or(0) + 10;
|
||||
// TntBlock.wasExploded hands the new TNT the blast's indirect
|
||||
// source, so a chain reaction keeps the player who started it.
|
||||
let (owner, guest) = match &self.combat.blast_owner {
|
||||
Some(_) => (Some(Who::Player), self.combat.blast_guest),
|
||||
None => (None, None),
|
||||
// source, so a chain reaction keeps a primed TNT's owner, player or mob.
|
||||
let (owner, guest) = match &self.combat.explicit_explosion_cause {
|
||||
// A crystal/cart's explicit DamageSource does not change
|
||||
// Explosion.getIndirectSourceEntity, which is null for them.
|
||||
Some(_) => (None, None),
|
||||
None => match &self.combat.blast_owner {
|
||||
Some(_) if self.combat.blast_owner_player => {
|
||||
(Some(Who::Player), self.combat.blast_guest)
|
||||
}
|
||||
Some((id, _)) => (Some(Who::Mob(*id)), None),
|
||||
None => (None, None),
|
||||
},
|
||||
};
|
||||
self.add_primed_tnt(
|
||||
BlockPos::new(pos[0], pos[1], pos[2]),
|
||||
@@ -1448,7 +1726,7 @@ mod tests {
|
||||
game.world.random.set_seed(7);
|
||||
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
|
||||
game.world.random.set_seed(7);
|
||||
let got = game.exploded_positions(center, radius, resists);
|
||||
let got = game.exploded_positions(center, radius, resists, false);
|
||||
assert!(expected.len() > 20, "{radius}: {}", expected.len());
|
||||
assert_eq!(got, expected, "radius {radius}, {resists:?}");
|
||||
}
|
||||
@@ -1795,6 +2073,115 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod explicit_source_tests {
|
||||
use super::*;
|
||||
use crate::game::native_explosions::NativeBlast;
|
||||
use horizon_client::{mobs::Mob, motion::PlayerMotion};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared resources"]
|
||||
fn explicit_sources_keep_names_without_inventing_kill_credit() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.options.dedicated = None;
|
||||
game.creative = false;
|
||||
game.ui.difficulty = 2;
|
||||
game.build_registries();
|
||||
for mode in 0..5 {
|
||||
let nested = mode == 4;
|
||||
game.combat = Default::default();
|
||||
game.mobs.clear();
|
||||
game.orbs.clear();
|
||||
game.dropped.clear();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, 8.5)).unwrap());
|
||||
let kind = game.res.mob_types().get("minecraft:husk").unwrap().clone();
|
||||
let species = kind.species;
|
||||
if mode == 2 || nested {
|
||||
game.mobs
|
||||
.push(Mob::new(43, kind, Vec3::new(40.0, 64.0, 40.0), 0.0));
|
||||
}
|
||||
// An enclosing blast must not leak its owner into an explicit source.
|
||||
game.combat.explosion_owner = Some(99);
|
||||
game.combat.exploded.push((99, Text::literal("Outer")));
|
||||
let cause = (mode != 0).then(|| ExplosionCause {
|
||||
entity: (43, Text::literal("Igniter")),
|
||||
player: false,
|
||||
guest: None,
|
||||
species: (mode != 1).then_some(species),
|
||||
});
|
||||
if nested {
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.combat.explicit_explosion_cause = Some(cause);
|
||||
game.combat.explicit_explosion_direct = Some(Text::literal("Outer"));
|
||||
game.combat.blast_owner = Some((99, Text::literal("Outer")));
|
||||
game.combat.blast_guest = Some(17);
|
||||
let center = [8.5, 64.0, 8.5];
|
||||
assert!(game.native_explode(
|
||||
"minecraft:overworld",
|
||||
center,
|
||||
2.0,
|
||||
false,
|
||||
NativeBlast::Keep
|
||||
));
|
||||
assert!(matches!(
|
||||
game.combat.explicit_explosion_cause,
|
||||
Some(Some(_))
|
||||
));
|
||||
assert!(game.combat.explicit_explosion_direct.is_some());
|
||||
assert_eq!(
|
||||
game.combat.blast_owner.as_ref().map(|owner| owner.0),
|
||||
Some(99)
|
||||
);
|
||||
assert_eq!(game.combat.blast_guest, Some(17));
|
||||
} else {
|
||||
game.server_explode_from_cause(
|
||||
[8.5, 64.0, 8.5],
|
||||
2.0,
|
||||
BlockInteraction::Keep,
|
||||
false,
|
||||
ExplosionSource::default()
|
||||
.with_entity(7, "minecraft:end_crystal")
|
||||
.with_silence(true),
|
||||
Some(cause),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let events = std::mem::take(&mut game.player.as_mut().unwrap().events);
|
||||
game.record_combat(&events);
|
||||
assert_eq!(
|
||||
game.combat
|
||||
.last_blow()
|
||||
.map(|(kind, id)| (kind.to_owned(), id)),
|
||||
Some((
|
||||
if mode == 0 || nested {
|
||||
"minecraft:explosion"
|
||||
} else {
|
||||
"minecraft:player_explosion"
|
||||
}
|
||||
.into(),
|
||||
if mode == 0 || nested { 0 } else { 43 }
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
game.combat.kill_credit(game.world.game_time),
|
||||
(mode == 2).then_some(43)
|
||||
);
|
||||
if mode == 1 || mode == 3 || nested {
|
||||
let death = game
|
||||
.combat_death_message_for(Text::literal("Victim"))
|
||||
.component();
|
||||
if nested {
|
||||
assert_eq!(death["with"].as_array().unwrap().len(), 1);
|
||||
} else {
|
||||
assert_eq!(death["with"][1]["text"].as_str(), Some("Igniter"));
|
||||
}
|
||||
}
|
||||
assert_eq!(game.combat.explosion_owner, Some(99));
|
||||
assert_eq!(game.combat.explicit_explosion_cause.is_some(), nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shulker_lock_drop_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
|
||||
@@ -756,7 +756,9 @@ impl Game<'_> {
|
||||
.filter_map(|(i, s)| Some((i, s?)))
|
||||
.filter(|(_, s)| counts(s));
|
||||
let (first, stack) = found.next()?;
|
||||
let total = stack.count + found.map(|(_, s)| s.count).sum::<u32>();
|
||||
let total = found
|
||||
.map(|(_, s)| s.count)
|
||||
.fold(stack.count, u32::saturating_add);
|
||||
(ranged || total > 1).then(|| (first, stack.item.clone(), total))
|
||||
}
|
||||
/// One widget's box in `corner`: icons in a column, texts beside them.
|
||||
|
||||
@@ -201,6 +201,15 @@ impl Rocket {
|
||||
}
|
||||
self.life += 1;
|
||||
}
|
||||
/// The end of `Projectile.shoot`: yaw and pitch are set straight from the
|
||||
/// shot's motion (the old rotation follows, as `rotation` is the old one
|
||||
/// on the next tick).
|
||||
fn turn_to_shot(&mut self) {
|
||||
let v = self.velocity;
|
||||
let horizontal = (v.x * v.x + v.z * v.z).sqrt();
|
||||
let atan2 = horizon_core::geometry::trig::atan2_degrees;
|
||||
self.rotation = [atan2(v.x, v.z), atan2(v.y, horizontal)];
|
||||
}
|
||||
/// `Projectile.updateRotation`: a fifth of the way from `old` (yaw,
|
||||
/// pitch) toward its motion's direction, the short way round.
|
||||
fn update_rotation(&mut self, old: [f32; 2]) {
|
||||
@@ -485,6 +494,7 @@ impl Game<'_> {
|
||||
let mut own = random_source::entity();
|
||||
let mut rocket = self.new_rocket(at, stack, false, &mut own);
|
||||
rocket.velocity = shoot(&mut own);
|
||||
rocket.turn_to_shot();
|
||||
rocket.angled = true;
|
||||
rocket.owner = owner;
|
||||
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
|
||||
@@ -1045,6 +1055,20 @@ mod events_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// `Projectile.shoot` turns a shot rocket to its motion at once.
|
||||
#[test]
|
||||
fn a_shot_rocket_starts_turned_to_its_motion() {
|
||||
use horizon_core::geometry::Vec3;
|
||||
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(0.0, 0.5, 0.0));
|
||||
rocket.turn_to_shot();
|
||||
assert!((rocket.rotation[1] - 90.0).abs() < 1e-3);
|
||||
let mut rocket = super::Rocket::remote(1, Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0));
|
||||
rocket.turn_to_shot();
|
||||
assert!((rocket.rotation[0] - 45.0).abs() < 1e-4);
|
||||
assert!(rocket.rotation[1].abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// A rocket draws its UUID's two longs first, then the motion's two triangles
|
||||
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
|
||||
/// the one random. The expected values come from `java.util.Random` replayed
|
||||
|
||||
@@ -24,6 +24,9 @@ use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::lan_actions::GuestPlay;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fishing_air_tests.rs"]
|
||||
mod air_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "fishing_block_hit_tests.rs"]
|
||||
mod block_hit_tests;
|
||||
@@ -714,8 +717,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// getOpenWaterTypeForBlock: 0 above water, 1 inside water, 2 invalid.
|
||||
fn open_water_type(&self, x: i32, y: i32, z: i32) -> u8 {
|
||||
let f = self.res.facts().get(self.world.block(x, y, z));
|
||||
if self.world.is_air(self.world.block(x, y, z)) || &*f.name == "minecraft:lily_pad" {
|
||||
let state = self.world.block(x, y, z);
|
||||
let f = self.res.facts().get(state);
|
||||
// `!state.isAir()`: cave_air and void_air are air too.
|
||||
if self.res.facts().is_air(state) || &*f.name == "minecraft:lily_pad" {
|
||||
return 0;
|
||||
}
|
||||
if f.fluid == Fluid::Water
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! `FishingHook.getOpenWaterTypeForBlock` tests `!state.isAir()`, and `isAir()` covers
|
||||
//! cave_air and void_air: a hook over a source under a cave's air is open water, so its
|
||||
//! catch can be treasure. (A stone block is not air, so it is not open water either.)
|
||||
use super::{FISHING_TABLE, fishing_loot};
|
||||
use horizon_world::loot::LootStack;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn cave_air_above_a_water_source_is_open_water_and_catches_treasure() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let (air, cave_air, void_air, water, stone) = {
|
||||
let registry = &game.pack.assets.registry;
|
||||
let id = |name: &str| registry.default_state(name).unwrap();
|
||||
(
|
||||
id("minecraft:air"),
|
||||
id("minecraft:cave_air"),
|
||||
id("minecraft:void_air"),
|
||||
id("minecraft:water"),
|
||||
id("minecraft:stone"),
|
||||
)
|
||||
};
|
||||
// The three air blocks read alike: ABOVE_WATER (0), never INVALID (2).
|
||||
for (name, state) in [("air", air), ("cave_air", cave_air), ("void_air", void_air)] {
|
||||
game.world.set(5, 63, 5, state).unwrap();
|
||||
assert_eq!(game.open_water_type(5, 63, 5), 0, "{name} is air");
|
||||
}
|
||||
// calculateOpenWater's 5x4x5 area around the hook's block: a source at -1 and the
|
||||
// cave's air at 0..2, as in a mineshaft room filled with water.
|
||||
for x in 3..=7 {
|
||||
for z in 3..=7 {
|
||||
game.world.set(x, 60, z, water).unwrap();
|
||||
for y in 61..=63 {
|
||||
game.world.set(x, y, z, cave_air).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
game.calculate_open_water([5, 61, 5]),
|
||||
"cave_air above a source is open water"
|
||||
);
|
||||
// One block that is not air: the area is not open water.
|
||||
game.world.set(5, 62, 5, stone).unwrap();
|
||||
assert!(!game.calculate_open_water([5, 61, 5]));
|
||||
game.world.set(5, 62, 5, cave_air).unwrap();
|
||||
|
||||
// The flag the hook's retrieval reads: `gameplay/fishing`'s treasure entry is
|
||||
// conditioned on `in_open_water`, so the cave's air decides whether treasure comes.
|
||||
let loot =
|
||||
horizon_client::structure_blocks::loot_data(&game.options.resources, &game.pack_files())
|
||||
.expect("the fishing loot table");
|
||||
// `gameplay/fishing/treasure`'s items (26.3's table).
|
||||
let treasure = [
|
||||
"minecraft:name_tag",
|
||||
"minecraft:saddle",
|
||||
"minecraft:bow",
|
||||
"minecraft:fishing_rod",
|
||||
"minecraft:book",
|
||||
"minecraft:nautilus_shell",
|
||||
];
|
||||
let catches = |open_water: bool| -> Vec<LootStack> {
|
||||
(0..400)
|
||||
.flat_map(|seed| {
|
||||
let mut sequences = horizon_client::random_sequences::RandomSequences::default();
|
||||
let mut level_random = horizon_core::random::LegacyRandom::new(seed);
|
||||
fishing_loot(
|
||||
&loot,
|
||||
FISHING_TABLE,
|
||||
&mut sequences,
|
||||
&mut level_random,
|
||||
seed,
|
||||
0.0,
|
||||
None,
|
||||
open_water,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let treasure_catches = |items: &[LootStack]| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| treasure.contains(&item.item.as_str()))
|
||||
.count()
|
||||
};
|
||||
assert_eq!(
|
||||
treasure_catches(&catches(false)),
|
||||
0,
|
||||
"not open water: no treasure"
|
||||
);
|
||||
assert!(
|
||||
treasure_catches(&catches(true)) > 0,
|
||||
"open water can catch treasure"
|
||||
);
|
||||
}
|
||||
@@ -95,12 +95,12 @@ impl Game<'_> {
|
||||
valid_fov_modifier(modifier, base)
|
||||
});
|
||||
let target = self.zoomed_fov_target(target);
|
||||
// A server's spectator camera on a mob: its eye height. Its modifier
|
||||
// stays 1 (another player's own modifier is not modelled here); the
|
||||
// zoom extra still narrows it.
|
||||
if let Some(m) = self.camera_mob() {
|
||||
let eye = m.eye_height() as f32;
|
||||
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
|
||||
// A server's spectator camera on a mob or an armor stand: its eye
|
||||
// height. Its modifier stays 1 (another player's own modifier is not
|
||||
// modelled here); the zoom extra still narrows it.
|
||||
if let Some(e) = self.camera_entity() {
|
||||
self.view
|
||||
.tick(Some(e.eye_height), self.zoomed_fov_target(1.0));
|
||||
return;
|
||||
}
|
||||
let eye = self.player.as_ref().map(|p| {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! A projectile that strikes an item frame hurts it (#11383): `ItemFrame.hurtServer`
|
||||
//! gives the item up, an empty frame breaks, and a fixed one refuses the hit.
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::ChunkPos;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
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 = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 63 0 15 63 15 stone",
|
||||
"fill 4 64 0 4 66 15 stone",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
game
|
||||
}
|
||||
|
||||
/// An arrow from `(0.5, 64.5, z)` flying east into the wall at x = 4.
|
||||
fn shoot(game: &mut Game<'_>, z: f64) {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon minecraft:arrow 0.5 64.5 {z} {{Motion:[1.0d,0.0d,0.0d]}}"
|
||||
)));
|
||||
for _ in 0..10 {
|
||||
game.tick_projectiles().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn dropped(game: &Game<'_>, item: &str) -> usize {
|
||||
game.dropped
|
||||
.iter()
|
||||
.filter(|d| &*d.stack.item == item)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_arrow_knocks_the_item_out_then_breaks_the_frame() {
|
||||
let mut game = game();
|
||||
// On the wall's west face (Facing 4: west).
|
||||
assert!(game.run_command(
|
||||
"summon minecraft:item_frame 3.5 64.5 6.5 {Facing:4b,Item:{id:\"minecraft:diamond\",count:1}}"
|
||||
));
|
||||
assert_eq!(game.item_frames.len(), 1);
|
||||
shoot(&mut game, 6.5);
|
||||
assert_eq!(game.item_frames.len(), 1, "the first hit leaves the frame");
|
||||
assert!(game.item_frames[0].item.is_none(), "the item came out");
|
||||
assert_eq!(dropped(&game, "minecraft:diamond"), 1);
|
||||
// The empty frame breaks and drops itself.
|
||||
shoot(&mut game, 6.5);
|
||||
assert!(game.item_frames.is_empty());
|
||||
assert_eq!(dropped(&game, "minecraft:item_frame"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_fixed_frame_refuses_the_arrow() {
|
||||
let mut game = game();
|
||||
assert!(game.run_command(
|
||||
"summon minecraft:item_frame 3.5 64.5 9.5 {Facing:4b,Fixed:1b,Item:{id:\"minecraft:diamond\",count:1}}"
|
||||
));
|
||||
shoot(&mut game, 9.5);
|
||||
assert_eq!(game.item_frames.len(), 1);
|
||||
assert!(
|
||||
game.item_frames[0].item.is_some(),
|
||||
"a fixed frame keeps its item"
|
||||
);
|
||||
assert_eq!(dropped(&game, "minecraft:diamond"), 0);
|
||||
// The frame took the arrow's hit and refused it, so the arrow turned back
|
||||
// (`AbstractArrow.onHitEntity`'s deflection) instead of reaching the wall.
|
||||
assert!(
|
||||
game.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
.all(|p| p.position.x < 3.9),
|
||||
"the arrow went through the frame"
|
||||
);
|
||||
}
|
||||
@@ -349,12 +349,45 @@ impl Game<'_> {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.hit_frame(i, true, self.creative);
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.hurtServer` from a projectile (`AbstractArrow.onHitEntity`,
|
||||
/// `Snowball`, `ThrownEgg`, `ThrownEnderpearl`, `SmallFireball`...): the
|
||||
/// damage source's entity is the projectile's owner. A fixed frame only
|
||||
/// gives way to a creative player's shot (`canHurtWhenFixed`), and then
|
||||
/// breaks whole (`BlockAttachedEntity.hurtServer`). `creative`: that player
|
||||
/// has infinite materials. False when the frame refused the hit.
|
||||
pub(super) fn projectile_hurts_frame(
|
||||
&mut self,
|
||||
id: u32,
|
||||
by_player: bool,
|
||||
creative: bool,
|
||||
) -> bool {
|
||||
let Some(i) = self.item_frames.iter().position(|f| f.id == id) else {
|
||||
return false;
|
||||
};
|
||||
if self.item_frames[i].fixed {
|
||||
if !creative {
|
||||
return false;
|
||||
}
|
||||
self.item_frames[i].item = None;
|
||||
}
|
||||
self.hit_frame(i, by_player, creative);
|
||||
true
|
||||
}
|
||||
/// The non-fixed part of `ItemFrame.hurtServer`: a filled frame gives its
|
||||
/// item up (`shouldDamageDropItem`), an empty one breaks
|
||||
/// (`BlockAttachedEntity.hurtServer`). `by_player`: the damage source's
|
||||
/// entity is the acting player, `creative` when they have infinite
|
||||
/// materials (no drops).
|
||||
fn hit_frame(&mut self, i: usize, by_player: bool, creative: bool) {
|
||||
let with_frame = self.item_frames[i].item.is_none();
|
||||
let entity_id = self.item_frames[i].id;
|
||||
if with_frame {
|
||||
let (kind, pos) = (self.item_frames[i].kind(), self.item_frames[i].pos);
|
||||
if self.hanging_break_cancelled(kind, pos, true) {
|
||||
return true;
|
||||
if self.hanging_break_cancelled(kind, pos, by_player) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let old_item = self.item_frames[i]
|
||||
@@ -380,13 +413,14 @@ impl Game<'_> {
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, with_frame, true);
|
||||
self.drop_frame_items(&mut f, with_frame, by_player, creative);
|
||||
if !with_frame {
|
||||
self.item_frames[i].random = f.random.clone();
|
||||
}
|
||||
// BLOCK_CHANGE by the attacker after the drops: ItemFrame.hurtServer
|
||||
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
let who = by_player.then_some(horizon_client::mobs::Who::Player);
|
||||
let ctx = self.who_context(who, None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
if !with_frame {
|
||||
// ItemFrame.hurtServer plays this after the event.
|
||||
@@ -394,7 +428,9 @@ impl Game<'_> {
|
||||
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
if by_player
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_item_frame_change(
|
||||
player,
|
||||
&name,
|
||||
@@ -404,7 +440,6 @@ impl Game<'_> {
|
||||
rotation.rem_euclid(8) as u8,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.setItem(stack, true)`: keeps one item, plays the add sound
|
||||
/// for a non-empty stack and updates the comparators.
|
||||
@@ -428,14 +463,20 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
|
||||
}
|
||||
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
|
||||
fn drop_frame_items(&mut self, f: &mut ItemFrame, with_frame: bool, by_player: bool) {
|
||||
fn drop_frame_items(
|
||||
&mut self,
|
||||
f: &mut ItemFrame,
|
||||
with_frame: bool,
|
||||
by_player: bool,
|
||||
creative: bool,
|
||||
) {
|
||||
// The framed map forgets the frame, except when the drops are off and
|
||||
// something caused the break (`removeFramedMap` runs only for a null
|
||||
// `causedBy` then).
|
||||
if self.ui.rules.bool("entity_drops") || !by_player {
|
||||
self.remove_framed_map(f);
|
||||
}
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && creative) {
|
||||
return;
|
||||
}
|
||||
let s = f.step().map(f64::from);
|
||||
@@ -498,7 +539,7 @@ impl Game<'_> {
|
||||
self.entity_killed_event_by(center(&f), self.explosion_kill_credit());
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
self.drop_frame_items(&mut f, true, false, false);
|
||||
// ItemFrame.dropItem(level, causedBy): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
@@ -532,7 +573,7 @@ impl Game<'_> {
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
self.drop_frame_items(&mut f, true, false, false);
|
||||
// ItemFrame.dropItem(level, null): sound, drops, then event.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Production gateway counters and direct phases over a silent, empty Game.
|
||||
use crate::game::{
|
||||
boats::Boat,
|
||||
entity::Entity,
|
||||
minecarts::{CartKind, Minecart},
|
||||
};
|
||||
use horizon_client::{
|
||||
end_gateway::Traveller,
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs explicit HORIZON_TEST_RESOURCES and HORIZON_PORTAL_REFERENCE"]
|
||||
fn gateway_counters_match_actual_vanilla_methods() {
|
||||
let reference: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(std::env::var_os("HORIZON_PORTAL_REFERENCE").unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let boat = Boat::new(1, "oak".into(), Vec3::ZERO, 0.0);
|
||||
let cart = Minecart::new(2, CartKind::Rideable, Vec3::ZERO);
|
||||
let arrow = Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
Vec3::ZERO,
|
||||
None,
|
||||
);
|
||||
let delays = [
|
||||
boat.dimension_changing_delay(),
|
||||
cart.dimension_changing_delay(),
|
||||
arrow.dimension_changing_delay(),
|
||||
horizon_client::nether_portal::ENTITY_DELAY,
|
||||
];
|
||||
assert_eq!(serde_json::json!(delays), reference["delays"]);
|
||||
let mut travellers: [Traveller; 4] = std::array::from_fn(|_| Traveller::default());
|
||||
let entry = BlockPos::new(0, 100, 0);
|
||||
for (traveller, delay) in travellers.iter_mut().zip(delays) {
|
||||
traveller.enter_with_delay(entry, delay);
|
||||
assert_eq!(traveller.travel_with_delay(true, delay), Some(entry));
|
||||
assert_eq!(traveller.cooldown(), delay);
|
||||
traveller.set_cooldown(5);
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for (step, ticks) in [1, 3, 1, 1, 8].into_iter().enumerate() {
|
||||
for _ in 0..ticks {
|
||||
for (i, (traveller, delay)) in travellers.iter_mut().zip(delays).enumerate() {
|
||||
if step < 2 && i == 2 {
|
||||
traveller.enter_with_delay(entry, delay);
|
||||
}
|
||||
traveller.travel_with_delay(true, delay);
|
||||
if step < 2 && i != 2 {
|
||||
traveller.enter_with_delay(entry, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push(
|
||||
travellers
|
||||
.iter()
|
||||
.map(Traveller::cooldown)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
let phases = game_gateway_phases();
|
||||
let report = serde_json::json!({"contact":rows,"phases":phases});
|
||||
assert_eq!(report["contact"], reference["contact"]);
|
||||
if let Some(path) = std::env::var_os("HORIZON_PORTAL_REPORT") {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real dispatch with cooldown-only states, so no teleport destination is resolved.
|
||||
fn game_gateway_phases() -> [[i32; 2]; 4] {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
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.headless = true;
|
||||
options.game_dir = Some(std::env::current_dir().unwrap());
|
||||
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.options.dedicated = Some(Vec::new());
|
||||
game.world = horizon_client::world::World::new(game.res.facts().air, 1);
|
||||
for z in -2..=2 {
|
||||
for x in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.dim = crate::game::Dim::End;
|
||||
let mut ids = Vec::new();
|
||||
for (index, kind) in ["minecraft:oak_boat", "minecraft:ender_pearl"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let uuid = [11318, 20, 0, index as i32];
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String(kind.into())),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
Tag::Double(210.0f64.to_bits()),
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
]);
|
||||
game.load_listed(kind, Some(uuid), None, tag).unwrap();
|
||||
ids.push(game.next_entity);
|
||||
}
|
||||
assert_eq!(game.boats.len(), 1);
|
||||
assert_eq!(game.creatures.projectiles.len(), 1);
|
||||
assert!(matches!(
|
||||
game.creatures.projectiles[0].kind,
|
||||
ProjectileKind::EnderPearl
|
||||
));
|
||||
game.tickets.update(Some(crate::ticket_levels::Sources {
|
||||
forced: vec![(0, 0)],
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(game.entity_ticking_area()(4, 4));
|
||||
for &id in &ids {
|
||||
let mut state = Traveller::default();
|
||||
state.set_cooldown(5);
|
||||
game.end.gateways.insert(id, state);
|
||||
}
|
||||
let counters = |game: &Game| {
|
||||
[
|
||||
game.end.gateways[&ids[0]].cooldown(),
|
||||
game.end.gateways[&ids[1]].cooldown(),
|
||||
]
|
||||
};
|
||||
let mut rows = [[0; 2]; 4];
|
||||
for tick in 0..2 {
|
||||
game.handle_end_gateway_portals();
|
||||
rows[tick * 2] = counters(&game);
|
||||
assert_eq!(rows[tick * 2], [4 - tick as i32, 5 - tick as i32]);
|
||||
game.handle_end_gateway_portals_phase(true);
|
||||
rows[tick * 2 + 1] = counters(&game);
|
||||
assert_eq!(rows[tick * 2 + 1], [4 - tick as i32, 4 - tick as i32]);
|
||||
}
|
||||
assert!(game.end.arrivals.is_empty());
|
||||
assert_eq!(game.creatures.projectiles.len(), 1);
|
||||
rows
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_items::mining::{self, DestroySpeedModifiers};
|
||||
|
||||
/// The point a `ServerboundInteractPacket` carries: `EntityHitResult.getLocation()
|
||||
/// - entity.position()`, measured from the entity's feet, as
|
||||
@@ -430,20 +431,29 @@ impl Game<'_> {
|
||||
return 0.0;
|
||||
}
|
||||
let held = self.held();
|
||||
let mut speed = self.items().mining_speed(held, &facts.name);
|
||||
let tool_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;
|
||||
if self.mining_eyes_in_water() {
|
||||
speed *= self.submerged_mining_speed();
|
||||
}
|
||||
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
|
||||
speed /= 5.0;
|
||||
}
|
||||
let harvest =
|
||||
self.items()
|
||||
.has_correct_tool_for_drops(held, facts.requires_tool, &facts.name);
|
||||
let player = DestroySpeedModifiers {
|
||||
// Vanilla reads the attribute (recomputing a dirty one) only then.
|
||||
mining_efficiency: if tool_speed > 1.0 {
|
||||
self.mining_efficiency()
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
effects: self.potions.effects.mining_speed_factor(),
|
||||
block_break_speed,
|
||||
submerged_mining_speed: self
|
||||
.mining_eyes_in_water()
|
||||
.then(|| self.submerged_mining_speed()),
|
||||
on_ground: self.player.as_ref().is_none_or(|p| p.ground.on_ground),
|
||||
};
|
||||
let mut speed = mining::destroy_speed(tool_speed, &player);
|
||||
// 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 }
|
||||
mining::destroy_progress(hardness, harvest, speed)
|
||||
}
|
||||
/// `EntityFluidInteraction`: compare the eyes with the camera fluid surface.
|
||||
fn mining_eyes_in_water(&self) -> bool {
|
||||
|
||||
@@ -147,7 +147,7 @@ impl Game<'_> {
|
||||
// ItemStack.isDamageableItem: component removal and
|
||||
// unbreakable are checked before enchantment RNG or hooks.
|
||||
if stack.count == 0
|
||||
|| !info.equip_damage_on_hurt
|
||||
|| !horizon_client::item_sync::damage_on_hurt(&stack, info.equip_damage_on_hurt)
|
||||
|| component("minecraft:unbreakable").is_some()
|
||||
|| component("!minecraft:max_damage").is_some()
|
||||
|| component("!minecraft:damage").is_some()
|
||||
@@ -242,10 +242,8 @@ impl Game<'_> {
|
||||
{
|
||||
stack.damage += amount;
|
||||
if stack.damage >= max {
|
||||
let broken = stack.item.clone();
|
||||
let gone = stack.clone();
|
||||
*cell = None;
|
||||
self.award_stat("broken", &broken, 1);
|
||||
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
|
||||
self.player_equipment_broke(break_slot, &gone);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ impl Game<'_> {
|
||||
let mut cells = horizon_client::entities::ItemCells::new(&items);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
// `Entity.checkBelowWorld`: below `Level.getMinY() - 64`.
|
||||
let void = f64::from(self.level_min_y() - 64);
|
||||
let mut obstacles =
|
||||
horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
@@ -144,7 +146,7 @@ impl Game<'_> {
|
||||
2
|
||||
};
|
||||
if merging
|
||||
&& (alive || item.age == 6000 && item.position.y > -128.0)
|
||||
&& (alive || item.age == 6000 && item.position.y >= void)
|
||||
&& item.ticks.is_multiple_of(rate)
|
||||
{
|
||||
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
|
||||
@@ -157,8 +159,8 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let item = &mut items[i];
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y >= void);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y >= void {
|
||||
let mut extra_life = 0;
|
||||
self.native_item_expire(item.id, &item.stack, &mut extra_life);
|
||||
if extra_life > 0 {
|
||||
@@ -497,6 +499,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -793,6 +796,20 @@ impl Game<'_> {
|
||||
self.player_drop(thrown);
|
||||
}
|
||||
|
||||
/// `ServerPlayer.drop(boolean)` for the guest `conn` (`gi` in the hub): the
|
||||
/// drop key's throw, then `setRemoteSlot` on the selected slot so a pickup
|
||||
/// that refills the stack is sent to the client that predicted the drop.
|
||||
pub(super) fn guest_drop_held(
|
||||
&mut self,
|
||||
host: &mut super::lan::LanHost,
|
||||
gi: usize,
|
||||
conn: u32,
|
||||
all: bool,
|
||||
) {
|
||||
self.as_guest(host, conn, |g| g.drop_held(all));
|
||||
host.hub.guests[gi].told_selected();
|
||||
}
|
||||
|
||||
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
|
||||
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
|
||||
/// drop key) names the player as the thrower; `drop(stack, false)`, for
|
||||
@@ -1032,3 +1049,7 @@ mod escape_tests {
|
||||
assert_eq!(game.world.random.next_float(), level.next_float());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "item_void_tests.rs"]
|
||||
mod item_void_tests;
|
||||
@@ -73,6 +73,9 @@ impl Game<'_> {
|
||||
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
|
||||
_ if let Some(consumable) = self.consumable(stack) => consumable.ticks(),
|
||||
_ if let Some(ticks) = patched_ticks => ticks,
|
||||
_ if let Some(seconds) = horizon_client::item_sync::consumable_seconds(stack) => {
|
||||
(seconds * 20.0) as i32
|
||||
}
|
||||
_ => match info {
|
||||
Some(i) if let Some(c) = &i.consumable => c.ticks(),
|
||||
_ if patched_blocks => 72_000,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `Entity.checkBelowWorld`: a dropped item is discarded below its level's
|
||||
//! floor minus 64, so the Nether and the End lose one at y = -64 while the
|
||||
//! Overworld keeps it down to y = -128.
|
||||
use super::super::Dim;
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn items_fall_to_each_dimensions_own_void_floor() {
|
||||
let mut game = server_game();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 8.5)).unwrap());
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(8, 8));
|
||||
let keys: HashSet<(i32, i32)> = [(0, 0)].into();
|
||||
for (dim, floor) in [
|
||||
(Dim::Overworld, -128.0),
|
||||
(Dim::Nether, -64.0),
|
||||
(Dim::End, -64.0),
|
||||
] {
|
||||
game.dim = dim;
|
||||
game.world.shape = dim.shape();
|
||||
for (y, survives) in [(floor + 0.5, true), (floor - 0.5, false)] {
|
||||
game.dropped.clear();
|
||||
game.spawn_item(
|
||||
ItemStack::new("minecraft:stone", 1),
|
||||
[8.5, y, 8.5],
|
||||
[0.0, 0.0, 0.0],
|
||||
0,
|
||||
);
|
||||
game.ticked_entities_through = u32::MAX;
|
||||
game.tick_items();
|
||||
assert_eq!(
|
||||
!game.dropped.is_empty(),
|
||||
survives,
|
||||
"{dim:?}: an item at y {y}, against the floor {floor}"
|
||||
);
|
||||
let mut saved = HashMap::new();
|
||||
game.save_every_entity(&keys, &mut saved);
|
||||
assert_eq!(
|
||||
saved.values().map(Vec::len).sum::<usize>() > 0,
|
||||
survives,
|
||||
"{dim:?}: an item at y {y} in the save"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user