mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 10:31:29 -10:00
Compare commits
969
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c39b4ed4b8 | ||
|
|
9510efd2fe | ||
|
|
747ccd0b5b | ||
|
|
396ff1e5f9 | ||
|
|
cb93abfe66 | ||
|
|
70ede6a0ef | ||
|
|
7a6dd1ffc9 | ||
|
|
656b7c7c9b | ||
|
|
e0cee924f0 | ||
|
|
061a7dda03 | ||
|
|
baae358894 | ||
|
|
83b29e43ff | ||
|
|
d35bf3ba33 | ||
|
|
6667abcb04 | ||
|
|
9b5e0026f3 | ||
|
|
12eb38e131 | ||
|
|
3c1d4b39c6 | ||
|
|
c5abd34fb1 | ||
|
|
434577c913 | ||
|
|
a55abdfe89 | ||
|
|
5a70e0ba0d | ||
|
|
56dcdc8464 | ||
|
|
2a009ad16f | ||
|
|
37b1f605d5 | ||
|
|
a6cf9d0370 | ||
|
|
68e9da706c | ||
|
|
f2f768bb36 | ||
|
|
dd4f61225b | ||
|
|
1b99614b7b | ||
|
|
2789409634 | ||
|
|
d9cbb0f4e6 | ||
|
|
3bfec3857d | ||
|
|
37aa729526 | ||
|
|
1c6992a28c | ||
|
|
7e902a6886 | ||
|
|
101280753b | ||
|
|
d5c4248f62 | ||
|
|
0580d262da | ||
|
|
d3a5cf3925 | ||
|
|
4bbd8f3e8e | ||
|
|
a71733bfe7 | ||
|
|
79c76034a0 | ||
|
|
1af0acfc37 | ||
|
|
e8897035e4 | ||
|
|
a26ebcb37e | ||
|
|
8fc8f02820 | ||
|
|
d3fd453cfa | ||
|
|
be529ea12d | ||
|
|
3275d82094 | ||
|
|
2dcafe3ee3 | ||
|
|
a87db94c53 | ||
|
|
33d8b6ebeb | ||
|
|
917e58b2da | ||
|
|
c830a170ad | ||
|
|
5eb665b1c3 | ||
|
|
672b204c0a | ||
|
|
a79436fb97 | ||
|
|
3da2b20cc8 | ||
|
|
4b461a9aae | ||
|
|
9990eabc7f | ||
|
|
d9e7cae928 | ||
|
|
dc29fd238b | ||
|
|
393a461720 | ||
|
|
973eeadcfd | ||
|
|
f82149a3dd | ||
|
|
0dc793fdb5 | ||
|
|
9054950e06 | ||
|
|
84ebab8a78 | ||
|
|
b307359409 | ||
|
|
424dc918e7 | ||
|
|
ce12bd5b6d | ||
|
|
0d04976fa1 | ||
|
|
303e418b08 | ||
|
|
403df829f3 | ||
|
|
8e40d11b27 | ||
|
|
e921ddb7c5 | ||
|
|
a4fec395ee | ||
|
|
d8d6dd15aa | ||
|
|
e825d36af8 | ||
|
|
ae19796505 | ||
|
|
ec156a856f | ||
|
|
d49bcab591 | ||
|
|
5f5acece3f | ||
|
|
51119592e2 | ||
|
|
f4a81d27ee | ||
|
|
1f4dba50e3 | ||
|
|
7a09947e46 | ||
|
|
6034bab012 | ||
|
|
1b06b53500 | ||
|
|
6907fe0a79 | ||
|
|
a12b5151e1 | ||
|
|
65b312814b | ||
|
|
771394b76d | ||
|
|
5f06dbbc16 | ||
|
|
ef7ef9959c | ||
|
|
a1f56c2f2e | ||
|
|
c3c2ffc43e | ||
|
|
0ec594d9d3 | ||
|
|
98b6a442cf | ||
|
|
96caf0bf88 | ||
|
|
31192110c6 | ||
|
|
00b1e2c12a | ||
|
|
b0cd391031 | ||
|
|
22c09057fc | ||
|
|
7366ff29b5 | ||
|
|
fd92adb803 | ||
|
|
b914de7e69 | ||
|
|
c5d4217dbb | ||
|
|
3c5dd6b03b | ||
|
|
c4c4b63aba | ||
|
|
56f9a17682 | ||
|
|
35212f09a5 | ||
|
|
e017850f93 | ||
|
|
0d1dccd5ff | ||
|
|
6307b54dea | ||
|
|
51629f9085 | ||
|
|
b74a78c970 | ||
|
|
e694791bb5 | ||
|
|
65030881fc | ||
|
|
ceb5791a69 | ||
|
|
4fa552591a | ||
|
|
699afa8007 | ||
|
|
dc35e78173 | ||
|
|
49db9b276b | ||
|
|
0b95a7a921 | ||
|
|
524071f9cf | ||
|
|
5d3a45f09f | ||
|
|
ab92024350 | ||
|
|
b91f0330f4 | ||
|
|
dc6e548538 | ||
|
|
95f097fd06 | ||
|
|
c6dd5e2205 | ||
|
|
1604f8abee | ||
|
|
f9d0a88d27 | ||
|
|
c739b7af73 | ||
|
|
177aff1240 | ||
|
|
062d66e495 | ||
|
|
aa44f715e6 | ||
|
|
ffe68c936f | ||
|
|
d2fe5c0768 | ||
|
|
f788711fe1 | ||
|
|
c6132e2b9b | ||
|
|
e1a530a71e | ||
|
|
b5b451de7c | ||
|
|
1498477aec | ||
|
|
c527d60b75 | ||
|
|
0a825b42a2 | ||
|
|
75867c69b9 | ||
|
|
475d096fe2 | ||
|
|
c0f09fed88 | ||
|
|
04c7cfc1af | ||
|
|
70436b6f7d | ||
|
|
ecd85c497f | ||
|
|
8bad582185 | ||
|
|
963b7d8bd9 | ||
|
|
ee7d443c8f | ||
|
|
3554efb04f | ||
|
|
94f48bbf6a | ||
|
|
ada4e6567d | ||
|
|
fb9db82d1b | ||
|
|
8922550fdf | ||
|
|
1085b42fc5 | ||
|
|
2e321e385a | ||
|
|
9c471a85ce | ||
|
|
0bb993afd7 | ||
|
|
6f26c1f355 | ||
|
|
2052e55b0b | ||
|
|
8975278e5e | ||
|
|
c4c7df589a | ||
|
|
f387c1d78f | ||
|
|
571e0e5341 | ||
|
|
68b90d860b | ||
|
|
53ba582419 | ||
|
|
c995c9b76e | ||
|
|
ab2ab8ef61 | ||
|
|
9e267fbf9e | ||
|
|
618469513b | ||
|
|
e387764263 | ||
|
|
0e6e9ab098 | ||
|
|
6f3757c8e4 | ||
|
|
49878e9aee | ||
|
|
dd63a5cc5f | ||
|
|
8e83c3eb23 | ||
|
|
48f539e7d3 | ||
|
|
6db9492f5f | ||
|
|
3ad2bda85f | ||
|
|
2817362973 | ||
|
|
4acfc70975 | ||
|
|
d214aa61d7 | ||
|
|
797d6512ea | ||
|
|
3c66c314b9 | ||
|
|
337b0e67fb | ||
|
|
12a7bf8b29 | ||
|
|
3a906e8647 | ||
|
|
d2b451af13 | ||
|
|
5560aadd24 | ||
|
|
795c8e96a0 | ||
|
|
f257d62aa3 | ||
|
|
4bd0bd7dc1 | ||
|
|
b0d000b4b4 | ||
|
|
860a3ec2b2 | ||
|
|
0c65b58a65 | ||
|
|
5ab1a8f543 | ||
|
|
0e282c022c | ||
|
|
dcddbbe841 | ||
|
|
03f7a69993 | ||
|
|
2d4e6ba0be | ||
|
|
cab2754fe1 | ||
|
|
dfb9e41079 | ||
|
|
1374959d6f | ||
|
|
0f1a100391 | ||
|
|
ac6f38c3d6 | ||
|
|
5d47a8557c | ||
|
|
48960bffd6 | ||
|
|
5370ae92cf | ||
|
|
77e8224441 | ||
|
|
c003ada028 | ||
|
|
4daf4be5c0 | ||
|
|
b3f160b674 | ||
|
|
13451e7207 | ||
|
|
00c456d313 | ||
|
|
6c8e7e3e59 | ||
|
|
a71dfda331 | ||
|
|
0d1681d649 | ||
|
|
318ed97be4 | ||
|
|
fde63c0597 | ||
|
|
1803841469 | ||
|
|
660babec5b | ||
|
|
3613a6eb0d | ||
|
|
49544daa24 | ||
|
|
44a07ac4bf | ||
|
|
1beefecb54 | ||
|
|
a2fa7b322d | ||
|
|
e654389d2a | ||
|
|
0ce6c9fe94 | ||
|
|
d57bcbca83 | ||
|
|
521a01282b | ||
|
|
aa1a9b9379 | ||
|
|
176cfef84f | ||
|
|
9d59efaf3f | ||
|
|
d9cbe12a4f | ||
|
|
2b2851a0dc | ||
|
|
1adb5cf92d | ||
|
|
4742c9b3f5 | ||
|
|
1f82ef2116 | ||
|
|
b9e348c2c0 | ||
|
|
f4da935e0f | ||
|
|
066eb31ad5 | ||
|
|
073afd484e | ||
|
|
2bc03baa6b | ||
|
|
3a5ac7ffed | ||
|
|
0a7ad01ed6 | ||
|
|
be0445848d | ||
|
|
f5c79065e9 | ||
|
|
a14cac7f7c | ||
|
|
08918a8a7f | ||
|
|
34118b285f | ||
|
|
b680d71bdc | ||
|
|
1eedd39c76 | ||
|
|
74351c8057 | ||
|
|
8486a32926 | ||
|
|
31300e6846 | ||
|
|
cf6e5c2474 | ||
|
|
deec9041ac | ||
|
|
c822f6eb82 | ||
|
|
e1314bbcc9 | ||
|
|
8d82e986f9 | ||
|
|
d51a2eb268 | ||
|
|
b9f756ebad | ||
|
|
cf38f00b54 | ||
|
|
76ab943b40 | ||
|
|
6f846cf6a5 | ||
|
|
d0fbd3f638 | ||
|
|
a14c9886ce | ||
|
|
07641bb0b2 | ||
|
|
bf29bd9166 | ||
|
|
820913dc4c | ||
|
|
e021395210 | ||
|
|
e311967b29 | ||
|
|
5edf728ee3 | ||
|
|
6e8e088c25 | ||
|
|
c27092a567 | ||
|
|
17b1f8fd19 | ||
|
|
6b3aacf619 | ||
|
|
f2d9c04794 | ||
|
|
19ca1c8b33 | ||
|
|
b781de899d | ||
|
|
af8f2fb7c2 | ||
|
|
04687c99c6 | ||
|
|
b90b7d8aac | ||
|
|
03331b3d52 | ||
|
|
bf4e738618 | ||
|
|
1de9f9fc69 | ||
|
|
2b85acfeaf | ||
|
|
e74907f551 | ||
|
|
85e7517b76 | ||
|
|
faf7d52f20 | ||
|
|
cb2a7fb1b6 | ||
|
|
7857967863 | ||
|
|
4fabf2025c | ||
|
|
3c580814c8 | ||
|
|
876d0003d5 | ||
|
|
af5528ef25 | ||
|
|
f4c1010c64 | ||
|
|
72f66bb71c | ||
|
|
f969e62a56 | ||
|
|
edbeffbcf3 | ||
|
|
8a2e5cb55f | ||
|
|
1c7eb12800 | ||
|
|
02c48952b8 | ||
|
|
0ddaf69abc | ||
|
|
0e060bb2ac | ||
|
|
754440dd55 | ||
|
|
cd79f2e326 | ||
|
|
aced527885 | ||
|
|
648531c6da | ||
|
|
b4ad459449 | ||
|
|
c08c051a92 | ||
|
|
3b284f173f | ||
|
|
18a900a360 | ||
|
|
a502eccc92 | ||
|
|
8dff50318a | ||
|
|
a3726f4bf8 | ||
|
|
d17163d417 | ||
|
|
c8c02381b5 | ||
|
|
a83ac6ac1c | ||
|
|
701168f1e5 | ||
|
|
15a18c3a5e | ||
|
|
c1a5052c43 | ||
|
|
7af000900b | ||
|
|
fd855f8815 | ||
|
|
c1db3bad48 | ||
|
|
f7ce36c8e1 | ||
|
|
4cbfa78bee | ||
|
|
f6154f9ebb | ||
|
|
f5eb5a18f3 | ||
|
|
a9b932e4c2 | ||
|
|
71e6b60d96 | ||
|
|
0c9ac0cfae | ||
|
|
f533c3af7d | ||
|
|
25e03754e2 | ||
|
|
e51783d5bd | ||
|
|
00c9b04674 | ||
|
|
20703366f9 | ||
|
|
9b47769757 | ||
|
|
d7cb3537ea | ||
|
|
59e71f00ef | ||
|
|
fcf76afc05 | ||
|
|
b12a499a9a | ||
|
|
59526900a0 | ||
|
|
b6e7d3e7c0 | ||
|
|
4783b8d552 | ||
|
|
ab6851d5a8 | ||
|
|
6350e5219d | ||
|
|
a03ce15c4e | ||
|
|
d60c8d4e0f | ||
|
|
152a0db8c3 | ||
|
|
b4a7018864 | ||
|
|
97f2c57e5d | ||
|
|
c7e33a9163 | ||
|
|
4b32fb8cf7 | ||
|
|
5d7c622895 | ||
|
|
fd41f0e36f | ||
|
|
7a4f7d5c99 | ||
|
|
9845e2cd9b | ||
|
|
b01025353e | ||
|
|
c9f49f3b54 | ||
|
|
8e93b54a41 | ||
|
|
9e50f5618c | ||
|
|
ce5086e2d3 | ||
|
|
9ab3cee2e8 | ||
|
|
ff8bd6a91f | ||
|
|
9c825e67df | ||
|
|
907b6e1dcc | ||
|
|
00448b2ea0 | ||
|
|
81410769f5 | ||
|
|
1df5237769 | ||
|
|
9ca78f1309 | ||
|
|
e37351ed75 | ||
|
|
0d6db579ec | ||
|
|
e749114173 | ||
|
|
05bc171f13 | ||
|
|
b7d754a60a | ||
|
|
1c8532cbe1 | ||
|
|
8fcfb3b8f4 | ||
|
|
2e97b2f5f9 | ||
|
|
ed6d97cbbb | ||
|
|
d52d37f944 | ||
|
|
8373160c35 | ||
|
|
d39eb5d638 | ||
|
|
91352551d6 | ||
|
|
ddbaa5a64c | ||
|
|
01e9ac5702 | ||
|
|
f429f93420 | ||
|
|
848c0b10d4 | ||
|
|
c96733f3c6 | ||
|
|
5459f6e232 | ||
|
|
e23b4b7d8f | ||
|
|
396f456556 | ||
|
|
e0cb05bfce | ||
|
|
6f6650a68f | ||
|
|
a02d0c4a1a | ||
|
|
46dfd693f5 | ||
|
|
768d20d998 | ||
|
|
b070f41dda | ||
|
|
8f376a4712 | ||
|
|
5a7cfb443f | ||
|
|
63db88b844 | ||
|
|
076c41fc27 | ||
|
|
b947048ad6 | ||
|
|
3f1a45313e | ||
|
|
9d0473d748 | ||
|
|
f6a757ac2e | ||
|
|
511b974107 | ||
|
|
ca9195fb02 | ||
|
|
c1c646a326 | ||
|
|
fca2169de9 | ||
|
|
aa906ecbe7 | ||
|
|
c505204c24 | ||
|
|
fbac0ae2d9 | ||
|
|
eeb1d3773d | ||
|
|
bdc422ad8c | ||
|
|
91c77af9ae | ||
|
|
594b50b510 | ||
|
|
548fca6308 | ||
|
|
1da354b67a | ||
|
|
d0a7c52171 | ||
|
|
92f69c1fca | ||
|
|
7c6bbc1cfd | ||
|
|
12299b07c9 | ||
|
|
0e780d404f | ||
|
|
4ee9816b82 | ||
|
|
d4740f8621 | ||
|
|
20b0fd4eca | ||
|
|
60203586ca | ||
|
|
0ad879b601 | ||
|
|
842028904a | ||
|
|
17aab6b601 | ||
|
|
8aff0594b5 | ||
|
|
da1f0fb3a4 | ||
|
|
c3139d6de7 | ||
|
|
e296924abb | ||
|
|
c62cba3f17 | ||
|
|
4d3ee1ace0 | ||
|
|
a434ad7ab9 | ||
|
|
33743fb052 | ||
|
|
762ce6114c | ||
|
|
cbbcb4323b | ||
|
|
d2c159e74d | ||
|
|
526c5f0b5b | ||
|
|
61dc245f09 | ||
|
|
aa284acb23 | ||
|
|
8fb961c146 | ||
|
|
0b2ed9162a | ||
|
|
2c80e75d81 | ||
|
|
942e3b2ded | ||
|
|
1167274823 | ||
|
|
f1807de201 | ||
|
|
fdc4a8b80e | ||
|
|
381a1ec66e | ||
|
|
53259f6b07 | ||
|
|
e6f5246fdf | ||
|
|
7ba8d7ee65 | ||
|
|
67e69ee447 | ||
|
|
a814e9d5a4 | ||
|
|
36c489208b | ||
|
|
fae78ab1d0 | ||
|
|
0f8f59ec22 | ||
|
|
b513f1b152 | ||
|
|
988049b64f | ||
|
|
a712692527 | ||
|
|
6f9467703a | ||
|
|
edc5593c76 | ||
|
|
04976d95eb | ||
|
|
65b0155b8e | ||
|
|
702f03a4b9 | ||
|
|
6a066d9076 | ||
|
|
f83505a484 | ||
|
|
9a644f3e72 | ||
|
|
a2d6d33d30 | ||
|
|
1ab895ea01 | ||
|
|
f646dc1e0d | ||
|
|
3132240f21 | ||
|
|
41d70dc6c5 | ||
|
|
da5895b445 | ||
|
|
7cee64a389 | ||
|
|
1bb3647ed6 | ||
|
|
f47cec589b | ||
|
|
6286de3abd | ||
|
|
17e5640d94 | ||
|
|
c30f519c16 | ||
|
|
11d1936a5e | ||
|
|
865ad0b89d | ||
|
|
93bab2181c | ||
|
|
6e5d821eeb | ||
|
|
bf016d1c54 | ||
|
|
b5ad463d2d | ||
|
|
83f723f666 | ||
|
|
bcdcc9a460 | ||
|
|
6ca4b32d06 | ||
|
|
d8b0934f65 | ||
|
|
a2429b8864 | ||
|
|
06460237d9 | ||
|
|
22263abf91 | ||
|
|
352d59f617 | ||
|
|
83a84b2637 | ||
|
|
2fa88ac250 | ||
|
|
ce517e179a | ||
|
|
3e2bcb00bc | ||
|
|
c27711cf2e | ||
|
|
0056f3445c | ||
|
|
51b2104c1d | ||
|
|
e2f04717c4 | ||
|
|
b8988e03b4 | ||
|
|
9382a17fed | ||
|
|
15df84b505 | ||
|
|
8682bd5781 | ||
|
|
0da29407d1 | ||
|
|
bdcc1d9f39 | ||
|
|
2e2c11005a | ||
|
|
bf5b9b089e | ||
|
|
3caf55ae8a | ||
|
|
96824f711d | ||
|
|
6daaf0449a | ||
|
|
8fbe40df70 | ||
|
|
02dc9e2e30 | ||
|
|
f15c9098b6 | ||
|
|
bdbcbf2970 | ||
|
|
7a5bbcd7c8 | ||
|
|
ed0d4931b0 | ||
|
|
8f022a0b27 | ||
|
|
e07b94681b | ||
|
|
c679ef53ea | ||
|
|
7bc7f9e783 | ||
|
|
19327a55b0 | ||
|
|
df1874cced | ||
|
|
726f588668 | ||
|
|
dbc84adbd3 | ||
|
|
dcc393acec | ||
|
|
6b5428915b | ||
|
|
bb6985ecb7 | ||
|
|
574d031d14 | ||
|
|
5edb5defbb | ||
|
|
91dc405a0f | ||
|
|
587f81f9c6 | ||
|
|
73be91512c | ||
|
|
d10f82a7cb | ||
|
|
17dd36e93a | ||
|
|
81d38acadc | ||
|
|
62ddcb7909 | ||
|
|
c3db4a63d5 | ||
|
|
ffae67874b | ||
|
|
a52ce752a0 | ||
|
|
17bd76282b | ||
|
|
174df925ca | ||
|
|
cb77d4cb54 | ||
|
|
cba5d53333 | ||
|
|
50f2ca1636 | ||
|
|
79efd6a2dc | ||
|
|
22115dfb11 | ||
|
|
34abe889db | ||
|
|
3ecec2ca7b | ||
|
|
6b44f2ba99 | ||
|
|
f8f276748b | ||
|
|
736d22009e | ||
|
|
32bb54e90c | ||
|
|
22ad79ba01 | ||
|
|
235e3e97c6 | ||
|
|
94a93ab7aa | ||
|
|
a827346dd5 | ||
|
|
b853fd4599 | ||
|
|
f10edd4f40 | ||
|
|
52ff5ebdc6 | ||
|
|
4e9ddb17d9 | ||
|
|
726d9e86ef | ||
|
|
b086d4298b | ||
|
|
d2186eb9aa | ||
|
|
4ca814d896 | ||
|
|
41672b1221 | ||
|
|
44471a42a8 | ||
|
|
8a806d23e0 | ||
|
|
e262d68fcb | ||
|
|
85dbd835d6 | ||
|
|
941c4498a2 | ||
|
|
16ae712381 | ||
|
|
69937e5495 | ||
|
|
486e3001d7 | ||
|
|
edeecb4971 | ||
|
|
5e2d9e758b | ||
|
|
f26519bb53 | ||
|
|
c7cc6e1256 | ||
|
|
fd21d6ee82 | ||
|
|
e2479b2272 | ||
|
|
d5f484c89f | ||
|
|
c274dad88d | ||
|
|
66f4bfbaae | ||
|
|
e4472a7423 | ||
|
|
0cbb5bae35 | ||
|
|
c81de0f908 | ||
|
|
69ec4ccdd6 | ||
|
|
217fb7af0d | ||
|
|
5a42da4fe1 | ||
|
|
2b78aa1735 | ||
|
|
95825f818c | ||
|
|
5c34936e1b | ||
|
|
267e7063d5 | ||
|
|
8a196eeb77 | ||
|
|
b32541cb88 | ||
|
|
1df4c50f81 | ||
|
|
7fb48c7154 | ||
|
|
ca7e8bed1a | ||
|
|
416678ba17 | ||
|
|
bbc9c46c17 | ||
|
|
1e14e33704 | ||
|
|
59a1f51d34 | ||
|
|
7ee787f8de | ||
|
|
737d74f592 | ||
|
|
fe89da2a2c | ||
|
|
1a37932efd | ||
|
|
0a341cc353 | ||
|
|
5866611176 | ||
|
|
da4db61ba9 | ||
|
|
fe8c64382d | ||
|
|
71571ed2cc | ||
|
|
9d23d4e542 | ||
|
|
2c39ef7cbf | ||
|
|
21ecb7f80b | ||
|
|
5f7338178c | ||
|
|
f0651e7540 | ||
|
|
1c27a0c66d | ||
|
|
6a03966205 | ||
|
|
cfcd6c9112 | ||
|
|
c172ab7dae | ||
|
|
d3d714e9eb | ||
|
|
cf5290e835 | ||
|
|
b3f866c5a6 | ||
|
|
a1dc00d466 | ||
|
|
fb51619741 | ||
|
|
2c74f84b97 | ||
|
|
cc1e38ef22 | ||
|
|
47a3b39fa9 | ||
|
|
2867be0d53 | ||
|
|
8a378f441c | ||
|
|
7090cd2d38 | ||
|
|
9a0e718599 | ||
|
|
66cc52fd9d | ||
|
|
adc6e8626e | ||
|
|
3bf35d81c8 | ||
|
|
61066655fe | ||
|
|
191fc3302d | ||
|
|
4e86e200a5 | ||
|
|
d24853c0a0 | ||
|
|
3df5f5d5c4 | ||
|
|
3da531255a | ||
|
|
c0f16e65e2 | ||
|
|
835733c7fc | ||
|
|
5d4016ca61 | ||
|
|
48055aa98f | ||
|
|
57eac62cd2 | ||
|
|
18f79e7683 | ||
|
|
9465fb9cb7 | ||
|
|
0c9468753d | ||
|
|
0887fd0c69 | ||
|
|
01c6de15a3 | ||
|
|
ca04a6de62 | ||
|
|
c361096ab0 | ||
|
|
fb8e62bc7e | ||
|
|
dab1861c21 | ||
|
|
fb8321177b | ||
|
|
3bff52e242 | ||
|
|
c7cfd91f14 | ||
|
|
8e10fc9924 | ||
|
|
bf103fc383 | ||
|
|
95d9303204 | ||
|
|
f69bf3deb5 | ||
|
|
1e8f93a692 | ||
|
|
034191391b | ||
|
|
d225a5fdf7 | ||
|
|
393d90d870 | ||
|
|
03c8dd0d98 | ||
|
|
e66a24edd4 | ||
|
|
df71f965b0 | ||
|
|
7255ecd851 | ||
|
|
a1e033003a | ||
|
|
a3aa60d7bc | ||
|
|
0e573b8e25 | ||
|
|
b4181cf43a | ||
|
|
14d7f036c3 | ||
|
|
c89bf6241d | ||
|
|
0271c62825 | ||
|
|
0bebf89220 | ||
|
|
3c2a49c4ef | ||
|
|
912b671416 | ||
|
|
1eeda33ae5 | ||
|
|
aba4ee3d56 | ||
|
|
803bff885b | ||
|
|
51b39ee118 | ||
|
|
3a11501e23 | ||
|
|
3c6999d700 | ||
|
|
4459fe3989 | ||
|
|
027aef4b3b | ||
|
|
2de673c4f8 | ||
|
|
956bd64c5d | ||
|
|
7a1a5e8e88 | ||
|
|
186540eb84 | ||
|
|
4b14acc1f6 | ||
|
|
16356bdc57 | ||
|
|
6be3dad37b | ||
|
|
ef024a082c | ||
|
|
e9eda298fd | ||
|
|
c8ee23f2b8 | ||
|
|
958522ce67 | ||
|
|
912a9febc7 | ||
|
|
84ac601f3a | ||
|
|
aff1d9864f | ||
|
|
558a9a13e9 | ||
|
|
2d41c9d432 | ||
|
|
699fae6296 | ||
|
|
6f42cbb13f | ||
|
|
37f449f91f | ||
|
|
9bdb5ec7fd | ||
|
|
9432147489 | ||
|
|
824953813a | ||
|
|
04cbf91533 | ||
|
|
3b584979a8 | ||
|
|
d5fd62c298 | ||
|
|
6ffe36494e | ||
|
|
265bb903d7 | ||
|
|
3891dd9410 | ||
|
|
0b73217d3d | ||
|
|
1dd92e5f84 | ||
|
|
350d5dbe2d | ||
|
|
dcd86510f5 | ||
|
|
91aa9c790f | ||
|
|
b87f65b1b6 | ||
|
|
df1a26d2bd | ||
|
|
05275d7682 | ||
|
|
907b5729b1 | ||
|
|
c2670e7bd2 | ||
|
|
2a6ae514f8 | ||
|
|
e445e0edf6 | ||
|
|
4f03c8a873 | ||
|
|
c1c7cdb3c1 | ||
|
|
8880df58c1 | ||
|
|
ccb85be48e | ||
|
|
6b0d88bf88 | ||
|
|
0917285a33 | ||
|
|
b886586ca6 | ||
|
|
2061f797ba | ||
|
|
3f690a811c | ||
|
|
381e2e9a84 | ||
|
|
f4b7a899b8 | ||
|
|
dff7cc1dda | ||
|
|
fa27fb4088 | ||
|
|
d59d8e7147 | ||
|
|
b2ba13377b | ||
|
|
3379afe807 | ||
|
|
f83be65d79 | ||
|
|
1a4ee0e7ee | ||
|
|
1e5aaec352 | ||
|
|
360582703d | ||
|
|
cb0be4adbe | ||
|
|
43777b9d16 | ||
|
|
ef917292ed | ||
|
|
08e7c00806 | ||
|
|
7e6da998fa | ||
|
|
dbce6420c6 | ||
|
|
9ee6b293d4 | ||
|
|
f1eedfcb40 | ||
|
|
effec4887d | ||
|
|
85ff723581 | ||
|
|
5b695d5a26 | ||
|
|
800e7cef33 | ||
|
|
73632dae7e | ||
|
|
633cd1dbce | ||
|
|
8ec6e3d9f4 | ||
|
|
a97afdc93f | ||
|
|
03ba7662d5 | ||
|
|
557c4c5cf9 | ||
|
|
29363ee3e9 | ||
|
|
7cb03beeed | ||
|
|
edefd8ef0c | ||
|
|
7aaa68865f | ||
|
|
723a2a44cb | ||
|
|
91bd434281 | ||
|
|
d2e26c32d4 | ||
|
|
7df9ed0545 | ||
|
|
735cdab711 | ||
|
|
ce91a02d84 | ||
|
|
2c803ae875 | ||
|
|
8f7c959679 | ||
|
|
baa05a30ec | ||
|
|
94e167b796 | ||
|
|
4ef3c1f54a | ||
|
|
4d1f533af6 | ||
|
|
dc925bf620 | ||
|
|
b916a49fbf | ||
|
|
99791ea225 | ||
|
|
807901034b | ||
|
|
3cb4177026 | ||
|
|
24c323960e | ||
|
|
e4b5b0f07f | ||
|
|
f84b88b2e9 | ||
|
|
6b8e3b8f5f | ||
|
|
0b0a91e4d4 | ||
|
|
5ee04257c5 | ||
|
|
8c1f50f616 | ||
|
|
df19d46a5a | ||
|
|
413e6e856e | ||
|
|
d9d0bd3289 | ||
|
|
def1dbef33 | ||
|
|
c449d797bd | ||
|
|
8bbfc5cbf0 | ||
|
|
5550a6c327 | ||
|
|
db38f50b12 | ||
|
|
f57fe836c4 | ||
|
|
d4a973f65f | ||
|
|
152258bb83 | ||
|
|
51cd5409da | ||
|
|
183e772727 | ||
|
|
3887ceefec | ||
|
|
0d322f4fca | ||
|
|
0239b3c97f | ||
|
|
27c3c0be31 | ||
|
|
52399eb28a | ||
|
|
dff6db1e86 | ||
|
|
71127f982f | ||
|
|
f3a3c1291b | ||
|
|
1761eb0842 | ||
|
|
da98be3276 | ||
|
|
967ec25579 | ||
|
|
b495d4931e | ||
|
|
f7b77fbc88 | ||
|
|
5aa2da4960 | ||
|
|
e9808d0d13 | ||
|
|
a8a6485243 | ||
|
|
1f9fdd9129 | ||
|
|
8d3541a364 | ||
|
|
ed279f27d5 | ||
|
|
0d598edee8 | ||
|
|
0b2e9da854 | ||
|
|
e701c9f458 | ||
|
|
720381e3cf | ||
|
|
6fb33260ae | ||
|
|
0559503258 | ||
|
|
69d060efe6 | ||
|
|
0c9e6293ee | ||
|
|
0db93570be | ||
|
|
f448660e4c | ||
|
|
d77cca177a | ||
|
|
0a353d57c9 | ||
|
|
1f21c74b01 | ||
|
|
fecb404485 | ||
|
|
7f19d9f95b | ||
|
|
54bb09a261 | ||
|
|
739185e2c9 | ||
|
|
c8477304a3 | ||
|
|
c97f4da629 | ||
|
|
dc5695dfa7 | ||
|
|
6ec46d1a81 | ||
|
|
925580fe3d | ||
|
|
8aeb55f0cf | ||
|
|
17383e6fe0 | ||
|
|
68cfbd61da | ||
|
|
c61911ffcd | ||
|
|
a9e1f452df | ||
|
|
45e1d44be2 | ||
|
|
e0a1b471ff | ||
|
|
131458ee0f | ||
|
|
90db82ba0e | ||
|
|
d21886b2f3 | ||
|
|
f250e84ecf | ||
|
|
a1454e00a2 | ||
|
|
994820e697 | ||
|
|
75b37506ab | ||
|
|
8ef51c4b69 | ||
|
|
c2bd40f270 | ||
|
|
245ea982b9 | ||
|
|
6528d566a1 | ||
|
|
89d4b1ce31 | ||
|
|
2010ef412f | ||
|
|
0e42a29fda | ||
|
|
56b327bd94 | ||
|
|
f6df8b8339 | ||
|
|
6529206304 | ||
|
|
bb7b1b27ad | ||
|
|
a68a897af5 | ||
|
|
296fd3d839 | ||
|
|
33f3444a49 | ||
|
|
2711a4a005 | ||
|
|
c37cab6b6a | ||
|
|
cbf57eb6ee | ||
|
|
e1c7a297bc | ||
|
|
bc3161c619 | ||
|
|
dd649971c8 | ||
|
|
50a1911cea | ||
|
|
b82ebb2261 | ||
|
|
40b0467e20 | ||
|
|
880eb7350f | ||
|
|
15a22366e3 | ||
|
|
a554d6e7c4 | ||
|
|
e716347329 | ||
|
|
df440f90b2 | ||
|
|
791ac3d928 | ||
|
|
6774e47aae | ||
|
|
7d227270a2 | ||
|
|
e855ded821 | ||
|
|
4264d34f1c | ||
|
|
88e10db8e5 | ||
|
|
8b65dc4516 | ||
|
|
f4bdc66781 | ||
|
|
e4c1af1fff | ||
|
|
0503ee0cbf | ||
|
|
6b01498127 | ||
|
|
5983bae947 | ||
|
|
ab82d71a96 | ||
|
|
c3daaa8762 | ||
|
|
a91763379f | ||
|
|
a9d14449e8 | ||
|
|
d39715cca0 | ||
|
|
f904f44047 | ||
|
|
51005638e0 | ||
|
|
3b3560d71d | ||
|
|
26fa54369c | ||
|
|
4812518327 | ||
|
|
7daca92c4a | ||
|
|
d27a7cbce3 | ||
|
|
88e7afbedf | ||
|
|
e83ef39df4 | ||
|
|
29d24f48df | ||
|
|
ae72e1f6ca | ||
|
|
164cfd6b11 | ||
|
|
09a9735630 | ||
|
|
f86c946a3c | ||
|
|
c4f7b4f964 | ||
|
|
cbd1b1c93e | ||
|
|
0e4c964d0a | ||
|
|
2228a7456e | ||
|
|
a7771347e6 | ||
|
|
83b958a824 | ||
|
|
148d592567 | ||
|
|
0a72ffbf5c | ||
|
|
7a082c2176 | ||
|
|
72317bb0c6 | ||
|
|
8e445b4c3d | ||
|
|
c4e54ad254 | ||
|
|
2ac85c50eb | ||
|
|
43f2497f1f | ||
|
|
f5f5532c19 | ||
|
|
cb5743dcc8 | ||
|
|
eea99bb586 | ||
|
|
295871fcab | ||
|
|
ae43424882 | ||
|
|
d5ae9945fa | ||
|
|
c20247d708 | ||
|
|
08947cd922 | ||
|
|
0e18c68b4d | ||
|
|
1b0b0a8149 | ||
|
|
ac22c4db04 | ||
|
|
e204d3c028 | ||
|
|
3246732087 | ||
|
|
c215a48c91 | ||
|
|
6aabf3f618 | ||
|
|
36ba5c65a6 | ||
|
|
a3489d33b4 | ||
|
|
9c16d40db1 | ||
|
|
730184c64f | ||
|
|
75267d2fc6 | ||
|
|
9b88df32d3 |
No files matched your search
@@ -233,9 +233,9 @@ jobs:
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
|
||||
@@ -52,6 +52,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
@@ -135,7 +136,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
|
||||
Generated
+39
@@ -561,6 +561,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cty"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1068,6 +1074,14 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-assets"
|
||||
version = "0.1.0"
|
||||
@@ -1105,6 +1119,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
@@ -1169,6 +1184,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1197,6 +1213,7 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
@@ -1344,6 +1361,7 @@ name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
@@ -1412,6 +1430,8 @@ name = "horizon-threads"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libmimalloc-sys",
|
||||
"mimalloc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1787,6 +1807,16 @@ version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.25"
|
||||
@@ -1878,6 +1908,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -13,6 +13,7 @@ members = [
|
||||
"crates/horizon-command",
|
||||
"crates/horizon-items",
|
||||
"crates/horizon-server-files",
|
||||
"crates/horizon-anticheat",
|
||||
"crates/horizon-server",
|
||||
"crates/horizon-bots",
|
||||
"crates/horizon-assets",
|
||||
|
||||
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
@@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
|
||||
|
||||
### Known issues
|
||||
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
|
||||
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+4
-4
@@ -76,9 +76,9 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. The WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353)). [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -490,7 +490,7 @@ Small mods written as scripts, in the style of KubeJS, with no compiler.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is removed. A script layer over the native hook points is to be designed.
|
||||
Waits for native mods to settle: a script layer over the native hook points is to be designed.
|
||||
|
||||
More: [docs/native-mods.md](docs/native-mods.md)
|
||||
|
||||
@@ -718,7 +718,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -19,3 +19,42 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
|
||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
||||
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
|
||||
the same grid.
|
||||
|
||||
## AMD FidelityFX SDK (FSR 3.1 frame generation)
|
||||
|
||||
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
|
||||
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
|
||||
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
|
||||
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
|
||||
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
|
||||
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
|
||||
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
|
||||
trademarks; Horizon is not affiliated with AMD.
|
||||
|
||||
## Mod loader logos
|
||||
|
||||
The launcher shows the logo of the mod loader an instance plays with.
|
||||
|
||||
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
|
||||
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
|
||||
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
|
||||
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
|
||||
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
|
||||
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
|
||||
unmodified (the launcher scales it up by whole pixels).
|
||||
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
|
||||
fetches them from their own sites when an instance needs them
|
||||
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
|
||||
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
|
||||
|
||||
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
|
||||
the project endorses Horizon.
|
||||
|
||||
## Sign in with Microsoft
|
||||
|
||||
The launcher's sign-in buttons follow Microsoft's published branding guidelines for apps that sign in
|
||||
with Microsoft accounts
|
||||
([Sign in with Microsoft branding](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps)).
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
@@ -13,7 +13,9 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
|
||||
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
|
||||
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
|
||||
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
|
||||
cargo horizon-mod registry list <checkout>";
|
||||
cargo horizon-mod registry list <checkout>
|
||||
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
|
||||
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
super::flag(args, name)
|
||||
@@ -154,6 +156,74 @@ pub fn main(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// A release of a project's repository, downloaded: the checks of a submission, then the
|
||||
// version as the index lists it (JSON on stdout), for the registry's release sync.
|
||||
Some("release") => {
|
||||
let [checkout, id, file] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let (tag, published) = (
|
||||
flag(rest, "--tag").ok_or(USAGE)?,
|
||||
flag(rest, "--published").ok_or(USAGE)?,
|
||||
);
|
||||
let read = |name: &str| {
|
||||
flag(rest, name)
|
||||
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
|
||||
.transpose()
|
||||
};
|
||||
let (notes, signature) = (read("--notes")?, read("--signature")?);
|
||||
let entry = entry_of(checkout, id)?;
|
||||
let path = Path::new(file);
|
||||
let cache = registry::Cache::read(Path::new(checkout), id)?;
|
||||
let entry = entry.resolved(&cache.page);
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
crate::check::check(
|
||||
path,
|
||||
entry.kind.clone(),
|
||||
entry.key.as_deref(),
|
||||
signature.as_deref(),
|
||||
)?;
|
||||
let version = registry::release_version(
|
||||
&entry,
|
||||
®istry::Release {
|
||||
tag,
|
||||
published,
|
||||
notes: notes.as_deref().unwrap_or(""),
|
||||
asset: flag(rest, "--name").unwrap_or(name),
|
||||
game: flag(rest, "--game").unwrap_or("26.3"),
|
||||
signature: signature.as_deref(),
|
||||
},
|
||||
path,
|
||||
)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&version).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// The page file of a project's repository, checked against its entry: the page as JSON.
|
||||
Some("page") => {
|
||||
let [checkout, id, file] = pos[..] else {
|
||||
return Err(USAGE.into());
|
||||
};
|
||||
let entry = entry_of(checkout, id)?;
|
||||
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
let page = registry::page(&entry, &text)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&page).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `mods/<id>.toml` of the checkout, which must name a repository.
|
||||
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
|
||||
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
|
||||
entries
|
||||
.into_iter()
|
||||
.find(|e| e.id == id && e.repo.is_some())
|
||||
.ok_or_else(|| format!("{id}: no entry with a repo"))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "1.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,303 @@
|
||||
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
|
||||
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
|
||||
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
|
||||
//!
|
||||
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
|
||||
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
|
||||
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
|
||||
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
|
||||
//! Any threshold at 0 turns that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Actions {
|
||||
pub setback: f32,
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
/// Violation level lost each second.
|
||||
pub decay: f32,
|
||||
}
|
||||
|
||||
macro_rules! checks {
|
||||
($(
|
||||
$(#[$doc:meta])*
|
||||
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
|
||||
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
|
||||
}
|
||||
)*) => {
|
||||
/// Every check, in the order of the players' violation levels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Check { $($Check,)* }
|
||||
|
||||
impl Check {
|
||||
pub const ALL: &[Check] = &[$(Check::$Check,)*];
|
||||
/// Its name in `server.toml` and in alerts.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(Check::$Check => stringify!($field),)* }
|
||||
}
|
||||
}
|
||||
|
||||
$(
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct $Config {
|
||||
pub enabled: bool,
|
||||
pub setback: f32,
|
||||
pub alert: f32,
|
||||
pub kick: f32,
|
||||
pub decay: f32,
|
||||
$( $(#[$xdoc])* pub $extra: $ty, )*
|
||||
}
|
||||
|
||||
impl Default for $Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
setback: $setback,
|
||||
alert: $alert,
|
||||
kick: $kick,
|
||||
decay: $decay,
|
||||
$($extra: $default,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
)*
|
||||
|
||||
/// `server.toml`'s `[anticheat]`: off by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Tell online operators of an alert in their chat (the console and the log always are).
|
||||
pub alerts: bool,
|
||||
/// The permission level a player needs to see alerts.
|
||||
pub alert_level: u8,
|
||||
/// Every flag, one line each, relative to the server's folder; empty: no file.
|
||||
pub log: String,
|
||||
$(pub $field: $Config,)*
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
$($field: $Config::default(),)*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Whether `check` runs: the anticheat and the check both on.
|
||||
pub fn runs(&self, check: Check) -> bool {
|
||||
self.enabled && match check { $(Check::$Check => self.$field.enabled,)* }
|
||||
}
|
||||
|
||||
pub fn actions(&self, check: Check) -> Actions {
|
||||
match check {
|
||||
$(Check::$Check => {
|
||||
let c = &self.$field;
|
||||
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
|
||||
})*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
checks! {
|
||||
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
/// spike's backlog arrives at once).
|
||||
max_lag_ms: f64 = 1000.0,
|
||||
}
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back.
|
||||
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
pub const CHECKS: usize = Check::ALL.len();
|
||||
|
||||
impl Config {
|
||||
/// The `[anticheat]` section of a whole `server.toml`; the default (off) when it has none.
|
||||
pub fn from_server_toml(text: &str) -> Result<Self, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
anticheat: Config,
|
||||
}
|
||||
// Other sections are the supervisor's to check: only this one is read here.
|
||||
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut only = toml::Table::new();
|
||||
if let Some(section) = table.remove("anticheat") {
|
||||
only.insert("anticheat".into(), section);
|
||||
}
|
||||
let file: File = only
|
||||
.try_into()
|
||||
.map_err(|e: toml::de::Error| format!("[anticheat]: {e}"))?;
|
||||
file.anticheat.check()?;
|
||||
Ok(file.anticheat)
|
||||
}
|
||||
|
||||
/// Thresholds can't be negative.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
for &c in Check::ALL {
|
||||
let a = self.actions(c);
|
||||
if [a.setback, a.alert, a.kick, a.decay]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite() || *v < 0.0)
|
||||
{
|
||||
return Err(format!(
|
||||
"anticheat.{}: setback, alert, kick and decay are numbers from 0 up",
|
||||
c.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a flag leads to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Verdict {
|
||||
/// The check's violation level after the flag.
|
||||
pub vl: f32,
|
||||
/// Cancel the move or action.
|
||||
pub setback: bool,
|
||||
/// Tell operators and the console (at most once a second per check).
|
||||
pub alert: bool,
|
||||
pub kick: bool,
|
||||
}
|
||||
|
||||
/// One player's anticheat state. Clean players cost nothing: levels only change when a check flags.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Player {
|
||||
vl: [f32; CHECKS],
|
||||
/// The server tick of each check's last flag (its decay runs from there).
|
||||
flagged: [u64; CHECKS],
|
||||
/// The server tick of each check's last alert.
|
||||
alerted: [Option<u64>; CHECKS],
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// The check's violation level at `tick`, with its decay since the last flag.
|
||||
pub fn vl(&self, config: &Config, check: Check, tick: u64) -> f32 {
|
||||
let i = check as usize;
|
||||
let seconds = tick.saturating_sub(self.flagged[i]) as f32 / 20.0;
|
||||
(self.vl[i] - config.actions(check).decay * seconds).max(0.0)
|
||||
}
|
||||
|
||||
/// `check` failed with this weight at server tick `tick`.
|
||||
pub fn flag(&mut self, config: &Config, check: Check, weight: f32, tick: u64) -> Verdict {
|
||||
let i = check as usize;
|
||||
let a = config.actions(check);
|
||||
let vl = self.vl(config, check, tick) + weight;
|
||||
self.vl[i] = vl;
|
||||
self.flagged[i] = tick;
|
||||
let reached = |threshold: f32| threshold > 0.0 && vl >= threshold;
|
||||
let alert = reached(a.alert) && self.alerted[i].is_none_or(|t| tick >= t + 20);
|
||||
if alert {
|
||||
self.alerted[i] = Some(tick);
|
||||
}
|
||||
Verdict {
|
||||
vl,
|
||||
setback: reached(a.setback),
|
||||
alert,
|
||||
kick: reached(a.kick),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn on() -> Config {
|
||||
Config {
|
||||
enabled: true,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_by_default_and_without_a_section() {
|
||||
assert!(!Config::default().enabled);
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!c.runs(Check::Timer));
|
||||
assert!(!c.runs(Check::Ground));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_its_section() {
|
||||
let text = "[server]\nport = 1\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 0\ntolerance_ms = 500\n";
|
||||
let c = Config::from_server_toml(text).unwrap();
|
||||
assert!(c.runs(Check::Timer));
|
||||
assert_eq!(c.timer.kick, 0.0);
|
||||
assert_eq!(c.timer.tolerance_ms, 500.0);
|
||||
assert_eq!(c.timer.alert, TimerConfig::default().alert);
|
||||
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
|
||||
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_unknown_keys_and_negative_levels() {
|
||||
assert!(Config::from_server_toml("[anticheat]\nenable = true\n").is_err());
|
||||
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_rise_decay_and_act_in_order() {
|
||||
let mut c = on();
|
||||
c.timer = TimerConfig {
|
||||
setback: 2.0,
|
||||
alert: 3.0,
|
||||
kick: 5.0,
|
||||
decay: 1.0,
|
||||
..TimerConfig::default()
|
||||
};
|
||||
let mut p = Player::default();
|
||||
let v = p.flag(&c, Check::Timer, 1.0, 100);
|
||||
assert_eq!((v.setback, v.alert, v.kick), (false, false, false));
|
||||
let v = p.flag(&c, Check::Timer, 1.0, 100);
|
||||
assert_eq!((v.vl, v.setback, v.alert), (2.0, true, false));
|
||||
let v = p.flag(&c, Check::Timer, 1.0, 100);
|
||||
assert!(v.alert && !v.kick);
|
||||
// At most one alert a second.
|
||||
assert!(!p.flag(&c, Check::Timer, 0.5, 110).alert);
|
||||
// 3 at tick 110 (half a level gone, half added); two seconds later two more are gone.
|
||||
assert_eq!(p.vl(&c, Check::Timer, 150), 1.0);
|
||||
assert_eq!(p.vl(&c, Check::Timer, 10_000), 0.0);
|
||||
let v = p.flag(&c, Check::Timer, 5.0, 10_000);
|
||||
assert!(v.kick && v.alert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_turns_an_action_off() {
|
||||
let mut c = on();
|
||||
c.timer.kick = 0.0;
|
||||
let mut p = Player::default();
|
||||
assert!(!p.flag(&c, Check::Timer, 1000.0, 1).kick);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The timer check's balance: how far a client's ticks are behind (positive) or ahead (negative) of the
|
||||
//! server's clock, in milliseconds.
|
||||
//!
|
||||
//! The server adds the wall-clock time of each of its ticks, and every ServerboundClientTickEndPacket
|
||||
//! takes away the length of one tick at the server's tick rate (vanilla's client ticks at that rate,
|
||||
//! `TickRateManager.millisecondsPerTick`). The balance is capped at `max_lag_ms` *after* the tick's
|
||||
//! packets are counted: a slow server tick that handles a second of packets at once also added that
|
||||
//! second, and a quiet connection banks at most `max_lag_ms` for the burst that ends a lag spike.
|
||||
|
||||
use crate::TimerConfig;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Balance {
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
impl Balance {
|
||||
/// The start of a server tick that came `elapsed_ms` after the previous one.
|
||||
pub fn elapse(&mut self, elapsed_ms: f64, config: &TimerConfig) {
|
||||
self.ms = self.ms.min(config.max_lag_ms) + elapsed_ms;
|
||||
}
|
||||
|
||||
/// One client tick ended, at a tick rate of `tick_rate` a second. Some(how far ahead, in ms) when the
|
||||
/// client is further ahead than the tolerance; the balance starts again from zero then, so a
|
||||
/// sustained speed-up is flagged again each time it gets that far ahead.
|
||||
pub fn tick_end(&mut self, tick_rate: f32, config: &TimerConfig) -> Option<f64> {
|
||||
self.ms -= 1000.0 / f64::from(tick_rate.max(1.0));
|
||||
if self.ms < -config.tolerance_ms {
|
||||
let ahead = -self.ms;
|
||||
self.ms = 0.0;
|
||||
return Some(ahead);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The tick rate changed or the player arrived: start again.
|
||||
pub fn reset(&mut self) {
|
||||
self.ms = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
|
||||
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
|
||||
let c = TimerConfig::default();
|
||||
let mut owed = 0.0;
|
||||
let mut flags = 0;
|
||||
for _ in 0..(seconds * 1000.0 / server_ms) as usize {
|
||||
b.elapse(server_ms, &c);
|
||||
owed += per_tick;
|
||||
while owed >= 1.0 {
|
||||
owed -= 1.0;
|
||||
flags += usize::from(b.tick_end(20.0, &c).is_some());
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_client_is_never_flagged() {
|
||||
let mut b = Balance::default();
|
||||
assert_eq!(run(&mut b, 50.0, 1.0, 600.0), 0);
|
||||
// A server that runs at 10 ticks a second handles two client ticks in each.
|
||||
assert_eq!(run(&mut b, 100.0, 2.0, 600.0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_server_tick_with_its_backlog_is_not_flagged() {
|
||||
let (mut b, c) = (Balance::default(), TimerConfig::default());
|
||||
b.elapse(3000.0, &c);
|
||||
for _ in 0..60 {
|
||||
assert_eq!(b.tick_end(20.0, &c), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_network_stall_is_not_flagged() {
|
||||
let (mut b, c) = (Balance::default(), TimerConfig::default());
|
||||
// 800 ms with nothing arriving, then the 16 ticks sent meanwhile.
|
||||
for _ in 0..16 {
|
||||
b.elapse(50.0, &c);
|
||||
}
|
||||
b.elapse(50.0, &c);
|
||||
for _ in 0..17 {
|
||||
assert_eq!(b.tick_end(20.0, &c), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sped_up_client_is_flagged_again_and_again() {
|
||||
let mut b = Balance::default();
|
||||
// 10 % faster: 5 ms ahead each tick, 300 ms after 3 s, so close to 20 flags a minute.
|
||||
let flags = run(&mut b, 50.0, 1.1, 60.0);
|
||||
assert!((15..=20).contains(&flags), "{flags}");
|
||||
// Twice as fast: a flag every 7 ticks.
|
||||
assert!(run(&mut Balance::default(), 50.0, 2.0, 10.0) >= 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pause_cant_be_saved_for_a_burst() {
|
||||
let (mut b, c) = (Balance::default(), TimerConfig::default());
|
||||
for _ in 0..200 {
|
||||
b.elapse(50.0, &c);
|
||||
}
|
||||
let flagged = (0..60).any(|_| b.tick_end(20.0, &c).is_some());
|
||||
assert!(flagged);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--server HOST:PORT default 127.0.0.1:25565
|
||||
--bots N default 10
|
||||
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
|
||||
stand: stays on the ground where the server puts it (/tp), for a survival test.
|
||||
One abusive client, to test a server's limits (run it beside a normal crowd):
|
||||
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
|
||||
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
|
||||
@@ -59,6 +60,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
@@ -67,12 +71,18 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--seed N default 12345
|
||||
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
|
||||
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
|
||||
--brand B the client brand sent in configuration (minecraft:brand); default none
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
--ground claim the ground in every move (with --height 0 on a flat world: walking
|
||||
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Idle,
|
||||
Stand,
|
||||
Walk,
|
||||
Explore,
|
||||
FloodMove,
|
||||
@@ -91,6 +101,7 @@ impl Behaviour {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Idle => "idle",
|
||||
Self::Stand => "stand",
|
||||
Self::Walk => "walk",
|
||||
Self::Explore => "explore",
|
||||
Self::FloodMove => "flood-move",
|
||||
@@ -117,10 +128,19 @@ struct Options {
|
||||
view_distance: i8,
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
name: String,
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
report: Option<String>,
|
||||
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
|
||||
timer: f64,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
@@ -134,10 +154,16 @@ fn options() -> Result<Options, String> {
|
||||
view_distance: 10,
|
||||
seed: 12345,
|
||||
explore_speed: 22.0,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
brand: None,
|
||||
timer: 1.0,
|
||||
};
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
@@ -149,6 +175,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--ground" {
|
||||
o.ground = true;
|
||||
continue;
|
||||
}
|
||||
let value = args.next().ok_or(format!("{arg} needs a value"))?;
|
||||
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
|
||||
match arg.as_str() {
|
||||
@@ -167,16 +197,27 @@ fn options() -> Result<Options, String> {
|
||||
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
|
||||
}
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
|
||||
"--timer" => {
|
||||
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
|
||||
if !(o.timer >= 0.1 && o.timer <= 20.0) {
|
||||
return Err(format!("--timer {value}: use 0.1 to 20"));
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unknown option {arg}; --help lists them")),
|
||||
}
|
||||
}
|
||||
const BEHAVIOURS: [&str; 14] = [
|
||||
const BEHAVIOURS: [&str; 15] = [
|
||||
"idle",
|
||||
"walk",
|
||||
"explore",
|
||||
"mixed",
|
||||
"stand",
|
||||
"flood-move",
|
||||
"flood-acks",
|
||||
"flood-packets",
|
||||
@@ -219,6 +260,11 @@ struct Bot {
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<(Option<u128>, Option<i32>)>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -398,6 +444,19 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
// ClientBrandRetriever: vanilla's client says "vanilla", Horizon's "horizon".
|
||||
if let Some(brand) = &o.brand {
|
||||
let mut w = Writer::new();
|
||||
w.string(brand);
|
||||
c.send(
|
||||
&configuration::Serverbound::CustomPayload(configuration::CustomPayload {
|
||||
channel: "minecraft:brand".into(),
|
||||
data: w.data,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
.map_err(err)?;
|
||||
}
|
||||
loop {
|
||||
let (id, body) = c.recv().map_err(err)?;
|
||||
let reply = match id {
|
||||
@@ -425,8 +484,9 @@ fn join(o: &Options, name: &str) -> Result<Connection, String> {
|
||||
payload: None,
|
||||
})
|
||||
}
|
||||
configuration::Clientbound::Disconnect(_) => {
|
||||
return Err("disconnected during configuration".into());
|
||||
configuration::Clientbound::Disconnect(p) => {
|
||||
let reason = horizon_protocol::text::plain(&p.reason, &Default::default());
|
||||
return Err(format!("disconnected during configuration: {reason}"));
|
||||
}
|
||||
configuration::Clientbound::FinishConfiguration(_) => {
|
||||
c.send(&configuration::Serverbound::FinishConfiguration(Empty).encode())
|
||||
@@ -531,6 +591,21 @@ fn read(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::ADD_ENTITY => {
|
||||
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
|
||||
let mut target = lock(&bot.target);
|
||||
if target.0 == Some(p.uuid) {
|
||||
target.1 = Some(p.id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::SET_HEALTH => {
|
||||
if let Ok(play::Clientbound::SetHealth(p)) = play::Clientbound::decode(id, &body) {
|
||||
*lock(&bot.health) = Some(p.health);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
cb::CHUNK_BATCH_START => {
|
||||
batch.started = Instant::now();
|
||||
Ok(())
|
||||
@@ -618,6 +693,7 @@ fn run_bot(
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
@@ -661,10 +737,11 @@ fn run_bot(
|
||||
let mut sequence = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
let cruise = spawn[1] + 24.0;
|
||||
let cruise = spawn[1] + o.height;
|
||||
let heading = random.unit() * std::f64::consts::TAU;
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
let (joined, mut tick) = (Instant::now(), 0u64);
|
||||
loop {
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
break;
|
||||
@@ -675,7 +752,9 @@ fn run_bot(
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
// The abusive client doesn't wait for its chunk, as a real client must.
|
||||
if behaviour == Behaviour::FloodMove {
|
||||
if behaviour == Behaviour::Stand {
|
||||
// Where the server put it, on the ground.
|
||||
} else if behaviour == Behaviour::FloodMove {
|
||||
// The last of these moves goes out below, with the tick's end.
|
||||
pos[1] = cruise;
|
||||
for _ in 1..o.flood.max(1) {
|
||||
@@ -711,7 +790,7 @@ fn run_bot(
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
} else {
|
||||
match behaviour {
|
||||
Behaviour::Idle => {}
|
||||
Behaviour::Idle | Behaviour::Stand => {}
|
||||
Behaviour::Walk => {
|
||||
let (dx, dz) = (target[0] - pos[0], target[2] - pos[2]);
|
||||
let distance = dx.hypot(dz);
|
||||
@@ -776,12 +855,21 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let target = lock(&bot.target).1;
|
||||
if let Some(id) = target
|
||||
&& joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
{
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
tick += 1;
|
||||
let ok = send(
|
||||
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
flags: 0,
|
||||
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
|
||||
})
|
||||
.encode(),
|
||||
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
|
||||
@@ -789,7 +877,7 @@ fn run_bot(
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
break;
|
||||
}
|
||||
next += Duration::from_millis(50);
|
||||
next += Duration::from_secs_f64(0.05 / o.timer);
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
thread::sleep(next - now);
|
||||
@@ -805,6 +893,7 @@ fn run_bot(
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
match mode {
|
||||
"idle" => Behaviour::Idle,
|
||||
"stand" => Behaviour::Stand,
|
||||
"walk" => Behaviour::Walk,
|
||||
"explore" => Behaviour::Explore,
|
||||
"flood-move" => Behaviour::FloodMove,
|
||||
@@ -918,6 +1007,8 @@ fn main() {
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *lock(&b.error),
|
||||
"attacks": b.attacks.load(Ordering::Relaxed),
|
||||
"health": *lock(&b.health),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -237,7 +237,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
@@ -248,6 +248,9 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_item = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
Some("show_entity") if e.get("id").is_some_and(Value::is_string) => {
|
||||
style.hover_entity = Some(Box::new(Value::Object(e.clone())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
|
||||
//! mappings and `keybind` text components.
|
||||
use crate::text::Text;
|
||||
use std::{collections::BTreeMap, sync::RwLock};
|
||||
|
||||
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
|
||||
/// character, others by translation.
|
||||
/// The keys that write a symbol, with what they write on a US layout.
|
||||
const SYMBOLS: [(&str, &str); 11] = [
|
||||
("key.keyboard.minus", "-"),
|
||||
("key.keyboard.equal", "="),
|
||||
("key.keyboard.left.bracket", "["),
|
||||
("key.keyboard.right.bracket", "]"),
|
||||
("key.keyboard.backslash", "\\"),
|
||||
("key.keyboard.semicolon", ";"),
|
||||
("key.keyboard.apostrophe", "'"),
|
||||
("key.keyboard.grave.accent", "`"),
|
||||
("key.keyboard.comma", ","),
|
||||
("key.keyboard.period", "."),
|
||||
("key.keyboard.slash", "/"),
|
||||
];
|
||||
|
||||
/// What the player's keyboard layout writes on each key that writes
|
||||
/// something, by key name, as the window reported it (`learn_layout`).
|
||||
static LAYOUT: RwLock<BTreeMap<String, String>> = RwLock::new(BTreeMap::new());
|
||||
|
||||
/// The keyboard layout writes `text` on the key `key` (`key.keyboard.q`):
|
||||
/// its name from now on, as `SDL_GetKeyFromScancode` names it with SDL's
|
||||
/// default keycode options. Only letter and symbol keys take it; a letter
|
||||
/// key keeps its Latin letter on a non-Latin layout (`latin_letters`), and
|
||||
/// the number row keeps its digits (`french_numbers`).
|
||||
pub fn learn_layout(key: &str, text: &str) {
|
||||
let Some(rest) = key.strip_prefix("key.keyboard.") else {
|
||||
return;
|
||||
};
|
||||
let mut chars = text.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else {
|
||||
return;
|
||||
};
|
||||
let letter_key = rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase();
|
||||
let latin = c.is_ascii() || ('\u{C0}'..='\u{24F}').contains(&c);
|
||||
if c.is_control()
|
||||
|| c.is_whitespace()
|
||||
|| !(letter_key || SYMBOLS.iter().any(|(k, _)| *k == key))
|
||||
|| (letter_key && c.is_alphabetic() && !latin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(mut layout) = LAYOUT.write() {
|
||||
layout.insert(key.to_owned(), c.to_uppercase().collect());
|
||||
}
|
||||
}
|
||||
|
||||
/// InputConstants.Key.getDisplayName: what the layout writes on the key,
|
||||
/// upper-case, else the US layout's character, else by translation.
|
||||
pub fn display_name(key: &str) -> Text {
|
||||
if let Some(label) = LAYOUT.read().ok().and_then(|l| l.get(key).cloned()) {
|
||||
return Text::literal(label);
|
||||
}
|
||||
if let Some(rest) = key.strip_prefix("key.keyboard.")
|
||||
&& rest.chars().count() == 1
|
||||
{
|
||||
let c = rest.to_uppercase();
|
||||
return Text::literal(c);
|
||||
}
|
||||
let symbol = match key {
|
||||
"key.keyboard.minus" => Some("-"),
|
||||
"key.keyboard.equal" => Some("="),
|
||||
"key.keyboard.left.bracket" => Some("["),
|
||||
"key.keyboard.right.bracket" => Some("]"),
|
||||
"key.keyboard.backslash" => Some("\\"),
|
||||
"key.keyboard.semicolon" => Some(";"),
|
||||
"key.keyboard.apostrophe" => Some("'"),
|
||||
"key.keyboard.grave.accent" => Some("`"),
|
||||
"key.keyboard.comma" => Some(","),
|
||||
"key.keyboard.period" => Some("."),
|
||||
"key.keyboard.slash" => Some("/"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = symbol {
|
||||
return Text::literal(s);
|
||||
if let Some((_, s)) = SYMBOLS.iter().find(|(k, _)| *k == key) {
|
||||
return Text::literal(*s);
|
||||
}
|
||||
if let Some(n) = key.strip_prefix("key.mouse.")
|
||||
&& n.parse::<u32>().is_ok()
|
||||
@@ -36,3 +72,36 @@ pub fn display_name(key: &str) -> Text {
|
||||
}
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_show_what_the_layout_writes() {
|
||||
// The layout table is shared: these keys are this test's alone.
|
||||
assert_eq!(display_name("key.keyboard.q").plain(), "Q");
|
||||
// AZERTY: A on the Q key, M on the semicolon key, a dead ^ on the
|
||||
// left bracket, a comma on the M key.
|
||||
learn_layout("key.keyboard.q", "a");
|
||||
learn_layout("key.keyboard.semicolon", "m");
|
||||
learn_layout("key.keyboard.left.bracket", "^");
|
||||
learn_layout("key.keyboard.m", ",");
|
||||
assert_eq!(display_name("key.keyboard.q").plain(), "A");
|
||||
assert_eq!(display_name("key.keyboard.semicolon").plain(), "M");
|
||||
assert_eq!(display_name("key.keyboard.left.bracket").plain(), "^");
|
||||
assert_eq!(display_name("key.keyboard.m").plain(), ",");
|
||||
// The number row keeps its digits; a Cyrillic letter, nothing
|
||||
// printable and keys that write nothing are left alone.
|
||||
learn_layout("key.keyboard.1", "&");
|
||||
learn_layout("key.keyboard.f", "а");
|
||||
learn_layout("key.keyboard.g", "\u{8}");
|
||||
learn_layout("key.keyboard.tab", "\t");
|
||||
learn_layout("key.keyboard.h", "ab");
|
||||
assert_eq!(display_name("key.keyboard.1").plain(), "1");
|
||||
assert_eq!(display_name("key.keyboard.f").plain(), "F");
|
||||
assert_eq!(display_name("key.keyboard.g").plain(), "G");
|
||||
assert_eq!(display_name("key.keyboard.h").plain(), "H");
|
||||
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ pub struct Registries {
|
||||
/// Game rule name -> integer-valued.
|
||||
pub game_rules: Vec<(String, bool)>,
|
||||
pub players: Vec<String>,
|
||||
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
|
||||
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
|
||||
/// each once.
|
||||
pub custom_completions: std::collections::BTreeSet<String>,
|
||||
/// Scoreboard objective names, team names and tracked score holders.
|
||||
pub objectives: Vec<String>,
|
||||
pub teams: Vec<String>,
|
||||
@@ -87,7 +91,55 @@ pub struct RegistryIds {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
const EMPTY: &[String] = &[];
|
||||
/// ClientboundCustomChatCompletionsPacket.Action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CustomCompletionAction {
|
||||
Add,
|
||||
Remove,
|
||||
Set,
|
||||
}
|
||||
impl CustomCompletionAction {
|
||||
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
|
||||
/// so an unknown id is `ADD`.
|
||||
pub fn from_id(id: i32) -> Self {
|
||||
match id {
|
||||
1 => Self::Remove,
|
||||
2 => Self::Set,
|
||||
_ => Self::Add,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
|
||||
/// removes or replaces the extra chat completions.
|
||||
pub fn modify_custom_completions(
|
||||
set: &mut std::collections::BTreeSet<String>,
|
||||
action: CustomCompletionAction,
|
||||
entries: &[String],
|
||||
) {
|
||||
match action {
|
||||
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
|
||||
CustomCompletionAction::Remove => {
|
||||
for e in entries {
|
||||
set.remove(e);
|
||||
}
|
||||
}
|
||||
CustomCompletionAction::Set => {
|
||||
set.clear();
|
||||
set.extend(entries.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Registries {
|
||||
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
|
||||
/// command) completes, the online players' names with the server's custom
|
||||
/// completions, each once.
|
||||
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
|
||||
self.players
|
||||
.iter()
|
||||
.chain(&self.custom_completions)
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
/// Fill what the game did not build itself from vanilla's exported
|
||||
/// registries (`command-registries.json`, written by
|
||||
/// `VanillaCommandParseOracle registries`): built-in registries such as
|
||||
|
||||
@@ -96,6 +96,8 @@ pub struct Style {
|
||||
pub hover: Option<Box<Text>>,
|
||||
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
|
||||
pub hover_item: Option<Box<Value>>,
|
||||
/// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object.
|
||||
pub hover_entity: Option<Box<Value>>,
|
||||
/// `Style.insertion`: the text a Shift-click puts in the chat box.
|
||||
pub insertion: Option<String>,
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
@@ -127,11 +129,12 @@ impl Style {
|
||||
if self.click.is_none() {
|
||||
self.click.clone_from(&base.click);
|
||||
}
|
||||
if self.hover.is_none() {
|
||||
// One HoverEvent: its three kinds are inherited together, so a style
|
||||
// with its own event never picks up another kind from `base`.
|
||||
if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() {
|
||||
self.hover.clone_from(&base.hover);
|
||||
}
|
||||
if self.hover_item.is_none() {
|
||||
self.hover_item.clone_from(&base.hover_item);
|
||||
self.hover_entity.clone_from(&base.hover_entity);
|
||||
}
|
||||
if self.insertion.is_none() {
|
||||
self.insertion.clone_from(&base.insertion);
|
||||
@@ -591,6 +594,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
let mut event = item.clone();
|
||||
event.insert("action".into(), "show_item".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
} else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() {
|
||||
let mut event = entity.clone();
|
||||
event.insert("action".into(), "show_entity".into());
|
||||
out.push(("hover_event".to_owned(), Value::Object(event)));
|
||||
}
|
||||
if let Some(insertion) = &style.insertion {
|
||||
out.push(("insertion".to_owned(), insertion.clone().into()));
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
//! Item-use and sneak multipliers come from the player's current state; this
|
||||
//! module does not implement sprint eligibility, poses or the input lifecycle.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use winit::keyboard::{KeyCode, ModifiersState};
|
||||
|
||||
fn length(v: [f32; 2]) -> f32 {
|
||||
f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32
|
||||
}
|
||||
@@ -65,3 +68,45 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing
|
||||
/// keyboard shortcut is held.
|
||||
/// - On macOS: Command (`super_key()`) + `Q`.
|
||||
/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`.
|
||||
pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet<KeyCode>) -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
modifiers.super_key() && keys.contains(&KeyCode::KeyQ)
|
||||
} else {
|
||||
modifiers.alt_key() && keys.contains(&KeyCode::F4)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quit_shortcut_follows_platform_modifiers_and_keys() {
|
||||
let mut keys = HashSet::new();
|
||||
let alt = ModifiersState::ALT;
|
||||
let super_key = ModifiersState::SUPER;
|
||||
|
||||
assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys));
|
||||
|
||||
keys.insert(KeyCode::F4);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(!is_quit_shortcut_down(alt, &keys));
|
||||
keys.remove(&KeyCode::F4);
|
||||
keys.insert(KeyCode::KeyQ);
|
||||
assert!(is_quit_shortcut_down(super_key, &keys));
|
||||
assert!(!is_quit_shortcut_down(alt, &keys));
|
||||
} else {
|
||||
assert!(is_quit_shortcut_down(alt, &keys));
|
||||
assert!(!is_quit_shortcut_down(super_key, &keys));
|
||||
keys.remove(&KeyCode::F4);
|
||||
keys.insert(KeyCode::KeyQ);
|
||||
assert!(!is_quit_shortcut_down(alt, &keys));
|
||||
assert!(!is_quit_shortcut_down(super_key, &keys));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,26 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
|
||||
}
|
||||
pub use horizon_client_core::key_display::display_name;
|
||||
|
||||
/// A key event: what the layout writes on its key, without modifiers, names
|
||||
/// that key from now on (`key_display::learn_layout`), so an AZERTY player's
|
||||
/// Q key shows A.
|
||||
pub fn learn_layout(event: &winit::event::KeyEvent) {
|
||||
use winit::keyboard::{Key, PhysicalKey};
|
||||
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
|
||||
let PhysicalKey::Code(code) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = key_name(code) else {
|
||||
return;
|
||||
};
|
||||
let learn = horizon_client_core::key_display::learn_layout;
|
||||
match event.key_without_modifiers() {
|
||||
Key::Character(text) => learn(name, &text),
|
||||
Key::Dead(Some(c)) => learn(name, c.encode_utf8(&mut [0; 4])),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current bindings by mapping name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct KeyBinds {
|
||||
|
||||
@@ -342,6 +342,28 @@ pub fn mouse_position_after_grab_change(
|
||||
}
|
||||
}
|
||||
|
||||
/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on
|
||||
/// macOS), a left click made with Control held is a right click, and so is
|
||||
/// its release, whatever Control does in between.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct RightClickEmulation {
|
||||
emulating: bool,
|
||||
}
|
||||
impl RightClickEmulation {
|
||||
/// Whether the left button's `pressed` or released event is a right click.
|
||||
/// `enabled` is the option on macOS.
|
||||
pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool {
|
||||
if pressed {
|
||||
self.emulating = enabled && control;
|
||||
}
|
||||
let right = self.emulating;
|
||||
if !pressed {
|
||||
self.emulating = false;
|
||||
}
|
||||
right
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -526,4 +548,21 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_click_is_a_right_click_until_released() {
|
||||
let mut e = RightClickEmulation::default();
|
||||
// The option off, or no Control: a left click stays one.
|
||||
assert!(!e.is_right_click(true, true, false));
|
||||
assert!(!e.is_right_click(false, true, false));
|
||||
assert!(!e.is_right_click(true, false, true));
|
||||
assert!(!e.is_right_click(false, true, true));
|
||||
// Control held at the press: the press and the release are right clicks,
|
||||
// even when Control was let go before the release.
|
||||
assert!(e.is_right_click(true, true, true));
|
||||
assert!(e.is_right_click(false, false, true));
|
||||
// The next left click is its own.
|
||||
assert!(!e.is_right_click(true, false, true));
|
||||
assert!(!e.is_right_click(false, false, true));
|
||||
}
|
||||
}
|
||||
@@ -284,12 +284,18 @@ impl FramerateLimiter {
|
||||
/// (`Window.updateFullscreenState` -> `Minecraft.fullscreenStateChanged`), and
|
||||
/// the window is only asked to change when the option itself changes
|
||||
/// (`Window.updateFullscreenIfChanged`), never re-forced to the option.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FullscreenState {
|
||||
/// The state the window system last reported (`Window.fullscreen`).
|
||||
actual: bool,
|
||||
/// The state last asked of the window system, or adopted from it.
|
||||
requested: bool,
|
||||
/// `Window.exclusiveFullscreen`.
|
||||
exclusive: bool,
|
||||
/// `Window.preferredFullscreenVideoMode`.
|
||||
mode: Option<VideoMode>,
|
||||
/// `Window.dirty`: the preferred mode changed and the screen has not been left yet.
|
||||
mode_dirty: bool,
|
||||
}
|
||||
|
||||
impl FullscreenState {
|
||||
@@ -298,9 +304,70 @@ impl FullscreenState {
|
||||
Self {
|
||||
actual: false,
|
||||
requested,
|
||||
exclusive: false,
|
||||
mode: None,
|
||||
mode_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.updateFullscreenIfChanged` with the exclusive option and preferred mode: asks the
|
||||
/// window to enter, leave or change its fullscreen only when one of them changed.
|
||||
/// `in_video_screen` holds a changed mode back until the Video Settings screen closes
|
||||
/// (`changeFullscreenVideoMode`).
|
||||
pub fn apply(
|
||||
&mut self,
|
||||
window: &winit::window::Window,
|
||||
wanted: bool,
|
||||
exclusive: bool,
|
||||
preferred: Option<VideoMode>,
|
||||
in_video_screen: bool,
|
||||
) {
|
||||
let request = self.request(wanted);
|
||||
let reapply = self.set_exclusive(exclusive);
|
||||
self.set_preferred_mode(preferred);
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.setExclusiveFullscreen`: true when the option changed while fullscreen is
|
||||
/// requested, so the window has to enter the other kind of fullscreen at once.
|
||||
pub fn set_exclusive(&mut self, exclusive: bool) -> bool {
|
||||
let changed = self.exclusive != exclusive;
|
||||
self.exclusive = exclusive;
|
||||
changed && self.requested
|
||||
}
|
||||
|
||||
/// `Window.setPreferredFullscreenVideoMode`: a changed mode waits for `take_mode_change`.
|
||||
pub fn set_preferred_mode(&mut self, mode: Option<VideoMode>) {
|
||||
if self.mode != mode {
|
||||
self.mode = mode;
|
||||
self.mode_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `Window.changeFullscreenVideoMode` (the Video Settings screen closing): true once when
|
||||
/// a changed mode has to be applied to a fullscreen window.
|
||||
pub fn take_mode_change(&mut self) -> bool {
|
||||
let apply = self.requested && self.mode_dirty;
|
||||
if apply {
|
||||
self.mode_dirty = false;
|
||||
}
|
||||
apply
|
||||
}
|
||||
|
||||
/// `Window.updateFullscreenIfChanged`: the state to ask of the window
|
||||
/// when the option `wanted` differs from what was last asked or adopted.
|
||||
pub fn request(&mut self, wanted: bool) -> Option<bool> {
|
||||
@@ -321,6 +388,132 @@ impl FullscreenState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `VideoMode`: one display mode of a monitor, as `fullscreenResolution` in options.txt keeps it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VideoMode {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub red_bits: i32,
|
||||
pub green_bits: i32,
|
||||
pub blue_bits: i32,
|
||||
pub refresh_rate: f32,
|
||||
}
|
||||
|
||||
impl VideoMode {
|
||||
/// `VideoMode.read`: the JSON object `write` makes; any other string is None.
|
||||
pub fn read(text: &str) -> Option<Self> {
|
||||
let number = |key: &str| -> Option<f64> {
|
||||
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
|
||||
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
|
||||
let end = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')))
|
||||
.unwrap_or(rest.len());
|
||||
rest[..end].parse().ok()
|
||||
};
|
||||
if !text.trim_start().starts_with('{') {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
width: number("width")? as i32,
|
||||
height: number("height")? as i32,
|
||||
red_bits: number("red_bits")? as i32,
|
||||
green_bits: number("green_bits")? as i32,
|
||||
blue_bits: number("blue_bits")? as i32,
|
||||
refresh_rate: number("refresh_rate")? as f32,
|
||||
})
|
||||
}
|
||||
|
||||
/// `VideoMode.write`: the Codec's JSON through Gson (a float prints as `60.0`).
|
||||
pub fn write(&self) -> String {
|
||||
let rate = if self.refresh_rate == self.refresh_rate.trunc() {
|
||||
format!("{:.1}", self.refresh_rate)
|
||||
} else {
|
||||
self.refresh_rate.to_string()
|
||||
};
|
||||
format!(
|
||||
"{{\"width\":{},\"height\":{},\"red_bits\":{},\"green_bits\":{},\"blue_bits\":{},\"refresh_rate\":{rate}}}",
|
||||
self.width, self.height, self.red_bits, self.green_bits, self.blue_bits
|
||||
)
|
||||
}
|
||||
|
||||
/// `VideoMode.refreshRateLabel`: a whole rate without decimals, any other with two.
|
||||
pub fn refresh_rate_label(&self) -> String {
|
||||
if self.refresh_rate == self.refresh_rate.round_ties_even() {
|
||||
(self.refresh_rate as i32).to_string()
|
||||
} else {
|
||||
format!("{:.2}", self.refresh_rate)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour depth `options.fullscreen.entry` shows.
|
||||
pub fn bits(&self) -> i32 {
|
||||
self.red_bits + self.green_bits + self.blue_bits
|
||||
}
|
||||
|
||||
/// A winit mode: its depth is the total, split evenly over the channels.
|
||||
pub fn from_winit(mode: &winit::monitor::VideoModeHandle) -> Self {
|
||||
let depth = i32::from(mode.bit_depth());
|
||||
let size = mode.size();
|
||||
Self {
|
||||
width: size.width as i32,
|
||||
height: size.height as i32,
|
||||
red_bits: depth / 3,
|
||||
green_bits: depth / 3,
|
||||
blue_bits: depth - 2 * (depth / 3),
|
||||
refresh_rate: mode.refresh_rate_millihertz() as f32 / 1000.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Monitor.videoModes`: the modes a monitor offers.
|
||||
pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<VideoMode> {
|
||||
monitor
|
||||
.video_modes()
|
||||
.map(|m| VideoMode::from_winit(&m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
|
||||
/// the monitor's own size with the highest refresh rate and depth.
|
||||
fn monitor_current_mode(
|
||||
monitor: &winit::monitor::MonitorHandle,
|
||||
) -> Option<winit::monitor::VideoModeHandle> {
|
||||
let size = monitor.size();
|
||||
monitor
|
||||
.video_modes()
|
||||
.filter(|m| m.size() == size)
|
||||
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
|
||||
}
|
||||
|
||||
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
|
||||
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
|
||||
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
|
||||
pub fn fullscreen_request(
|
||||
window: &winit::window::Window,
|
||||
exclusive: bool,
|
||||
preferred: Option<&VideoMode>,
|
||||
) -> winit::window::Fullscreen {
|
||||
use winit::window::Fullscreen;
|
||||
if !exclusive {
|
||||
return Fullscreen::Borderless(None);
|
||||
}
|
||||
let Some(monitor) = window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
else {
|
||||
return Fullscreen::Borderless(None);
|
||||
};
|
||||
let wanted = preferred.and_then(|p| {
|
||||
monitor
|
||||
.video_modes()
|
||||
.find(|m| VideoMode::from_winit(m) == *p)
|
||||
});
|
||||
match wanted.or_else(|| monitor_current_mode(&monitor)) {
|
||||
Some(mode) => Fullscreen::Exclusive(mode),
|
||||
None => Fullscreen::Borderless(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
@@ -340,11 +533,63 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn video_mode_round_trips_the_options_json() {
|
||||
let mode = VideoMode {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
red_bits: 8,
|
||||
green_bits: 8,
|
||||
blue_bits: 8,
|
||||
refresh_rate: 60.0,
|
||||
};
|
||||
let text = mode.write();
|
||||
assert_eq!(
|
||||
text,
|
||||
r#"{"width":1920,"height":1080,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#
|
||||
);
|
||||
assert_eq!(VideoMode::read(&text), Some(mode));
|
||||
assert_eq!(VideoMode::read("1920x1080"), None);
|
||||
assert_eq!(VideoMode::read(r#"{"width":1}"#), None);
|
||||
}
|
||||
#[test]
|
||||
fn refresh_rate_label_is_whole_or_two_decimals() {
|
||||
let mut mode = VideoMode::read(
|
||||
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":144.0}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(mode.refresh_rate_label(), "144");
|
||||
mode.refresh_rate = 59.94;
|
||||
assert_eq!(mode.refresh_rate_label(), "59.94");
|
||||
assert_eq!(mode.bits(), 24);
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_reapplies_only_while_fullscreen_is_requested() {
|
||||
let mut f = FullscreenState::new(false);
|
||||
assert!(!f.set_exclusive(true));
|
||||
assert!(!f.set_exclusive(true));
|
||||
assert_eq!(f.request(true), Some(true));
|
||||
assert!(f.set_exclusive(false));
|
||||
assert!(!f.set_exclusive(false));
|
||||
}
|
||||
#[test]
|
||||
fn a_changed_video_mode_applies_once_when_fullscreen() {
|
||||
let mode = VideoMode::read(
|
||||
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#,
|
||||
);
|
||||
let mut f = FullscreenState::new(false);
|
||||
f.set_preferred_mode(mode);
|
||||
assert!(!f.take_mode_change());
|
||||
f.request(true);
|
||||
f.set_preferred_mode(mode);
|
||||
assert!(f.take_mode_change());
|
||||
assert!(!f.take_mode_change());
|
||||
}
|
||||
#[test]
|
||||
fn fullscreen_follows_the_option_only_when_it_changes() {
|
||||
let mut f = FullscreenState::new(false);
|
||||
|
||||
@@ -23,6 +23,7 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
base64 = "0.22"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
# Sound (ALSA on Linux); the `audio` feature.
|
||||
cpal = { version = "0.16", optional = true }
|
||||
@@ -36,6 +37,7 @@ png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10.9"
|
||||
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
|
||||
@@ -192,8 +192,9 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the models and
|
||||
/// the sprites' layers, without the textures' pixels.
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
@@ -202,14 +203,19 @@ impl RenderAssets {
|
||||
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
|
||||
Self::load_with(root, assets, true)
|
||||
}
|
||||
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
|
||||
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
|
||||
let registry = assets.registry;
|
||||
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
crate::models::PreparedModels::default()
|
||||
};
|
||||
let model_library = parsed.into_item_library();
|
||||
// Fluids are drawn by the fluid renderer, not block models; breaking
|
||||
// progress uses the destroy-stage overlays.
|
||||
@@ -226,7 +232,7 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if pixels {
|
||||
if draws {
|
||||
sprites.finish();
|
||||
} else {
|
||||
sprites.finish_without_pixels();
|
||||
|
||||
@@ -165,6 +165,8 @@ pub struct Audio {
|
||||
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
|
||||
/// any), with or without an audio device, as `heard`.
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -634,6 +636,7 @@ impl Audio {
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -817,6 +820,13 @@ impl Audio {
|
||||
false,
|
||||
);
|
||||
}
|
||||
/// `SoundPreviewHandler.preview`: a category volume slider moved to
|
||||
/// `volume` on the title screen's options.
|
||||
pub fn preview(&mut self, source: usize, volume: f32) {
|
||||
let mut preview = std::mem::take(&mut self.preview);
|
||||
preview.preview(self, source, volume);
|
||||
self.preview = preview;
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
|
||||
@@ -52,20 +52,20 @@ impl AuthSession {
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
|
||||
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
|
||||
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
|
||||
let name = field("name").ok_or("the session file has no name")?;
|
||||
let name = field("name").ok_or("the session has no name")?;
|
||||
if name.is_empty() || name.chars().count() > 16 {
|
||||
return Err("the session file's name is not a valid player name".into());
|
||||
return Err("the session's name is not a valid player name".into());
|
||||
}
|
||||
let uuid = field("uuid")
|
||||
.and_then(parse_uuid)
|
||||
.ok_or("the session file has no valid uuid")?;
|
||||
.ok_or("the session has no valid uuid")?;
|
||||
let access_token = field("access_token")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or("the session file has no access token")?;
|
||||
.ok_or("the session has no access token")?;
|
||||
if field("user_type").is_some_and(|t| t != "msa") {
|
||||
return Err("the session file is not a Microsoft account session".into());
|
||||
return Err("the session is not a Microsoft account session".into());
|
||||
}
|
||||
Ok(Self {
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
use crate::Result;
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GrassModifier {
|
||||
@@ -102,6 +107,9 @@ pub struct BiomeInfo {
|
||||
/// The biome's own fog colour attribute; otherwise the dimension's applies.
|
||||
pub fog: Option<u32>,
|
||||
pub water_fog: u32,
|
||||
/// `BiomeGenerationSettings.features`: the placed feature ids of each
|
||||
/// `GenerationStep.Decoration` step, in order.
|
||||
pub features: Vec<Vec<String>>,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
|
||||
@@ -130,10 +138,27 @@ impl BiomeInfo {
|
||||
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
|
||||
}
|
||||
}
|
||||
/// The game's biomes, by id. The built-in (vanilla) biomes come first, in
|
||||
/// file order, and keep their ids whatever a world's data packs do; a pack's
|
||||
/// new biomes follow them (`with_packs`). Those are the client's own ids:
|
||||
/// the network registry's are `registry_order`'s.
|
||||
#[derive(Clone)]
|
||||
pub struct Biomes {
|
||||
pub list: Vec<BiomeInfo>,
|
||||
pub index: HashMap<String, u16>,
|
||||
info_noise: SimplexNoise,
|
||||
colormaps: Arc<Colormaps>,
|
||||
/// The built-in biomes, the first of `list` unless packs replaced them.
|
||||
builtin: Arc<Vec<BiomeInfo>>,
|
||||
/// The data packs' biome files (new biomes and replaced built-in ones),
|
||||
/// for the tables that read more of a biome than `BiomeInfo` keeps.
|
||||
packs: BTreeMap<String, Arc<Value>>,
|
||||
}
|
||||
/// The grass, foliage and dry foliage colormaps.
|
||||
struct Colormaps {
|
||||
grass: Vec<u32>,
|
||||
foliage: Vec<u32>,
|
||||
dry: Vec<u32>,
|
||||
}
|
||||
fn color(v: &Value) -> Option<u32> {
|
||||
match v {
|
||||
@@ -166,11 +191,90 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 {
|
||||
let index = (y << 8 | x) as usize;
|
||||
map.get(index).copied().unwrap_or(fallback)
|
||||
}
|
||||
impl BiomeInfo {
|
||||
/// A biome file's (`worldgen/biome/<name>.json`) values, the colours it
|
||||
/// leaves out sampled from the colormaps.
|
||||
fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self {
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
|
||||
features: v["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|step| {
|
||||
step.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Identifier.compareTo`: the path first, then the namespace.
|
||||
pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
fn split(id: &str) -> (&str, &str) {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
(path, ns)
|
||||
}
|
||||
split(a).cmp(&split(b))
|
||||
}
|
||||
impl Biomes {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let grass = colormap(root, "grass")?;
|
||||
let foliage = colormap(root, "foliage")?;
|
||||
let dry = colormap(root, "dry_foliage")?;
|
||||
let maps = Colormaps {
|
||||
grass: colormap(root, "grass")?,
|
||||
foliage: colormap(root, "foliage")?,
|
||||
dry: colormap(root, "dry_foliage")?,
|
||||
};
|
||||
let mut list = Vec::new();
|
||||
let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))?
|
||||
.filter_map(|e| e.ok())
|
||||
@@ -186,57 +290,7 @@ impl Biomes {
|
||||
.ok_or("bad biome")?
|
||||
);
|
||||
let v: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
let effects = &v["effects"];
|
||||
let attributes = &v["attributes"];
|
||||
let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32;
|
||||
let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32;
|
||||
let modifier = match effects["grass_color_modifier"].as_str() {
|
||||
Some("dark_forest") => GrassModifier::DarkForest,
|
||||
Some("swamp") => GrassModifier::Swamp,
|
||||
_ => GrassModifier::None,
|
||||
};
|
||||
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
|
||||
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
|
||||
// A modifier object ({argument, modifier: overlay}) or the plain value;
|
||||
// the overlay over the dimension's empty default is the biome's value.
|
||||
let natural = if natural["argument"].is_object() {
|
||||
&natural["argument"]
|
||||
} else {
|
||||
natural
|
||||
};
|
||||
let categories = &natural["spawns_by_category"];
|
||||
for (category, entries) in categories.as_object().into_iter().flatten() {
|
||||
let list = parse_entries(entries);
|
||||
spawns.insert(category.clone(), list);
|
||||
}
|
||||
list.push(BiomeInfo {
|
||||
spawns,
|
||||
spawn_costs: parse_costs(&natural["spawn_costs"]),
|
||||
creature_probability:
|
||||
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.1) as f32,
|
||||
increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"]
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
temperature,
|
||||
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
|
||||
downfall,
|
||||
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
|
||||
grass: color(&effects["grass_color"])
|
||||
.unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)),
|
||||
modifier,
|
||||
foliage: color(&effects["foliage_color"])
|
||||
.unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)),
|
||||
dry_foliage: color(&effects["dry_foliage_color"])
|
||||
.unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)),
|
||||
water: color(&effects["water_color"]).unwrap_or(0x3F76E4),
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"])
|
||||
.unwrap_or(0x050533),
|
||||
name,
|
||||
});
|
||||
list.push(BiomeInfo::from_json(name, &v, &maps));
|
||||
}
|
||||
let index = list
|
||||
.iter()
|
||||
@@ -244,11 +298,65 @@ impl Biomes {
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
list,
|
||||
index,
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
})
|
||||
}
|
||||
/// The built-in biomes with a world's data packs' biome files (id →
|
||||
/// JSON) over them, as `RegistryDataLoader` builds the registry: a file
|
||||
/// for a built-in biome replaces it, keeping its id, and new biomes join
|
||||
/// after the built-in ones, in registry order. Starts from the built-in
|
||||
/// table whatever packs `self` has.
|
||||
pub fn with_packs<'a>(&self, files: impl IntoIterator<Item = (&'a str, &'a Value)>) -> Self {
|
||||
let mut out = self.builtin_only();
|
||||
let mut files: Vec<(&str, &Value)> = files.into_iter().collect();
|
||||
files.sort_by(|a, b| identifier_order(a.0, b.0));
|
||||
for (name, json) in files {
|
||||
let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps);
|
||||
match out.index.get(name) {
|
||||
Some(&id) => out.list[usize::from(id)] = info,
|
||||
None if out.list.len() < usize::from(u16::MAX) => {
|
||||
out.index.insert(name.to_owned(), out.list.len() as u16);
|
||||
out.list.push(info);
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
out.packs.insert(name.to_owned(), Arc::new(json.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The built-in biomes alone: this table without what packs gave it.
|
||||
pub fn builtin_only(&self) -> Self {
|
||||
let mut out = self.clone();
|
||||
if self.packs.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.list = self.builtin.to_vec();
|
||||
let builtin = out.list.len();
|
||||
out.index.retain(|_, id| usize::from(*id) < builtin);
|
||||
out.packs.clear();
|
||||
out
|
||||
}
|
||||
/// The data pack file a world's biome `name` came from, if a pack added
|
||||
/// or replaced it.
|
||||
pub fn pack_file(&self, name: &str) -> Option<&Value> {
|
||||
self.packs.get(name).map(|v| &**v)
|
||||
}
|
||||
/// Whether data packs added or replaced any biome.
|
||||
pub fn has_packs(&self) -> bool {
|
||||
!self.packs.is_empty()
|
||||
}
|
||||
/// The biome names in the network registry's order: `RegistryDataLoader`
|
||||
/// sorts a registry's files by `Identifier` (path, then namespace).
|
||||
pub fn registry_order(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect();
|
||||
names.sort_by(|a, b| identifier_order(a, b));
|
||||
names
|
||||
}
|
||||
pub fn id(&self, name: &str) -> u16 {
|
||||
self.index
|
||||
.get(name)
|
||||
@@ -344,6 +452,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3])
|
||||
result.unwrap_or([0.0; 3])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pack_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A built-in table of `names` (no colormaps: sampled colours fall back).
|
||||
fn builtin(names: &[&str]) -> Biomes {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<BiomeInfo> = names
|
||||
.iter()
|
||||
.map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps))
|
||||
.collect();
|
||||
Biomes {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_order_is_path_then_namespace() {
|
||||
use std::cmp::Ordering::*;
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less);
|
||||
assert_eq!(identifier_order("z:a", "a:b"), Less);
|
||||
assert_eq!(identifier_order("b:same", "a:same"), Greater);
|
||||
assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less);
|
||||
assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
|
||||
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
|
||||
let hot = json!({"effects": {"grass_color": 0x123456}});
|
||||
let files = [
|
||||
("pack:zeta", &hot),
|
||||
("minecraft:molten_peaks", &hot),
|
||||
("minecraft:river", &red),
|
||||
];
|
||||
let world = base.with_packs(files);
|
||||
// The built-in ids stay; the river takes the pack's values.
|
||||
assert_eq!(world.id("minecraft:plains"), 1);
|
||||
assert_eq!(world.id("minecraft:river"), 2);
|
||||
assert_eq!(world.get(2).water, 0xFF0000);
|
||||
assert_eq!(world.get(2).temperature, 2.0);
|
||||
// New biomes follow, in registry order.
|
||||
assert_eq!(world.id("minecraft:molten_peaks"), 3);
|
||||
assert_eq!(world.id("pack:zeta"), 4);
|
||||
assert_eq!(world.get(3).grass, 0x123456);
|
||||
assert!(world.pack_file("minecraft:river").is_some());
|
||||
assert!(world.pack_file("minecraft:plains").is_none());
|
||||
// The network registry sorts every name by path, then namespace.
|
||||
assert_eq!(
|
||||
world.registry_order(),
|
||||
[
|
||||
"minecraft:meadow",
|
||||
"minecraft:molten_peaks",
|
||||
"minecraft:plains",
|
||||
"minecraft:river",
|
||||
"pack:zeta"
|
||||
]
|
||||
);
|
||||
// Another world starts again from the built-in biomes.
|
||||
let other = world.with_packs([]);
|
||||
assert_eq!(other.list.len(), 3);
|
||||
assert!(!other.contains("minecraft:molten_peaks"));
|
||||
assert_eq!(other.get(2).water, 0x3F76E4);
|
||||
assert!(!other.has_packs());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod blend_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::chunk::block_entity::BlockEntity;
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -70,6 +70,9 @@ pub struct BlockEntities {
|
||||
replaced: FxHashSet<Pos>,
|
||||
/// `Ticking` by block state id; empty until the game builds it.
|
||||
states: Vec<Ticking>,
|
||||
/// The containers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`.
|
||||
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BlockEntities {
|
||||
@@ -152,6 +155,13 @@ impl BlockEntities {
|
||||
pub fn ticking(&self, state: u32) -> Ticking {
|
||||
self.states.get(state as usize).copied().unwrap_or_default()
|
||||
}
|
||||
pub fn set_mod_containers(&mut self, containers: FxHashMap<u32, Arc<ModContainer>>) {
|
||||
self.mod_containers = containers;
|
||||
}
|
||||
/// The container of a mod block's block entity in block state `state`.
|
||||
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
|
||||
self.mod_containers.get(&state)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u32 {
|
||||
let n = self.next_slot;
|
||||
|
||||
@@ -1114,7 +1114,11 @@ impl BlockEntityRenderer {
|
||||
pub fn renders(&self, state: u32) -> bool {
|
||||
!matches!(self.kind(state), Kind::None)
|
||||
}
|
||||
/// ChestRenderer and BannerRenderer submit their animated models for crumbling.
|
||||
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
|
||||
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
|
||||
/// BellRenderer.submit, LecternRenderer.submit (the book) and
|
||||
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
|
||||
/// destroy stage.
|
||||
pub fn render_breaking(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
@@ -1123,7 +1127,16 @@ impl BlockEntityRenderer {
|
||||
stage: [f32; 2],
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) {
|
||||
if !matches!(
|
||||
self.kind(state),
|
||||
Kind::Banner { .. }
|
||||
| Kind::Chest { .. }
|
||||
| Kind::Skull { .. }
|
||||
| Kind::Shulker { .. }
|
||||
| Kind::Bell
|
||||
| Kind::Lectern { .. }
|
||||
| Kind::CopperGolem { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
|
||||
|
||||
@@ -36,6 +36,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
|
||||
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
|
||||
format!("item.minecraft.{prefix}.effect.{suffix}")
|
||||
}
|
||||
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
|
||||
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
|
||||
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
let potion = stack.potion().and_then(self::potion);
|
||||
let custom = stack.custom_potion_effects();
|
||||
potion
|
||||
.map_or(&[][..], |d| d.effects)
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(
|
||||
custom
|
||||
.iter()
|
||||
.filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
|
||||
pub fn tooltip(
|
||||
potion: Option<&str>,
|
||||
|
||||
@@ -627,6 +627,34 @@ pub fn perspective(
|
||||
m[11] = -1.0;
|
||||
m
|
||||
}
|
||||
/// `Frustum.getNearPlaneBounds`: the box around the four corners of the
|
||||
/// culling frustum's NDC `z = -1` plane, relative to the camera (the caller
|
||||
/// moves it to the eye). The culling projection is `Camera.update`'s
|
||||
/// `setPerspective(fov, aspect, 0.05, depthFar, zZeroToOne)`.
|
||||
pub fn near_plane_bounds(
|
||||
fov_degrees: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
far: f32,
|
||||
) -> ([f64; 3], [f64; 3]) {
|
||||
let projection =
|
||||
glam::Mat4::from_cols_array(&perspective(fov_degrees, width, height, 0.05, far, true));
|
||||
// Frustum.calculateFrustum: projection * modelView.
|
||||
let clip_to_world = (projection * view_matrix(yaw, pitch)).inverse();
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for (x, y) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
|
||||
let p = clip_to_world * glam::Vec4::new(x, y, -1.0, 1.0);
|
||||
let p = [p.x / p.w, p.y / p.w, p.z / p.w];
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(f64::from(p[k]));
|
||||
max[k] = max[k].max(f64::from(p[k]));
|
||||
}
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
/// `GameRenderer.render3dHud`: the first-person hand and held items are
|
||||
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
|
||||
/// death and fluids), never the FOV option or the sprint, speed and bow
|
||||
@@ -834,6 +862,19 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn near_plane_bounds_sit_half_a_near_distance_ahead_of_the_eye() {
|
||||
// Facing +Z (yaw 0): the zero-to-one culling projection puts NDC
|
||||
// z = -1 at -far * near / (2 far - near) in front of the camera.
|
||||
let far = 512.0;
|
||||
let (min, max) = super::near_plane_bounds(70.0, 1600.0, 900.0, 0.0, 0.0, far);
|
||||
let depth = f64::from(far * 0.05 / (2.0 * far - 0.05));
|
||||
assert!((min[2] - depth).abs() < 1e-4 && (max[2] - depth).abs() < 1e-4);
|
||||
let half_height = depth * 35.0f64.to_radians().tan();
|
||||
assert!((max[1] - half_height).abs() < 1e-4 && (min[1] + half_height).abs() < 1e-4);
|
||||
let half_width = half_height * 1600.0 / 900.0;
|
||||
assert!((max[0] - half_width).abs() < 1e-3 && (min[0] + half_width).abs() < 1e-3);
|
||||
}
|
||||
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
|
||||
/// cape leans back as the player walks forward (south, body yaw 0).
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
/// A player message's signature (MessageSignature), which names it to a
|
||||
/// server's deletion.
|
||||
pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -165,6 +171,8 @@ struct Message {
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
source: ChatSource,
|
||||
/// `GuiMessage.signature`: a signed player message's, for deletion.
|
||||
signature: Option<Box<Signature>>,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -205,6 +213,9 @@ pub struct Chat {
|
||||
pub received: u64,
|
||||
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
|
||||
abilities: ChatAbilities,
|
||||
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
|
||||
/// too new to replace, with the tick from which they may be.
|
||||
deletions: Vec<(Box<Signature>, u64)>,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -227,6 +238,38 @@ impl Chat {
|
||||
) {
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
}
|
||||
/// `add` of a player message with its signature, which a server's
|
||||
/// deletion names it by (`GuiMessage.signature`).
|
||||
pub fn add_signed(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
|
||||
}
|
||||
fn add_signed_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator: None,
|
||||
source: ChatSource::Player,
|
||||
signature: signature.map(Box::new),
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
@@ -236,20 +279,28 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
signature: None,
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
|
||||
if !self.abilities.shows(message.source) {
|
||||
return;
|
||||
}
|
||||
// ChatComponent.logChatMessage: every message shown is in the log.
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
|
||||
let plain = message.content.plain();
|
||||
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}"));
|
||||
if self.log {
|
||||
println!("[CHAT] {}", content.plain());
|
||||
println!("[CHAT] {plain}");
|
||||
}
|
||||
let message = Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
};
|
||||
self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
@@ -319,8 +370,66 @@ impl Chat {
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.lines.clear();
|
||||
self.deletions.clear();
|
||||
self.scroll = 0;
|
||||
}
|
||||
/// `ChatComponent.deleteMessage`: the server deleted a message. One
|
||||
/// shown for 60 ticks becomes the grey "deleted" marker now; a newer
|
||||
/// one waits until then (`messageDeletionQueue`).
|
||||
pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) {
|
||||
self.delete_message_with(&atlas.font, signature, ticks);
|
||||
}
|
||||
fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) {
|
||||
if let Some(delayed) = self.delete_or_delay(font, signature, ticks) {
|
||||
self.deletions.push((Box::new(*signature), delayed));
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed
|
||||
/// deletions whose message has shown long enough.
|
||||
pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) {
|
||||
self.process_deletions(&atlas.font, ticks);
|
||||
}
|
||||
fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) {
|
||||
let mut deletions = std::mem::take(&mut self.deletions);
|
||||
deletions.retain(|(signature, after)| {
|
||||
ticks < *after || self.delete_or_delay(font, signature, ticks).is_some()
|
||||
});
|
||||
self.deletions = deletions;
|
||||
}
|
||||
/// `ChatComponent.deleteMessageOrDelay`: the newest message with this
|
||||
/// signature, replaced by `createDeletedMarker` once 60 ticks have
|
||||
/// passed since it was added; otherwise the tick to retry from.
|
||||
fn delete_or_delay(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
signature: &Signature,
|
||||
ticks: u64,
|
||||
) -> Option<u64> {
|
||||
let message = self
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|m| m.signature.as_deref() == Some(signature))?;
|
||||
let deletable = message.added + DELETION_DELAY;
|
||||
if ticks < deletable {
|
||||
return Some(deletable);
|
||||
}
|
||||
*message = Message {
|
||||
added: message.added,
|
||||
content: Self::deleted_marker(),
|
||||
indicator: Some(SYSTEM_INDICATOR),
|
||||
source: ChatSource::Server,
|
||||
signature: None,
|
||||
};
|
||||
// refreshTrimmedMessages.
|
||||
self.rewrap(font);
|
||||
None
|
||||
}
|
||||
/// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic.
|
||||
fn deleted_marker() -> Text {
|
||||
Text::translate("chat.deleted_marker", &[])
|
||||
.with_color(text::GRAY)
|
||||
.italic()
|
||||
}
|
||||
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
|
||||
/// start the chat's history (`ChatComponent`'s constructor).
|
||||
pub fn load_history(&mut self, game_folder: &std::path::Path) {
|
||||
@@ -929,7 +1038,9 @@ impl ChatScreen {
|
||||
} else if !command.chars().all(char::is_whitespace) {
|
||||
let partial = &command[..cursor_b];
|
||||
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
|
||||
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the players
|
||||
// and the server's custom chat completions.
|
||||
builder.suggest_matching(c.registries.custom_tab_suggestions());
|
||||
let (range, list) = merge_suggestions(partial, builder.out);
|
||||
self.awaiting = None;
|
||||
self.pending = Some((range.0, list));
|
||||
|
||||
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
|
||||
assert_eq!(screen.value(), "/coords");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completes_the_servers_custom_completions_with_the_players() {
|
||||
// CommandSuggestions.updateCommandInfo completes chat from
|
||||
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
|
||||
// and what ClientboundCustomChatCompletionsPacket added.
|
||||
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let mut registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
|
||||
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
|
||||
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
|
||||
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
|
||||
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
|
||||
assert_eq!(
|
||||
registries
|
||||
.custom_tab_suggestions()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
["#trade", "Notch", "Steve", "Zuri"]
|
||||
);
|
||||
let completed = |registries: &Registries, line: &str| {
|
||||
let dispatcher = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
dispatcher,
|
||||
registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, line);
|
||||
// Chat shows no list by itself: Tab shows it, Tab again uses it.
|
||||
for _ in 0..2 {
|
||||
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
|
||||
}
|
||||
screen.value().to_owned()
|
||||
};
|
||||
assert_eq!(completed(®istries, "hi no"), "hi Notch");
|
||||
assert_eq!(completed(®istries, "sell #tr"), "sell #trade");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi al");
|
||||
// SET replaces them all; the players stay.
|
||||
let set = &mut registries.custom_completions;
|
||||
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
|
||||
assert_eq!(completed(®istries, "hi no"), "hi no");
|
||||
assert_eq!(completed(®istries, "hi al"), "hi Alex");
|
||||
assert_eq!(completed(®istries, "hi st"), "hi Steve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
|
||||
@@ -467,3 +519,62 @@ fn a_guests_line_follows_the_player_message_permission() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60
|
||||
/// ticks from when it was added, then becomes the grey italic
|
||||
/// `chat.deleted_marker` line with the system bar; the other lines stay.
|
||||
#[test]
|
||||
fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
let secret = [7; 256];
|
||||
chat.add_signed_message(
|
||||
&font,
|
||||
Text::literal("<Steve> secret"),
|
||||
Some(secret),
|
||||
100,
|
||||
false,
|
||||
);
|
||||
chat.add_signed_message(
|
||||
&font,
|
||||
Text::literal("<Alex> hi"),
|
||||
Some([8; 256]),
|
||||
110,
|
||||
false,
|
||||
);
|
||||
let lines =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
let marker = Chat::deleted_marker().plain();
|
||||
chat.delete_message_with(&font, &secret, 130);
|
||||
assert_eq!(
|
||||
lines(&chat),
|
||||
["<Alex> hi", "<Steve> secret"],
|
||||
"too new: delayed"
|
||||
);
|
||||
chat.process_deletions(&font, 159);
|
||||
assert_eq!(lines(&chat), ["<Alex> hi", "<Steve> secret"]);
|
||||
chat.process_deletions(&font, 160);
|
||||
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
let line = &chat.lines[1];
|
||||
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.added, 100, "the marker keeps the message's time");
|
||||
assert!(
|
||||
line.content
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
|
||||
);
|
||||
assert_eq!(
|
||||
chat.messages[1].content.translation_key(),
|
||||
Some("chat.deleted_marker")
|
||||
);
|
||||
// A message shown 60 ticks already is replaced at once; an unknown or
|
||||
// already deleted signature changes nothing.
|
||||
chat.delete_message_with(&font, &[8; 256], 170);
|
||||
assert_eq!(lines(&chat), [marker.clone(), marker.clone()]);
|
||||
chat.delete_message_with(&font, &secret, 200);
|
||||
chat.delete_message_with(&font, &[9; 256], 200);
|
||||
assert_eq!(lines(&chat), [marker.clone(), marker]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
}
|
||||
@@ -82,13 +82,45 @@ impl LevelShape {
|
||||
/// vanilla Overworld's `-64..320` so that vanilla's Nether and End keep
|
||||
/// the 24 sections they have always had.
|
||||
pub fn column(self) -> ColumnLayout {
|
||||
let low = self.min_y.min(MIN_Y);
|
||||
let high = self.max_y().max(MIN_Y + HEIGHT);
|
||||
ColumnLayout {
|
||||
min_y: low,
|
||||
sections: ((high - low) / 16) as usize,
|
||||
ColumnLayout::of_level(self.min_y, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shapes of a world's three levels, from its dimension types.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LevelShapes {
|
||||
pub overworld: LevelShape,
|
||||
pub nether: LevelShape,
|
||||
pub end: LevelShape,
|
||||
}
|
||||
impl LevelShapes {
|
||||
pub const VANILLA: Self = Self {
|
||||
overworld: LevelShape::OVERWORLD,
|
||||
nether: LevelShape::NETHER,
|
||||
end: LevelShape::END,
|
||||
};
|
||||
/// The shape of the level `id` (`minecraft:overworld`,
|
||||
/// `minecraft:the_nether`, `minecraft:the_end`); others get the
|
||||
/// Overworld's.
|
||||
pub fn get(&self, id: &str) -> LevelShape {
|
||||
match id {
|
||||
"minecraft:the_nether" => self.nether,
|
||||
"minecraft:the_end" => self.end,
|
||||
_ => self.overworld,
|
||||
}
|
||||
}
|
||||
pub fn get_mut(&mut self, id: &str) -> &mut LevelShape {
|
||||
match id {
|
||||
"minecraft:the_nether" => &mut self.nether,
|
||||
"minecraft:the_end" => &mut self.end,
|
||||
_ => &mut self.overworld,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Default for LevelShapes {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
}
|
||||
}
|
||||
|
||||
/// The span of block Y a chunk holds: from `min_y`, `sections` sections up
|
||||
@@ -103,6 +135,16 @@ impl ColumnLayout {
|
||||
min_y: MIN_Y,
|
||||
sections: SECTIONS,
|
||||
};
|
||||
/// The column of a level from `min_y`, `height` high
|
||||
/// (`LevelShape::column`).
|
||||
pub fn of_level(min_y: i32, height: i32) -> Self {
|
||||
let low = min_y.min(MIN_Y);
|
||||
let high = (min_y + height).max(MIN_Y + HEIGHT);
|
||||
Self {
|
||||
min_y: low,
|
||||
sections: ((high - low) / 16) as usize,
|
||||
}
|
||||
}
|
||||
/// `DimensionType.MIN_Y`, `MAX_Y`: 12 bits of `BlockPos` Y, less 32.
|
||||
pub const LOWEST_Y: i32 = -2032;
|
||||
pub const HIGHEST_Y: i32 = 2031;
|
||||
@@ -155,6 +197,13 @@ impl ColumnLayout {
|
||||
self.min_y + index as i32 * 16
|
||||
}
|
||||
}
|
||||
/// `true` for each section of `layout`: what callers that keep no
|
||||
/// per-section record (of fluids, say) pass as "every section may have it".
|
||||
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
|
||||
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
|
||||
[true; crate::section_mask::SectionMask::CAPACITY];
|
||||
&ALL[..layout.sections()]
|
||||
}
|
||||
impl Default for ColumnLayout {
|
||||
fn default() -> Self {
|
||||
Self::VANILLA
|
||||
@@ -194,6 +243,47 @@ pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
|
||||
pub fn set_block_states(states: usize) {
|
||||
let bits = horizon_core::paletted::ceil_log2(states).max(16);
|
||||
BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed);
|
||||
forget_shared();
|
||||
}
|
||||
|
||||
/// One allocation per kind of uniform section (a single block, a single
|
||||
/// biome and one light value), shared by every column that has it, keyed by
|
||||
/// (block, biome, light). A tall column is mostly such sections: air with
|
||||
/// full sky light above the terrain. Edits copy them (`Arc::make_mut`).
|
||||
type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc<Section>>;
|
||||
static SHARED: std::sync::Mutex<Option<SharedSections>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Replaces the uniform sections of `sections` with the shared ones.
|
||||
pub fn share_uniform(sections: &mut [Arc<Section>]) {
|
||||
let key = |s: &Section| -> Option<(u32, u32, u8)> {
|
||||
let Light::Uniform(light) = s.light else {
|
||||
return None;
|
||||
};
|
||||
if s.blocks.bits() != 0 || s.biomes.bits() != 0 {
|
||||
return None;
|
||||
}
|
||||
Some((s.blocks.uniform()?, s.biomes.uniform()?, light))
|
||||
};
|
||||
let Ok(mut shared) = SHARED.lock() else {
|
||||
return;
|
||||
};
|
||||
let shared = shared.get_or_insert_with(Default::default);
|
||||
for section in sections {
|
||||
let Some(k) = key(section) else {
|
||||
continue;
|
||||
};
|
||||
let one = shared.entry(k).or_insert_with(|| section.clone());
|
||||
if !Arc::ptr_eq(one, section) {
|
||||
*section = one.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the shared sections, when the palette changes.
|
||||
fn forget_shared() {
|
||||
if let Ok(mut shared) = SHARED.lock() {
|
||||
*shared = None;
|
||||
}
|
||||
}
|
||||
/// Packed light per block: sky light in the high nibble, block light in the low one.
|
||||
#[derive(Clone)]
|
||||
@@ -325,13 +415,14 @@ impl Chunk {
|
||||
Self::with_layout(x, z, ColumnLayout::VANILLA)
|
||||
}
|
||||
pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self {
|
||||
let mut one = [Arc::new(Section::default())];
|
||||
share_uniform(&mut one);
|
||||
let [empty] = one;
|
||||
Self {
|
||||
x,
|
||||
z,
|
||||
min_y: layout.min_y(),
|
||||
sections: (0..layout.sections())
|
||||
.map(|_| Arc::new(Section::default()))
|
||||
.collect(),
|
||||
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
|
||||
surface: OnceLock::new(),
|
||||
motion_blocking: OnceLock::new(),
|
||||
lit: false,
|
||||
|
||||
@@ -80,9 +80,63 @@ pub enum BlockEntity {
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
/// A block entity of a mod (`native_block_entity`), kept as its saved
|
||||
/// compound for the mod to read and change.
|
||||
Mod(Box<ModBlockEntity>),
|
||||
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
|
||||
PotentSulfur(PotentSulfur),
|
||||
/// `StructureBlockEntity` (`structure_block.rs`).
|
||||
StructureBlock(Box<crate::structure_block::StructureBlock>),
|
||||
/// `JigsawBlockEntity` (`jigsaw_block.rs`).
|
||||
JigsawBlock(Box<crate::jigsaw_block::JigsawBlock>),
|
||||
/// `TestBlockEntity` (`test_block.rs`).
|
||||
TestBlock(Box<crate::test_block::TestBlock>),
|
||||
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
|
||||
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
|
||||
}
|
||||
/// A mod's block entity: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
/// (`Items`) when its block has a container (`ModContainer`), and the data
|
||||
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
|
||||
/// which the mod sets and nothing saves.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModBlockEntity {
|
||||
pub id: String,
|
||||
pub nbt: Compound,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
pub data: Vec<i32>,
|
||||
}
|
||||
/// The container of a mod block's block entity, as a `WorldlyContainer`
|
||||
/// says it: its slot count, the slots each face reaches
|
||||
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
|
||||
/// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take
|
||||
/// from (`canTakeItemThroughFace`); and the vanilla menu that shows its
|
||||
/// slots when the block is used, if any (`MOD_MENUS`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ModContainer {
|
||||
pub slots: usize,
|
||||
pub faces: [Vec<usize>; 6],
|
||||
pub inputs: Vec<usize>,
|
||||
pub outputs: Vec<usize>,
|
||||
pub menu: Option<String>,
|
||||
}
|
||||
/// The vanilla menus a mod container can open, with the slot count each
|
||||
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
|
||||
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
|
||||
/// (`data[2]` of `data[3]`), as `AbstractFurnaceBlockEntity.dataAccess`.
|
||||
pub const MOD_MENUS: [(&str, usize); 11] = [
|
||||
("minecraft:furnace", 3),
|
||||
("minecraft:blast_furnace", 3),
|
||||
("minecraft:smoker", 3),
|
||||
("minecraft:hopper", 5),
|
||||
("minecraft:generic_3x3", 9),
|
||||
("minecraft:generic_9x1", 9),
|
||||
("minecraft:generic_9x2", 18),
|
||||
("minecraft:generic_9x3", 27),
|
||||
("minecraft:generic_9x4", 36),
|
||||
("minecraft:generic_9x5", 45),
|
||||
("minecraft:generic_9x6", 54),
|
||||
];
|
||||
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
|
||||
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
|
||||
/// last eruption block event, which the client's plume counts from.
|
||||
|
||||
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -306,6 +307,27 @@ impl BlockEntity {
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:structure_block" {
|
||||
// The entity's mode is the state's (`StructureBlock.MODE`); the block's
|
||||
// state is not known here, so a new one starts in its default, SAVE.
|
||||
return Some(Self::StructureBlock(Box::new(
|
||||
crate::structure_block::StructureBlock::new(
|
||||
crate::structure_block::StructureMode::Save,
|
||||
),
|
||||
)));
|
||||
}
|
||||
if name == "minecraft:test_block" {
|
||||
// The mode is the state's, which is not known here.
|
||||
return Some(Self::TestBlock(Box::new(
|
||||
crate::test_block::TestBlock::new(crate::test_block::TestBlockMode::Fail),
|
||||
)));
|
||||
}
|
||||
if name == "minecraft:test_instance_block" {
|
||||
return Some(Self::TestInstanceBlock(Box::default()));
|
||||
}
|
||||
if name == "minecraft:jigsaw" {
|
||||
return Some(Self::JigsawBlock(Box::default()));
|
||||
}
|
||||
if name == "minecraft:potent_sulfur" {
|
||||
return Some(Self::PotentSulfur(PotentSulfur::default()));
|
||||
}
|
||||
@@ -319,7 +341,7 @@ impl BlockEntity {
|
||||
};
|
||||
Some(Self::Chest(vec![None; size]))
|
||||
}
|
||||
pub fn id(&self) -> &'static str {
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Chest(items) => match items.len() {
|
||||
HOPPER_SLOTS => "minecraft:hopper",
|
||||
@@ -352,7 +374,12 @@ impl BlockEntity {
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
Self::Mod(m) => &m.id,
|
||||
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
|
||||
Self::StructureBlock(_) => "minecraft:structure_block",
|
||||
Self::JigsawBlock(_) => "minecraft:jigsaw",
|
||||
Self::TestBlock(_) => "minecraft:test_block",
|
||||
Self::TestInstanceBlock(_) => "minecraft:test_instance_block",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -372,8 +399,13 @@ impl BlockEntity {
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::StructureBlock(_)
|
||||
| Self::JigsawBlock(_)
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
@@ -397,11 +429,16 @@ impl BlockEntity {
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::StructureBlock(_)
|
||||
| Self::JigsawBlock(_)
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::Mod(m) => &m.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -426,11 +463,16 @@ impl BlockEntity {
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::PotentSulfur(_)
|
||||
| Self::StructureBlock(_)
|
||||
| Self::JigsawBlock(_)
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::Mod(m) => &mut m.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -296,8 +296,9 @@ fn memory() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran.
|
||||
pub fn system_details() -> Vec<(String, String)> {
|
||||
/// The report's details: Horizon's and the machine's, then what the game set as it ran. A dedicated
|
||||
/// server renders nothing: it leaves out the render settings it read from options.txt.
|
||||
pub fn system_details(side: &str) -> Vec<(String, String)> {
|
||||
let mut out = vec![
|
||||
("Horizon Version".to_owned(), version()),
|
||||
(
|
||||
@@ -308,18 +309,30 @@ pub fn system_details() -> Vec<(String, String)> {
|
||||
("CPU".to_owned(), cpu()),
|
||||
("Memory".to_owned(), memory()),
|
||||
];
|
||||
out.extend(details().into_iter().map(|(k, v)| (k.to_owned(), v)));
|
||||
out.extend(
|
||||
details()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| side != "server" || *k != "Render Settings")
|
||||
.map(|(k, v)| (k.to_owned(), v)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn thread_name() -> String {
|
||||
/// The thread's name as vanilla's reports say it: the main thread is the client's "Render thread"
|
||||
/// and the dedicated server's "Server thread".
|
||||
fn thread_name(side: &str) -> String {
|
||||
let current = std::thread::current();
|
||||
match current.name() {
|
||||
Some("main") | None if side == "server" => "Server thread".into(),
|
||||
Some("main") | None => "Render thread".into(),
|
||||
Some(name) => name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
|
||||
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
|
||||
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
|
||||
|
||||
/// Writes the report and tells where it is; the log gets the error too.
|
||||
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
@@ -343,7 +356,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
|
||||
crate::telemetry::keep_crash(home, &report);
|
||||
match report.save(home) {
|
||||
Ok(path) => {
|
||||
eprintln!("Horizon: crash report saved to {}", path.display());
|
||||
eprintln!("{SAVED_LINE}{}", path.display());
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -365,9 +378,9 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
description: "Watching Server".into(),
|
||||
error: format!("{message} (Watching Server)"),
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -476,15 +489,31 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
|
||||
fn is_worker_thread(name: Option<&str>) -> bool {
|
||||
name.is_some_and(|n| n.starts_with("horizon-worker-"))
|
||||
}
|
||||
|
||||
/// `Util.onThreadException`: a panic on a worker thread is made a report titled "Exception on
|
||||
/// worker thread", with the thread's name, as `BlockableEventLoop.relayDelayCrash` hands it to
|
||||
/// the main thread.
|
||||
pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Report {
|
||||
Report {
|
||||
description: "Exception on worker thread".into(),
|
||||
..panic_report(info, side)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
|
||||
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
@@ -496,10 +525,14 @@ pub fn install(home: PathBuf, side: &'static str) {
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
deliver(&home, panic_report(info, side));
|
||||
} else if is_worker_thread(std::thread::current().name()) {
|
||||
deliver(&home, worker_panic_report(info, side));
|
||||
// The crash ends the game, as the relayed report does on vanilla's main thread.
|
||||
std::process::exit(101);
|
||||
} else {
|
||||
log::error!(
|
||||
"Caught previously unhandled exception in thread {}: {}",
|
||||
thread_name(),
|
||||
thread_name(side),
|
||||
panic_message(info)
|
||||
);
|
||||
}
|
||||
@@ -548,9 +581,9 @@ pub fn report_error(
|
||||
description: DebugCrash::MESSAGE.into(),
|
||||
error: DebugCrash::MESSAGE.into(),
|
||||
stack: debug.stack.clone(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
);
|
||||
@@ -568,9 +601,9 @@ pub fn report_error(
|
||||
description: "Fatal error".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(),
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
},
|
||||
)
|
||||
@@ -668,6 +701,26 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dedicated_servers_report_names_its_server_thread_and_no_render_settings() {
|
||||
let unnamed = |side: &'static str| {
|
||||
std::thread::spawn(move || thread_name(side))
|
||||
.join()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(unnamed("server"), "Server thread");
|
||||
assert_eq!(unnamed("client"), "Render thread");
|
||||
set_detail("Render Settings", "render distance 12");
|
||||
let has = |side| {
|
||||
system_details(side)
|
||||
.iter()
|
||||
.any(|(k, _)| k == "Render Settings")
|
||||
};
|
||||
assert!(has("client"));
|
||||
assert!(!has("server"));
|
||||
clear_detail("Render Settings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacks_start_at_the_panicking_frame() {
|
||||
let bt = format!(
|
||||
@@ -701,6 +754,13 @@ mod tests {
|
||||
assert!(first.contains("crash.rs"), "{}", debug.stack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_threads_are_the_streaming_pool() {
|
||||
assert!(is_worker_thread(Some("horizon-worker-3")));
|
||||
assert!(!is_worker_thread(Some("main")));
|
||||
assert!(!is_worker_thread(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_a_report() {
|
||||
// A real panic through a hook, as the game's would be.
|
||||
|
||||
@@ -618,6 +618,37 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
|
||||
enabled
|
||||
}
|
||||
|
||||
/// The level stems of `packs` (`data/<namespace>/dimension/<path>.json`, lowest priority first,
|
||||
/// as `enabled_ids` orders them): the id `namespace:path` and the parsed JSON, a later pack's
|
||||
/// file for an id replacing an earlier one's. This is `context.datapackDimensions()` of
|
||||
/// `WorldOpenFlows.loadWorldStem`.
|
||||
pub fn dimension_stems(packs: &[&DataPack]) -> BTreeMap<String, serde_json::Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_kinds(&["dimension"]) {
|
||||
let Some(rest) = file.strip_prefix("data/") else {
|
||||
continue;
|
||||
};
|
||||
let Some((namespace, rest)) = rest.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = rest
|
||||
.strip_prefix("dimension/")
|
||||
.and_then(|p| p.strip_suffix(".json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(json) = pack
|
||||
.read(&file)
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
{
|
||||
out.insert(format!("{namespace}:{path}"), json);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- worldgen ----------------------------------------------------------------
|
||||
|
||||
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
|
||||
@@ -636,11 +667,6 @@ pub fn worldgen_overrides(
|
||||
let read = || String::from_utf8(pack.read(&file)?).ok();
|
||||
match out.add_file(&pack.id, &file, read) {
|
||||
PackFile::Read | PackFile::Other => {}
|
||||
PackFile::DimensionType { id, min_y, height } => warnings.push(format!(
|
||||
"{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \
|
||||
the dimension keeps its vanilla build height",
|
||||
pack.id
|
||||
)),
|
||||
PackFile::NotRead(kind) => {
|
||||
*not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1
|
||||
}
|
||||
@@ -713,10 +739,13 @@ mod tests {
|
||||
);
|
||||
let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect();
|
||||
assert_eq!(tag_packs, ["file/b", "file/a"]);
|
||||
assert_eq!(warnings.len(), 3, "{warnings:?}");
|
||||
assert!(warnings[0].contains("height 2096"));
|
||||
assert!(warnings[1].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[2].contains("worldgen/world_preset"));
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("minecraft:new_peaks"));
|
||||
assert!(warnings[1].contains("worldgen/world_preset"));
|
||||
assert_eq!(
|
||||
o.level_height("minecraft:overworld").map(|h| h.height),
|
||||
Some(2096)
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
use horizon_world::nbt::parse_snbt;
|
||||
@@ -906,9 +935,17 @@ mod tests {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, t).unwrap();
|
||||
};
|
||||
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write(
|
||||
"fns",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
write("fns", "data/x/function/f.mcfunction", "say hi");
|
||||
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
|
||||
write(
|
||||
"off",
|
||||
"pack.mcmeta",
|
||||
r#"{"pack":{"min_format":121,"max_format":121}}"#,
|
||||
);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
|
||||
@@ -148,6 +148,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving
|
||||
/// an infinite (-1) or zero duration alone.
|
||||
pub fn scaled_duration(duration: i32, scale: f32) -> i32 {
|
||||
if duration == -1 || duration == 0 {
|
||||
duration
|
||||
} else {
|
||||
((duration as f32 * scale).floor() as i32).max(1)
|
||||
}
|
||||
}
|
||||
/// What an omen's `applyEffectTick` decided.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OmenTick {
|
||||
@@ -162,6 +171,9 @@ pub enum OmenTick {
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
pub tick_count: i32,
|
||||
/// How many times `add` added or updated an effect: each is one
|
||||
/// `onEffectAdded` or `onEffectUpdated` (`EffectsChangedTrigger`).
|
||||
pub changes: u32,
|
||||
}
|
||||
impl PlayerEffects {
|
||||
pub fn get(&self, id: &str) -> Option<&EffectInstance> {
|
||||
@@ -172,13 +184,17 @@ impl PlayerEffects {
|
||||
}
|
||||
/// `LivingEntity.addEffect`; returns whether anything changed.
|
||||
pub fn add(&mut self, effect: EffectInstance) -> bool {
|
||||
match self.list.iter_mut().find(|e| e.id == effect.id) {
|
||||
let changed = match self.list.iter_mut().find(|e| e.id == effect.id) {
|
||||
Some(existing) => existing.update(&effect),
|
||||
None => {
|
||||
self.list.push(effect);
|
||||
true
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.changes = self.changes.wrapping_add(1);
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
@@ -308,19 +324,29 @@ impl PlayerEffects {
|
||||
pub fn potion_effects(
|
||||
potion: Option<&str>,
|
||||
scale: f32,
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let effects = potion
|
||||
.and_then(potions::potion)
|
||||
.map_or(&[][..], |d| d.effects);
|
||||
Self::contents_effects(effects, scale)
|
||||
}
|
||||
/// `PotionContents.forEachEffect`'s effects, as `potion_effects`.
|
||||
pub fn contents_effects(
|
||||
effects: &[(&'static str, i32, i32)],
|
||||
scale: f32,
|
||||
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
|
||||
let mut lasting = Vec::new();
|
||||
let mut now = Vec::new();
|
||||
if let Some(def) = potion.and_then(potions::potion) {
|
||||
for (id, duration, amp) in def.effects {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
// withScaledDuration: max(floor(d * scale), 1).
|
||||
let d = ((*duration as f32) * scale).floor() as i32;
|
||||
lasting.push(EffectInstance::new(id, d.max(1), *amp));
|
||||
}
|
||||
for (id, duration, amp) in effects {
|
||||
let id = potions::effect(id).map_or("speed", |e| e.id);
|
||||
if potions::effect(id).is_some_and(|e| e.instant) {
|
||||
now.extend(instant(id, *amp, 1.0));
|
||||
} else {
|
||||
lasting.push(EffectInstance::new(
|
||||
id,
|
||||
scaled_duration(*duration, scale),
|
||||
*amp,
|
||||
));
|
||||
}
|
||||
}
|
||||
(lasting, now)
|
||||
@@ -463,6 +489,19 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adding_counts_a_change_only_when_the_effect_changed() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("speed", 100, 0));
|
||||
assert_eq!(fx.changes, 1);
|
||||
// A longer duration updates the effect (`onEffectUpdated`).
|
||||
fx.add(EffectInstance::new("speed", 200, 0));
|
||||
assert_eq!(fx.changes, 2);
|
||||
// A shorter one with the same amplifier is hidden by nothing: no change.
|
||||
fx.add(EffectInstance::new("speed", 50, 0));
|
||||
assert_eq!(fx.changes, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
|
||||
@@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
FogType::PowderSnow => {
|
||||
fog.environmental_start = 0.0;
|
||||
fog.environmental_end = 2.0;
|
||||
fog.sky_end = 2.0;
|
||||
fog.cloud_end = 2.0;
|
||||
// PowderedSnowFogEnvironment.setupFog.
|
||||
(fog.environmental_start, fog.environmental_end) = if input.spectator {
|
||||
(-8.0, rd * 0.5)
|
||||
} else {
|
||||
(0.0, 2.0)
|
||||
};
|
||||
fog.sky_end = fog.environmental_end;
|
||||
fog.cloud_end = fog.environmental_end;
|
||||
}
|
||||
_ if input.blindness.is_some() => {
|
||||
let (duration, infinite) = input.blindness.unwrap_or((0, true));
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
@@ -153,14 +154,42 @@ fn world_pass_after_culling<'e>(
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn section_instance(origin: [f32; 3]) -> [f32; 4] {
|
||||
/// A section's instance: its camera-relative origin and its UV step, negated
|
||||
/// past its fade code while it fades in over far terrain (`lod::instance`).
|
||||
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
1.0 / crate::mesher::UV_STEPS,
|
||||
if fade == 0 {
|
||||
// The step is a power of two, so its mantissa is free: it holds the vanilla
|
||||
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
|
||||
// `instance_visibility` read it back). A whole section is the bare step.
|
||||
let faded = ((1.0 - visibility) * 8_388_608.0)
|
||||
.round()
|
||||
.clamp(0.0, 8_388_607.0) as u32;
|
||||
f32::from_bits(step.to_bits() | faded)
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
|
||||
/// uploaded `elapsed_ms` ago is.
|
||||
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
|
||||
if elapsed_ms >= fade_ms {
|
||||
1.0
|
||||
} else {
|
||||
elapsed_ms as f32 / fade_ms as f32
|
||||
}
|
||||
}
|
||||
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
|
||||
fn fade_millis(seconds: f32) -> u64 {
|
||||
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
|
||||
}
|
||||
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
|
||||
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
|
||||
/// A new value for `Renderer::block_entity_revision`.
|
||||
fn next_block_entity_revision() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -209,6 +238,28 @@ struct SectionGpu {
|
||||
sorted_indices: Option<(u32, u64)>,
|
||||
/// Its id for flat draws (`flat::Flat::add`), when they are on.
|
||||
flat_id: Option<u32>,
|
||||
/// When it began to fade in over the far terrain it replaces, which
|
||||
/// stays whole behind it until then: a section new where far tiles are
|
||||
/// drawn. Meshed again, it stays as it was.
|
||||
fading: Option<std::time::Instant>,
|
||||
/// When it began to fade out, its column gone over far terrain that
|
||||
/// stays behind it (`Renderer::leave_section`); released once it has.
|
||||
leaving: Option<std::time::Instant>,
|
||||
}
|
||||
impl SectionGpu {
|
||||
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
|
||||
/// its column has left the render distance, far terrain is coming in
|
||||
/// under it (`lod::HELD`).
|
||||
fn fade(&self, now: std::time::Instant, retiring: bool) -> u32 {
|
||||
match self.leaving {
|
||||
Some(since) => lod::fade_out_code(since, 0, now),
|
||||
None if retiring => lod::HELD,
|
||||
None => self
|
||||
.fading
|
||||
.and_then(|since| lod::fade_code(Some(since), 0, now))
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Line segments the hit outline can hold.
|
||||
const MAX_OUTLINE_EDGES: u64 = 256;
|
||||
@@ -339,6 +390,11 @@ pub struct Frame<'a> {
|
||||
/// `Screen.extractBlurredBackground`: blur the frame by this radius
|
||||
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
|
||||
pub menu_blur: Option<f32>,
|
||||
/// `Options.chunkSectionFadeInTime`, in seconds: how long a section takes to fade in once
|
||||
/// uploaded (0 draws it whole at once).
|
||||
pub chunk_fade: f32,
|
||||
/// `GameRenderer.requestedPostEffects`: the chains that run over the world.
|
||||
pub post_effects: crate::post_chain::Request<'a>,
|
||||
}
|
||||
/// One frame of the world border wall (`WorldBorderRenderer.render`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -514,6 +570,8 @@ pub struct Renderer {
|
||||
lod_cutout: crate::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Removed sections of `lod_cutout` columns, released once tiles cover them.
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Removed sections fading out over far terrain (`leave_section`).
|
||||
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
|
||||
@@ -551,6 +609,9 @@ pub struct Renderer {
|
||||
shadow_runs: page_runs::PageRuns,
|
||||
/// Block-entity blocks per meshed section (world position, state id).
|
||||
block_entities: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
|
||||
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
|
||||
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
|
||||
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
|
||||
/// Changes whenever `block_entities` does (unique across renderers).
|
||||
block_entity_revision: u64,
|
||||
/// VisGraph face connectivity per meshed section (cave culling).
|
||||
@@ -586,10 +647,16 @@ pub struct Renderer {
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
/// `post_effect` chains, made on first use.
|
||||
post_effects: Option<crate::post_chain::PostEffects>,
|
||||
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
/// Consecutive frames whose surface acquisition failed validation. The
|
||||
/// first gets the swapchain a fresh configuration; later ones reattach
|
||||
/// the window, as a lost surface does.
|
||||
acquire_validation_failures: u32,
|
||||
pub drawn_sections: usize,
|
||||
/// Last frame's section draws (face ranges and translucent sections),
|
||||
/// and the draw calls the backend issued for them.
|
||||
@@ -792,9 +859,14 @@ fn mip_chain(
|
||||
/// pass is also what passes each draw's first instance to shaders reading
|
||||
/// `instance_index`, and DirectX 12 is the fallback, where working matters
|
||||
/// more than CPU time.
|
||||
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
|
||||
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
|
||||
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
|
||||
/// try at a backend that had no adapter at all.
|
||||
fn instance_descriptor(
|
||||
backends: wgpu::Backends,
|
||||
window: Option<&Arc<Window>>,
|
||||
allow_nonconformant: bool,
|
||||
) -> wgpu::InstanceDescriptor {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if let Some(window) = window {
|
||||
@@ -807,7 +879,13 @@ fn instance_descriptor(
|
||||
.flags
|
||||
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
|
||||
}
|
||||
descriptor.with_env()
|
||||
let mut descriptor = descriptor.with_env();
|
||||
if allow_nonconformant {
|
||||
descriptor
|
||||
.flags
|
||||
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
@@ -946,96 +1024,147 @@ async fn open_device(
|
||||
if attempt > 0 {
|
||||
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
|
||||
}
|
||||
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => {
|
||||
failures.push(format!("{name} surface: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => {
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
failures.push(format!("{name}: {e}"));
|
||||
continue;
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
|
||||
match driver.trim() {
|
||||
"" => "unnamed",
|
||||
driver => driver,
|
||||
},
|
||||
limits.max_buffer_size,
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => {
|
||||
return Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
});
|
||||
}
|
||||
Err(e) => failures.push(format!("{name} device: {e}")),
|
||||
match open_with_retry(backends, window, offscreen, layers).await {
|
||||
Ok(opened) => return Ok(opened),
|
||||
Err(e) => failures.push(e),
|
||||
}
|
||||
}
|
||||
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
|
||||
}
|
||||
/// Why a backend gave no device: no adapter at all, which a second try with the
|
||||
/// adapters wgpu hides can still find, or a failure after an adapter.
|
||||
enum NoDevice {
|
||||
NoAdapter(String),
|
||||
Failed(String),
|
||||
}
|
||||
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
|
||||
/// for reporting no Vulkan conformance version tries it again with them shown:
|
||||
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
|
||||
/// without this the player is told they have no graphics driver while Vulkan
|
||||
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
|
||||
/// all they get). Returns why the backend gave no device.
|
||||
async fn open_with_retry(
|
||||
backends: wgpu::Backends,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Opened, String> {
|
||||
match open_backend(backends, window, offscreen, false, layers).await {
|
||||
Err(NoDevice::NoAdapter(hidden)) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
|
||||
ones wgpu hides as non-conformant",
|
||||
backends_name(backends)
|
||||
);
|
||||
match open_backend(backends, window, offscreen, true, layers).await {
|
||||
Ok(opened) => {
|
||||
log::warn!(
|
||||
"Horizon renderer: using {} although its driver reports no Vulkan \
|
||||
conformance version",
|
||||
opened.adapter.get_info().name
|
||||
);
|
||||
Ok(opened)
|
||||
}
|
||||
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
Err(NoDevice::Failed(e)) => Err(e),
|
||||
Ok(opened) => Ok(opened),
|
||||
}
|
||||
}
|
||||
/// Opens a device on `backends` alone: its surface, adapter and device, or what
|
||||
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
|
||||
async fn open_backend(
|
||||
backends: wgpu::Backends,
|
||||
window: &Arc<Window>,
|
||||
offscreen: bool,
|
||||
allow_nonconformant: bool,
|
||||
layers: u32,
|
||||
) -> std::result::Result<Opened, NoDevice> {
|
||||
let name = backends_name(backends);
|
||||
let instance = wgpu::Instance::new(instance_descriptor(
|
||||
backends,
|
||||
Some(window),
|
||||
allow_nonconformant,
|
||||
));
|
||||
let surface = if offscreen {
|
||||
None
|
||||
} else {
|
||||
match instance.create_surface(window.clone()) {
|
||||
Ok(surface) => Some(surface),
|
||||
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
|
||||
}
|
||||
};
|
||||
let adapter = match instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
power_preference: wgpu::PowerPreference::HighPerformance,
|
||||
compatible_surface: surface.as_ref(),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(adapter) => adapter,
|
||||
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
|
||||
};
|
||||
describe_adapter(&adapter);
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
if let Err(e) = check_layers(&limits, layers) {
|
||||
return Err(NoDevice::Failed(format!("{name}: {e}")));
|
||||
}
|
||||
// Mappable primary buffers: section pages that no longer fit in
|
||||
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
|
||||
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
|
||||
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
|
||||
let driver = format!("{} {}", info.driver, info.driver_info);
|
||||
startup_step(format_args!(
|
||||
"requesting the device (driver {}) with features [{features}] and the adapter's \
|
||||
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
|
||||
match driver.trim() {
|
||||
"" => "unnamed",
|
||||
driver => driver,
|
||||
},
|
||||
limits.max_buffer_size,
|
||||
limits.max_storage_buffer_binding_size,
|
||||
limits.max_texture_array_layers,
|
||||
));
|
||||
let desc = wgpu::DeviceDescriptor {
|
||||
label: Some("Horizon device"),
|
||||
required_features: features,
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let opened = match info.backend {
|
||||
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
|
||||
_ => None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let opened = None;
|
||||
let opened = match opened {
|
||||
Some(opened) => opened,
|
||||
None => adapter.request_device(&desc).await,
|
||||
};
|
||||
match opened {
|
||||
Ok((device, queue)) => Ok(Opened {
|
||||
instance,
|
||||
surface,
|
||||
adapter,
|
||||
device,
|
||||
queue,
|
||||
}),
|
||||
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
|
||||
}
|
||||
}
|
||||
/// What to tell a player whose system offers no usable graphics adapter.
|
||||
/// Vulkan is loaded at run time (the game links no graphics library), so a
|
||||
/// missing driver shows here rather than as a missing-DLL dialog.
|
||||
@@ -2530,10 +2659,19 @@ impl Renderer {
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
})];
|
||||
let info = adapter.get_info();
|
||||
let min_sections = if flat::is_broken_intel_vulkan(&info)
|
||||
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
{
|
||||
0
|
||||
} else {
|
||||
flat::MIN_SECTIONS
|
||||
};
|
||||
flat::Flat::new(
|
||||
&device,
|
||||
&bindings,
|
||||
arena.page_vertices(),
|
||||
min_sections,
|
||||
|layout, label, fragment, blend| {
|
||||
pipeline_in(
|
||||
layout,
|
||||
@@ -2721,6 +2859,7 @@ impl Renderer {
|
||||
lods: HashMap::new(),
|
||||
lod_cutout: Default::default(),
|
||||
lod_retiring: Default::default(),
|
||||
lod_leaving: Vec::new(),
|
||||
lod_pending: None,
|
||||
detail_columns: Default::default(),
|
||||
detail_revision: 0,
|
||||
@@ -2744,6 +2883,7 @@ impl Renderer {
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
block_entities: Default::default(),
|
||||
section_uploaded: Default::default(),
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
layout: crate::chunk::ColumnLayout::VANILLA,
|
||||
@@ -2761,9 +2901,11 @@ impl Renderer {
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
post_effects: None,
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
acquire_validation_failures: 0,
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
@@ -3024,8 +3166,19 @@ impl Renderer {
|
||||
if !self.lod_retiring.is_empty() {
|
||||
self.lod_retiring.remove(&key);
|
||||
}
|
||||
let fading = match self.sections.find(key) {
|
||||
Some(old) => old.fading,
|
||||
None => (!self.lods.is_empty()).then(std::time::Instant::now),
|
||||
};
|
||||
if fading.is_none() {
|
||||
self.section_uploaded
|
||||
.entry(key)
|
||||
.or_insert_with(std::time::Instant::now);
|
||||
}
|
||||
self.release_section(key);
|
||||
let section = self.upload_mesh(mesh);
|
||||
let section = self
|
||||
.upload_mesh(mesh)
|
||||
.map(|section| SectionGpu { fading, ..section });
|
||||
self.set_detail(
|
||||
key,
|
||||
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
|
||||
@@ -3115,6 +3268,8 @@ impl Renderer {
|
||||
),
|
||||
sorted_indices,
|
||||
flat_id,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
})
|
||||
}
|
||||
/// The section copies recorded since the last submission, closed.
|
||||
@@ -3179,6 +3334,28 @@ impl Renderer {
|
||||
pub fn block_entities(&self) -> impl Iterator<Item = &([i32; 3], u32)> {
|
||||
self.block_entities.values().flatten()
|
||||
}
|
||||
/// `block_entities` of the sections far enough into their fade-in to draw them
|
||||
/// (`LevelExtractor.extractVisibleBlockEntities`: `getVisibility(now, fade) >= 0.3`).
|
||||
pub fn visible_block_entities(
|
||||
&self,
|
||||
fade_seconds: f32,
|
||||
) -> impl Iterator<Item = &([i32; 3], u32)> {
|
||||
let fade = fade_millis(fade_seconds);
|
||||
let now = std::time::Instant::now();
|
||||
self.block_entities
|
||||
.iter()
|
||||
.filter(move |(key, _)| {
|
||||
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
>= CHUNK_VISIBILITY_THRESHOLD
|
||||
})
|
||||
.flat_map(|(_, blocks)| blocks)
|
||||
}
|
||||
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
|
||||
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
|
||||
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
|
||||
now.saturating_duration_since(*t).as_millis() as u64
|
||||
})
|
||||
}
|
||||
/// A value that changes whenever `block_entities` gives something else.
|
||||
pub fn block_entity_revision(&self) -> u64 {
|
||||
self.block_entity_revision
|
||||
@@ -3186,11 +3363,13 @@ impl Renderer {
|
||||
/// Drops every section, for a level whose chunks hold `layout`.
|
||||
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
|
||||
self.block_entities.clear();
|
||||
self.section_uploaded.clear();
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
self.lods.clear();
|
||||
self.lod_cutout.clear();
|
||||
self.lod_retiring.clear();
|
||||
self.lod_leaving.clear();
|
||||
self.lod_pending = None;
|
||||
self.detail_columns.clear();
|
||||
self.detail_revision += 1;
|
||||
@@ -3249,6 +3428,40 @@ impl Renderer {
|
||||
/// Every section of these columns goes: one lookup per column where the
|
||||
/// tables are by column, and the meshes' ranges freed in one batch. A
|
||||
/// teleport removes a few thousand columns in one frame.
|
||||
/// A section whose column far terrain now draws fades out over it, chunk by
|
||||
/// chunk as it came, and is released once it has (`release_left`): taken
|
||||
/// away at once, the column's detail dropped to the far terrain's in one
|
||||
/// frame.
|
||||
fn leave_section(&mut self, key: SectionKey) {
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(section) = self.sections.find_mut(key)
|
||||
&& section.leaving.is_none()
|
||||
{
|
||||
section.leaving = Some(now);
|
||||
self.lod_leaving.push((key, now));
|
||||
}
|
||||
}
|
||||
/// Releases the sections that have faded out (`leave_section`).
|
||||
fn release_left(&mut self, now: std::time::Instant) {
|
||||
let mut done = Vec::new();
|
||||
self.lod_leaving.retain(|&(key, since)| {
|
||||
let left = now.saturating_duration_since(since) >= lod::FADE;
|
||||
if left {
|
||||
done.push((key, since));
|
||||
}
|
||||
!left
|
||||
});
|
||||
for (key, since) in done {
|
||||
// Meshed again since (the player came back): it stays.
|
||||
if self
|
||||
.sections
|
||||
.find(key)
|
||||
.is_some_and(|s| s.leaving == Some(since))
|
||||
{
|
||||
self.release_section(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
|
||||
let mut released = Vec::new();
|
||||
let mut block_entities = false;
|
||||
@@ -3261,9 +3474,13 @@ impl Renderer {
|
||||
for sy in 0..self.layout.sections() as i32 {
|
||||
let key = (x, sy, z);
|
||||
block_entities |= self.block_entities.remove(&key).is_some();
|
||||
self.section_uploaded.remove(&key);
|
||||
if retiring {
|
||||
// Distant tiles still leave its column out: drawn until they don't.
|
||||
self.lod_retiring.insert(key);
|
||||
} else if !self.lods.is_empty() && self.sections.contains(key) {
|
||||
// Far terrain draws the column: it fades out over it.
|
||||
self.leave_section(key);
|
||||
} else if let Some(section) = self.sections.remove(key) {
|
||||
released.push(section);
|
||||
}
|
||||
@@ -3765,9 +3982,32 @@ impl Renderer {
|
||||
return Ok(false);
|
||||
}
|
||||
Some(wgpu::CurrentSurfaceTexture::Validation) => {
|
||||
return Err("surface acquisition failed validation".into());
|
||||
// Acquire caught a validation error: the swapchain cannot be
|
||||
// used as configured (wgpu reports a configuration raced with
|
||||
// a submission this way). Give it a fresh configuration like
|
||||
// an Outdated one, and once that has not helped, reattach the
|
||||
// window like a Lost one — a fullscreen loading screen then
|
||||
// skips frames instead of failing every one of them.
|
||||
if self.acquire_validation_failures == 0 {
|
||||
if let Some(surface) = &self.surface {
|
||||
Self::configure(
|
||||
&self.device,
|
||||
surface,
|
||||
&self.surface_config(),
|
||||
self.hdr.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.recreate_surface()?;
|
||||
}
|
||||
self.acquire_validation_failures =
|
||||
self.acquire_validation_failures.saturating_add(1);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
if surface_frame.is_some() {
|
||||
self.acquire_validation_failures = 0;
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
@@ -3960,6 +4200,13 @@ impl Renderer {
|
||||
}
|
||||
let reachable = &self.occlusion;
|
||||
let cutout = &self.lod_cutout;
|
||||
// Sections fading out (`leave_section`) are past the render distance:
|
||||
// drawn there until they have, or they popped out at once.
|
||||
let leaving: crate::fast_hash::FxHashSet<(i32, i32)> = self
|
||||
.lod_leaving
|
||||
.iter()
|
||||
.map(|&((x, _, z), _)| (x, z))
|
||||
.collect();
|
||||
let mut visible = std::mem::take(&mut self.visible);
|
||||
visible.clear();
|
||||
crate::spans::time("visibility", || {
|
||||
@@ -3968,10 +4215,14 @@ impl Renderer {
|
||||
(frame.detail_distance / 16.0) as i32,
|
||||
&view,
|
||||
|k| !culling || reachable.contains(k),
|
||||
|x, z| !cutout.is_empty() && cutout.contains(&(x, z)),
|
||||
|x, z| {
|
||||
(!cutout.is_empty() && cutout.contains(&(x, z)))
|
||||
|| (!leaving.is_empty() && leaving.contains(&(x, z)))
|
||||
},
|
||||
&mut visible,
|
||||
)
|
||||
});
|
||||
let now = std::time::Instant::now();
|
||||
let mut visible_lods = std::mem::take(&mut self.visible_lods);
|
||||
visible_lods.clear();
|
||||
if frame.title {
|
||||
@@ -3980,7 +4231,7 @@ impl Renderer {
|
||||
visible_lods.extend(
|
||||
self.lods
|
||||
.iter()
|
||||
.filter_map(|(&key, tile)| tile.visible(key, camera, frame.distance, &view)),
|
||||
.filter_map(|(&key, tile)| tile.visible(key, camera, &view, now)),
|
||||
);
|
||||
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
|
||||
}
|
||||
@@ -4033,12 +4284,34 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
self.origins.clear();
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
let uploaded = &self.section_uploaded;
|
||||
let visibility = |key: SectionKey| {
|
||||
if fade_ms == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
uploaded.get(&key).map_or(1.0, |t| {
|
||||
chunk_visibility(
|
||||
now.saturating_duration_since(*t).as_millis() as u64,
|
||||
fade_ms,
|
||||
)
|
||||
})
|
||||
};
|
||||
self.origins.extend(
|
||||
visible
|
||||
.iter()
|
||||
.map(|v| section_instance(v.origin))
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
|
||||
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
|
||||
.map(|v| {
|
||||
let code = sections.get(v.slot).fade(now, retiring.contains(&v.key));
|
||||
section_instance(v.origin, code, visibility(v.key))
|
||||
})
|
||||
.chain(visible_lods.iter().map(|v| lod::instance(v.origin, v.fade)))
|
||||
.chain(
|
||||
shadow_visible
|
||||
.iter()
|
||||
.map(|v| section_instance(v.origin, 0, visibility(v.key))),
|
||||
),
|
||||
);
|
||||
if !self.origins.is_empty() {
|
||||
self.uploads.buffer(
|
||||
@@ -4129,7 +4402,7 @@ impl Renderer {
|
||||
let tiles = self.draws.len();
|
||||
self.solid_runs.clear();
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let s = &self.lods[&v.key].mesh;
|
||||
let s = self.lods[&v.key].drawn();
|
||||
let start = self.draws.len();
|
||||
let instance = (visible.len() + i) as u32;
|
||||
facing_runs(&s.sides, eye, v.origin, |first, quads| {
|
||||
@@ -4198,7 +4471,7 @@ impl Renderer {
|
||||
for (i, v) in visible_lods.iter().enumerate() {
|
||||
let instance = visible.len() + i;
|
||||
let tile = self.lods.get_mut(&v.key).expect("visible resident tile");
|
||||
let s = &mut tile.mesh;
|
||||
let s = tile.drawn_mut();
|
||||
if let Some((index_page, index_start)) = s.sorted_indices {
|
||||
let scheduled = (instance + count - self.translucency_cursor) % count < budget;
|
||||
if (!s.sort.is_sorted() || scheduled)
|
||||
@@ -4232,8 +4505,26 @@ impl Renderer {
|
||||
}
|
||||
// Near water and distant water share one back-to-front draw order.
|
||||
if self.translucent_draws.len() > section_translucent {
|
||||
// Distant water goes after the near chunks', and replaced tiles'
|
||||
// after all: each is a hair behind the one before, so depth leaves
|
||||
// it only where that one has none. Back to front together, a far
|
||||
// tile's water under a near chunk's (one fading in, or brought
|
||||
// into view by a longer render distance) blended twice and looked
|
||||
// opaque. The cost: far water just behind near water shows no
|
||||
// surface of its own there.
|
||||
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
|
||||
None => 0,
|
||||
Some(tile) if visible_lods[tile].old => 2,
|
||||
Some(_) => 1,
|
||||
};
|
||||
self.translucent_draws
|
||||
.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
|
||||
.sort_unstable_by_key(|&(distance, instance, ..)| {
|
||||
(
|
||||
tier(instance),
|
||||
std::cmp::Reverse(distance),
|
||||
std::cmp::Reverse(instance),
|
||||
)
|
||||
});
|
||||
}
|
||||
for &(_, _, draw, pages) in &self.translucent_draws {
|
||||
self.draws.push(draw);
|
||||
@@ -4605,7 +4896,7 @@ impl Renderer {
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
let sections = &self.sections;
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
let lods = &self.lods;
|
||||
let sorted = &self.sorted_indices;
|
||||
let phase2 = self.cull.as_ref().filter(|_| culling).map(|c| &c.phase2);
|
||||
@@ -4624,12 +4915,16 @@ impl Renderer {
|
||||
.iter()
|
||||
.chain(&shadow_visible)
|
||||
.filter_map(|v| {
|
||||
let id = sections.get(v.slot).flat_id?;
|
||||
Some((id, section_instance(v.origin)))
|
||||
let section = sections.get(v.slot);
|
||||
let code = section.fade(now, retiring.contains(&v.key));
|
||||
Some((
|
||||
section.flat_id?,
|
||||
section_instance(v.origin, code, visibility(v.key)),
|
||||
))
|
||||
})
|
||||
.chain(visible_lods.iter().filter_map(|v| {
|
||||
let id = lods[&v.key].mesh.flat_id?;
|
||||
Some((id, lod::instance(v.origin)))
|
||||
let id = lods[&v.key].drawn().flat_id?;
|
||||
Some((id, lod::instance(v.origin, v.fade)))
|
||||
})),
|
||||
|page| sorted.buffer(page).clone(),
|
||||
)
|
||||
@@ -5321,6 +5616,26 @@ impl Renderer {
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
|
||||
if !frame.title
|
||||
&& !frame.post_effects.effects.is_empty()
|
||||
&& target_texture
|
||||
.usage()
|
||||
.contains(wgpu::TextureUsages::COPY_SRC)
|
||||
{
|
||||
let device = &self.device;
|
||||
self.post_effects
|
||||
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
|
||||
.apply(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
&frame.post_effects,
|
||||
);
|
||||
}
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5535,7 +5850,7 @@ fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
|
||||
fn copy_frame(
|
||||
pub(crate) fn copy_frame(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
@@ -5947,6 +6262,23 @@ mod tests {
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
/// The second try at a backend shows the adapters wgpu hides for reporting
|
||||
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
|
||||
/// Haswell, reports 0.0.0.0 and works).
|
||||
#[test]
|
||||
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
|
||||
let shown = |allow| {
|
||||
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
|
||||
};
|
||||
assert!(shown(true));
|
||||
// The first try keeps wgpu's own filtering, unless `with_env` turns the
|
||||
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
|
||||
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
|
||||
assert!(!shown(false));
|
||||
}
|
||||
}
|
||||
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
|
||||
/// writes it for FXC (shader model 5.1), entry point by entry point.
|
||||
#[test]
|
||||
@@ -6021,6 +6353,36 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn chunk_fade_is_vanillas_get_visibility() {
|
||||
use super::{chunk_visibility, fade_millis};
|
||||
assert_eq!(fade_millis(0.75), 750);
|
||||
assert_eq!(fade_millis(0.0), 0);
|
||||
assert_eq!(chunk_visibility(0, 750), 0.0_f32);
|
||||
assert_eq!(chunk_visibility(375, 750), 0.5);
|
||||
assert_eq!(chunk_visibility(750, 750), 1.0);
|
||||
assert_eq!(chunk_visibility(0, 0), 1.0, "Off: whole at once");
|
||||
assert_eq!(chunk_visibility(u64::MAX, 750), 1.0);
|
||||
}
|
||||
#[test]
|
||||
fn section_instance_carries_the_fade_in_the_step() {
|
||||
use super::section_instance;
|
||||
let step = 1.0 / crate::mesher::UV_STEPS;
|
||||
// Fully visible (the option Off) is exactly the record from before the fade.
|
||||
assert_eq!(
|
||||
section_instance([1.0, 2.0, 3.0], 0, 1.0),
|
||||
[1.0, 2.0, 3.0, step]
|
||||
);
|
||||
for visibility in [0.0_f32, 0.25, 0.5, 0.999] {
|
||||
let w = section_instance([0.0; 3], 0, visibility)[3];
|
||||
let bits = w.to_bits();
|
||||
assert_eq!(f32::from_bits(bits & 0xff80_0000), step);
|
||||
let read = 1.0 - (bits & 0x7f_ffff) as f32 / 8_388_608.0;
|
||||
assert!((read - visibility).abs() < 1e-6, "{visibility}: {read}");
|
||||
}
|
||||
// A section fading in over far terrain keeps its dither code unchanged.
|
||||
assert_eq!(section_instance([0.0; 3], 7, 0.5)[3], -(7.0 + step));
|
||||
}
|
||||
#[test]
|
||||
fn terrain_attributes_follow_the_terrain_vertex() {
|
||||
use super::TerrainVertex;
|
||||
use std::mem::offset_of;
|
||||
@@ -6102,7 +6464,7 @@ mod tests {
|
||||
#[test]
|
||||
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
|
||||
let validated = |backends| {
|
||||
super::instance_descriptor(backends, None)
|
||||
super::instance_descriptor(backends, None, false)
|
||||
.flags
|
||||
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
|
||||
};
|
||||
|
||||
@@ -147,7 +147,7 @@ impl SpriteAnimator {
|
||||
group,
|
||||
});
|
||||
}
|
||||
let Some(pipeline) = pipeline else {
|
||||
let Some((pipeline, layout)) = pipeline else {
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -193,7 +193,6 @@ impl SpriteAnimator {
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let groups = keys
|
||||
.iter()
|
||||
.zip(&counts)
|
||||
@@ -364,11 +363,15 @@ impl SpriteAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
|
||||
/// driver can list compute shaders among its extensions while wgpu writes
|
||||
/// the GLSL version of its core version, which has none (Mesa's
|
||||
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
|
||||
/// unless the device fails to make the pipeline: an OpenGL driver can list
|
||||
/// compute shaders among its extensions while wgpu writes the GLSL version
|
||||
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
|
||||
/// older drivers). A device lost between the scopes' pushes answers pipeline
|
||||
/// creation with a silent placeholder and reports its invalidity only when
|
||||
/// the layout is asked for — inside the scopes that error is caught, outside
|
||||
/// them it is uncaptured and panics (#3866).
|
||||
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => Some(pipeline),
|
||||
None => Some((pipeline, layout)),
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
|
||||
None
|
||||
|
||||
@@ -115,7 +115,7 @@ pub struct Flat {
|
||||
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
|
||||
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
|
||||
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
|
||||
const MIN_SECTIONS: usize = 1600;
|
||||
pub const MIN_SECTIONS: usize = 1600;
|
||||
|
||||
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
|
||||
/// shaders can read storage buffers large enough for an index page.
|
||||
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
Ok("1") => return supported(adapter, device),
|
||||
_ => {}
|
||||
}
|
||||
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
|
||||
let info = adapter.get_info();
|
||||
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
|
||||
&& supported(adapter, device)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
|
||||
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
|
||||
/// and flicker.
|
||||
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Vulkan
|
||||
&& info.vendor == 0x8086
|
||||
&& (info.name.contains("ICL")
|
||||
|| info.name.contains("Ice Lake")
|
||||
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
|
||||
}
|
||||
|
||||
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
@@ -254,6 +267,7 @@ impl Flat {
|
||||
device: &wgpu::Device,
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
min_sections: usize,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -357,11 +371,7 @@ impl Flat {
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
shadow_calls: [0; 2],
|
||||
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
|
||||
0
|
||||
} else {
|
||||
MIN_SECTIONS
|
||||
},
|
||||
min_sections,
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
.max(1),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow; the interpolation and the setting
|
||||
//! that runs them come next.
|
||||
|
||||
// Unused until the interpolation and the renderer use it.
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
@@ -0,0 +1,653 @@
|
||||
// This file is part of the FidelityFX SDK.
|
||||
//
|
||||
// Copyright (C) 2024 Advanced Micro Devices, Inc.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files(the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions :
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to
|
||||
//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this
|
||||
//! one's, and scene change detection, for frame interpolation (`interpolation.rs`).
|
||||
//!
|
||||
//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged
|
||||
//! between frames; a histogram of the luma for scene change detection; then from the
|
||||
//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and
|
||||
//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma
|
||||
//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup
|
||||
//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD).
|
||||
//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a
|
||||
//! scene change and no motion, and rewrite them.
|
||||
//!
|
||||
//! The vectors point from a block of this frame to where it was in the previous one, in
|
||||
//! display pixels: content moving right by 4 pixels gives (-4, 0).
|
||||
|
||||
/// The size in pixels of the square blocks the flow has one vector for.
|
||||
pub const BLOCK_SIZE: u32 = 8;
|
||||
/// `OpticalFlowMaxPyramidLevels`: the search's levels.
|
||||
const LEVELS: usize = 7;
|
||||
/// `HistogramBins * HistogramsPerDim²`: the scene change histograms.
|
||||
const HISTOGRAM_WIDTH: u64 = 256 * 9;
|
||||
|
||||
/// `optical_flow.wgsl`'s `Params`.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Params {
|
||||
display: [i32; 2],
|
||||
level: u32,
|
||||
level_count: u32,
|
||||
frame_index: u32,
|
||||
transfer_function: u32,
|
||||
min_max_luminance: [f32; 2],
|
||||
}
|
||||
|
||||
struct Pipelines {
|
||||
prepare_luma: wgpu::ComputePipeline,
|
||||
luma_pyramid: wgpu::ComputePipeline,
|
||||
scd_histogram: wgpu::ComputePipeline,
|
||||
scd_divergence: wgpu::ComputePipeline,
|
||||
search: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
scale: wgpu::ComputePipeline,
|
||||
}
|
||||
|
||||
/// One frame parity's bindings: which luma pyramid is this frame's.
|
||||
struct Frame {
|
||||
/// This frame's luma at the display size, where `prepare_luma` writes.
|
||||
luma0: wgpu::TextureView,
|
||||
luma_pyramid: [wgpu::BindGroup; LEVELS - 1],
|
||||
scd_histogram: wgpu::BindGroup,
|
||||
search: [wgpu::BindGroup; LEVELS],
|
||||
scale: [wgpu::BindGroup; LEVELS - 1],
|
||||
}
|
||||
|
||||
pub struct OpticalFlow {
|
||||
display: [u32; 2],
|
||||
pipelines: Pipelines,
|
||||
/// One per level, the level's constants (the passes outside the search use level 0's).
|
||||
params: Vec<wgpu::Buffer>,
|
||||
/// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1.
|
||||
frames: [Frame; 2],
|
||||
filter: [wgpu::BindGroup; LEVELS],
|
||||
scd_divergence: wgpu::BindGroup,
|
||||
scd_histogram: wgpu::Buffer,
|
||||
scd_previous_histogram: wgpu::Buffer,
|
||||
scd_temp: wgpu::Buffer,
|
||||
scd_output: wgpu::Buffer,
|
||||
vectors: wgpu::TextureView,
|
||||
flow_sizes: [[u32; 2]; LEVELS],
|
||||
/// `resourceFrameIndex` and `frameIndex`.
|
||||
resource_frame: u32,
|
||||
frame_index: u32,
|
||||
first: bool,
|
||||
}
|
||||
|
||||
fn texture(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bind `entries` (binding number, resource) for `pipeline`'s own layout.
|
||||
fn bind(
|
||||
device: &wgpu::Device,
|
||||
pipeline: &wgpu::ComputePipeline,
|
||||
entries: &[(u32, wgpu::BindingResource)],
|
||||
) -> wgpu::BindGroup {
|
||||
let entries: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(binding, resource)| wgpu::BindGroupEntry {
|
||||
binding: *binding,
|
||||
resource: resource.clone(),
|
||||
})
|
||||
.collect();
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Optical flow"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
|
||||
wgpu::BindingResource::TextureView(v)
|
||||
}
|
||||
|
||||
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
|
||||
b.as_entire_binding()
|
||||
}
|
||||
|
||||
impl OpticalFlow {
|
||||
/// For frames of `display` size.
|
||||
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Optical flow"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()),
|
||||
});
|
||||
let pipeline = |entry: &str| {
|
||||
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipelines = Pipelines {
|
||||
prepare_luma: pipeline("prepare_luma"),
|
||||
luma_pyramid: pipeline("luma_pyramid"),
|
||||
scd_histogram: pipeline("scd_histogram_pass"),
|
||||
scd_divergence: pipeline("scd_divergence_pass"),
|
||||
search: pipeline("search_pass"),
|
||||
filter: pipeline("filter_flow"),
|
||||
scale: pipeline("scale_pass"),
|
||||
};
|
||||
let params: Vec<_> = (0..LEVELS)
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Optical flow constants"),
|
||||
size: std::mem::size_of::<Params>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let luma: [Vec<wgpu::TextureView>; 2] = [0, 1].map(|_| {
|
||||
(0..LEVELS)
|
||||
.map(|k| {
|
||||
let size = display.map(|v| (v >> k).max(1));
|
||||
texture(
|
||||
device,
|
||||
"Optical flow luma",
|
||||
size,
|
||||
wgpu::TextureFormat::R32Float,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut flow_sizes = [[0; 2]; LEVELS];
|
||||
flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE));
|
||||
for l in 1..LEVELS {
|
||||
flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2));
|
||||
}
|
||||
let flow = |label| -> Vec<wgpu::TextureView> {
|
||||
flow_sizes
|
||||
.iter()
|
||||
.map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint))
|
||||
.collect()
|
||||
};
|
||||
// Per level: the scaled prediction from the level above, the search's vectors and
|
||||
// the filtered ones (level 0's are the output).
|
||||
let (predicted, searched, filtered) = (
|
||||
flow("Optical flow prediction"),
|
||||
flow("Optical flow search"),
|
||||
flow("Optical flow"),
|
||||
);
|
||||
let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4);
|
||||
let scd_previous_histogram = buffer(
|
||||
device,
|
||||
"Scene change previous histogram",
|
||||
HISTOGRAM_WIDTH * 4,
|
||||
);
|
||||
let scd_temp = buffer(device, "Scene change temp", 12);
|
||||
let scd_output = buffer(device, "Scene change detection", 12);
|
||||
let frame = |current: usize| {
|
||||
let (cur, prev) = (&luma[current], &luma[1 - current]);
|
||||
Frame {
|
||||
luma0: cur[0].clone(),
|
||||
luma_pyramid: std::array::from_fn(|k| {
|
||||
bind(
|
||||
device,
|
||||
&pipelines.luma_pyramid,
|
||||
&[(2, view(&cur[k + 1])), (3, view(&cur[k]))],
|
||||
)
|
||||
}),
|
||||
scd_histogram: bind(
|
||||
device,
|
||||
&pipelines.scd_histogram,
|
||||
&[
|
||||
(0, whole(¶ms[0])),
|
||||
(3, view(&cur[0])),
|
||||
(7, whole(&scd_histogram)),
|
||||
],
|
||||
),
|
||||
search: std::array::from_fn(|l| {
|
||||
bind(
|
||||
device,
|
||||
&pipelines.search,
|
||||
&[
|
||||
(0, whole(¶ms[l])),
|
||||
(3, view(&cur[l])),
|
||||
(4, view(&prev[l])),
|
||||
(5, view(&predicted[l])),
|
||||
(6, view(&searched[l])),
|
||||
(10, whole(&scd_output)),
|
||||
],
|
||||
)
|
||||
}),
|
||||
scale: std::array::from_fn(|i| {
|
||||
let l = i + 1;
|
||||
bind(
|
||||
device,
|
||||
&pipelines.scale,
|
||||
&[
|
||||
(0, whole(¶ms[l])),
|
||||
(3, view(&cur[l])),
|
||||
(4, view(&prev[l])),
|
||||
(5, view(&filtered[l])),
|
||||
(6, view(&predicted[l - 1])),
|
||||
(10, whole(&scd_output)),
|
||||
],
|
||||
)
|
||||
}),
|
||||
}
|
||||
};
|
||||
let frames = [frame(0), frame(1)];
|
||||
let filter = std::array::from_fn(|l| {
|
||||
bind(
|
||||
device,
|
||||
&pipelines.filter,
|
||||
&[(5, view(&searched[l])), (6, view(&filtered[l]))],
|
||||
)
|
||||
});
|
||||
let scd_divergence = bind(
|
||||
device,
|
||||
&pipelines.scd_divergence,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(10, whole(&scd_output)),
|
||||
],
|
||||
);
|
||||
Self {
|
||||
display,
|
||||
pipelines,
|
||||
params,
|
||||
frames,
|
||||
filter,
|
||||
scd_divergence,
|
||||
scd_histogram,
|
||||
scd_previous_histogram,
|
||||
scd_temp,
|
||||
scd_output,
|
||||
vectors: filtered[0].clone(),
|
||||
flow_sizes,
|
||||
resource_frame: 0,
|
||||
frame_index: 0,
|
||||
first: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The flow from the previous call's `color` to this one's. `color`: `display` size,
|
||||
/// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's
|
||||
/// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first
|
||||
/// frame, resize, teleport).
|
||||
pub fn dispatch(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
reset: bool,
|
||||
) {
|
||||
let reset = reset || self.first;
|
||||
self.first = false;
|
||||
self.frame_index = if reset { 0 } else { self.frame_index + 1 };
|
||||
if reset {
|
||||
for b in [
|
||||
&self.scd_temp,
|
||||
&self.scd_output,
|
||||
&self.scd_histogram,
|
||||
&self.scd_previous_histogram,
|
||||
] {
|
||||
encoder.clear_buffer(b, 0, None);
|
||||
}
|
||||
}
|
||||
for (level, buffer) in self.params.iter().enumerate() {
|
||||
let params = Params {
|
||||
display: self.display.map(|v| v as i32),
|
||||
level: level as u32,
|
||||
level_count: LEVELS as u32,
|
||||
frame_index: self.frame_index,
|
||||
transfer_function: 0,
|
||||
min_max_luminance: [0.0, 1.0],
|
||||
};
|
||||
queue.write_buffer(buffer, 0, bytemuck::bytes_of(¶ms));
|
||||
}
|
||||
let frame = &self.frames[(self.resource_frame & 1) as usize];
|
||||
let prepare = bind(
|
||||
device,
|
||||
&self.pipelines.prepare_luma,
|
||||
&[
|
||||
(0, whole(&self.params[0])),
|
||||
(1, view(color)),
|
||||
(2, view(&frame.luma0)),
|
||||
],
|
||||
);
|
||||
let p = &self.pipelines;
|
||||
let [w, h] = self.display;
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Optical flow"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(x, y, z);
|
||||
};
|
||||
run(
|
||||
&p.prepare_luma,
|
||||
&prepare,
|
||||
(w.div_ceil(2)).div_ceil(16),
|
||||
(h.div_ceil(2)).div_ceil(16),
|
||||
1,
|
||||
);
|
||||
for (k, group) in frame.luma_pyramid.iter().enumerate() {
|
||||
let size = self.display.map(|v| (v >> (k + 1)).max(1));
|
||||
run(
|
||||
&p.luma_pyramid,
|
||||
group,
|
||||
size[0].div_ceil(8),
|
||||
size[1].div_ceil(8),
|
||||
1,
|
||||
);
|
||||
}
|
||||
run(
|
||||
&p.scd_histogram,
|
||||
&frame.scd_histogram,
|
||||
((w / 4) / 3).div_ceil(32),
|
||||
16,
|
||||
9,
|
||||
);
|
||||
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
|
||||
for level in (0..LEVELS).rev() {
|
||||
let luma = self.display.map(|v| (v >> level).max(1));
|
||||
run(
|
||||
&p.search,
|
||||
&frame.search[level],
|
||||
(luma[0].div_ceil(4) * 16).div_ceil(64),
|
||||
luma[1].div_ceil(16),
|
||||
1,
|
||||
);
|
||||
let size = self.flow_sizes[level];
|
||||
run(
|
||||
&p.filter,
|
||||
&self.filter[level],
|
||||
size[0].div_ceil(16),
|
||||
size[1].div_ceil(4),
|
||||
1,
|
||||
);
|
||||
if level > 0 {
|
||||
let next = self.flow_sizes[level - 1];
|
||||
run(
|
||||
&p.scale,
|
||||
&frame.scale[level - 1],
|
||||
next[0].div_ceil(4),
|
||||
next[1].div_ceil(4),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(pass);
|
||||
self.resource_frame = (self.resource_frame + 1) % 16;
|
||||
}
|
||||
|
||||
/// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size
|
||||
/// over `BLOCK_SIZE` rounded up,
|
||||
/// holding the values FSR's `R16G16_SINT` optical flow vector texture holds.
|
||||
pub fn vectors(&self) -> &wgpu::TextureView {
|
||||
&self.vectors
|
||||
}
|
||||
|
||||
/// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage
|
||||
/// buffer of 3 `u32`: element `i` holds texel `i`'s value.
|
||||
pub fn scene_change(&self) -> &wgpu::Buffer {
|
||||
&self.scd_output
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
|
||||
/// without a GPU.
|
||||
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`.
|
||||
/// A `seed` of 100 or more gives cells of one primary each.
|
||||
pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec<u8> {
|
||||
let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
|
||||
for y in 0..size[1] as i32 {
|
||||
for x in 0..size[0] as i32 {
|
||||
let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4));
|
||||
let mut h = (cx as u32).wrapping_mul(0x9e37_79b9)
|
||||
^ (cy as u32).wrapping_mul(0x85eb_ca6b)
|
||||
^ seed.wrapping_mul(0xc2b2_ae35);
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x2c1b_3c6d);
|
||||
h ^= h >> 12;
|
||||
let v = (h & 0xff) as u8;
|
||||
if seed >= 100 {
|
||||
// Primaries: no two neighbours' colours point the same way.
|
||||
let mut c = [0, 0, 0, 255];
|
||||
c[(h >> 8) as usize % 3] = 128 + v / 2;
|
||||
pixels.extend_from_slice(&c);
|
||||
} else {
|
||||
pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
pixels
|
||||
}
|
||||
|
||||
pub fn upload(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
pixels: &[u8],
|
||||
) -> wgpu::TextureView {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
pixels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
texture.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// `texture`'s texels (`texel` bytes each), row by row.
|
||||
pub fn read(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
texture: &wgpu::Texture,
|
||||
texel: u32,
|
||||
) -> Vec<u8> {
|
||||
let size = texture.size();
|
||||
let row = (size.width * texel).next_multiple_of(256);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(row * size.height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
size,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = buffer.slice(..).get_mapped_range().unwrap();
|
||||
mapped
|
||||
.chunks(row as usize)
|
||||
.flat_map(|r| r[..(size.width * texel) as usize].to_vec())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
flow: &mut OpticalFlow,
|
||||
color: &wgpu::TextureView,
|
||||
reset: bool,
|
||||
) {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
flow.dispatch(device, queue, &mut encoder, color, reset);
|
||||
queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> {
|
||||
read(device, queue, flow.vectors.texture(), 8)
|
||||
.chunks(8)
|
||||
.map(|c| {
|
||||
[
|
||||
i32::from_le_bytes(c[0..4].try_into().unwrap()),
|
||||
i32::from_le_bytes(c[4..8].try_into().unwrap()),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
|
||||
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
|
||||
#[test]
|
||||
fn finds_a_shift_and_a_scene_change() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = [512, 288];
|
||||
let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0]));
|
||||
let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8]));
|
||||
// Scene changes are told by the luma's histogram: a much darker frame.
|
||||
let dark: Vec<u8> = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect();
|
||||
let other = upload(&device, &queue, size, &dark);
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
// The first 6 frames after a reset are scene changes.
|
||||
run(&device, &queue, &mut flow, &still, true);
|
||||
for _ in 0..6 {
|
||||
run(&device, &queue, &mut flow, &still, false);
|
||||
}
|
||||
run(&device, &queue, &mut flow, &moved, false);
|
||||
let [w, h] = flow.flow_sizes[0];
|
||||
let found = vectors(&device, &queue, &flow);
|
||||
let inside: Vec<_> = (4..h - 4)
|
||||
.flat_map(|y| (4..w - 4).map(move |x| (x, y)))
|
||||
.map(|(x, y)| found[(y * w + x) as usize])
|
||||
.collect();
|
||||
let right = inside.iter().filter(|v| **v == [-16, -8]).count();
|
||||
assert!(
|
||||
right * 10 >= inside.len() * 9,
|
||||
"{right} of {} blocks: {:?}",
|
||||
inside.len(),
|
||||
&inside[..8]
|
||||
);
|
||||
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
};
|
||||
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
|
||||
run(&device, &queue, &mut flow, &other, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
// This file is part of the FidelityFX SDK.
|
||||
//
|
||||
// Copyright (C) 2024 Advanced Micro Devices, Inc.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files(the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions :
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL
|
||||
// for Horizon. What differs from AMD's (optical_flow.rs lists why):
|
||||
// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers;
|
||||
// - the luma pyramid is one pass per level instead of SPD's single pass;
|
||||
// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a
|
||||
// wave's lockstep have barriers;
|
||||
// - the scene change textures are storage buffers;
|
||||
// - the search reads its prediction from one texture and writes another (rg32sint can't
|
||||
// be read_write everywhere).
|
||||
|
||||
struct Params {
|
||||
// Display size (cbOF.iInputLumaResolution).
|
||||
display: vec2<i32>,
|
||||
level: u32,
|
||||
level_count: u32,
|
||||
frame_index: u32,
|
||||
transfer_function: u32,
|
||||
min_max_luminance: vec2<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> params: Params;
|
||||
@group(0) @binding(1) var input_color: texture_2d<f32>;
|
||||
@group(0) @binding(2) var luma_out: texture_storage_2d<r32float, write>;
|
||||
// The current frame's luma at this level, and the previous frame's.
|
||||
@group(0) @binding(3) var luma_in: texture_2d<f32>;
|
||||
@group(0) @binding(4) var luma_previous: texture_2d<f32>;
|
||||
// Search: the prediction from the level above; filter: the search; scale: the filtered.
|
||||
@group(0) @binding(5) var flow_in: texture_2d<i32>;
|
||||
@group(0) @binding(6) var flow_out: texture_storage_2d<rg32sint, write>;
|
||||
@group(0) @binding(7) var<storage, read_write> scd_histogram: array<atomic<u32>>;
|
||||
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
|
||||
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
|
||||
|
||||
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
|
||||
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
|
||||
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
|
||||
|
||||
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let size = vec2<i32>(textureDimensions(t));
|
||||
if any(p < vec2(0)) || any(p >= size) {
|
||||
return 0u;
|
||||
}
|
||||
return u32(textureLoad(t, p, 0).x);
|
||||
}
|
||||
|
||||
fn load_flow(p: vec2<i32>) -> vec2<i32> {
|
||||
let size = vec2<i32>(textureDimensions(flow_in));
|
||||
if any(p < vec2(0)) || any(p >= size) {
|
||||
return vec2(0);
|
||||
}
|
||||
return textureLoad(flow_in, p, 0).xy;
|
||||
}
|
||||
|
||||
fn store_flow(p: vec2<i32>, v: vec2<i32>) {
|
||||
let size = vec2<i32>(textureDimensions(flow_out));
|
||||
if all(p >= vec2(0)) && all(p < size) {
|
||||
textureStore(flow_out, p, vec4(v, 0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 {
|
||||
var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u);
|
||||
if x < 0 {
|
||||
let filler = packed & 0xffu;
|
||||
if x <= -1 {
|
||||
packed = (packed << 8u) | filler;
|
||||
}
|
||||
if x <= -2 {
|
||||
packed = (packed << 8u) | filler;
|
||||
}
|
||||
if x <= -3 {
|
||||
packed = (packed << 8u) | filler;
|
||||
}
|
||||
} else if x > width - 4 {
|
||||
let filler = packed & 0xff000000u;
|
||||
if x >= width - 3 {
|
||||
packed = (packed >> 8u) | filler;
|
||||
}
|
||||
if x >= width - 2 {
|
||||
packed = (packed >> 8u) | filler;
|
||||
}
|
||||
if x >= width - 1 {
|
||||
packed = (packed >> 8u) | filler;
|
||||
}
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
fn load_packed(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let width = params.display.x >> params.level;
|
||||
let height = params.display.y >> params.level;
|
||||
let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1));
|
||||
return packed_luma(
|
||||
width,
|
||||
p.x,
|
||||
load_luma(t, q),
|
||||
load_luma(t, q + vec2(1, 0)),
|
||||
load_luma(t, q + vec2(2, 0)),
|
||||
load_luma(t, q + vec2(3, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
fn sad(a: u32, b: u32) -> u32 {
|
||||
return u32(abs(i32(a & 0xffu) - i32(b & 0xffu))
|
||||
+ abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu))
|
||||
+ abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu))
|
||||
+ abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu)));
|
||||
}
|
||||
|
||||
fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4<u32> {
|
||||
var a0 = a0_in;
|
||||
var a1 = a1_in;
|
||||
var s: vec4<u32>;
|
||||
s.x = sad(a0, b);
|
||||
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
|
||||
a1 = a1 >> 8u;
|
||||
s.y = sad(a0, b);
|
||||
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
|
||||
a1 = a1 >> 8u;
|
||||
s.z = sad(a0, b);
|
||||
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
|
||||
s.w = sad(a0, b);
|
||||
return s;
|
||||
}
|
||||
|
||||
fn is_scene_changed() -> bool {
|
||||
if params.frame_index <= 5u {
|
||||
return true;
|
||||
}
|
||||
return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u;
|
||||
}
|
||||
|
||||
// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's
|
||||
// frames); the HDR transfer functions follow AMD's.
|
||||
fn luminance_to_perceived(luminance: f32) -> f32 {
|
||||
var perceived: f32;
|
||||
if luminance <= 216.0 / 24389.0 {
|
||||
perceived = luminance * (24389.0 / 27.0);
|
||||
} else {
|
||||
perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0;
|
||||
}
|
||||
return perceived * 0.01;
|
||||
}
|
||||
|
||||
fn linear_from_pq(x: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(x, vec3(0.0126833));
|
||||
return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn prepare_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
for (var y = 0; y < 2; y++) {
|
||||
for (var x = 0; x < 2; x++) {
|
||||
let pos = vec2<i32>(gid.xy) * 2 + vec2(x, y);
|
||||
if any(pos >= params.display) {
|
||||
continue;
|
||||
}
|
||||
let c = textureLoad(input_color, pos, 0).rgb;
|
||||
var y_lum = 0.0;
|
||||
if params.transfer_function == 0u {
|
||||
y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
} else if params.transfer_function == 1u {
|
||||
let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y);
|
||||
y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593)));
|
||||
} else if params.transfer_function == 2u {
|
||||
let mn = params.min_max_luminance.x;
|
||||
let mx = params.min_max_luminance.y;
|
||||
let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0);
|
||||
y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass.
|
||||
@compute @workgroup_size(8, 8)
|
||||
fn luma_pyramid(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
if any(p >= vec2<i32>(textureDimensions(luma_out))) {
|
||||
return;
|
||||
}
|
||||
let s = p * 2;
|
||||
let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x
|
||||
+ textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25;
|
||||
textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
// ffx_opticalflow_generate_scd_histogram.h
|
||||
const LBASE: u32 = 10u;
|
||||
var<workgroup> scd_buffer: array<atomic<u32>, 2560>;
|
||||
|
||||
@compute @workgroup_size(32, 8)
|
||||
fn scd_histogram_pass(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let histograms_per_dim = 3u;
|
||||
let div = vec2<u32>(params.display) / histograms_per_dim;
|
||||
let nx = gid.z % histograms_per_dim;
|
||||
let ny = gid.z / histograms_per_dim;
|
||||
let start = div * vec2(nx, ny);
|
||||
let stop = start + div;
|
||||
let offset = local_index * LBASE;
|
||||
for (var i = 0u; i < LBASE; i++) {
|
||||
atomicStore(&scd_buffer[offset + i], 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
var coord = vec2<i32>(i32(start.x + 4u * gid.x), i32(start.y + gid.y));
|
||||
if u32(coord.x) < stop.x {
|
||||
for (; u32(coord.y) < stop.y; coord.y += 128) {
|
||||
let color = vec4(
|
||||
load_luma(luma_in, coord),
|
||||
load_luma(luma_in, coord + vec2(1, 0)),
|
||||
load_luma(luma_in, coord + vec2(2, 0)),
|
||||
load_luma(luma_in, coord + vec2(3, 0)),
|
||||
) * LBASE;
|
||||
let scrambling = local_index % LBASE;
|
||||
atomicAdd(&scd_buffer[color.x + scrambling], 1u);
|
||||
atomicAdd(&scd_buffer[color.y + scrambling], 1u);
|
||||
atomicAdd(&scd_buffer[color.z + scrambling], 1u);
|
||||
atomicAdd(&scd_buffer[color.w + scrambling], 1u);
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
var value = 0u;
|
||||
for (var i = 0u; i < LBASE; i++) {
|
||||
value += atomicLoad(&scd_buffer[offset + i]);
|
||||
}
|
||||
let histogram_start = 256u * gid.z;
|
||||
atomicAdd(&scd_histogram[histogram_start + local_index], value);
|
||||
}
|
||||
|
||||
// ffx_opticalflow_compute_scd_divergence.h
|
||||
var<workgroup> source_histogram: array<f32, 256>;
|
||||
var<workgroup> filtered_histogram: array<f32, 256>;
|
||||
var<workgroup> temp_buffer: array<f32, 256>;
|
||||
var<workgroup> temp_buffer2: array<vec2<f32>, 256>;
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn scd_divergence_pass(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let factor = 1000000.0;
|
||||
let where_to_stop = 3u * 9u - 1u;
|
||||
let histogram_count = 9.0;
|
||||
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
|
||||
let li = i32(local_index);
|
||||
source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x]));
|
||||
workgroupBarrier();
|
||||
let read = li - 5;
|
||||
var val = 0.0;
|
||||
val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)];
|
||||
val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)];
|
||||
val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)];
|
||||
val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)];
|
||||
val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)];
|
||||
val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)];
|
||||
val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)];
|
||||
val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)];
|
||||
val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)];
|
||||
val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)];
|
||||
val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)];
|
||||
val += 1.0;
|
||||
if gid.y == 0u {
|
||||
if li == 0 {
|
||||
filtered_histogram[255] = 1.0;
|
||||
} else {
|
||||
filtered_histogram[li - 1] = val;
|
||||
}
|
||||
} else if gid.y == 1u {
|
||||
filtered_histogram[li] = val;
|
||||
} else if gid.y == 2u {
|
||||
if li == 255 {
|
||||
filtered_histogram[0] = 1.0;
|
||||
} else {
|
||||
filtered_histogram[li + 1] = val;
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
temp_buffer[li] = filtered_histogram[li];
|
||||
workgroupBarrier();
|
||||
for (var stride = 128; stride > 0; stride >>= 1u) {
|
||||
if li < stride {
|
||||
temp_buffer[li] += temp_buffer[li + stride];
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
filtered_histogram[li] /= temp_buffer[0];
|
||||
let current = filtered_histogram[li];
|
||||
let previous = scd_previous_histogram[gid.x];
|
||||
temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current));
|
||||
workgroupBarrier();
|
||||
for (var stride = 128; stride > 0; stride >>= 1u) {
|
||||
if li < stride {
|
||||
temp_buffer2[li] += temp_buffer2[li + stride];
|
||||
}
|
||||
workgroupBarrier();
|
||||
}
|
||||
if li == 0 {
|
||||
let sum = temp_buffer2[0];
|
||||
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
|
||||
let res_uint = u32((res_float / histogram_count) * factor);
|
||||
atomicAdd(&scd_temp[gid.y], res_uint);
|
||||
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
|
||||
if finished == where_to_stop {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / factor;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
if gid.y == 1u {
|
||||
scd_previous_histogram[gid.x] = current;
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels,
|
||||
// 2x2 blocks per workgroup of 64.
|
||||
var<workgroup> pixels: array<array<u32, 2>, 8>;
|
||||
// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas.
|
||||
var<workgroup> search_buffer: array<u32, 144>;
|
||||
var<workgroup> sad_map: array<array<array<u32, 5>, 16>, 4>;
|
||||
var<workgroup> wave_min: atomic<u32>;
|
||||
var<workgroup> wave_sum: atomic<u32>;
|
||||
var<workgroup> scene_changed: u32;
|
||||
|
||||
fn abs_2(v: vec2<i32>) -> vec2<u32> {
|
||||
let mask = v >> vec2(31u);
|
||||
return vec2<u32>((v + mask) ^ mask);
|
||||
}
|
||||
|
||||
// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins.
|
||||
fn encode_search_coord(coord: vec2<i32>) -> u32 {
|
||||
let a = abs_2(coord - 8);
|
||||
return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x);
|
||||
}
|
||||
|
||||
fn decode_search_coord(bits: u32) -> vec2<i32> {
|
||||
return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8);
|
||||
}
|
||||
|
||||
fn calculate_qsads2(search_id: vec2<i32>) -> vec4<u32> {
|
||||
var s = vec4(0u);
|
||||
for (var dy = 0; dy < 8; dy++) {
|
||||
let row = (search_id.y + dy) * 6;
|
||||
let a0 = search_buffer[row + search_id.x];
|
||||
let a1 = search_buffer[row + search_id.x + 1];
|
||||
let a2 = search_buffer[row + search_id.x + 2];
|
||||
s += qsad(a0, a1, pixels[dy][0]);
|
||||
s += qsad(a1, a2, pixels[dy][1]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn search_pass(
|
||||
@builtin(workgroup_id) group_id: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let li = i32(local_index);
|
||||
let search_id = vec2(li & 3, (li >> 2u) & 15);
|
||||
let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u);
|
||||
let group = vec2<i32>(group_id.xy);
|
||||
let px = (group << vec2(4u)) + search_id * vec2(4, 1);
|
||||
if local_index == 0u {
|
||||
scene_changed = u32(is_scene_changed());
|
||||
}
|
||||
if workgroupUniformLoad(&scene_changed) != 0u {
|
||||
if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 {
|
||||
store_flow(px >> vec2(3u), vec2(0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let use_prediction = params.level != params.level_count - 1u;
|
||||
let packed = load_packed(luma_in, px);
|
||||
let sad_4blocks = sad(packed, load_packed(luma_previous, px));
|
||||
let of_group = group << vec2(1u);
|
||||
let pixel_group = group << vec2(4u);
|
||||
for (var by = 0; by < 2; by++) {
|
||||
for (var bx = 0; bx < 2; bx++) {
|
||||
let block = vec2(bx, by);
|
||||
var current = vec2(0);
|
||||
if use_prediction {
|
||||
current = load_flow(of_group + block);
|
||||
}
|
||||
if lane_block == by * 2 + bx {
|
||||
pixels[search_id.y & 7][search_id.x & 1] = packed;
|
||||
}
|
||||
// LoadSearchBuffer
|
||||
let base = pixel_group + block * 8 + current - vec2(8, 8);
|
||||
for (var id = li; id < 144; id += 64) {
|
||||
search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6));
|
||||
}
|
||||
if local_index == 0u {
|
||||
atomicStore(&wave_min, 0xffffffffu);
|
||||
atomicStore(&wave_sum, 0u);
|
||||
}
|
||||
workgroupBarrier();
|
||||
let q = calculate_qsads2(search_id);
|
||||
// PrepareSadMap
|
||||
let sx = search_id.x;
|
||||
let sy = search_id.y;
|
||||
sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy));
|
||||
sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy));
|
||||
sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy));
|
||||
sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy));
|
||||
workgroupBarrier();
|
||||
// SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK)
|
||||
let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]);
|
||||
let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]);
|
||||
atomicMin(&wave_min, min(min01, min23));
|
||||
atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2));
|
||||
workgroupBarrier();
|
||||
let min_sad = atomicLoad(&wave_min);
|
||||
let block_sad_sum = atomicLoad(&wave_sum);
|
||||
var v = current + decode_search_coord(min_sad);
|
||||
if params.level == 0u && block_sad_sum <= (min_sad >> 16u) {
|
||||
v = vec2(0);
|
||||
}
|
||||
if local_index == 0u {
|
||||
store_flow(of_group + block, v);
|
||||
}
|
||||
// The next block reuses the shared buffers and counters.
|
||||
workgroupBarrier();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others.
|
||||
@compute @workgroup_size(16, 4)
|
||||
fn filter_flow(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let p = vec2<i32>(gid.xy);
|
||||
var mv: array<vec2<i32>, 9>;
|
||||
var idx = 0;
|
||||
for (var xx = -1; xx < 2; xx++) {
|
||||
for (var yy = -1; yy < 2; yy++) {
|
||||
mv[idx] = load_flow(p + vec2(xx, yy));
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
var ret = 0xffffffffu;
|
||||
for (var i = 0; i < 9; i++) {
|
||||
var tmp = 0u;
|
||||
for (var j = 0; j < 9; j++) {
|
||||
let delta = mv[i] - mv[j];
|
||||
tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp);
|
||||
}
|
||||
ret = min((tmp << 4u) | u32(i), ret);
|
||||
}
|
||||
store_flow(p, mv[ret & 0xfu]);
|
||||
}
|
||||
|
||||
// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the
|
||||
// best of its 4 nearest at this level.
|
||||
var<workgroup> nearest_vectors: array<array<array<vec2<i32>, 4>, 4>, 4>;
|
||||
var<workgroup> local_region: array<array<array<u32, 4>, 4>, 4>;
|
||||
var<workgroup> sads: array<array<array<u32, 4>, 4>, 4>;
|
||||
|
||||
@compute @workgroup_size(4, 4, 4)
|
||||
fn scale_pass(
|
||||
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||
@builtin(local_invocation_id) lid: vec3<u32>,
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
if local_index == 0u {
|
||||
scene_changed = u32(is_scene_changed());
|
||||
}
|
||||
if workgroupUniformLoad(&scene_changed) != 0u {
|
||||
store_flow(vec2<i32>(gid.xy), vec2(0));
|
||||
return;
|
||||
}
|
||||
let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u);
|
||||
let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u);
|
||||
let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset);
|
||||
let nearest = load_flow(src);
|
||||
nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2;
|
||||
let max_y = 4u;
|
||||
for (var n = lid.z; n < max_y; n += 4u) {
|
||||
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n));
|
||||
local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos);
|
||||
}
|
||||
workgroupBarrier();
|
||||
var s = 0u;
|
||||
for (var n = 0u; n < max_y; n++) {
|
||||
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest;
|
||||
s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos));
|
||||
}
|
||||
sads[lid.z][lid.y][lid.x] = s;
|
||||
workgroupBarrier();
|
||||
if lid.z == 0u {
|
||||
var best_sad = 0xffffffffu;
|
||||
var best = 0u;
|
||||
for (var n = 0u; n < 4u; n++) {
|
||||
if sads[n][lid.y][lid.x] < best_sad {
|
||||
best_sad = sads[n][lid.y][lid.x];
|
||||
best = n;
|
||||
}
|
||||
}
|
||||
store_flow(vec2<i32>(gid.xy), nearest_vectors[best][lid.y][lid.x]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::{Frustum, Renderer, SectionGpu};
|
||||
use crate::{
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
lod::TileKey,
|
||||
lod::{MAX_LEVEL, TileKey},
|
||||
mesher::SectionMesh,
|
||||
};
|
||||
use glam::{DVec3, Vec3};
|
||||
@@ -45,6 +45,47 @@ impl Pending {
|
||||
pub(super) struct Tile {
|
||||
pub mesh: SectionGpu,
|
||||
bounds: Bounds,
|
||||
/// When it began to fade in; none while the rest of its job uploads.
|
||||
shown: Option<Instant>,
|
||||
/// When its replacements began to fade in; it goes once they have.
|
||||
dying: Option<Instant>,
|
||||
/// Dying with nothing to replace it (out of range): it fades out.
|
||||
alone: bool,
|
||||
/// Its mesh before near chunks took some of its columns, drawn in its
|
||||
/// place until they have faded in (from the job's end, the instant), so
|
||||
/// no hole shows through them.
|
||||
previous: Option<(SectionGpu, Option<Instant>)>,
|
||||
}
|
||||
|
||||
/// How long a tile takes to fade in over the ones it replaces.
|
||||
pub(super) const FADE: Duration = Duration::from_millis(600);
|
||||
|
||||
/// A tile or section fading in: 0 drawn whole, else 1 + `level` * 129 +
|
||||
/// `shown`, `shown` 1 to 63 sixty-fourths of `FADE` (`fade_out_code`: 65 to
|
||||
/// 128, fading out). Chunk by chunk (columns of its own grid, wider for
|
||||
/// coarse tiles: 2^`level` blocks), each starting in turn over the first half
|
||||
/// and fading over the second, as a fine ordered pattern fixed to the terrain
|
||||
/// (terrain.wgsl's `faded`). It is drawn a hair toward the camera, the newest
|
||||
/// most, over the solid tiles it replaces, which stay until it is whole
|
||||
/// (`upload_lods`): no pixel fought over, no hole. None: nothing to draw yet.
|
||||
pub(super) fn fade_code(shown: Option<Instant>, level: u8, now: Instant) -> Option<u32> {
|
||||
let faded = now.saturating_duration_since(shown?).as_secs_f32() / FADE.as_secs_f32();
|
||||
let shown = (faded * 64.0) as u32 + 1;
|
||||
Some(if shown >= 64 {
|
||||
0
|
||||
} else {
|
||||
1 + u32::from(level) * 129 + shown
|
||||
})
|
||||
}
|
||||
/// A section past the render distance that far terrain is coming in under:
|
||||
/// whole, a fading-out code at its start (terrain.wgsl's `fade_depth` draws
|
||||
/// leaving sections in front of far terrain).
|
||||
pub(super) const HELD: u32 = 1 + 64;
|
||||
/// `fade_code` for one leaving since `since`, chunk by chunk as it came, over
|
||||
/// what stays behind it; once `FADE` has passed it draws nothing.
|
||||
pub(super) fn fade_out_code(since: Instant, level: u8, now: Instant) -> u32 {
|
||||
let faded = now.saturating_duration_since(since).as_secs_f32() / FADE.as_secs_f32();
|
||||
1 + u32::from(level) * 129 + 64 + ((faded * 64.0) as u32 + 1).min(64)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -72,13 +113,13 @@ impl Bounds {
|
||||
Some(bounds)
|
||||
}
|
||||
|
||||
fn visible(self, origin: DVec3, distance: f32, frustum: &Frustum) -> Option<u64> {
|
||||
/// In view: the plan alone decides which tiles are in range (a circle),
|
||||
/// so a tile leaving it fades out (`upload_lods`). Cut here at the range,
|
||||
/// tiles popped out as the player moved, before the plan dropped them.
|
||||
fn visible(self, origin: DVec3, frustum: &Frustum) -> Option<u64> {
|
||||
let min = origin + self.min.as_dvec3();
|
||||
let max = origin + self.max.as_dvec3();
|
||||
let nearest = DVec3::ZERO.clamp(min, max);
|
||||
if nearest.x * nearest.x + nearest.z * nearest.z > f64::from(distance).powi(2)
|
||||
|| !frustum.intersects(min.as_vec3(), max.as_vec3())
|
||||
{
|
||||
if !frustum.intersects(min.as_vec3(), max.as_vec3()) {
|
||||
return None;
|
||||
}
|
||||
Some(((min + max) * 0.5).length_squared().to_bits())
|
||||
@@ -86,12 +127,19 @@ impl Bounds {
|
||||
}
|
||||
|
||||
/// A tile's instance: like a section's, with the UV step of merged faces.
|
||||
pub(super) fn instance(origin: [f32; 3]) -> [f32; 4] {
|
||||
/// A fading tile's step is negated past its `fade_code`, which the shaders
|
||||
/// read back as the whole part (they take the step as `fract(abs(w))`).
|
||||
pub(super) fn instance(origin: [f32; 3], fade: u32) -> [f32; 4] {
|
||||
let step = 1.0 / crate::mesher::TILED_UV_STEPS;
|
||||
[
|
||||
origin[0],
|
||||
origin[1],
|
||||
origin[2],
|
||||
1.0 / crate::mesher::TILED_UV_STEPS,
|
||||
if fade == 0 {
|
||||
step
|
||||
} else {
|
||||
-(fade as f32 + step)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -99,45 +147,119 @@ pub(super) struct Visible {
|
||||
pub key: TileKey,
|
||||
pub distance: u64,
|
||||
pub origin: [f32; 3],
|
||||
pub fade: u32,
|
||||
/// Replaced, standing until its replacements are whole: its water goes
|
||||
/// after theirs (`Renderer::render`'s water tiers), so depth leaves it only
|
||||
/// where they have none yet.
|
||||
pub old: bool,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
/// The mesh drawn this frame: the previous one while it stands in.
|
||||
pub fn drawn(&self) -> &SectionGpu {
|
||||
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
|
||||
}
|
||||
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
|
||||
match &mut self.previous {
|
||||
Some((mesh, _)) => mesh,
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
key: TileKey,
|
||||
camera: DVec3,
|
||||
distance: f32,
|
||||
frustum: &Frustum,
|
||||
now: Instant,
|
||||
) -> Option<Visible> {
|
||||
let fade = match self.dying {
|
||||
Some(since) if self.alone => fade_out_code(since, key.level, now),
|
||||
_ => fade_code(self.shown, key.level, now)?,
|
||||
};
|
||||
let origin = DVec3::from(key.origin().map(f64::from)) - camera;
|
||||
Some(Visible {
|
||||
key,
|
||||
distance: self.bounds.visible(origin, distance, frustum)?,
|
||||
distance: self.bounds.visible(origin, frustum)?,
|
||||
origin: origin.as_vec3().to_array(),
|
||||
fade,
|
||||
old: self.dying.is_some() || self.previous.is_some(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
|
||||
self.remove_lod(key);
|
||||
if let Some(bounds) = Bounds::mesh(mesh)
|
||||
&& let Some(mesh) = self.upload_mesh(mesh)
|
||||
{
|
||||
self.lods.insert(key, Tile { mesh, bounds });
|
||||
/// A tile meshed again (finer detail) takes its place at once, but for
|
||||
/// one meshed around near chunks or finer tiles that are new (`keep`):
|
||||
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
|
||||
/// (`upload_lods`), then fades in.
|
||||
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
|
||||
let (shown, previous) = match self.lods.remove(&key) {
|
||||
Some(old) if old.dying.is_none() => match old.previous {
|
||||
Some(previous) => {
|
||||
self.release_mesh(old.mesh);
|
||||
(old.shown, Some(previous))
|
||||
}
|
||||
None if keep => (old.shown, Some((old.mesh, None))),
|
||||
None => {
|
||||
self.release_mesh(old.mesh);
|
||||
(old.shown, None)
|
||||
}
|
||||
},
|
||||
Some(old) => {
|
||||
self.release_tile(old);
|
||||
(None, None)
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
|
||||
Some((bounds, mesh)) => {
|
||||
self.lods.insert(
|
||||
key,
|
||||
Tile {
|
||||
mesh,
|
||||
bounds,
|
||||
shown,
|
||||
dying: None,
|
||||
alone: false,
|
||||
previous,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if let Some((previous, _)) = previous {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release_tile(&mut self, tile: Tile) {
|
||||
self.release_mesh(tile.mesh);
|
||||
if let Some((previous, _)) = tile.previous {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_lod(&mut self, key: TileKey) {
|
||||
if let Some(tile) = self.lods.remove(&key) {
|
||||
self.release_mesh(tile.mesh);
|
||||
self.release_tile(tile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_lods(&mut self) {
|
||||
self.lod_pending = None;
|
||||
// No far terrain left to fade out over.
|
||||
for (key, since) in std::mem::take(&mut self.lod_leaving) {
|
||||
if self
|
||||
.sections
|
||||
.find(key)
|
||||
.is_some_and(|s| s.leaving == Some(since))
|
||||
{
|
||||
self.release_section(key);
|
||||
}
|
||||
}
|
||||
for (_, tile) in std::mem::take(&mut self.lods) {
|
||||
self.release_mesh(tile.mesh);
|
||||
self.release_tile(tile);
|
||||
}
|
||||
self.set_lod_cutout(Default::default());
|
||||
}
|
||||
@@ -160,14 +282,134 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Uploads queued tile meshes for about `budget` (at least one), then,
|
||||
/// once all are in, retires the tiles they replace.
|
||||
/// once all are in, fades them in over the tiles they replace.
|
||||
pub fn upload_lods(&mut self, budget: Duration) {
|
||||
let start = Instant::now();
|
||||
self.release_left(start);
|
||||
let faded: Vec<_> = self
|
||||
.lods
|
||||
.iter()
|
||||
.filter(|(_, tile)| tile.dying.is_some_and(|d| start - d >= FADE))
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
for key in faded {
|
||||
self.remove_lod(key);
|
||||
}
|
||||
let stood_in: Vec<_> = self
|
||||
.lods
|
||||
.values_mut()
|
||||
.filter_map(|tile| match tile.previous {
|
||||
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (previous, _) in stood_in {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
// The columns near chunks take in this job, and the tiles holding the
|
||||
// job's new finer ones: a tile meshed again around them keeps its
|
||||
// old mesh until they are in, so no hole shows through them.
|
||||
let mut around_new = FxHashSet::default();
|
||||
let new = self
|
||||
.lod_pending
|
||||
.iter()
|
||||
.flat_map(|pending| pending.meshes.keys())
|
||||
.filter(|key| !self.lods.contains_key(key))
|
||||
.chain(
|
||||
self.lods
|
||||
.iter()
|
||||
.filter(|(_, tile)| tile.shown.is_none())
|
||||
.map(|(key, _)| key),
|
||||
);
|
||||
for &key in new {
|
||||
let mut key = key;
|
||||
while key.level < MAX_LEVEL && around_new.insert(key.parent()) {
|
||||
key = key.parent();
|
||||
}
|
||||
}
|
||||
let taken: Vec<_> = self
|
||||
.lod_pending
|
||||
.iter()
|
||||
.flat_map(|pending| pending.cutout.iter().flatten())
|
||||
.filter(|column| !self.lod_cutout.contains(column))
|
||||
.copied()
|
||||
.collect();
|
||||
while let Some(pending) = &mut self.lod_pending {
|
||||
let Some(&key) = pending.meshes.keys().next() else {
|
||||
let pending = self.lod_pending.take().expect("pending tiles");
|
||||
// A tile fades in over the ones it replaces. One over nothing
|
||||
// shows whole: its hidden chunks would open holes to the sky
|
||||
// or the sea bed (dark patches, as far terrain first loads).
|
||||
let replaced: FxHashSet<TileKey> = pending
|
||||
.removed
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| self.lods.contains_key(key))
|
||||
.collect();
|
||||
let mut above_replaced = FxHashSet::default();
|
||||
for &key in &replaced {
|
||||
let mut key = key;
|
||||
while key.level < MAX_LEVEL && above_replaced.insert(key.parent()) {
|
||||
key = key.parent();
|
||||
}
|
||||
}
|
||||
let over_replaced = |key: TileKey| {
|
||||
let mut ancestor = key;
|
||||
replaced.contains(&key)
|
||||
|| above_replaced.contains(&key)
|
||||
|| (key.level..MAX_LEVEL).any(|_| {
|
||||
ancestor = ancestor.parent();
|
||||
replaced.contains(&ancestor)
|
||||
})
|
||||
};
|
||||
// A removed tile no new tile overlaps, and no near chunk takes
|
||||
// a column of, leaves the plan (out of range): it fades out.
|
||||
// Over near chunks fading in it stays whole: two patterns
|
||||
// fading at once left holes between them.
|
||||
// Two tiles overlap when one is the other or its ancestor:
|
||||
// `kept` holds the new tiles and the near chunks' columns
|
||||
// (level 0), `above` their ancestors.
|
||||
let mut kept: FxHashSet<TileKey> = self
|
||||
.lods
|
||||
.iter()
|
||||
.filter(|(_, tile)| tile.shown.is_none())
|
||||
.map(|(&key, _)| key)
|
||||
.collect();
|
||||
let cut = pending.cutout.as_ref().unwrap_or(&self.lod_cutout);
|
||||
kept.extend(cut.iter().map(|&(x, z)| TileKey { x, z, level: 0 }));
|
||||
let mut above = FxHashSet::default();
|
||||
for &key in &kept {
|
||||
let mut key = key;
|
||||
while key.level < MAX_LEVEL && above.insert(key.parent()) {
|
||||
key = key.parent();
|
||||
}
|
||||
}
|
||||
let alone = |key: TileKey| {
|
||||
let mut ancestor = key;
|
||||
!kept.contains(&key)
|
||||
&& !above.contains(&key)
|
||||
&& !(key.level..MAX_LEVEL).any(|_| {
|
||||
ancestor = ancestor.parent();
|
||||
kept.contains(&ancestor)
|
||||
})
|
||||
};
|
||||
let whole = start.checked_sub(FADE).unwrap_or(start);
|
||||
for (&key, tile) in &mut self.lods {
|
||||
if tile.shown.is_none() {
|
||||
tile.shown = Some(if over_replaced(key) { start } else { whole });
|
||||
}
|
||||
if let Some((_, since)) = &mut tile.previous {
|
||||
since.get_or_insert(start);
|
||||
}
|
||||
}
|
||||
for key in pending.removed {
|
||||
self.remove_lod(key);
|
||||
let alone = alone(key);
|
||||
if let Some(tile) = self.lods.get_mut(&key)
|
||||
&& tile.dying.is_none()
|
||||
{
|
||||
tile.dying = Some(start);
|
||||
tile.alone = alone;
|
||||
}
|
||||
}
|
||||
if let Some(cutout) = pending.cutout {
|
||||
self.set_lod_cutout(cutout);
|
||||
@@ -175,7 +417,13 @@ impl Renderer {
|
||||
return;
|
||||
};
|
||||
let mesh = pending.meshes.remove(&key).expect("pending tile");
|
||||
self.upload_lod(key, &mesh);
|
||||
let [x, _, z] = key.origin();
|
||||
let size = key.size();
|
||||
let keep = around_new.contains(&key)
|
||||
|| taken.iter().any(|&(cx, cz)| {
|
||||
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
|
||||
});
|
||||
self.upload_lod(key, &mesh, keep);
|
||||
if start.elapsed() >= budget {
|
||||
return;
|
||||
}
|
||||
@@ -194,7 +442,7 @@ impl Renderer {
|
||||
.collect();
|
||||
for key in released {
|
||||
self.lod_retiring.remove(&key);
|
||||
self.release_section(key);
|
||||
self.leave_section(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +452,36 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
|
||||
#[test]
|
||||
fn tiles_fade_in_on_their_own_grid() {
|
||||
let start = Instant::now();
|
||||
let at = |ms| start + Duration::from_millis(ms);
|
||||
// terrain.wgsl's `faded` reads the level and sixty-fourths back.
|
||||
let decode = |code: u32| ((code - 1) / 129, (code - 1) % 129);
|
||||
assert_eq!(fade_code(None, 0, at(0)), None, "waits for its job");
|
||||
assert_eq!(fade_code(Some(start), 3, at(0)).map(decode), Some((3, 1)));
|
||||
let mut last = 0;
|
||||
for ms in [0, 100, 300, 500, 590] {
|
||||
let (level, shown) = decode(fade_code(Some(start), 10, at(ms)).unwrap());
|
||||
assert_eq!(level, 10);
|
||||
assert!(shown > last && shown < 64, "{shown} at {ms} ms");
|
||||
last = shown;
|
||||
}
|
||||
assert_eq!(fade_code(Some(start), 10, at(600)), Some(0), "whole");
|
||||
// Fading out: 65 to 128, then nothing drawn (128) until released.
|
||||
let mut last = 64;
|
||||
for ms in [0, 100, 300, 500, 590] {
|
||||
let (level, out) = decode(fade_out_code(start, 3, at(ms)));
|
||||
assert_eq!(level, 3);
|
||||
assert!(out > last && out < 128, "{out} at {ms} ms");
|
||||
last = out;
|
||||
}
|
||||
assert_eq!(decode(fade_out_code(start, 3, at(700))), (3, 128));
|
||||
// A section's largest code keeps its UV step exact (terrain_uv).
|
||||
let w = 129.0f32 + 1.0 / 32768.0;
|
||||
assert_eq!(w.fract(), 1.0 / 32768.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_jobs_apply_as_if_in_turn() {
|
||||
let key = |x| TileKey { x, z: 0, level: 0 };
|
||||
@@ -244,16 +522,17 @@ mod tests {
|
||||
* crate::camera::view_matrix(0.0, 0.0),
|
||||
),
|
||||
};
|
||||
// The tile origin is farther than the range, but its far edge reaches
|
||||
// the camera. Negative world origins therefore remain visible.
|
||||
// The tile origin is far behind, but its far edge reaches the camera.
|
||||
// Negative world origins therefore remain visible.
|
||||
assert!(
|
||||
bounds
|
||||
.visible(DVec3::new(-1000.0, -80.0, -512.0), 256.0, &view)
|
||||
.visible(DVec3::new(-1000.0, -80.0, -512.0), &view)
|
||||
.is_some()
|
||||
);
|
||||
// Wholly behind the camera: out of view.
|
||||
assert!(
|
||||
bounds
|
||||
.visible(DVec3::new(-2000.0, -80.0, -512.0), 256.0, &view)
|
||||
.visible(DVec3::new(-512.0, -80.0, -2000.0), &view)
|
||||
.is_none()
|
||||
);
|
||||
assert!(Bounds::mesh(&SectionMesh::default()).is_none());
|
||||
|
||||
@@ -176,7 +176,7 @@ impl PackAtlas {
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
|
||||
|
||||
@@ -57,6 +57,14 @@ impl<T> Resident<T> {
|
||||
pub fn values(&self) -> impl Iterator<Item = &T> {
|
||||
self.items.iter().map(|(_, v)| v)
|
||||
}
|
||||
/// `key`'s value, when it is resident.
|
||||
pub fn find(&self, key: SectionKey) -> Option<&T> {
|
||||
self.slots.get(&key).map(|&slot| self.get(slot))
|
||||
}
|
||||
pub fn find_mut(&mut self, key: SectionKey) -> Option<&mut T> {
|
||||
let slot = *self.slots.get(&key)?;
|
||||
Some(self.get_mut(slot))
|
||||
}
|
||||
/// The section in `slot` (`Visible::slot`).
|
||||
pub fn get(&self, slot: u32) -> &T {
|
||||
&self.items[slot as usize].1
|
||||
|
||||
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
|
||||
|
||||
pub struct Uploads {
|
||||
belt: wgpu::util::StagingBelt,
|
||||
chunk: u64,
|
||||
}
|
||||
|
||||
impl Uploads {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
Self::with_chunk(device, CHUNK)
|
||||
}
|
||||
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
|
||||
/// a buffer of their own).
|
||||
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
|
||||
/// a buffer of its own, rounded up to a power of two).
|
||||
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
|
||||
Self {
|
||||
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
|
||||
chunk,
|
||||
}
|
||||
}
|
||||
/// A mapped staging slice of at least `size` bytes. The belt gives a
|
||||
/// write larger than its chunk a buffer of exactly that size, keeps it for
|
||||
/// good and only reuses it for writes that fit: an upload that grows a
|
||||
/// little every frame (the entity batch while mobs appear) took a new
|
||||
/// buffer each frame until the game ran out of memory (#2826). Rounded up
|
||||
/// to a power of two, the large buffers it keeps are reused, one per
|
||||
/// doubling.
|
||||
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
|
||||
let size = if size.get() > self.chunk {
|
||||
size.checked_next_power_of_two().unwrap_or(size)
|
||||
} else {
|
||||
size
|
||||
};
|
||||
self.belt.allocate(size, alignment)
|
||||
}
|
||||
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
|
||||
/// level of one array layer, padding rows to the copy alignment.
|
||||
pub fn texture(
|
||||
@@ -41,7 +58,7 @@ impl Uploads {
|
||||
};
|
||||
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
|
||||
.expect("non-zero alignment");
|
||||
let slice = self.belt.allocate(size, alignment);
|
||||
let slice = self.allocate(size, alignment);
|
||||
{
|
||||
let mut view = slice
|
||||
.get_mapped_range_mut()
|
||||
@@ -80,8 +97,20 @@ impl Uploads {
|
||||
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
|
||||
return;
|
||||
};
|
||||
self.belt
|
||||
.write_buffer(encoder, target, offset, size)
|
||||
// The belt's own `write_buffer` checks, kept now that a large write's
|
||||
// slice is rounded up past them.
|
||||
assert!(
|
||||
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
|
||||
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
|
||||
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
|
||||
);
|
||||
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
|
||||
let slice = self.allocate(size, alignment).slice(..size.get());
|
||||
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
|
||||
slice
|
||||
.get_mapped_range_mut()
|
||||
.expect("staging belt slices are mapped")
|
||||
.slice(..)
|
||||
.copy_from_slice(bytes);
|
||||
}
|
||||
/// Close this frame's staging buffers: they come back for reuse once the
|
||||
@@ -217,6 +246,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Staging buffers the belt holds, once every submitted frame is done
|
||||
/// with them. The belt only reports its buffers through `Debug`.
|
||||
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Finishing an empty frame takes back every recalled buffer.
|
||||
uploads.finish(&device.create_command_encoder(&Default::default()));
|
||||
let belt = format!("{:?}", uploads.belt);
|
||||
let count = |field: &str| -> usize {
|
||||
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
|
||||
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
|
||||
digits.and_then(|d| d.parse().ok()).expect(&belt)
|
||||
};
|
||||
count("active_chunks") + count("closed_chunks") + count("free_chunks")
|
||||
}
|
||||
|
||||
/// #2826: a write larger than the chunk that grows a little every frame
|
||||
/// (the entity batch while mobs appear) got a new exact-size buffer each
|
||||
/// frame, all of them kept, until the game ran out of memory.
|
||||
#[test]
|
||||
fn growing_large_writes_reuse_the_belt_buffers() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
const CHUNK: u64 = 1 << 16;
|
||||
const TARGET: usize = 4 * CHUNK as usize;
|
||||
let target = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: TARGET as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut uploads = Uploads::with_chunk(&device, CHUNK);
|
||||
let mut expected = vec![0u8; TARGET];
|
||||
let frames = 64;
|
||||
for frame in 0..frames {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// One small write, then the large one, 1.5 to 3.5 chunks long.
|
||||
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
|
||||
expected[TARGET - 4..].fill(9);
|
||||
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
|
||||
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
|
||||
uploads.buffer(&mut encoder, &target, 0, &bytes);
|
||||
expected[..len].copy_from_slice(&bytes);
|
||||
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
// Only the written bytes land: the rest of the target is untouched.
|
||||
assert!(
|
||||
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
|
||||
"frame {frame}"
|
||||
);
|
||||
readback.unmap();
|
||||
}
|
||||
// A chunk for the small writes and a buffer per doubling of the large
|
||||
// one (2 and 4 chunks), maybe one more where a small write took the
|
||||
// start of a buffer the large one needed whole: not one per frame.
|
||||
let kept = kept_buffers(&device, &mut uploads);
|
||||
assert!(
|
||||
kept <= 4,
|
||||
"the belt kept {kept} buffers over {frames} frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn belt_buffer_writes_land_in_order_at_their_offsets() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -131,8 +131,20 @@ impl Style {
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
value,
|
||||
hover && active,
|
||||
active,
|
||||
dragging && active,
|
||||
);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -855,6 +855,7 @@ impl GraphicsUi {
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
|
||||
@@ -168,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless
|
||||
/// it names other dimensions or the frames run along a row.
|
||||
fn first_frame(
|
||||
(w, h, pixels): (u32, u32, Vec<u8>),
|
||||
animation: Option<&AnimationMetadata>,
|
||||
) -> (u32, u32, Vec<u8>) {
|
||||
let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(side, side, strip_frame(&pixels, w, 0, 0, fw, fh))
|
||||
}
|
||||
_ => (w, h, pixels),
|
||||
}
|
||||
}
|
||||
/// `image` inside a one-texel border copied from its opposite edges, the
|
||||
/// texels repeat addressing reads past each edge.
|
||||
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
@@ -184,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
/// such as other namespaces' item textures; and the animated ones' names.
|
||||
fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<String>) {
|
||||
use crate::atlas_sources::{self, SpriteOrigin};
|
||||
let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
@@ -229,29 +255,45 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
};
|
||||
let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| {
|
||||
origins.iter().find_map(|(order, origin)| {
|
||||
let mut animated = false;
|
||||
let image = match origin {
|
||||
SpriteOrigin::File(texture) => {
|
||||
let path = texture.strip_prefix("minecraft:").unwrap_or(texture);
|
||||
load(texture, "textures").map(|(w, h, p)| {
|
||||
// Animated strips keep their first frame, like the packed files.
|
||||
if h > w && (path.starts_with("block/") || path.starts_with("item/")) {
|
||||
(w, w, p[..(w * w * 4) as usize].to_vec())
|
||||
// Its path under `textures/` in any namespace (a server
|
||||
// pack's `lobby:item/...` as well as vanilla's).
|
||||
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
|
||||
let file = atlas_sources::texture_path(root, texture, "textures");
|
||||
decode(&file).ok().map(|image| {
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
(w, h, p)
|
||||
image
|
||||
}
|
||||
})
|
||||
}
|
||||
generated => atlas_sources::generate(generated, &load),
|
||||
};
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels)))
|
||||
image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated))
|
||||
})
|
||||
};
|
||||
let mut out: Vec<_> = crate::par::par_map(&names, first)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
out.sort_unstable_by_key(|(order, _)| *order);
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
out.sort_unstable_by_key(|(order, ..)| *order);
|
||||
let animated = out
|
||||
.iter()
|
||||
.filter(|(.., animated)| *animated)
|
||||
.map(|(_, image, _)| image.0.clone())
|
||||
.collect();
|
||||
(
|
||||
out.into_iter().map(|(_, image, _)| image).collect(),
|
||||
animated,
|
||||
)
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// Keep a ready profile skin when another source only has its default.
|
||||
@@ -413,49 +455,48 @@ impl HudAtlas {
|
||||
for folder in folders {
|
||||
walk(&textures.join(folder), &mut files);
|
||||
}
|
||||
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
|
||||
if folders.contains(&"entity") {
|
||||
for namespace in horizon_items::content_namespaces::namespaces(root) {
|
||||
walk(
|
||||
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
|
||||
&mut files,
|
||||
);
|
||||
}
|
||||
}
|
||||
let assets = root.join("assets");
|
||||
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
|
||||
if full && let Ok(namespaces) = fs::read_dir(&assets) {
|
||||
for ns in namespaces.flatten() {
|
||||
if ns.file_name() != "minecraft" {
|
||||
walk(&ns.path().join("textures/gui"), &mut files);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.retain(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = path
|
||||
.strip_prefix(&textures)
|
||||
.ok()?
|
||||
.with_extension("")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let (mut w, mut h, pixels) = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = mcmeta
|
||||
.as_deref()
|
||||
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
|
||||
.and_then(|meta| {
|
||||
meta.get("animation")
|
||||
.and_then(|section| AnimationMetadata::from_json(§ion.to_string()).ok())
|
||||
});
|
||||
// An animated strip keeps its first frame: the region the mcmeta's
|
||||
// frame size picks (`AnimationMetadataSection.frameSize`), which is
|
||||
// the top-left square unless it names other dimensions or the
|
||||
// frames run along a row.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let pixels = if frames {
|
||||
let stride = w;
|
||||
let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| {
|
||||
meta.frame_size(w as i32, h as i32)
|
||||
});
|
||||
match (u32::try_from(fw), u32::try_from(fh)) {
|
||||
(Ok(fw), Ok(fh))
|
||||
if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) =>
|
||||
{
|
||||
let side = fw.max(fh);
|
||||
(w, h) = (side, side);
|
||||
strip_frame(&pixels, stride, 0, 0, fw, fh)
|
||||
}
|
||||
_ => pixels,
|
||||
let name = match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
let image = decode(path).ok()?;
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames = name.starts_with("block/") || name.starts_with("item/");
|
||||
let (w, h, pixels) = if frames {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
pixels
|
||||
image
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
@@ -507,7 +548,9 @@ impl HudAtlas {
|
||||
images.push(image);
|
||||
}
|
||||
if full {
|
||||
images.extend(atlas_sprites(root, &images));
|
||||
let (more, more_animated) = atlas_sprites(root, &images);
|
||||
images.extend(more);
|
||||
animated.extend(more_animated);
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
@@ -579,7 +622,15 @@ impl HudAtlas {
|
||||
let Some(rect) = sprites.get(&name).copied() else {
|
||||
continue;
|
||||
};
|
||||
let layer = loaded.insert(root, &format!("minecraft:{name}"))?;
|
||||
// Another namespace's sprite is named with it (`lobby:item/...`).
|
||||
let id = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let Ok(layer) = loaded.insert(root, &id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned()
|
||||
else {
|
||||
continue;
|
||||
@@ -644,6 +695,45 @@ pub struct HudVertex {
|
||||
/// unless `uv.x >= 2`.
|
||||
pub glint_uv: [f32; 2],
|
||||
}
|
||||
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
|
||||
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
|
||||
let inside = |v: &HudVertex| {
|
||||
if keep_below {
|
||||
v.pos[axis] < limit
|
||||
} else {
|
||||
v.pos[axis] >= limit
|
||||
}
|
||||
};
|
||||
let lerp = |a: &HudVertex, b: &HudVertex, t: f32| {
|
||||
let mix = |p: f32, q: f32| p + (q - p) * t;
|
||||
let mut v = *a;
|
||||
v.pos = [mix(a.pos[0], b.pos[0]), mix(a.pos[1], b.pos[1])];
|
||||
v.uv = [mix(a.uv[0], b.uv[0]), mix(a.uv[1], b.uv[1])];
|
||||
v.depth = mix(a.depth, b.depth);
|
||||
v.glint_uv = [
|
||||
mix(a.glint_uv[0], b.glint_uv[0]),
|
||||
mix(a.glint_uv[1], b.glint_uv[1]),
|
||||
];
|
||||
v.pos[axis] = limit;
|
||||
v
|
||||
};
|
||||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (&poly[i], &poly[(i + 1) % poly.len()]);
|
||||
let (ia, ib) = (inside(a), inside(b));
|
||||
if ia {
|
||||
out.push(*a);
|
||||
}
|
||||
if ia != ib {
|
||||
out.push(lerp(
|
||||
a,
|
||||
b,
|
||||
(limit - a.pos[axis]) / (b.pos[axis] - a.pos[axis]),
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The GUI Scale option (0 = auto).
|
||||
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
/// One frame of overlay geometry in GUI pixels.
|
||||
@@ -883,8 +973,8 @@ impl HudBatch {
|
||||
}
|
||||
}
|
||||
/// Clip the quads drawn since `start` to `[x0, x1) x [y0, y1)` (the
|
||||
/// GUI scissor): axis-aligned quads are cut with their UVs, others are
|
||||
/// kept or dropped by their centre.
|
||||
/// GUI scissor): axis-aligned quads are cut with their UVs, others (item
|
||||
/// triangles) are clipped as polygons.
|
||||
/// Scale the vertices pushed since `start` about `(cx, cy)` (a GUI pose
|
||||
/// matrix of translate, scale, translate back).
|
||||
pub fn scale_since(&mut self, start: usize, cx: f32, cy: f32, sx: f32, sy: f32) {
|
||||
@@ -896,7 +986,11 @@ impl HudBatch {
|
||||
pub fn clip_since(&mut self, start: usize, x0: f32, y0: f32, x1: f32, y1: f32) {
|
||||
let quads: Vec<[HudVertex; 6]> = self.vertices[start..].as_chunks::<6>().0.to_vec();
|
||||
self.vertices.truncate(start);
|
||||
// Where each source quad now begins, to move the blend ranges (foil
|
||||
// items are an additive run) along with their vertices.
|
||||
let mut moved = Vec::with_capacity(quads.len() + 1);
|
||||
for q in quads {
|
||||
moved.push(self.vertices.len() as u32);
|
||||
let corners = [q[0], q[1], q[2], q[5]];
|
||||
let (mut x_lo, mut x_hi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
let (mut y_lo, mut y_hi) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
@@ -915,10 +1009,26 @@ impl HudBatch {
|
||||
find(x_lo, y_hi),
|
||||
);
|
||||
let (Some(tl), Some(tr), Some(br), Some(bl)) = rect else {
|
||||
let cx = corners.iter().map(|v| v.pos[0]).sum::<f32>() / 4.0;
|
||||
let cy = corners.iter().map(|v| v.pos[1]).sum::<f32>() / 4.0;
|
||||
if cx >= x0 && cx < x1 && cy >= y0 && cy < y1 {
|
||||
self.vertices.extend_from_slice(&q);
|
||||
// Item geometry arrives as triangles: clip each against the
|
||||
// rectangle and re-emit the fan, so a triangle straddling the
|
||||
// edge keeps its visible part instead of vanishing whole.
|
||||
for tri in [[q[0], q[1], q[2]], [q[3], q[4], q[5]]] {
|
||||
let area = (tri[1].pos[0] - tri[0].pos[0]) * (tri[2].pos[1] - tri[0].pos[1])
|
||||
- (tri[2].pos[0] - tri[0].pos[0]) * (tri[1].pos[1] - tri[0].pos[1]);
|
||||
if area == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut poly = tri.to_vec();
|
||||
for (axis, limit, keep_below) in
|
||||
[(0, x0, false), (0, x1, true), (1, y0, false), (1, y1, true)]
|
||||
{
|
||||
poly = clip_polygon(&poly, axis, limit, keep_below);
|
||||
}
|
||||
for i in 1..poly.len().saturating_sub(1) {
|
||||
// A triangle as the quad `a, b, c, c`.
|
||||
let (a, b, c) = (poly[0], poly[i], poly[i + 1]);
|
||||
self.vertices.extend_from_slice(&[a, b, c, a, c, c]);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
@@ -942,6 +1052,14 @@ impl HudBatch {
|
||||
tl.color,
|
||||
);
|
||||
}
|
||||
moved.push(self.vertices.len() as u32);
|
||||
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
|
||||
for (range, _) in &mut self.blends {
|
||||
if range.start as usize >= start {
|
||||
*range = at(range.start)..at(range.end);
|
||||
}
|
||||
}
|
||||
self.blends.retain(|(range, _)| !range.is_empty());
|
||||
}
|
||||
/// `blit` of the top-left `w x h` texels of a `tw x th` texture.
|
||||
pub fn sprite_region(
|
||||
@@ -1816,6 +1934,57 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let start = batch.vertices.len();
|
||||
// A right triangle (a quad `a, b, c, c`) whose centre lies outside the rect.
|
||||
batch.quad(
|
||||
[[0.0, 0.0], [16.0, 0.0], [0.0, 16.0], [0.0, 16.0]],
|
||||
[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]],
|
||||
u32::MAX,
|
||||
);
|
||||
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
|
||||
assert!(!batch.vertices.is_empty());
|
||||
assert!(batch.vertices.iter().all(|v| v.pos[1] <= 4.0));
|
||||
let area: f32 = batch
|
||||
.vertices
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|t| {
|
||||
((t[1].pos[0] - t[0].pos[0]) * (t[2].pos[1] - t[0].pos[1])
|
||||
- (t[2].pos[0] - t[0].pos[0]) * (t[1].pos[1] - t[0].pos[1]))
|
||||
.abs()
|
||||
/ 2.0
|
||||
})
|
||||
.sum();
|
||||
// Rows 0..4 of the triangle: 16 * 16 / 2 - 12 * 12 / 2.
|
||||
assert!((area - 56.0).abs() < 1e-3, "{area}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_since_moves_blend_ranges_with_their_vertices() {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let start = batch.vertices.len();
|
||||
let tri = |batch: &mut HudBatch, y: f32| {
|
||||
batch.quad(
|
||||
[[0.0, y], [8.0, y], [0.0, y + 8.0], [0.0, y + 8.0]],
|
||||
[[0.0; 2]; 4],
|
||||
u32::MAX,
|
||||
);
|
||||
};
|
||||
tri(&mut batch, 100.0); // outside, dropped
|
||||
let foil = batch.vertices.len();
|
||||
tri(&mut batch, 0.0); // straddles the bottom edge
|
||||
batch.blend_since(foil, Blend::Additive);
|
||||
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
|
||||
assert_eq!(
|
||||
batch.blends,
|
||||
[(0..batch.vertices.len() as u32, Blend::Additive)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Write a PNG `width x height` whose `frames` stacked rows are solid
|
||||
/// colours, then its mcmeta beside it.
|
||||
fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) {
|
||||
@@ -1858,6 +2027,34 @@ mod tests {
|
||||
atlas.pixels[at..at + 4].try_into().unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_equipment_textures_pack_under_their_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
|
||||
fs::create_dir_all(&equipment).unwrap();
|
||||
write_strip(
|
||||
&equipment.join("ruby.png"),
|
||||
64,
|
||||
32,
|
||||
&[[200, 0, 0, 255]],
|
||||
None,
|
||||
);
|
||||
let other = root.join("assets/gems/textures/item");
|
||||
fs::create_dir_all(&other).unwrap();
|
||||
write_strip(&other.join("ruby.png"), 16, 16, &[[0, 200, 0, 255]], None);
|
||||
fs::write(
|
||||
root.join(horizon_items::content_namespaces::MARKER),
|
||||
br#"["gems"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
|
||||
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
|
||||
let root = animated_root();
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
@@ -1890,6 +2087,39 @@ mod tests {
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let item = root.join("assets/lobby/textures/item/npc");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&item).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A server pack's NPC skin: two 16x16 frames, green then red.
|
||||
write_strip(
|
||||
&item.join("skin.png"),
|
||||
16,
|
||||
32,
|
||||
&[[0, 255, 0, 255], [255, 0, 0, 255]],
|
||||
Some(r#"{"animation": {"frametime": 3}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["lobby:item/npc/skin"];
|
||||
// One frame, not the whole strip, which a model's UVs would span.
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
@@ -1946,6 +2176,33 @@ mod tests {
|
||||
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
|
||||
}
|
||||
}
|
||||
/// A content mod's GUI textures join the atlas under its namespace;
|
||||
/// its other textures and vanilla's names stay as they were.
|
||||
#[test]
|
||||
fn a_content_mods_gui_textures_are_sprites_under_its_namespace() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-mod-gui-{}", std::process::id()));
|
||||
write_png(&root, "gui/container/furnace", 256, 256);
|
||||
let png = |rel: &str, w: u32, h: u32| {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels = vec![255; (w * h * 4) as usize];
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
png("assets/mymod/textures/gui/container/machine.png", 176, 166);
|
||||
png("assets/mymod/textures/block/machine.png", 16, 16);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(atlas.sprites["mymod:gui/container/machine"].w, 176);
|
||||
assert!(atlas.sprites.contains_key("gui/container/furnace"));
|
||||
assert!(!atlas.sprites.contains_key("mymod:block/machine"));
|
||||
}
|
||||
#[test]
|
||||
fn the_default_textures_keep_a_4096_wide_atlas() {
|
||||
let images = sized(400, 64, 64);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! `JigsawBlockEntity`: the jigsaw block's name, target, pool, joint, final
|
||||
//! state and priorities, with its save format (`saveAdditional` /
|
||||
//! `loadAdditional`).
|
||||
use crate::structure_block::parse_identifier;
|
||||
use horizon_client_core::nbt_value::{number, string, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `JigsawBlockEntity.EMPTY_ID` and `Pools.EMPTY`.
|
||||
pub const EMPTY_ID: &str = "minecraft:empty";
|
||||
|
||||
/// `JigsawBlockEntity.JointType`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum JointType {
|
||||
Rollable,
|
||||
Aligned,
|
||||
}
|
||||
|
||||
impl JointType {
|
||||
pub const ALL: [Self; 2] = [Self::Rollable, Self::Aligned];
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rollable => "rollable",
|
||||
Self::Aligned => "aligned",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|j| j.serialized_name() == name)
|
||||
}
|
||||
/// `JointType.getTranslatedName`'s lang key.
|
||||
pub fn label_key(self) -> String {
|
||||
format!("jigsaw_block.joint.{}", self.serialized_name())
|
||||
}
|
||||
/// `StructureTemplate.getDefaultJointType`: the joint of a block whose
|
||||
/// front faces `front` (the first word of its `orientation` value).
|
||||
pub fn default_for_front(front: &str) -> Self {
|
||||
if is_vertical_front(front) {
|
||||
Self::Rollable
|
||||
} else {
|
||||
Self::Aligned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `JigsawBlock.getFrontFacing(state)` from the `orientation` value
|
||||
/// (`down_east`, `north_up`...): its front is the part before the `_`.
|
||||
pub fn front_of_orientation(orientation: &str) -> &str {
|
||||
orientation.split('_').next().unwrap_or(orientation)
|
||||
}
|
||||
|
||||
pub fn is_vertical_front(front: &str) -> bool {
|
||||
matches!(front, "up" | "down")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct JigsawBlock {
|
||||
pub name: String,
|
||||
pub target: String,
|
||||
pub pool: String,
|
||||
pub joint: JointType,
|
||||
pub final_state: String,
|
||||
pub placement_priority: i32,
|
||||
pub selection_priority: i32,
|
||||
}
|
||||
|
||||
impl Default for JigsawBlock {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: EMPTY_ID.into(),
|
||||
target: EMPTY_ID.into(),
|
||||
pool: EMPTY_ID.into(),
|
||||
joint: JointType::Rollable,
|
||||
final_state: "minecraft:air".into(),
|
||||
placement_priority: 0,
|
||||
selection_priority: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JigsawBlock {
|
||||
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
vec![
|
||||
("name", string(&self.name)),
|
||||
("target", string(&self.target)),
|
||||
("pool", string(&self.pool)),
|
||||
("final_state", string(&self.final_state)),
|
||||
("joint", string(self.joint.serialized_name())),
|
||||
("placement_priority", Tag::Int(self.placement_priority)),
|
||||
("selection_priority", Tag::Int(self.selection_priority)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `loadAdditional`. A missing joint takes `default_joint`
|
||||
/// (`StructureTemplate.getDefaultJointType` of the block's state).
|
||||
pub fn load(tag: &Tag, default_joint: JointType) -> Self {
|
||||
let id = |key: &str| {
|
||||
text(tag.get(key))
|
||||
.and_then(|s| parse_identifier(&s))
|
||||
.unwrap_or_else(|| EMPTY_ID.into())
|
||||
};
|
||||
Self {
|
||||
name: id("name"),
|
||||
target: id("target"),
|
||||
pool: id("pool"),
|
||||
final_state: text(tag.get("final_state")).unwrap_or_else(|| "minecraft:air".into()),
|
||||
joint: text(tag.get("joint"))
|
||||
.and_then(|j| JointType::of_serialized_name(&j))
|
||||
.unwrap_or(default_joint),
|
||||
placement_priority: number(tag.get("placement_priority")).unwrap_or(0) as i32,
|
||||
selection_priority: number(tag.get("selection_priority")).unwrap_or(0) as i32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_jigsaw_block_entity() {
|
||||
let block = JigsawBlock {
|
||||
name: "minecraft:bottom".into(),
|
||||
target: "test:top".into(),
|
||||
pool: "minecraft:village/plains/houses".into(),
|
||||
joint: JointType::Aligned,
|
||||
final_state: "minecraft:stone[x=1]".into(),
|
||||
placement_priority: 4,
|
||||
selection_priority: -1,
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(text(tag.get("joint")).as_deref(), Some("aligned"));
|
||||
assert_eq!(JigsawBlock::load(&tag, JointType::Rollable), block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_defaults_like_vanilla() {
|
||||
let tag = Tag::compound(vec![
|
||||
("name", string("Bad Name")),
|
||||
("target", string("door")),
|
||||
]);
|
||||
let block = JigsawBlock::load(&tag, JointType::Aligned);
|
||||
assert_eq!(block.name, EMPTY_ID);
|
||||
assert_eq!(block.target, "minecraft:door");
|
||||
assert_eq!(block.pool, EMPTY_ID);
|
||||
assert_eq!(block.final_state, "minecraft:air");
|
||||
assert_eq!(block.joint, JointType::Aligned);
|
||||
assert_eq!(JointType::default_for_front("up"), JointType::Rollable);
|
||||
assert_eq!(
|
||||
JointType::default_for_front(front_of_orientation("north_up")),
|
||||
JointType::Aligned
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,261 @@ pub enum Reason {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// `LevelLoadTracker.CLIENT_WAIT_TIMEOUT_MS`.
|
||||
pub const CLIENT_WAIT_TIMEOUT_MS: u64 = 30_000;
|
||||
/// `LevelLoadTracker.LEVEL_LOAD_CLOSE_DELAY_MS`: how long a new world's loading
|
||||
/// screen stays once the level is ready.
|
||||
pub const LEVEL_LOAD_CLOSE_DELAY_MS: u64 = 500;
|
||||
|
||||
/// `ClientPacketListener.determineLevelLoadingReason`.
|
||||
pub fn reason_for_respawn(player_died: bool, dimension: &str, old_dimension: &str) -> Reason {
|
||||
const NETHER: &str = "minecraft:the_nether";
|
||||
const END: &str = "minecraft:the_end";
|
||||
if player_died {
|
||||
Reason::Other
|
||||
} else if dimension == NETHER || old_dimension == NETHER {
|
||||
Reason::NetherPortal
|
||||
} else if dimension == END || old_dimension == END {
|
||||
Reason::EndPortal
|
||||
} else {
|
||||
Reason::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadTracker.ClientState`, with the times in milliseconds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ClientState {
|
||||
/// `WaitingForServer`: until the loading start game event.
|
||||
WaitingForServer { timeout_after: u64 },
|
||||
/// `WaitingForPlayerChunk`: until the section around the player is shown.
|
||||
WaitingForPlayerChunk {
|
||||
player_section_ready: bool,
|
||||
timeout_after: u64,
|
||||
},
|
||||
/// `ClientLevelReady`.
|
||||
Ready { ready_at: u64 },
|
||||
}
|
||||
|
||||
/// What `WaitingForPlayerChunk.isReady` asks of the player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PlayerCheck {
|
||||
/// `ClientLevel.isOutsideBuildHeight(camera y)`.
|
||||
pub outside_build_height: bool,
|
||||
pub spectator: bool,
|
||||
pub alive: bool,
|
||||
}
|
||||
|
||||
/// The client half of `LevelLoadTracker`: what the loading screen waits for
|
||||
/// after a login or a respawn, and the screen it is shown on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LevelLoadTracker {
|
||||
state: Option<ClientState>,
|
||||
close_delay_ms: u64,
|
||||
/// `LevelLoadingScreen.reason`.
|
||||
pub reason: Reason,
|
||||
}
|
||||
impl LevelLoadTracker {
|
||||
pub fn new(close_delay_ms: u64) -> Self {
|
||||
Self {
|
||||
state: None,
|
||||
close_delay_ms,
|
||||
reason: Reason::Other,
|
||||
}
|
||||
}
|
||||
/// `startClientLoad`, for the screen `reason` (`startWaitingForNewLevel`).
|
||||
pub fn start_client_load(&mut self, now: u64, reason: Reason) {
|
||||
self.state = Some(ClientState::WaitingForServer {
|
||||
timeout_after: now + CLIENT_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
self.reason = reason;
|
||||
}
|
||||
/// The tracker's work is over and the screen has closed
|
||||
/// (`ClientPacketListener.tick` drops it once the level is ready).
|
||||
pub fn finish(&mut self) {
|
||||
self.state = None;
|
||||
}
|
||||
/// A load is under way, so its screen is up.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.state.is_some()
|
||||
}
|
||||
/// `loadingPacketsReceived`: `LEVEL_CHUNKS_LOAD_START` came.
|
||||
pub fn loading_packets_received(&mut self) {
|
||||
if let Some(ClientState::WaitingForServer { timeout_after }) = self.state {
|
||||
self.state = Some(ClientState::WaitingForPlayerChunk {
|
||||
player_section_ready: false,
|
||||
timeout_after,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `getPlayerCompiledSectionCallback` is not null: the renderer is to say
|
||||
/// when the player's section is shown.
|
||||
pub fn wants_player_section(&self) -> bool {
|
||||
matches!(self.state, Some(ClientState::WaitingForPlayerChunk { .. }))
|
||||
}
|
||||
/// The callback: the player's section is compiled and visible.
|
||||
pub fn player_section_ready(&mut self) {
|
||||
if let Some(ClientState::WaitingForPlayerChunk {
|
||||
player_section_ready,
|
||||
..
|
||||
}) = &mut self.state
|
||||
{
|
||||
*player_section_ready = true;
|
||||
}
|
||||
}
|
||||
/// `tickClientLoad`.
|
||||
pub fn tick_client_load(&mut self, now: u64, player: PlayerCheck) {
|
||||
if let Some(ClientState::WaitingForPlayerChunk {
|
||||
player_section_ready,
|
||||
timeout_after,
|
||||
}) = self.state
|
||||
{
|
||||
let ready = if now > timeout_after {
|
||||
log::warn!(
|
||||
"Timed out while waiting for the client to load chunks, letting the player into the world anyway"
|
||||
);
|
||||
true
|
||||
} else if !player.outside_build_height && !player.spectator && player.alive {
|
||||
player_section_ready
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if ready {
|
||||
self.state = Some(ClientState::Ready { ready_at: now });
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `isLevelReady`.
|
||||
pub fn is_level_ready(&self, now: u64) -> bool {
|
||||
matches!(self.state, Some(ClientState::Ready { ready_at }) if now >= ready_at + self.close_delay_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadListener.Stage`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
StartServer,
|
||||
PrepareGlobalSpawn,
|
||||
LoadInitialChunks,
|
||||
LoadPlayerChunks,
|
||||
}
|
||||
|
||||
/// `LevelLoadProgressTracker` (with `includePlayerChunks`).
|
||||
#[derive(Debug, Default)]
|
||||
struct ProgressTracker {
|
||||
total_weight: usize,
|
||||
finalized_weight: usize,
|
||||
segment_weight: usize,
|
||||
segment_fraction: f32,
|
||||
progress: f32,
|
||||
}
|
||||
impl ProgressTracker {
|
||||
fn tracks(stage: Stage) -> bool {
|
||||
matches!(stage, Stage::LoadInitialChunks | Stage::LoadPlayerChunks)
|
||||
}
|
||||
fn begin_segment(&mut self, weight: usize) {
|
||||
self.segment_weight = weight;
|
||||
self.segment_fraction = 0.0;
|
||||
self.update_progress();
|
||||
}
|
||||
fn finish_segment(&mut self) {
|
||||
self.finalized_weight += self.segment_weight;
|
||||
self.segment_weight = 0;
|
||||
self.update_progress();
|
||||
}
|
||||
fn update_progress(&mut self) {
|
||||
self.progress = if self.total_weight == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.finalized_weight as f32 + self.segment_fraction * self.segment_weight as f32)
|
||||
/ self.total_weight as f32
|
||||
};
|
||||
}
|
||||
fn start(&mut self, stage: Stage, total_chunks: usize) {
|
||||
match stage {
|
||||
Stage::LoadInitialChunks => {
|
||||
self.total_weight = PREPARE_WEIGHT + total_chunks + PLAYER_CHUNKS;
|
||||
self.begin_segment(PREPARE_WEIGHT);
|
||||
self.finish_segment();
|
||||
self.begin_segment(total_chunks);
|
||||
}
|
||||
Stage::LoadPlayerChunks => self.begin_segment(PLAYER_CHUNKS),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn update(&mut self, stage: Stage, current: usize, total: usize) {
|
||||
if Self::tracks(stage) {
|
||||
self.segment_fraction = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
current as f32 / total as f32
|
||||
};
|
||||
self.update_progress();
|
||||
}
|
||||
}
|
||||
fn finish(&mut self, stage: Stage) {
|
||||
if Self::tracks(stage) {
|
||||
self.finish_segment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The integrated server's side of `LevelLoadTracker`: what its
|
||||
/// `LevelLoadListener` reported (`serverProgress`, `hasProgress`) and the
|
||||
/// chunk status map around the load focus (`ChunkLoadStatusView`), which the
|
||||
/// server thread writes and the window's loading screen reads.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ServerLoadView {
|
||||
tracker: std::sync::Mutex<ProgressTracker>,
|
||||
/// `serverStage != null`.
|
||||
has_progress: std::sync::atomic::AtomicBool,
|
||||
/// `statusView.get(x, z)` for x and z in `0..=2 * STATUS_RADIUS`.
|
||||
cells: std::sync::Mutex<Vec<Option<Status>>>,
|
||||
}
|
||||
impl ServerLoadView {
|
||||
/// `LevelLoadListener.start`.
|
||||
pub fn start(&self, stage: Stage, total_chunks: usize) {
|
||||
Self::lock(&self.tracker).start(stage, total_chunks);
|
||||
// `serverStage = stage`, whatever the stage.
|
||||
self.has_progress
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// `LevelLoadListener.update`.
|
||||
pub fn update(&self, stage: Stage, current: usize, total: usize) {
|
||||
Self::lock(&self.tracker).update(stage, current, total);
|
||||
}
|
||||
/// `LevelLoadListener.finish`.
|
||||
pub fn finish(&self, stage: Stage) {
|
||||
Self::lock(&self.tracker).finish(stage);
|
||||
}
|
||||
/// `LevelLoadTracker.hasProgress`.
|
||||
pub fn has_progress(&self) -> bool {
|
||||
self.has_progress.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// `LevelLoadTracker.serverProgress`.
|
||||
pub fn server_progress(&self) -> f32 {
|
||||
Self::lock(&self.tracker).progress
|
||||
}
|
||||
/// The map `ChunkLoadStatusView.get` reads, replaced as a whole.
|
||||
pub fn set_status(&self, cells: Vec<Option<Status>>) {
|
||||
*Self::lock(&self.cells) = cells;
|
||||
}
|
||||
/// `ChunkLoadStatusView.get`.
|
||||
pub fn status(&self, x: i32, z: i32) -> Option<Status> {
|
||||
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
|
||||
let (x, z) = (usize::try_from(x).ok()?, usize::try_from(z).ok()?);
|
||||
if x >= diameter || z >= diameter {
|
||||
return None;
|
||||
}
|
||||
Self::lock(&self.cells)
|
||||
.get(x * diameter + z)
|
||||
.copied()
|
||||
.flatten()
|
||||
}
|
||||
fn lock<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadProgressTracker.get` once the server is prepared, with `ready`
|
||||
/// of the player's 49 chunks loaded (no forced chunks to load first).
|
||||
pub fn progress(ready: usize) -> f32 {
|
||||
@@ -165,6 +420,118 @@ pub fn draw_connecting(
|
||||
}
|
||||
}
|
||||
|
||||
/// `FileFixerProgressScreen`'s Cancel button corner on a GUI `w` x `h`:
|
||||
/// ((width - 200) / 2, height / 2 + 100), 200 x 20.
|
||||
pub fn file_fixer_cancel(w: f32, h: f32) -> (f32, f32) {
|
||||
(((w as i32 - 200) / 2) as f32, (h as i32 / 2 + 100) as f32)
|
||||
}
|
||||
|
||||
/// `GuiGraphicsExtractor.centeredText` with the screen's grey (`-6250336`) or white.
|
||||
fn centered_line(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
x_center: i32,
|
||||
y: i32,
|
||||
color: u32,
|
||||
) {
|
||||
let tw = atlas.font.width_text(text) as i32;
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
text,
|
||||
(x_center - tw / 2) as f32,
|
||||
y as f32,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/// `FileFixerProgressScreen.extractProgressBar`.
|
||||
fn file_fixer_bar(batch: &mut HudBatch, atlas: &HudAtlas, x_center: i32, y: i32, progress: f32) {
|
||||
let left = x_center - 100;
|
||||
batch.fill(atlas, left as f32, y as f32, 200.0, 2.0, opaque(0));
|
||||
batch.fill(
|
||||
atlas,
|
||||
left as f32,
|
||||
y as f32,
|
||||
(progress * 200.0).round(),
|
||||
2.0,
|
||||
opaque(0x00FF00),
|
||||
);
|
||||
}
|
||||
|
||||
/// `FileFixerProgressScreen.extractRenderState` (with `extractProgress`,
|
||||
/// `extractProgressBar`, `extractFileStats`, `extractFileFixerCount`,
|
||||
/// `extractTypeText`, `extractTypeProgress` and `extractScanning`) over the menu background: the
|
||||
/// title, then either the scanning line or the running fixer's bar, the
|
||||
/// totals, the stage, the type's label and its bar and percentage; and
|
||||
/// Cancel, inactive once pressed.
|
||||
pub fn draw_file_fixer(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
progress: &horizon_world::datafix::progress::UpgradeProgress,
|
||||
cursor: (f32, f32),
|
||||
) {
|
||||
const GREY: u32 = 0xFFA0_A0A0;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let (x_center, y_center) = ((w as i32) / 2, (h as i32) / 2);
|
||||
let text_top = y_center - 50;
|
||||
centered_line(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::translate("upgradeWorld.title", &[]),
|
||||
x_center,
|
||||
text_top,
|
||||
u32::MAX,
|
||||
);
|
||||
let total = progress.total_file_fix_stats();
|
||||
if total.total_operations() > 0 {
|
||||
let of_type = progress.type_file_fix_stats();
|
||||
let running = progress.running_file_fixer_stats();
|
||||
let mut y = text_top + 9 + 3;
|
||||
file_fixer_bar(batch, atlas, x_center, y, running.progress());
|
||||
y += 7;
|
||||
// extractFileStats
|
||||
let converted = Text::tr(
|
||||
"upgradeWorld.info.converted",
|
||||
&[&total.finished_operations()],
|
||||
);
|
||||
centered_line(batch, atlas, &converted, x_center, y, GREY);
|
||||
let sum = Text::tr("upgradeWorld.info.total", &[&total.total_operations()]);
|
||||
centered_line(batch, atlas, &sum, x_center, y + 9 + 3, GREY);
|
||||
y += 9 * 2 + 6;
|
||||
// extractFileFixerCount
|
||||
let stage = Text::tr(
|
||||
"upgradeWorld.info.file_fix_stage",
|
||||
&[&running.finished_operations(), &running.total_operations()],
|
||||
);
|
||||
centered_line(batch, atlas, &stage, x_center, y, GREY);
|
||||
y += 9 + 30 - 5;
|
||||
// extractTypeText
|
||||
if let Some(ty) = progress.upgrade_type() {
|
||||
let label = Text::translate(&ty.label_key(), &[]);
|
||||
centered_line(batch, atlas, &label, x_center, y, GREY);
|
||||
}
|
||||
y += 9 + 3;
|
||||
file_fixer_bar(batch, atlas, x_center, y, of_type.progress());
|
||||
y += 7;
|
||||
// extractTypeProgress: Mth.floor(progress * 100)
|
||||
let percent = (of_type.progress() * 100.0).floor() as i32;
|
||||
let text = Text::tr("upgradeWorld.progress.percentage", &[&percent]);
|
||||
centered_line(batch, atlas, &text, x_center, y, GREY);
|
||||
} else {
|
||||
// extractScanning
|
||||
let scanning = Text::translate("upgradeWorld.info.scanning", &[]);
|
||||
centered_line(batch, atlas, &scanning, x_center, text_top + 9 + 3, GREY);
|
||||
}
|
||||
let (x, y) = file_fixer_cancel(w, h);
|
||||
let active = !progress.is_canceled();
|
||||
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
|
||||
let label = Text::translate("gui.cancel", &[]);
|
||||
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, active, hover);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -179,6 +546,12 @@ mod tests {
|
||||
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_fixer_cancel_sits_below_the_centre() {
|
||||
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
|
||||
assert_eq!(file_fixer_cancel(426.0, 241.0), (113.0, 220.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_are_opaque_rgba() {
|
||||
assert_eq!(opaque(Status::Full.color()), u32::MAX);
|
||||
@@ -190,3 +563,145 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tracker_tests {
|
||||
use super::*;
|
||||
|
||||
const ALIVE: PlayerCheck = PlayerCheck {
|
||||
outside_build_height: false,
|
||||
spectator: false,
|
||||
alive: true,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn server_progress_follows_the_listener_like_the_progress_tracker() {
|
||||
let view = ServerLoadView::default();
|
||||
assert!(!view.has_progress() && view.server_progress() == 0.0);
|
||||
// A stage the tracker ignores still counts as progress being made.
|
||||
view.start(Stage::StartServer, 0);
|
||||
assert!(view.has_progress() && view.server_progress() == 0.0);
|
||||
// prepareLevels with no forced chunks: the 10 of preparing, of 59.
|
||||
view.start(Stage::LoadInitialChunks, 0);
|
||||
view.finish(Stage::LoadInitialChunks);
|
||||
assert_eq!(view.server_progress(), 10.0 / 59.0);
|
||||
// PrepareSpawnTask: the 49 player chunks.
|
||||
view.start(Stage::LoadPlayerChunks, 49);
|
||||
view.update(Stage::LoadPlayerChunks, 20, 49);
|
||||
assert_eq!(view.server_progress(), (10.0 + 20.0) / 59.0);
|
||||
assert_eq!(view.server_progress(), progress(20));
|
||||
view.update(Stage::LoadPlayerChunks, 49, 49);
|
||||
view.finish(Stage::LoadPlayerChunks);
|
||||
assert_eq!(view.server_progress(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_map_is_read_from_the_north_west_corner() {
|
||||
let view = ServerLoadView::default();
|
||||
assert_eq!(view.status(0, 0), None);
|
||||
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
|
||||
let mut cells = vec![None; diameter * diameter];
|
||||
cells[3 * diameter + 5] = Some(Status::Full);
|
||||
view.set_status(cells);
|
||||
assert_eq!(view.status(3, 5), Some(Status::Full));
|
||||
assert_eq!(view.status(5, 3), None);
|
||||
assert_eq!(view.status(-1, 0), None);
|
||||
assert_eq!(view.status(diameter as i32, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_load_waits_for_the_server_then_the_players_section() {
|
||||
let mut tracker = LevelLoadTracker::new(0);
|
||||
assert!(!tracker.is_active());
|
||||
tracker.start_client_load(1_000, Reason::NetherPortal);
|
||||
assert!(tracker.is_active() && tracker.reason == Reason::NetherPortal);
|
||||
// Nothing is asked of the renderer before the start event, and a
|
||||
// section shown early counts for nothing.
|
||||
assert!(!tracker.wants_player_section());
|
||||
tracker.player_section_ready();
|
||||
tracker.tick_client_load(1_050, ALIVE);
|
||||
assert!(!tracker.is_level_ready(1_050));
|
||||
tracker.loading_packets_received();
|
||||
assert!(tracker.wants_player_section());
|
||||
tracker.tick_client_load(1_100, ALIVE);
|
||||
assert!(!tracker.is_level_ready(1_100));
|
||||
tracker.player_section_ready();
|
||||
tracker.tick_client_load(1_150, ALIVE);
|
||||
assert!(tracker.is_level_ready(1_150));
|
||||
tracker.finish();
|
||||
assert!(!tracker.is_active() && !tracker.is_level_ready(2_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_who_cannot_see_the_section_does_not_wait_for_it() {
|
||||
for player in [
|
||||
PlayerCheck {
|
||||
outside_build_height: true,
|
||||
..ALIVE
|
||||
},
|
||||
PlayerCheck {
|
||||
spectator: true,
|
||||
..ALIVE
|
||||
},
|
||||
PlayerCheck {
|
||||
alive: false,
|
||||
..ALIVE
|
||||
},
|
||||
] {
|
||||
let mut tracker = LevelLoadTracker::new(0);
|
||||
tracker.start_client_load(0, Reason::Other);
|
||||
tracker.loading_packets_received();
|
||||
tracker.tick_client_load(10, player);
|
||||
assert!(tracker.is_level_ready(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wait_times_out_after_thirty_seconds_but_only_once_the_server_has_started() {
|
||||
let mut tracker = LevelLoadTracker::new(0);
|
||||
tracker.start_client_load(0, Reason::Other);
|
||||
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
|
||||
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
|
||||
tracker.loading_packets_received();
|
||||
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS, ALIVE);
|
||||
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS));
|
||||
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
|
||||
assert!(tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_worlds_screen_stays_for_the_close_delay() {
|
||||
let mut tracker = LevelLoadTracker::new(LEVEL_LOAD_CLOSE_DELAY_MS);
|
||||
tracker.start_client_load(0, Reason::Other);
|
||||
tracker.loading_packets_received();
|
||||
tracker.player_section_ready();
|
||||
tracker.tick_client_load(100, ALIVE);
|
||||
assert!(!tracker.is_level_ready(599));
|
||||
assert!(tracker.is_level_ready(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_respawn_names_its_screen_by_the_dimensions() {
|
||||
let (overworld, nether, end) = (
|
||||
"minecraft:overworld",
|
||||
"minecraft:the_nether",
|
||||
"minecraft:the_end",
|
||||
);
|
||||
assert_eq!(
|
||||
reason_for_respawn(false, nether, overworld),
|
||||
Reason::NetherPortal
|
||||
);
|
||||
assert_eq!(
|
||||
reason_for_respawn(false, overworld, nether),
|
||||
Reason::NetherPortal
|
||||
);
|
||||
assert_eq!(reason_for_respawn(false, end, overworld), Reason::EndPortal);
|
||||
assert_eq!(reason_for_respawn(false, end, nether), Reason::NetherPortal);
|
||||
assert_eq!(
|
||||
reason_for_respawn(false, overworld, overworld),
|
||||
Reason::Other
|
||||
);
|
||||
// A death never shows a portal.
|
||||
assert_eq!(reason_for_respawn(true, nether, overworld), Reason::Other);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
@@ -50,6 +51,7 @@ pub mod mipmap;
|
||||
pub mod mod_list;
|
||||
pub mod model_library;
|
||||
pub mod models;
|
||||
pub mod multiline_edit;
|
||||
pub mod options_file;
|
||||
pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
@@ -59,6 +61,7 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod post_chain;
|
||||
pub mod potent_sulfur;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
@@ -71,13 +74,17 @@ pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
pub mod structure_block;
|
||||
pub mod tab_list;
|
||||
pub mod telemetry;
|
||||
pub mod test_block;
|
||||
pub mod test_instance_block;
|
||||
pub mod testgen;
|
||||
pub mod title_edition;
|
||||
pub mod trims;
|
||||
|
||||
@@ -467,8 +467,15 @@ fn pack_lighting(
|
||||
/// edits and immutable snapshots retained by other workers.
|
||||
pub fn apply_lighting(chunk: &mut Chunk, lights: Vec<Light>) {
|
||||
for (section, light) in chunk.sections.iter_mut().zip(lights) {
|
||||
// A shared section already lit so stays as it is.
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (§ion.light, &light)
|
||||
&& a == b
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Arc::make_mut(section).light = light;
|
||||
}
|
||||
crate::chunk::share_uniform(&mut chunk.sections);
|
||||
}
|
||||
|
||||
/// `apply_lighting` for a chunk lit before: writes only the sections whose
|
||||
@@ -535,6 +542,7 @@ pub fn light_from_layers<L: AsRef<[u8]>>(
|
||||
section.light = Light::Full(data);
|
||||
section.compact();
|
||||
}
|
||||
crate::chunk::share_uniform(&mut chunk.sections);
|
||||
chunk.lit = true;
|
||||
chunk.light_loaded = true;
|
||||
}
|
||||
@@ -658,6 +666,12 @@ impl<'w> Region<'w> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
Some(self.slots[slot].section.get(index(x, y, z)))
|
||||
}
|
||||
/// The one state of the section holding `y`, when it has a single one.
|
||||
fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
let section = &self.slots[slot].section;
|
||||
(section.blocks.bits() == 0).then(|| section.get(0))
|
||||
}
|
||||
/// Packed light (sky << 4 | block), None outside loaded, lit chunks.
|
||||
fn light(&mut self, x: i32, y: i32, z: i32) -> Option<u8> {
|
||||
let slot = self.slot(x, y, z)?;
|
||||
@@ -826,6 +840,15 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -
|
||||
return min_y;
|
||||
};
|
||||
while y > min_y {
|
||||
// A section of one state that lets light through itself (the air of
|
||||
// a tall column) holds no source edge: go to its bottom block.
|
||||
if let Some(state) = world.uniform_state(x, y - 1, z)
|
||||
&& state == top
|
||||
&& !tables.edge_occluded(top as usize, top as usize)
|
||||
{
|
||||
y = ((y - 1 - min_y) & !15) + min_y;
|
||||
continue;
|
||||
}
|
||||
let Some(bottom) = world.state(x, y - 1, z) else {
|
||||
break;
|
||||
};
|
||||
@@ -856,6 +879,23 @@ pub fn relight(
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
}
|
||||
/// The columns `changed` touched that border a loaded column not lit yet
|
||||
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
|
||||
/// darkening can take away light such a neighbour sends in and never put it
|
||||
/// back, and nothing relights the column when that neighbour's own light
|
||||
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
|
||||
/// keeps one light storage for every chunk, so its edits and its chunk
|
||||
/// lighting see each other. These columns need a full light job, which
|
||||
/// lights them from the blocks of their whole 3x3, that neighbour's included.
|
||||
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
|
||||
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
|
||||
columns.sort_unstable();
|
||||
columns.dedup();
|
||||
columns.retain(|&(x, z)| {
|
||||
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
|
||||
});
|
||||
columns
|
||||
}
|
||||
fn relight_region(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
@@ -1109,6 +1149,70 @@ mod tests {
|
||||
// Without the shape test the room would be lit (slabs have no dampening).
|
||||
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
|
||||
}
|
||||
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
|
||||
/// was lit from the 3x3's blocks, its torch's light and the far torch's
|
||||
/// in (1, 0) both. Taking its torch away darkens everything the torch
|
||||
/// lit, the far torch's light at the border too, and `relight` cannot
|
||||
/// bring that back from a column it doesn't read. Columns like that are
|
||||
/// lit again whole; without it the border stays at 0 for good once
|
||||
/// (1, 0)'s light lands beside it.
|
||||
#[test]
|
||||
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
|
||||
let tables = tables();
|
||||
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
|
||||
for z in -1..=1 {
|
||||
for x in -1..=1 {
|
||||
let mut c = Chunk::empty(x, z);
|
||||
for y in MIN_Y..0 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
if (x, z) == (0, 0) {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
} else if (x, z) == (1, 0) {
|
||||
c.set(6, 0, 8, TORCH);
|
||||
}
|
||||
w.insert((x, z), Arc::new(c));
|
||||
}
|
||||
}
|
||||
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
|
||||
})
|
||||
});
|
||||
light_chunk(®ion, &tables, true)
|
||||
};
|
||||
let keys: Vec<_> = w.keys().copied().collect();
|
||||
for key in &keys {
|
||||
let lights = light(&w, *key);
|
||||
let c = Arc::make_mut(w.get_mut(key).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = *key != (1, 0);
|
||||
}
|
||||
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
|
||||
assert_eq!(block(&w, 15, 0, 8), 13);
|
||||
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
|
||||
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
|
||||
for &key in &again {
|
||||
let lights = light(&w, key);
|
||||
let c = Arc::make_mut(w.get_mut(&key).unwrap());
|
||||
apply_relighting(c, lights, &mut LightChanges::default());
|
||||
}
|
||||
// (1, 0)'s own job lands.
|
||||
let lights = light(&w, (1, 0));
|
||||
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
|
||||
apply_lighting(c, lights);
|
||||
c.lit = true;
|
||||
assert_eq!(block(&w, 16, 0, 8), 8);
|
||||
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
|
||||
assert_eq!(block(&w, 14, 0, 8), 6);
|
||||
assert!(again.contains(&(0, 0)), "{again:?}");
|
||||
}
|
||||
#[test]
|
||||
fn relight_keeps_unchanged_and_unlit_sections() {
|
||||
let mut w = world(|_| {});
|
||||
@@ -1271,6 +1375,14 @@ mod tests {
|
||||
assert_eq!(chunk.light(8, 1601, 8) >> 4, 15);
|
||||
assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof");
|
||||
assert_eq!(chunk.light(8, 2031, 8), 0xF0);
|
||||
// The empty sections above are one shared allocation.
|
||||
let top = chunk.sections.len() - 1;
|
||||
assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10]));
|
||||
let other = Chunk::with_layout(1, 0, layout);
|
||||
apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]);
|
||||
let mut lit = other.clone();
|
||||
apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]);
|
||||
assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top]));
|
||||
}
|
||||
#[test]
|
||||
fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() {
|
||||
@@ -1545,9 +1657,10 @@ mod tests {
|
||||
.iter()
|
||||
.all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 }))
|
||||
);
|
||||
let section = Arc::as_ptr(&chunk.sections[0]);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
assert_eq!(section, Arc::as_ptr(&chunk.sections[0]));
|
||||
// Uniform sections are one shared allocation (`share_uniform`).
|
||||
let first = &chunk.sections[0];
|
||||
assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
streaming::{LodSource, WorkerContext},
|
||||
};
|
||||
pub mod cache;
|
||||
mod mesh;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
@@ -40,7 +41,14 @@ fn sampling_threads(cores: usize) -> usize {
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
|
||||
const MAX_LEVEL: u8 = 10;
|
||||
pub(crate) const MAX_LEVEL: u8 = 10;
|
||||
/// The coarsest tile level whose chunks are generated in full (trees,
|
||||
/// structures) rather than sampled: cells of up to 4 blocks, out to about
|
||||
/// 1024 blocks at Full far detail.
|
||||
const DETAIL_LEVEL: u8 = 2;
|
||||
/// The most chunks one detail request asks for, one request each 250 ms:
|
||||
/// generated ones arrive at the decoration threads' pace, cached ones at once.
|
||||
const DETAIL_REQUEST: usize = 512;
|
||||
/// Top solid face and optional fluid face, in absolute block coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Surface {
|
||||
@@ -186,8 +194,15 @@ impl Column {
|
||||
self.push(span);
|
||||
}
|
||||
}
|
||||
/// `kinds`: `state_kinds`, per block state.
|
||||
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
|
||||
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
|
||||
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
|
||||
fn from_chunk(
|
||||
chunk: &Chunk,
|
||||
kinds: &[u8],
|
||||
water: Option<BlockStateId>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Self {
|
||||
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
|
||||
let face_light = |y: i32| {
|
||||
let mut sky = 0;
|
||||
@@ -212,7 +227,7 @@ impl Column {
|
||||
};
|
||||
let mut column = Self::default();
|
||||
for (sy, section) in chunk.sections.iter().enumerate() {
|
||||
let min = MIN_Y + sy as i32 * 16;
|
||||
let min = chunk.min_y() + sy as i32 * 16;
|
||||
let uniform = section.blocks.bits() == 0
|
||||
&& section.biomes.bits() == 0
|
||||
&& matches!(section.light, crate::chunk::Light::Uniform(_));
|
||||
@@ -222,11 +237,17 @@ impl Column {
|
||||
let step = if uniform { 16 } else { 1 };
|
||||
for offset in (0..16).step_by(step) {
|
||||
let y = min + offset;
|
||||
let state = BlockStateId(chunk.get(x, y, z));
|
||||
let kind = kind(state);
|
||||
let mut state = BlockStateId(chunk.get(x, y, z));
|
||||
let mut kind = kind(state);
|
||||
if kind & KEPT == 0 {
|
||||
continue;
|
||||
}
|
||||
if kind & WATERY != 0
|
||||
&& let Some(water) = water
|
||||
{
|
||||
state = water;
|
||||
kind = kinds[water.0 as usize];
|
||||
}
|
||||
let light = face_light(y);
|
||||
column.push(Span {
|
||||
min: y as i16,
|
||||
@@ -256,7 +277,7 @@ impl Column {
|
||||
&& let Some((height, state)) = water
|
||||
{
|
||||
column.surface = Some(Surface {
|
||||
height: MIN_Y,
|
||||
height: chunk.min_y(),
|
||||
state: BlockStateId(
|
||||
kinds.iter().position(|&kind| kind & AIR != 0).unwrap_or(0) as u32
|
||||
),
|
||||
@@ -269,15 +290,28 @@ impl Column {
|
||||
}
|
||||
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
|
||||
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
|
||||
let water = water_source(kinds);
|
||||
Arc::new(std::array::from_fn(|i| {
|
||||
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
|
||||
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
|
||||
}))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
|
||||
/// emission is in the high nibble.
|
||||
/// The water source state: the first plain kept fluid without light
|
||||
/// (`minecraft:water` comes before lava, and its level 0 first).
|
||||
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
|
||||
kinds
|
||||
.iter()
|
||||
.position(|&kind| kind == KEPT | FLUID)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
}
|
||||
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
|
||||
/// holding water; the light emission is in the high nibble.
|
||||
const KEPT: u8 = 1;
|
||||
const FLUID: u8 = 2;
|
||||
const AIR: u8 = 4;
|
||||
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
|
||||
/// waterlogged blocks that aren't kept): far terrain draws water there, or
|
||||
/// kelp reaching the surface leaves holes in the sea.
|
||||
const WATERY: u8 = 8;
|
||||
/// What `Column::from_chunk` needs of each block state, read from the facts
|
||||
/// once: matching their names for every block made observing a chunk slow.
|
||||
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
@@ -296,14 +330,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
|| f.full_collision
|
||||
|| f.name.ends_with("_leaves")
|
||||
|| chorus
|
||||
|| fluid);
|
||||
(u8::from(kept) * KEPT)
|
||||
| (u8::from(fluid) * FLUID)
|
||||
|| fluid
|
||||
|| nearly_full(&f.name));
|
||||
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
|
||||
(u8::from(kept || watery) * KEPT)
|
||||
| (u8::from(fluid || watery) * FLUID)
|
||||
| (u8::from(watery) * WATERY)
|
||||
| (u8::from(state == facts.air) * AIR)
|
||||
| (f.emission.min(15) << 4)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Blocks that fill their cell but neither draw solid nor collide as a full
|
||||
/// block: far terrain draws them as blocks, or patches of powder snow leave
|
||||
/// holes in mountains (and paths and fields dips).
|
||||
fn nearly_full(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:powder_snow"
|
||||
| "minecraft:dirt_path"
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:honey_block"
|
||||
) || name.ends_with("_stairs")
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct TileKey {
|
||||
pub x: i32,
|
||||
@@ -317,7 +366,7 @@ impl TileKey {
|
||||
pub fn origin(self) -> [i32; 3] {
|
||||
[self.x * self.size(), 0, self.z * self.size()]
|
||||
}
|
||||
fn parent(self) -> Self {
|
||||
pub(crate) fn parent(self) -> Self {
|
||||
Self {
|
||||
x: self.x.div_euclid(2),
|
||||
z: self.z.div_euclid(2),
|
||||
@@ -331,6 +380,12 @@ impl TileKey {
|
||||
level: self.level - 1,
|
||||
})
|
||||
}
|
||||
/// The squared horizontal distance from `camera` to the tile's nearest point.
|
||||
fn reach(self, camera: [i32; 2]) -> i64 {
|
||||
let [x, _, z] = self.origin();
|
||||
let gap = |c: i32, lo: i32| i64::from((lo - c).max(c - lo - self.size()).max(0));
|
||||
gap(camera[0], x).pow(2) + gap(camera[1], z).pow(2)
|
||||
}
|
||||
fn distance(self, camera: [i32; 2]) -> i32 {
|
||||
let [x, _, z] = self.origin();
|
||||
(x - camera[0])
|
||||
@@ -339,27 +394,6 @@ impl TileKey {
|
||||
.max(camera[1] - z - self.size())
|
||||
.max(0)
|
||||
}
|
||||
fn in_view(self, camera: [i32; 2], yaw: f32) -> bool {
|
||||
let [x, _, z] = self.origin();
|
||||
let (sin, cos) = yaw.sin_cos();
|
||||
let mut forward = f32::NEG_INFINITY;
|
||||
let mut left = f32::NEG_INFINITY;
|
||||
let mut right = f32::NEG_INFINITY;
|
||||
for (dx, dz) in [
|
||||
(0, 0),
|
||||
(self.size(), 0),
|
||||
(0, self.size()),
|
||||
(self.size(), self.size()),
|
||||
] {
|
||||
let (x, z) = ((x + dx - camera[0]) as f32, (z + dz - camera[1]) as f32);
|
||||
let f = -x * sin + z * cos;
|
||||
let side = x * cos + z * sin;
|
||||
forward = forward.max(f);
|
||||
left = left.max(f * 0.866 + side * 0.5);
|
||||
right = right.max(f * 0.866 - side * 0.5);
|
||||
}
|
||||
forward > 0. && left > 0. && right > 0.
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Plan {
|
||||
@@ -411,7 +445,9 @@ impl Plan {
|
||||
previous: &Self,
|
||||
reduced: bool,
|
||||
) {
|
||||
if key.distance(camera) > range {
|
||||
// The range is a circle, as the renderer's: a tile leaves it, and
|
||||
// fades out (gfx/lod.rs), as the player moves.
|
||||
if key.reach(camera) > i64::from(range) * i64::from(range) {
|
||||
return;
|
||||
}
|
||||
self.nodes.insert(key);
|
||||
@@ -604,7 +640,6 @@ pub struct Lod {
|
||||
/// Reduced far detail (`set_reduced_detail`).
|
||||
reduced_detail: bool,
|
||||
pending: FxHashSet<TileKey>,
|
||||
sample_nearby: bool,
|
||||
detail_masks: FxHashMap<TileKey, [u64; 4]>,
|
||||
detail_request_pending: bool,
|
||||
detail_request_at: std::time::Instant,
|
||||
@@ -709,7 +744,9 @@ impl Lod {
|
||||
.expect("far sampling worker"),
|
||||
);
|
||||
}
|
||||
let mut mesher = mesh::Mesher::default();
|
||||
// One mesher per thread: each keeps its material cache.
|
||||
let mut meshers: Vec<_> =
|
||||
(0..count.max(1)).map(|_| mesh::Mesher::default()).collect();
|
||||
'work: loop {
|
||||
for (key, columns) in sampler.drain_details() {
|
||||
if tx
|
||||
@@ -769,24 +806,48 @@ impl Lod {
|
||||
covered,
|
||||
bounds,
|
||||
} => {
|
||||
let mut meshes = Vec::with_capacity(keys.len());
|
||||
for key in keys {
|
||||
if worker.stop.load(Ordering::Relaxed) {
|
||||
break 'work;
|
||||
}
|
||||
let finer = finer.remove(&key).unwrap_or_default();
|
||||
let input = snapshot.mesh_input(key, finer);
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
&covered,
|
||||
bounds,
|
||||
&mesh_context,
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
let work: Vec<_> = keys
|
||||
.into_iter()
|
||||
.map(|key| (key, finer.remove(&key).unwrap_or_default()))
|
||||
.collect();
|
||||
let per_thread = work.len().div_ceil(meshers.len()).max(1);
|
||||
let (inputs, cut) = (&snapshot, &covered);
|
||||
let (stop, context) = (&worker.stop, &mesh_context);
|
||||
let meshes: Vec<_> = thread::scope(|scope| {
|
||||
let threads: Vec<_> = work
|
||||
.chunks(per_thread)
|
||||
.zip(meshers.iter_mut())
|
||||
.map(|(work, mesher)| {
|
||||
scope.spawn(move || {
|
||||
let mut meshes = Vec::with_capacity(work.len());
|
||||
for (key, finer) in work {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let input = inputs.mesh_input(*key, finer.clone());
|
||||
let mut mesh = mesher.mesh(
|
||||
input.key,
|
||||
&input.columns(),
|
||||
cut,
|
||||
bounds,
|
||||
context,
|
||||
&input.neighbors,
|
||||
&input.finer,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
meshes.push((input.key, mesh));
|
||||
}
|
||||
meshes
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
threads
|
||||
.into_iter()
|
||||
.flat_map(|thread| thread.join().unwrap_or_default())
|
||||
.collect()
|
||||
});
|
||||
if worker.stop.load(Ordering::Relaxed) {
|
||||
break 'work;
|
||||
}
|
||||
Done::Mesh {
|
||||
active: snapshot.active,
|
||||
@@ -799,7 +860,11 @@ impl Lod {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Under the lock the samplers wait on: one between its check
|
||||
// and its wait would miss the wake and never be joined.
|
||||
let jobs = worker.jobs.lock();
|
||||
worker.stop.store(true, Ordering::Relaxed);
|
||||
drop(jobs);
|
||||
worker.wake.notify_all();
|
||||
for handle in sampling {
|
||||
let _ = handle.join();
|
||||
@@ -820,7 +885,6 @@ impl Lod {
|
||||
reduced_detail: false,
|
||||
plan: Plan::default(),
|
||||
pending: FxHashSet::default(),
|
||||
sample_nearby: true,
|
||||
detail_masks: FxHashMap::default(),
|
||||
detail_request_pending: false,
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
@@ -1078,13 +1142,23 @@ impl Lod {
|
||||
.active
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
key.level <= 1
|
||||
key.level <= DETAIL_LEVEL
|
||||
&& !self.plan.split.contains(key)
|
||||
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
|
||||
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
// Retiring fine columns are empty stand-ins, excluded from `active`
|
||||
// while their normal meshes still cover the gap. Request their detail
|
||||
// explicitly: neither the active-tile requests nor sampling an already
|
||||
// ready stand-in can fill it, so its GPU sections would never retire.
|
||||
keys.extend(retiring_detail(
|
||||
&self.plan,
|
||||
&self.retiring,
|
||||
&self.saved,
|
||||
&self.detail_masks,
|
||||
));
|
||||
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
|
||||
let mut chunks = FxHashSet::default();
|
||||
for key in keys {
|
||||
@@ -1105,7 +1179,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
if chunks.len() >= 32 {
|
||||
if chunks.len() >= DETAIL_REQUEST {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1189,7 @@ impl Lod {
|
||||
let dz = i64::from(z * 16 + 8 - camera[1]);
|
||||
dx * dx + dz * dz
|
||||
});
|
||||
chunks.truncate(32);
|
||||
chunks.truncate(DETAIL_REQUEST);
|
||||
if !chunks.is_empty() {
|
||||
self.detail_request_pending = true;
|
||||
self.shared
|
||||
@@ -1132,7 +1206,6 @@ impl Lod {
|
||||
pub fn update(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
yaw: f32,
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
covered: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -1141,14 +1214,6 @@ impl Lod {
|
||||
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
|
||||
* 32;
|
||||
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
|
||||
// Fill the view ahead first; a nearest-only ring spends most work out
|
||||
// of sight while visible forests remain undecorated noise terrain.
|
||||
let ahead = (detail * 16 + 128).max(elevation * 2).min(4096) as f32;
|
||||
let yaw = yaw.to_radians();
|
||||
let focus = [
|
||||
camera[0] - (yaw.sin() * ahead) as i32,
|
||||
camera[1] + (yaw.cos() * ahead) as i32,
|
||||
];
|
||||
// Replan at chunk boundaries, not on every camera movement.
|
||||
let area = (
|
||||
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
|
||||
@@ -1181,10 +1246,8 @@ impl Lod {
|
||||
if !self.meshing {
|
||||
crate::spans::time("lod_select", || self.select());
|
||||
}
|
||||
crate::spans::time("lod_request", || self.request_detail(focus));
|
||||
crate::spans::time("lod_wanted", || {
|
||||
self.request_samples(camera, focus, yaw, detail, elevation)
|
||||
});
|
||||
crate::spans::time("lod_request", || self.request_detail(camera));
|
||||
crate::spans::time("lod_wanted", || self.request_samples(camera));
|
||||
meshes
|
||||
}
|
||||
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
|
||||
@@ -1338,14 +1401,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn request_samples(
|
||||
&mut self,
|
||||
camera: [i32; 2],
|
||||
focus: [i32; 2],
|
||||
yaw: f32,
|
||||
detail: i32,
|
||||
elevation: i32,
|
||||
) {
|
||||
fn request_samples(&mut self, camera: [i32; 2]) {
|
||||
// Every sample slot busy: no tile can be requested this frame.
|
||||
let free = if self.held() {
|
||||
usize::from(
|
||||
@@ -1366,80 +1422,36 @@ impl Lod {
|
||||
if self.held() {
|
||||
self.held_sample_at = std::time::Instant::now();
|
||||
}
|
||||
// Load desired nearby leaves directly, alongside broad fallback coverage.
|
||||
// Waiting for every ancestor/sibling made full detail take minutes to appear.
|
||||
let mut wanted = Vec::new();
|
||||
for &root in &self.plan.roots {
|
||||
if self.plan.nodes.contains(&root) && !self.ready.contains_key(&root) {
|
||||
wanted.push((0, 0, root));
|
||||
}
|
||||
}
|
||||
for &key in &self.plan.nodes {
|
||||
if !self.plan.split.contains(&key) && !self.ready.contains_key(&key) {
|
||||
wanted.push((
|
||||
if key.in_view(camera, yaw) { 1 } else { 3 },
|
||||
i64::from(key.distance(focus)) * 4 + i64::from(key.distance(camera)),
|
||||
key,
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut fallback = Vec::new();
|
||||
for &parent in &self.active {
|
||||
if self.plan.split.contains(&parent) {
|
||||
for child in parent.children() {
|
||||
if self.plan.nodes.contains(&child) && !self.ready.contains_key(&child) {
|
||||
// Covered near terrain bounds projected error. Using
|
||||
// zero distance for a parent touching the camera kept
|
||||
// refining it while huge visible walls waited elsewhere.
|
||||
let priority =
|
||||
i64::from(child.distance(camera).max((detail * 16).max(elevation)))
|
||||
* 1024
|
||||
/ i64::from(child.size());
|
||||
fallback.push((
|
||||
if child.in_view(camera, yaw) { 2 } else { 4 },
|
||||
priority,
|
||||
child,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the first `free` keys not already pending are taken. A key is
|
||||
// listed at most twice (as a root and as a node), so the best
|
||||
// `2 * (sample_jobs + pending)` entries hold them, in order.
|
||||
let best = 2 * (self.sample_jobs + self.pending.len());
|
||||
// Only the tiles the plan keeps, each at the level its distance
|
||||
// calls for, nearest first, whichever way the player looks: far
|
||||
// terrain grows outward from them in rings. Coarse tiles first under
|
||||
// fine ones near the player cost a far tile's work for terrain that
|
||||
// fine tiles replace moments later.
|
||||
let mut wanted: Vec<_> = self
|
||||
.plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| !self.plan.split.contains(key) && !self.ready.contains_key(key))
|
||||
.map(|&key| (i64::from(key.distance(camera)), key))
|
||||
.collect();
|
||||
// Only the first `free` keys not already pending are taken: the best
|
||||
// `sample_jobs + pending` entries hold them, in order.
|
||||
let best = self.sample_jobs + self.pending.len();
|
||||
if wanted.len() > best {
|
||||
wanted.select_nth_unstable(best);
|
||||
wanted.truncate(best);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
// Alternate one nearby final tile with one broad refinement tile.
|
||||
if let Some(fallback) = fallback
|
||||
.into_iter()
|
||||
.filter(|(_, _, k)| !self.pending.contains(k))
|
||||
.min()
|
||||
{
|
||||
let index = if self.sample_nearby {
|
||||
wanted
|
||||
.iter()
|
||||
.position(|(_, _, k)| !self.pending.contains(k))
|
||||
.map_or(0, |i| i + 1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
wanted.insert(index, fallback);
|
||||
}
|
||||
let mut unique = FxHashSet::default();
|
||||
let wanted: Vec<_> = wanted
|
||||
.into_iter()
|
||||
.map(|(_, _, key)| key)
|
||||
.map(|(_, key)| key)
|
||||
.filter(|key| !self.pending.contains(key) && unique.insert(*key))
|
||||
.collect();
|
||||
if !wanted.is_empty() {
|
||||
let mut jobs = self.shared.jobs.lock().unwrap();
|
||||
for key in wanted.into_iter().take(free) {
|
||||
self.pending.insert(key);
|
||||
self.sample_nearby = !self.sample_nearby;
|
||||
jobs.push_back(Job::Sample(key));
|
||||
}
|
||||
drop(jobs);
|
||||
@@ -1582,18 +1594,94 @@ impl Snapshot {
|
||||
}
|
||||
impl Drop for Lod {
|
||||
fn drop(&mut self) {
|
||||
// Under the jobs lock, as the waiters check it: see the samplers' stop.
|
||||
let jobs = self.shared.jobs.lock();
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
drop(jobs);
|
||||
self.shared.wake.notify_all();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
|
||||
/// can release their normal meshes. The caller keeps the usual 32-column
|
||||
/// request budget and waits for the replacement mesh before changing cutout.
|
||||
fn retiring_detail<'a>(
|
||||
plan: &'a Plan,
|
||||
retiring: &'a FxHashSet<(i32, i32)>,
|
||||
saved: &'a Saved,
|
||||
masks: &'a FxHashMap<TileKey, [u64; 4]>,
|
||||
) -> impl Iterator<Item = TileKey> + 'a {
|
||||
retiring.iter().filter_map(move |&(x, z)| {
|
||||
let key = TileKey { x, z, level: 0 };
|
||||
(plan.nodes.contains(&key)
|
||||
&& !saved.contains_key(&(x, z))
|
||||
&& masks.get(&key) != Some(&[u64::MAX; 4]))
|
||||
.then_some(key)
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
|
||||
let column = (12, 0);
|
||||
let fine = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 0,
|
||||
};
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.insert(fine);
|
||||
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
|
||||
let retiring = [column].into_iter().collect();
|
||||
let saved = Saved::default();
|
||||
let mut masks = FxHashMap::default();
|
||||
// A covered stand-in is ready, but empty: ordinary sampling skips it,
|
||||
// and selection leaves it out of `active`. It cannot yet replace the
|
||||
// old full-detail column, even though the near world has unloaded it.
|
||||
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
assert_eq!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
|
||||
[fine]
|
||||
);
|
||||
// The existing RequestDetail -> apply_fine path marks complete detail;
|
||||
// only then may selection retire it and publish the new mesh cutout.
|
||||
masks.insert(fine, [u64::MAX; 4]);
|
||||
assert!(
|
||||
retiring_detail(&plan, &retiring, &saved, &masks)
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
|
||||
== Some(&[u64::MAX; 4])));
|
||||
}
|
||||
#[test]
|
||||
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
|
||||
let fine = |x| TileKey { x, z: 0, level: 0 };
|
||||
let mut plan = Plan::default();
|
||||
plan.nodes.extend((0..4).map(fine));
|
||||
plan.nodes.insert(fine(5));
|
||||
let retiring = (0..5).map(|x| (x, 0)).collect();
|
||||
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let masks = [
|
||||
(fine(2), [u64::MAX; 4]),
|
||||
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
|
||||
requested.sort_unstable();
|
||||
// Saved or complete detail needs no request; an unplanned retiree and
|
||||
// a planned column that is not retiring must not consume the budget.
|
||||
assert_eq!(requested, [fine(0), fine(3)]);
|
||||
}
|
||||
#[test]
|
||||
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
|
||||
let leaves = |plan: &Plan| {
|
||||
plan.nodes
|
||||
@@ -1615,8 +1703,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let (full, reduced) = (leaves(&full), leaves(&reduced));
|
||||
// At 64 chunks most tiles are the near area's finest, the
|
||||
// same in both: about half there, well under from 128.
|
||||
let bound = if distance == 64 { full * 21 / 20 } else { full };
|
||||
assert!(
|
||||
reduced * 2 < full,
|
||||
reduced * 2 < bound,
|
||||
"{reduced} of {full} tiles at {distance}"
|
||||
);
|
||||
}
|
||||
@@ -1751,8 +1842,9 @@ mod tests {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
let mut retained = 0;
|
||||
let range = i64::from(256 * 16);
|
||||
for key in &before.split {
|
||||
if key.distance([16, 0]) < key.size() * 10 {
|
||||
if key.distance([16, 0]) < key.size() * 10 && key.reach([16, 0]) <= range * range {
|
||||
assert!(after.split.contains(key));
|
||||
retained += 1;
|
||||
}
|
||||
@@ -1840,6 +1932,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn nearly_full_blocks_are_kept() {
|
||||
assert!(nearly_full("minecraft:powder_snow"));
|
||||
assert!(nearly_full("minecraft:dirt_path"));
|
||||
assert!(nearly_full("minecraft:oak_stairs"));
|
||||
assert!(!nearly_full("minecraft:oak_fence"));
|
||||
assert!(!nearly_full("minecraft:wheat"));
|
||||
}
|
||||
#[test]
|
||||
fn kelp_to_the_surface_is_drawn_as_water() {
|
||||
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
|
||||
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in [3, 4] {
|
||||
for y in MIN_Y..40 {
|
||||
chunk.set(x, y, 3, 1);
|
||||
}
|
||||
}
|
||||
for y in 40..63 {
|
||||
chunk.set(3, y, 3, 3);
|
||||
chunk.set(4, y, 3, 2);
|
||||
}
|
||||
let water = water_source(&kinds);
|
||||
assert_eq!(water, Some(BlockStateId(2)));
|
||||
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
|
||||
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
|
||||
assert_eq!(kelp.spans, sea.spans);
|
||||
assert_eq!(
|
||||
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
|
||||
Some((40, 63, BlockStateId(2)))
|
||||
);
|
||||
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
|
||||
assert_eq!(kelp.surface.unwrap().height, 40);
|
||||
}
|
||||
#[test]
|
||||
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
|
||||
let span = |min, max, state| Span {
|
||||
min,
|
||||
@@ -2035,6 +2161,24 @@ mod tests {
|
||||
chunk.set(8, 71, 8, chorus.0);
|
||||
chunk.set(8, 72, 8, chorus.0);
|
||||
chunk.set(8, 73, 8, flower.0);
|
||||
// Kelp to the surface is water; powder snow and a waterlogged stair
|
||||
// are kept as themselves.
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
|
||||
let stair = facts
|
||||
.states
|
||||
.iter()
|
||||
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
|
||||
.map(|i| BlockStateId(i as u32))
|
||||
.unwrap();
|
||||
chunk.set(2, 54, 2, stone.0);
|
||||
for y in 55..64 {
|
||||
chunk.set(2, y, 2, kelp.0);
|
||||
}
|
||||
chunk.set(6, 90, 6, stone.0);
|
||||
chunk.set(6, 91, 6, powder_snow.0);
|
||||
chunk.set(10, 54, 10, stone.0);
|
||||
chunk.set(10, 55, 10, stair.0);
|
||||
lod.observe(Arc::new(chunk.clone()), &facts);
|
||||
receive_observed(&mut lod);
|
||||
let observed = &lod.saved[&(-1, -1)];
|
||||
@@ -2052,6 +2196,12 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
|
||||
);
|
||||
let kelp_column = &observed[2 * 16 + 2];
|
||||
assert_eq!(kelp_column.surface.unwrap().height, 55);
|
||||
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
|
||||
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
|
||||
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
|
||||
|
||||
let fine = TileKey {
|
||||
x: -1,
|
||||
@@ -2121,7 +2271,7 @@ mod tests {
|
||||
let await_meshes = |lod: &mut Lod| {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let meshes = lod.update(camera, 0.0, 2, 64, None);
|
||||
let meshes = lod.update(camera, 2, 64, None);
|
||||
if !meshes.is_empty() {
|
||||
return meshes;
|
||||
}
|
||||
@@ -2139,7 +2289,7 @@ mod tests {
|
||||
lod.ready.insert(child, lod.ready[&key].clone());
|
||||
}
|
||||
lod.reselect = true;
|
||||
assert!(lod.update(camera, 0.0, 2, 64, None).is_empty());
|
||||
assert!(lod.update(camera, 2, 64, None).is_empty());
|
||||
assert!(
|
||||
lod.active.contains(&key),
|
||||
"parent retired before child meshes"
|
||||
@@ -2169,7 +2319,7 @@ mod tests {
|
||||
lod.replanned = true;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
lod.update(camera, 0., 2, 64, None);
|
||||
lod.update(camera, 2, 64, None);
|
||||
if !lod.meshing && lod.active.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -2207,33 +2357,26 @@ mod tests {
|
||||
level: 0,
|
||||
})
|
||||
.collect();
|
||||
lod.sample_nearby = true;
|
||||
lod.detail_request_pending = true;
|
||||
lod.meshing = true;
|
||||
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("nearby detail was not scheduled");
|
||||
};
|
||||
assert_eq!(nearby.level, 0);
|
||||
assert!(
|
||||
nearby.origin()[2] > -256 + 64,
|
||||
"detail should load ahead of the view"
|
||||
assert_eq!(
|
||||
nearby.distance([-256, -256]),
|
||||
0,
|
||||
"the nearest tiles load first"
|
||||
);
|
||||
lod.finish_sample(nearby, vec![Column::default(); 256]);
|
||||
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(fallback)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("fallback refinement was starved");
|
||||
};
|
||||
assert!(fallback.level > 0);
|
||||
lod.pending.remove(&fallback);
|
||||
lod.sample_nearby = true;
|
||||
lod.update(camera, 90.0, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(turned)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("turned view was not scheduled");
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("the next ring was not scheduled");
|
||||
};
|
||||
assert!(
|
||||
turned.origin()[0] < -256 - 64,
|
||||
"loading priority should follow a turn"
|
||||
!lod.plan.split.contains(&next) && next.distance([-256, -256]) <= 64,
|
||||
"the next nearest final tile, never a coarse one under it: {next:?}"
|
||||
);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
let near = TileKey {
|
||||
@@ -2266,17 +2409,19 @@ mod tests {
|
||||
level: 0,
|
||||
})
|
||||
.collect();
|
||||
lod.sample_nearby = false;
|
||||
lod.update(camera, 0., 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(broad)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("projected-error refinement missing")
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("no final tile scheduled")
|
||||
};
|
||||
assert_eq!(
|
||||
broad.level, 5,
|
||||
"a near tile touching the camera must not starve a huge visible distant tile"
|
||||
(first.level, first.parent()),
|
||||
(1, near),
|
||||
"the nearest parent's final tiles come first"
|
||||
);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.active = (0..40).map(|x| TileKey { x, z: 0, level: 0 }).collect();
|
||||
lod.active = (0..DETAIL_REQUEST as i32 + 8)
|
||||
.map(|x| TileKey { x, z: 0, level: 0 })
|
||||
.collect();
|
||||
lod.plan.split.clear();
|
||||
lod.detail_masks.clear();
|
||||
lod.covered = [(0, 0)].into_iter().collect();
|
||||
@@ -2289,7 +2434,9 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
chunks,
|
||||
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
|
||||
(1..=DETAIL_REQUEST as i32)
|
||||
.map(|x| (x, 0))
|
||||
.collect::<Vec<_>>(),
|
||||
"one unfinished fine tile must not starve neighbouring requests"
|
||||
);
|
||||
|
||||
@@ -2306,7 +2453,7 @@ mod tests {
|
||||
lod.set_near_loading(near_loading);
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.pending.clear();
|
||||
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
|
||||
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter(|job| matches!(job, Job::Sample(_)))
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Decorated far-terrain chunks kept on disk, beside a dimension's save, so a
|
||||
//! world opened again shows them without generating them again. One append-only
|
||||
//! file per 32x32 chunk region; a file written by another build, block registry
|
||||
//! or format is cleared on first use.
|
||||
use super::{Column, Span, Surface};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"HLOD";
|
||||
const FORMAT: u32 = 1;
|
||||
const HEADER: u64 = 16;
|
||||
/// A record: chunk x and z, then the deflated columns' length.
|
||||
const RECORD: u64 = 12;
|
||||
/// Columns of a chunk are a few kilobytes deflated; anything larger is damage.
|
||||
const MAX_RECORD: u32 = 1 << 22;
|
||||
|
||||
type Columns = Arc<[Column; 256]>;
|
||||
|
||||
struct Region {
|
||||
file: File,
|
||||
/// Each chunk's latest record: the offset of its deflated columns, and
|
||||
/// their length.
|
||||
index: FxHashMap<(i32, i32), (u64, u32)>,
|
||||
}
|
||||
|
||||
pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
key: u64,
|
||||
// ponytail: one lock for every region, with file reads under it; per-region
|
||||
// locks if the sampling threads ever wait on it.
|
||||
regions: Mutex<FxHashMap<(i32, i32), Option<Region>>>,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
/// `key`: what the cached columns depend on (`build_key`); files with
|
||||
/// another are cleared.
|
||||
pub fn new(dir: PathBuf, key: u64) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
key,
|
||||
regions: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, x: i32, z: i32) -> Option<Columns> {
|
||||
let mut regions = self.regions.lock().ok()?;
|
||||
let region = self.region(&mut regions, x, z, false)?;
|
||||
let &(offset, len) = region.index.get(&(x, z))?;
|
||||
let mut bytes = vec![0; len as usize];
|
||||
region.file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
region.file.read_exact(&mut bytes).ok()?;
|
||||
drop(regions);
|
||||
decode(&bytes).map(Arc::new)
|
||||
}
|
||||
|
||||
fn contains(&self, x: i32, z: i32) -> bool {
|
||||
let Ok(mut regions) = self.regions.lock() else {
|
||||
return false;
|
||||
};
|
||||
self.region(&mut regions, x, z, false)
|
||||
.is_some_and(|region| region.index.contains_key(&(x, z)))
|
||||
}
|
||||
|
||||
/// Writes a chunk not cached yet. One already cached stays as it is: the
|
||||
/// build key clears stale ones, and a generation batch hands back
|
||||
/// neighbours cached before, which appended again grew the files each
|
||||
/// session.
|
||||
pub fn put(&self, x: i32, z: i32, columns: &[Column; 256]) {
|
||||
if self.contains(x, z) {
|
||||
return;
|
||||
}
|
||||
let bytes = encode(columns);
|
||||
let Ok(mut regions) = self.regions.lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(region) = self.region(&mut regions, x, z, true) else {
|
||||
return;
|
||||
};
|
||||
// Another sampler's thread may have written it while this one encoded.
|
||||
if region.index.contains_key(&(x, z)) {
|
||||
return;
|
||||
}
|
||||
let mut record = Vec::with_capacity(RECORD as usize + bytes.len());
|
||||
record.extend_from_slice(&x.to_le_bytes());
|
||||
record.extend_from_slice(&z.to_le_bytes());
|
||||
record.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
||||
record.extend_from_slice(&bytes);
|
||||
let Ok(end) = region.file.seek(SeekFrom::End(0)) else {
|
||||
return;
|
||||
};
|
||||
// One write, so a crash leaves at most a torn last record, which
|
||||
// the next open cuts off.
|
||||
if region.file.write_all(&record).is_ok() {
|
||||
region
|
||||
.index
|
||||
.insert((x, z), (end + RECORD, bytes.len() as u32));
|
||||
}
|
||||
}
|
||||
|
||||
fn region<'a>(
|
||||
&self,
|
||||
regions: &'a mut FxHashMap<(i32, i32), Option<Region>>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
create: bool,
|
||||
) -> Option<&'a mut Region> {
|
||||
let at = (x >> 5, z >> 5);
|
||||
if create && matches!(regions.get(&at), Some(None)) {
|
||||
regions.remove(&at);
|
||||
}
|
||||
regions
|
||||
.entry(at)
|
||||
.or_insert_with(|| self.open(at, create))
|
||||
.as_mut()
|
||||
}
|
||||
|
||||
fn open(&self, (rx, rz): (i32, i32), create: bool) -> Option<Region> {
|
||||
let path = self.dir.join(format!("r.{rx}.{rz}.hlod"));
|
||||
if !create && !path.exists() {
|
||||
return None;
|
||||
}
|
||||
if create {
|
||||
std::fs::create_dir_all(&self.dir).ok()?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(create)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
let mut header = [0; HEADER as usize];
|
||||
let current = file.read_exact(&mut header).is_ok()
|
||||
&& header[..4] == *MAGIC
|
||||
&& header[4..8] == FORMAT.to_le_bytes()
|
||||
&& header[8..] == self.key.to_le_bytes();
|
||||
let mut index = FxHashMap::default();
|
||||
if current {
|
||||
let size = file.metadata().ok()?.len();
|
||||
let mut at = HEADER;
|
||||
let mut record = [0; RECORD as usize];
|
||||
while at + RECORD <= size {
|
||||
file.seek(SeekFrom::Start(at)).ok()?;
|
||||
file.read_exact(&mut record).ok()?;
|
||||
let field = |i: usize| u32::from_le_bytes(record[i..i + 4].try_into().unwrap());
|
||||
let len = field(8);
|
||||
if len > MAX_RECORD || at + RECORD + u64::from(len) > size {
|
||||
break;
|
||||
}
|
||||
index.insert((field(0) as i32, field(4) as i32), (at + RECORD, len));
|
||||
at += RECORD + u64::from(len);
|
||||
}
|
||||
if at < size {
|
||||
file.set_len(at).ok()?;
|
||||
}
|
||||
} else {
|
||||
file.set_len(0).ok()?;
|
||||
file.seek(SeekFrom::Start(0)).ok()?;
|
||||
let mut header = Vec::with_capacity(HEADER as usize);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.extend_from_slice(&FORMAT.to_le_bytes());
|
||||
header.extend_from_slice(&self.key.to_le_bytes());
|
||||
file.write_all(&header).ok()?;
|
||||
}
|
||||
Some(Region { file, index })
|
||||
}
|
||||
}
|
||||
|
||||
/// What cached columns depend on: this build (its commit, or for a local
|
||||
/// build the executable's own time, so a rebuilt generator starts afresh),
|
||||
/// the block states and the biomes.
|
||||
pub fn build_key(states: usize, biomes: usize) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::hash::DefaultHasher::new();
|
||||
FORMAT.hash(&mut hasher);
|
||||
env!("CARGO_PKG_VERSION").hash(&mut hasher);
|
||||
match option_env!("HORIZON_COMMIT") {
|
||||
Some(commit) => commit.hash(&mut hasher),
|
||||
None => std::env::current_exe()
|
||||
.and_then(|exe| exe.metadata()?.modified())
|
||||
.ok()
|
||||
.hash(&mut hasher),
|
||||
}
|
||||
states.hash(&mut hasher);
|
||||
biomes.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn encode(columns: &[Column; 256]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => out.push(0),
|
||||
Some(s) => {
|
||||
out.push(1 + u8::from(s.water.is_some()));
|
||||
out.extend_from_slice(&s.height.to_le_bytes());
|
||||
out.extend_from_slice(&s.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&s.biome.to_le_bytes());
|
||||
if let Some((height, state)) = s.water {
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&state.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
|
||||
for span in &column.spans {
|
||||
out.extend_from_slice(&span.min.to_le_bytes());
|
||||
out.extend_from_slice(&span.max.to_le_bytes());
|
||||
out.extend_from_slice(&span.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&span.biome.to_le_bytes());
|
||||
out.extend_from_slice(&span.light.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut deflate =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
// Writing to a Vec cannot fail.
|
||||
deflate.write_all(&out).unwrap();
|
||||
deflate.finish().unwrap()
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<[Column; 256]> {
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::DeflateDecoder::new(bytes)
|
||||
.take(u64::from(MAX_RECORD) * 8)
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let mut at = 0;
|
||||
let mut take = |n: usize| {
|
||||
let field = raw.get(at..at + n)?;
|
||||
at += n;
|
||||
Some(field)
|
||||
};
|
||||
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
|
||||
for column in &mut columns {
|
||||
let i32_ = |b: &[u8]| i32::from_le_bytes(b.try_into().unwrap());
|
||||
let u32_ = |b: &[u8]| u32::from_le_bytes(b.try_into().unwrap());
|
||||
let u16_ = |b: &[u8]| u16::from_le_bytes(b.try_into().unwrap());
|
||||
let i16_ = |b: &[u8]| i16::from_le_bytes(b.try_into().unwrap());
|
||||
let tag = take(1)?[0];
|
||||
if tag > 2 {
|
||||
return None;
|
||||
}
|
||||
if tag > 0 {
|
||||
let height = i32_(take(4)?);
|
||||
let state = BlockStateId(u32_(take(4)?));
|
||||
let biome = u16_(take(2)?);
|
||||
let water = if tag == 2 {
|
||||
Some((i32_(take(4)?), BlockStateId(u32_(take(4)?))))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
column.surface = Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water,
|
||||
biome,
|
||||
});
|
||||
}
|
||||
let count = u16_(take(2)?);
|
||||
column.spans.reserve(count.into());
|
||||
for _ in 0..count {
|
||||
let span = Span {
|
||||
min: i16_(take(2)?),
|
||||
max: i16_(take(2)?),
|
||||
state: BlockStateId(u32_(take(4)?)),
|
||||
biome: u16_(take(2)?),
|
||||
light: u16_(take(2)?),
|
||||
};
|
||||
if span.min >= span.max {
|
||||
return None;
|
||||
}
|
||||
column.spans.push(span);
|
||||
}
|
||||
}
|
||||
(at == raw.len()).then_some(columns)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn columns(seed: u32) -> [Column; 256] {
|
||||
std::array::from_fn(|i| {
|
||||
let i = i as u32 + seed;
|
||||
Column {
|
||||
surface: (!i.is_multiple_of(3)).then(|| Surface {
|
||||
height: 60 + (i % 7) as i32,
|
||||
state: BlockStateId(i),
|
||||
water: (i.is_multiple_of(2)).then_some((63, BlockStateId(9))),
|
||||
biome: (i % 5) as u16,
|
||||
}),
|
||||
spans: (0..i % 4)
|
||||
.map(|n| Span {
|
||||
min: (n * 10) as i16 - 64,
|
||||
max: (n * 10 + 4) as i16 - 64,
|
||||
state: BlockStateId(n + i),
|
||||
biome: 2,
|
||||
light: 240,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_survive_reopening_and_a_torn_last_record() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-lod-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let cache = DiskCache::new(dir.clone(), 7);
|
||||
assert!(cache.get(3, -40).is_none(), "nothing cached yet");
|
||||
cache.put(3, -40, &columns(0));
|
||||
cache.put(4, -40, &columns(1));
|
||||
let path = dir.join("r.0.-2.hlod");
|
||||
let size = std::fs::metadata(&path).unwrap().len();
|
||||
// A chunk cached already is not appended again.
|
||||
cache.put(3, -40, &columns(2));
|
||||
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), size);
|
||||
drop(cache);
|
||||
|
||||
let file = OpenOptions::new().write(true).open(&path).unwrap();
|
||||
file.set_len(size + 7).unwrap();
|
||||
drop(file);
|
||||
let cache = DiskCache::new(dir.clone(), 7);
|
||||
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
|
||||
assert_eq!(*cache.get(4, -40).unwrap(), columns(1));
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().len(),
|
||||
size,
|
||||
"torn tail cut"
|
||||
);
|
||||
drop(cache);
|
||||
|
||||
// Another build's files are cleared, not read.
|
||||
let cache = DiskCache::new(dir.clone(), 8);
|
||||
assert!(cache.get(3, -40).is_none());
|
||||
cache.put(5, -40, &columns(3));
|
||||
assert!(cache.get(4, -40).is_none());
|
||||
assert_eq!(*cache.get(5, -40).unwrap(), columns(3));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_records_read_as_missing() {
|
||||
let bytes = encode(&columns(0));
|
||||
assert_eq!(decode(&bytes).unwrap(), columns(0));
|
||||
assert!(decode(&bytes[..bytes.len() / 2]).is_none());
|
||||
assert!(decode(b"not deflate").is_none());
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,18 @@ impl Mesher {
|
||||
covered_bounds,
|
||||
neighbors,
|
||||
finer,
|
||||
|span, direction| self.material(span, direction, context),
|
||||
|span, direction, at| self.material(span, direction, at, context),
|
||||
)
|
||||
}
|
||||
fn material(&mut self, span: &Span, direction: Direction, context: &WorkerContext) -> Material {
|
||||
/// `at`: the block column the face covers, for the grass colours that
|
||||
/// change across a biome (swamps).
|
||||
fn material(
|
||||
&mut self,
|
||||
span: &Span,
|
||||
direction: Direction,
|
||||
at: [i32; 2],
|
||||
context: &WorkerContext,
|
||||
) -> Material {
|
||||
let styles = *self
|
||||
.styles
|
||||
.entry((span.state, direction as u8))
|
||||
@@ -179,7 +187,11 @@ impl Mesher {
|
||||
face.map(|face| {
|
||||
let rgb = match face.tint {
|
||||
Tint::None => 0xffffff,
|
||||
Tint::Grass => biome.grass,
|
||||
Tint::Grass => {
|
||||
context
|
||||
.biomes
|
||||
.grass(span.biome, f64::from(at[0]), f64::from(at[1]))
|
||||
}
|
||||
Tint::Foliage => biome.foliage,
|
||||
Tint::Dry => biome.dry_foliage,
|
||||
Tint::Water => biome.water,
|
||||
@@ -256,7 +268,7 @@ fn mesh_columns(
|
||||
columns: &[Column],
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
material: impl FnMut(&Span, Direction) -> Material,
|
||||
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
mesh_columns_with_neighbors(key, columns, covered, covered_bounds, &[], material)
|
||||
}
|
||||
@@ -267,7 +279,7 @@ fn mesh_columns_with_neighbors(
|
||||
covered: &FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
neighbors: &[Column],
|
||||
material: impl FnMut(&Span, Direction) -> Material,
|
||||
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert!(neighbors.is_empty() || neighbors.len() == 4 * CELLS);
|
||||
let stride = key.size() / CELLS as i32;
|
||||
@@ -293,7 +305,7 @@ fn mesh_columns_with_edges(
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
finer: &[TileKey],
|
||||
mut material: impl FnMut(&Span, Direction) -> Material,
|
||||
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert_eq!(columns.len(), CELLS * CELLS);
|
||||
let mut count = CELLS * CELLS;
|
||||
@@ -325,17 +337,31 @@ fn mesh_columns_with_edges(
|
||||
neighbors,
|
||||
stride,
|
||||
|x, z| cell_step(x, z) == stride,
|
||||
|span| material(span, Direction::Up).fluid != 0,
|
||||
|span| material(span, Direction::Up, [ox, oz]).fluid != 0,
|
||||
);
|
||||
// A cell's faces take the colours at its centre. The tile's neighbours'
|
||||
// columns only hide faces, so their colours never show.
|
||||
let centre = |i: usize| {
|
||||
if i < CELLS * CELLS {
|
||||
[
|
||||
ox + (i % CELLS) as i32 * stride + stride / 2,
|
||||
oz + (i / CELLS) as i32 * stride + stride / 2,
|
||||
]
|
||||
} else {
|
||||
[ox, oz]
|
||||
}
|
||||
};
|
||||
let prepared: Vec<Vec<Segment<'_>>> = filled
|
||||
.iter()
|
||||
.map(|column| &**column)
|
||||
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
|
||||
.map(|column| {
|
||||
.enumerate()
|
||||
.map(|(index, column)| {
|
||||
let at = centre(index);
|
||||
let materials: Vec<_> = column
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| material(s, Direction::Up))
|
||||
.map(|s| material(s, Direction::Up, at))
|
||||
.collect();
|
||||
column
|
||||
.spans
|
||||
@@ -425,7 +451,7 @@ fn mesh_columns_with_edges(
|
||||
u1: x1,
|
||||
v1: z1,
|
||||
},
|
||||
material(segment.span, Direction::Down),
|
||||
material(segment.span, Direction::Down, centre(z * CELLS + x)),
|
||||
);
|
||||
}
|
||||
for (direction, nx, nz, edge, nw, plane, u0, u1) in [
|
||||
@@ -470,21 +496,25 @@ fn mesh_columns_with_edges(
|
||||
x1,
|
||||
),
|
||||
] {
|
||||
let side = material(segment.span, direction);
|
||||
let side = material(segment.span, direction, centre(z * CELLS + x));
|
||||
// Near meshes may have culled against the real neighbor.
|
||||
// Keep a finite seal where their full-detail area begins.
|
||||
// Keep a finite seal where their full-detail area begins:
|
||||
// opaque only, as the near water carries on into the far
|
||||
// water and a seal shows through it as a wall.
|
||||
if is_covered(nw[0], nw[1]) {
|
||||
add_side(
|
||||
&mut faces,
|
||||
direction,
|
||||
plane,
|
||||
u0,
|
||||
u1,
|
||||
segment,
|
||||
&[],
|
||||
side,
|
||||
own_sky,
|
||||
);
|
||||
if segment.material.opaque {
|
||||
add_side(
|
||||
&mut faces,
|
||||
direction,
|
||||
plane,
|
||||
u0,
|
||||
u1,
|
||||
segment,
|
||||
&[],
|
||||
side,
|
||||
own_sky,
|
||||
);
|
||||
}
|
||||
} else if !edge {
|
||||
add_side(
|
||||
&mut faces, direction, plane, u0, u1, segment, column, side,
|
||||
@@ -597,7 +627,7 @@ mod refinement_tests {
|
||||
None,
|
||||
&std::array::from_fn(|_| Vec::new()),
|
||||
&[fine],
|
||||
|_, _| Material {
|
||||
|_, _, _| Material {
|
||||
faces: [
|
||||
Some(Paint {
|
||||
layer: 1,
|
||||
@@ -646,13 +676,15 @@ mod refinement_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tile's columns with the gaps no neighbour's open air reaches filled
|
||||
/// with the material below them, or the fluid above them (kelp and seagrass
|
||||
/// leave gaps in water): a cave seen from nowhere adds no faces. A
|
||||
/// gap is open when a neighbouring column's top lies below the gap's top, so
|
||||
/// that column's open air looks into it. Unknown neighbours, and cells that
|
||||
/// `whole` rejects (they meet full-detail or finer terrain), count as open;
|
||||
/// those cells keep their gaps.
|
||||
/// The tile's columns with the gaps no open air reaches filled with the
|
||||
/// material below them, or the fluid above them (kelp and seagrass leave gaps
|
||||
/// in water): a cave seen from nowhere adds no faces. A gap is open when a
|
||||
/// neighbouring column's top lies below the gap's top, so that column's open
|
||||
/// air looks into it, when a gap of the tile's neighbour meets it, or when it
|
||||
/// meets an open gap of a neighbouring column: the air under a canopy reaches
|
||||
/// the sky through any clearing, where filling it raised pillars of the floor
|
||||
/// block. Unknown neighbours, and cells that `whole` rejects (they meet
|
||||
/// full-detail or finer terrain), count as open; those cells keep their gaps.
|
||||
fn fill_buried_gaps<'a>(
|
||||
columns: &'a [Column],
|
||||
neighbors: &[Vec<EdgeColumn>; 4],
|
||||
@@ -669,6 +701,18 @@ fn fill_buried_gaps<'a>(
|
||||
.find(|span| span.min < span.max)
|
||||
.map_or(i16::MIN, |span| span.max)
|
||||
};
|
||||
// Each column's gaps: the index of the span above, the gap's bottom and top.
|
||||
let gaps_of = |column: &Column| -> Vec<(usize, i16, i16)> {
|
||||
column
|
||||
.spans
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(_, pair)| pair[0].max < pair[1].min)
|
||||
.map(|(k, pair)| (k + 1, pair[0].max, pair[1].min))
|
||||
.collect()
|
||||
};
|
||||
let meets =
|
||||
|(lo, hi): (i16, i16), (other_lo, other_hi): (i16, i16)| lo < other_hi && other_lo < hi;
|
||||
let tops: Vec<_> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
@@ -678,57 +722,110 @@ fn fill_buried_gaps<'a>(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let edge = |side: usize, cell: usize| {
|
||||
let gaps: Vec<_> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
if whole(i % CELLS, i / CELLS) {
|
||||
gaps_of(&columns[i])
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let touching = |side: usize, cell: usize| {
|
||||
let (start, end) = (cell as i32 * stride, (cell as i32 + 1) * stride);
|
||||
neighbors[side]
|
||||
.iter()
|
||||
.filter(|edge| edge.start < end && start < edge.end)
|
||||
.filter(move |edge| edge.start < end && start < edge.end)
|
||||
};
|
||||
let edge = |side: usize, cell: usize| {
|
||||
touching(side, cell)
|
||||
.map(|edge| top(&edge.column))
|
||||
.min()
|
||||
.unwrap_or(i16::MIN)
|
||||
};
|
||||
// A gap of the tile's neighbour may be open through tiles this one can't
|
||||
// see: it counts as open.
|
||||
let edge_gap = |side: usize, cell: usize, gap: (i16, i16)| {
|
||||
touching(side, cell).any(|edge| {
|
||||
gaps_of(&edge.column)
|
||||
.iter()
|
||||
.any(|&(_, lo, hi)| meets((lo, hi), gap))
|
||||
})
|
||||
};
|
||||
let around = |i: usize| {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
[
|
||||
(x > 0).then(|| i - 1),
|
||||
(x + 1 < CELLS).then(|| i + 1),
|
||||
(z > 0).then(|| i - CELLS),
|
||||
(z + 1 < CELLS).then(|| i + CELLS),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
};
|
||||
let mut open: Vec<Vec<bool>> = gaps.iter().map(|g| vec![false; g.len()]).collect();
|
||||
let mut reached = Vec::new();
|
||||
for i in 0..CELLS * CELLS {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
let lowest = [
|
||||
if x > 0 { tops[i - 1] } else { edge(0, z) },
|
||||
if x + 1 < CELLS {
|
||||
tops[i + 1]
|
||||
} else {
|
||||
edge(1, z)
|
||||
},
|
||||
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
|
||||
if z + 1 < CELLS {
|
||||
tops[i + CELLS]
|
||||
} else {
|
||||
edge(3, x)
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(i16::MIN);
|
||||
for (g, &(_, lo, hi)) in gaps[i].iter().enumerate() {
|
||||
let gap = (lo, hi);
|
||||
if hi > lowest
|
||||
|| (x == 0 && edge_gap(0, z, gap))
|
||||
|| (x + 1 == CELLS && edge_gap(1, z, gap))
|
||||
|| (z == 0 && edge_gap(2, x, gap))
|
||||
|| (z + 1 == CELLS && edge_gap(3, x, gap))
|
||||
{
|
||||
open[i][g] = true;
|
||||
reached.push((i, g));
|
||||
}
|
||||
}
|
||||
}
|
||||
while let Some((i, g)) = reached.pop() {
|
||||
let (_, lo, hi) = gaps[i][g];
|
||||
for j in around(i) {
|
||||
for (h, &(_, other_lo, other_hi)) in gaps[j].iter().enumerate() {
|
||||
if !open[j][h] && meets((lo, hi), (other_lo, other_hi)) {
|
||||
open[j][h] = true;
|
||||
reached.push((j, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, column)| {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
if column.spans.len() < 2 || !whole(x, z) {
|
||||
return Cow::Borrowed(column);
|
||||
}
|
||||
let open = [
|
||||
if x > 0 { tops[i - 1] } else { edge(0, z) },
|
||||
if x + 1 < CELLS {
|
||||
tops[i + 1]
|
||||
} else {
|
||||
edge(1, z)
|
||||
},
|
||||
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
|
||||
if z + 1 < CELLS {
|
||||
tops[i + CELLS]
|
||||
} else {
|
||||
edge(3, x)
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.min()
|
||||
.unwrap_or(i16::MIN);
|
||||
let buried = |below: &Span, above: &Span| below.max < above.min && above.min <= open;
|
||||
if !column
|
||||
.spans
|
||||
.windows(2)
|
||||
.any(|pair| buried(&pair[0], &pair[1]))
|
||||
{
|
||||
if open[i].iter().all(|&open| open) {
|
||||
return Cow::Borrowed(column);
|
||||
}
|
||||
let mut filled = Column {
|
||||
surface: column.surface,
|
||||
spans: Vec::with_capacity(column.spans.len()),
|
||||
};
|
||||
for &span in &column.spans {
|
||||
for (k, &span) in column.spans.iter().enumerate() {
|
||||
let mut span = span;
|
||||
if let Some(below) = filled.spans.last_mut()
|
||||
&& buried(below, &span)
|
||||
{
|
||||
let buried = gaps[i]
|
||||
.iter()
|
||||
.zip(&open[i])
|
||||
.any(|(&(above, _, _), &open)| above == k && !open);
|
||||
if buried && let Some(below) = filled.spans.last_mut() {
|
||||
if fluid(&span) {
|
||||
span.min = below.max;
|
||||
} else {
|
||||
@@ -919,7 +1016,7 @@ mod tests {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn material(span: &Span, _: Direction) -> Material {
|
||||
fn material(span: &Span, _: Direction, _: [i32; 2]) -> Material {
|
||||
let water = span.state.0 == 2;
|
||||
Material {
|
||||
faces: [
|
||||
@@ -1117,6 +1214,42 @@ mod tests {
|
||||
assert!(cave[0].iter().all(|v| v.light == 0));
|
||||
}
|
||||
#[test]
|
||||
fn air_under_a_canopy_stays_open_through_a_clearing() {
|
||||
let column = |spans: Vec<Span>| Column {
|
||||
surface: None,
|
||||
spans,
|
||||
};
|
||||
// The tile's neighbours are solid hills: no open air from the sides.
|
||||
let hill = || EdgeColumn {
|
||||
start: 0,
|
||||
end: CELLS as i32,
|
||||
column: column(vec![span(0, 20, 1)]),
|
||||
};
|
||||
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
|
||||
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
|
||||
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
|
||||
.iter()
|
||||
.map(|filled| filled.spans.clone())
|
||||
.collect()
|
||||
};
|
||||
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
|
||||
// A closed canopy hides the floor: filling it under there shows nowhere.
|
||||
assert!(fill(&forest).iter().all(|spans| spans[0].max == 10));
|
||||
// One clearing lets the sky in: the air under every tree stays air,
|
||||
// not a pillar of the floor block up to the leaves.
|
||||
forest[5 * CELLS + 5] = column(vec![span(0, 5, 1)]);
|
||||
let filled = fill(&forest);
|
||||
for (filled, column) in filled.iter().zip(&forest) {
|
||||
assert_eq!(filled, &column.spans);
|
||||
}
|
||||
// A cave two columns wide sealed in rock is still filled.
|
||||
let mut rock = vec![column(vec![span(0, 20, 1)]); CELLS * CELLS];
|
||||
for cave in [5 * CELLS + 5, 5 * CELLS + 6] {
|
||||
rock[cave] = column(vec![span(0, 8, 1), span(12, 20, 1)]);
|
||||
}
|
||||
assert!(fill(&rock).iter().all(|spans| spans == &[span(0, 20, 1)]));
|
||||
}
|
||||
#[test]
|
||||
fn caves_no_neighbor_sees_into_add_no_faces() {
|
||||
let mut columns = columns();
|
||||
for column in &mut columns {
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
//! Messages go through the `log` facade. Horizon's own still print their plain text on stderr,
|
||||
//! as they did before the log existed; other crates' errors only reach the file (their
|
||||
//! warnings are mostly the Vulkan loader's startup chatter).
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Local, NaiveDate};
|
||||
|
||||
@@ -20,6 +21,11 @@ use chrono::{DateTime, Local, NaiveDate};
|
||||
pub const MAX_ARCHIVES_PER_DAY: u32 = 7;
|
||||
/// Formatted lines kept in memory for crash reports.
|
||||
const RECENT: usize = 50;
|
||||
/// Warnings and errors one line of code may log in a [`FLOOD_WINDOW`]; past that they are counted,
|
||||
/// not written, so a warning repeated every packet can't fill the disk. Vanilla has no such limit:
|
||||
/// below it the log is vanilla's, line for line.
|
||||
const FLOOD_LINES: u32 = 100;
|
||||
const FLOOD_WINDOW: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The platform data folder (what the `dirs` crate's data_dir gives).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
@@ -45,9 +51,20 @@ pub fn data_dir() -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
|
||||
/// platform data folder (launcher/src/backend.rs `game_home`).
|
||||
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
|
||||
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Makes `game_home` return `home` for the rest of the run (`--home`).
|
||||
pub fn set_game_home(home: PathBuf) {
|
||||
let _ = HOME.set(home);
|
||||
}
|
||||
|
||||
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
|
||||
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
|
||||
pub fn game_home() -> PathBuf {
|
||||
if let Some(home) = HOME.get() {
|
||||
return home.clone();
|
||||
}
|
||||
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
|
||||
Some(h) => PathBuf::from(h),
|
||||
None => data_dir().join("horizon"),
|
||||
@@ -272,6 +289,59 @@ struct Logger {
|
||||
/// What the main thread is called in the log: "Render thread", or "Server thread" for the
|
||||
/// dedicated server, as in vanilla.
|
||||
main_thread: &'static str,
|
||||
floods: Mutex<Floods>,
|
||||
}
|
||||
|
||||
/// What the flood limit does with one warning or error.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Gate {
|
||||
/// Write it, after saying how many lines like it were left out in the window before.
|
||||
Write {
|
||||
left_out: u32,
|
||||
},
|
||||
/// Write it, then say the next ones from this line of code wait for the next window.
|
||||
Last,
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Warnings and errors per line of code, in windows of [`FLOOD_WINDOW`].
|
||||
#[derive(Default)]
|
||||
struct Floods {
|
||||
sites: HashMap<(String, u32), Flood>,
|
||||
}
|
||||
|
||||
struct Flood {
|
||||
since: Instant,
|
||||
written: u32,
|
||||
left_out: u32,
|
||||
}
|
||||
|
||||
impl Floods {
|
||||
fn gate(&mut self, file: &str, line: u32, now: Instant) -> Gate {
|
||||
let flood = self.sites.entry((file.to_owned(), line)).or_insert(Flood {
|
||||
since: now,
|
||||
written: 0,
|
||||
left_out: 0,
|
||||
});
|
||||
let mut left_out = 0;
|
||||
if now.duration_since(flood.since) >= FLOOD_WINDOW {
|
||||
left_out = flood.left_out;
|
||||
*flood = Flood {
|
||||
since: now,
|
||||
written: 0,
|
||||
left_out: 0,
|
||||
};
|
||||
}
|
||||
flood.written += 1;
|
||||
match flood.written.cmp(&FLOOD_LINES) {
|
||||
std::cmp::Ordering::Less => Gate::Write { left_out },
|
||||
std::cmp::Ordering::Equal => Gate::Last,
|
||||
std::cmp::Ordering::Greater => {
|
||||
flood.left_out += 1;
|
||||
Gate::Skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
|
||||
@@ -297,8 +367,37 @@ impl log::Log for Logger {
|
||||
Some(name) => name,
|
||||
None => "unnamed",
|
||||
};
|
||||
let gate = if record.level() <= log::Level::Warn {
|
||||
let mut floods = self.floods.lock().unwrap_or_else(|e| e.into_inner());
|
||||
floods.gate(
|
||||
record.file().unwrap_or(record.target()),
|
||||
record.line().unwrap_or(0),
|
||||
Instant::now(),
|
||||
)
|
||||
} else {
|
||||
Gate::Write { left_out: 0 }
|
||||
};
|
||||
let level = record.level();
|
||||
match gate {
|
||||
Gate::Skip => return,
|
||||
Gate::Write { left_out } if left_out > 0 => self.write(
|
||||
thread,
|
||||
level,
|
||||
&format!(
|
||||
"{left_out} more lines like the next one were not logged in the last minute"
|
||||
),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
let stderr = ours(record.target()).then(|| message.clone());
|
||||
self.send(thread, record.level(), &message, stderr);
|
||||
self.send(thread, level, &message, stderr);
|
||||
if gate == Gate::Last {
|
||||
self.write(
|
||||
thread,
|
||||
level,
|
||||
&format!("{FLOOD_LINES} lines like this one in a minute: the next ones are counted, not logged"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits (a second at most) until every line logged before is in the file.
|
||||
@@ -392,6 +491,7 @@ pub fn init(home: &Path, main_thread: &'static str) {
|
||||
out,
|
||||
recent: Mutex::new(VecDeque::new()),
|
||||
main_thread,
|
||||
floods: Mutex::new(Floods::default()),
|
||||
}
|
||||
});
|
||||
if log::set_logger(logger).is_ok() {
|
||||
@@ -467,6 +567,35 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
|
||||
let (mut floods, start) = (Floods::default(), Instant::now());
|
||||
let gates: Vec<Gate> = (0..140)
|
||||
.map(|i| floods.gate("lan_vehicles.rs", 345, start + Duration::from_millis(i * 7)))
|
||||
.collect();
|
||||
assert!(
|
||||
gates[..99]
|
||||
.iter()
|
||||
.all(|g| *g == Gate::Write { left_out: 0 })
|
||||
);
|
||||
assert_eq!(gates[99], Gate::Last);
|
||||
assert!(gates[100..].iter().all(|g| *g == Gate::Skip));
|
||||
// Another line of code has its own count.
|
||||
assert_eq!(
|
||||
floods.gate("lan_vehicles.rs", 330, start),
|
||||
Gate::Write { left_out: 0 }
|
||||
);
|
||||
// The next minute writes again, and says how many were left out.
|
||||
assert_eq!(
|
||||
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
|
||||
Gate::Write { left_out: 40 }
|
||||
);
|
||||
assert_eq!(
|
||||
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
|
||||
Gate::Write { left_out: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
fn day(d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
|
||||
}
|
||||
|
||||
@@ -662,6 +662,15 @@ impl Padded {
|
||||
c.get(x & 15, wy, z & 15)
|
||||
};
|
||||
light[i] = c.light(x & 15, wy, z & 15);
|
||||
} else if let Some(c) = region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
blocks[i] = c.get(ex, wy, ez);
|
||||
light[i] = c.light(ex, wy, ez);
|
||||
} else {
|
||||
light[i] = 0xF0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! changed.
|
||||
use crate::{
|
||||
blocks::{BlockFacts, Fluid, MAP_COLORS},
|
||||
chunk::{Chunk, MIN_Y, SECTIONS},
|
||||
chunk::Chunk,
|
||||
fast_hash::FxHashMap,
|
||||
world::World,
|
||||
};
|
||||
@@ -113,7 +113,8 @@ fn scan_column(
|
||||
return (color | WALL, head as i16, 0);
|
||||
}
|
||||
}
|
||||
while y > MIN_Y {
|
||||
let bottom = chunk.min_y();
|
||||
while y > bottom {
|
||||
let (mut color, flags) = palette.get(chunk.get(x, y, z));
|
||||
if color == 0 {
|
||||
y -= 1;
|
||||
@@ -123,7 +124,7 @@ fn scan_column(
|
||||
let fluid = flags & FLUID;
|
||||
if fluid != 0 {
|
||||
let mut below = y - 1;
|
||||
while below > MIN_Y && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
while below > bottom && palette.get(chunk.get(x, below, z)).1 & FLUID != 0 {
|
||||
depth = depth.saturating_add(1);
|
||||
below -= 1;
|
||||
}
|
||||
@@ -134,11 +135,11 @@ fn scan_column(
|
||||
}
|
||||
return (color, y as i16, depth);
|
||||
}
|
||||
(0, MIN_Y as i16, 0)
|
||||
(0, bottom as i16, 0)
|
||||
}
|
||||
/// The highest block of a chunk worth scanning from.
|
||||
fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
let surface = (0..SECTIONS)
|
||||
let surface = (0..chunk.sections.len())
|
||||
.rev()
|
||||
.find(|&s| {
|
||||
chunk
|
||||
@@ -146,7 +147,7 @@ fn top_of(chunk: &Chunk, air: u32, layer: Layer) -> i32 {
|
||||
.get(s)
|
||||
.is_some_and(|sec| !sec.is_uniform(air))
|
||||
})
|
||||
.map_or(MIN_Y, |s| MIN_Y + s as i32 * 16 + 15);
|
||||
.map_or(chunk.min_y(), |s| chunk.min_y() + s as i32 * 16 + 15);
|
||||
match layer {
|
||||
Layer::Surface => surface,
|
||||
Layer::Below(head) => surface.min(head),
|
||||
@@ -156,7 +157,7 @@ fn scan_tile(chunk: &Chunk, palette: &Palette, air: u32, layer: Layer, revision:
|
||||
let top = top_of(chunk, air, layer);
|
||||
let mut tile = Tile {
|
||||
color: [0; 256],
|
||||
height: [MIN_Y as i16; 256],
|
||||
height: [chunk.min_y() as i16; 256],
|
||||
depth: [0; 256],
|
||||
revision,
|
||||
layer,
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct ModelInstance {
|
||||
pub layer: u32,
|
||||
pub shade: f32,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct PreparedModels {
|
||||
pub catalog: StateModels,
|
||||
pub placements: Vec<(BlockPos, BlockStateId)>,
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct BakedModel {
|
||||
pub cube: Option<[super::cube::CubeStyle; 6]>,
|
||||
}
|
||||
type VariantKey = (String, [u8; 3], bool);
|
||||
/// Empty by default: no state has a model (a dedicated server, which draws
|
||||
/// nothing).
|
||||
#[derive(Default)]
|
||||
pub struct StateModels {
|
||||
/// Each definition's choices, a variant standing as its index in `baked`.
|
||||
choices: Vec<CompiledChoices<u32>>,
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
//! `MultiLineEditBox` over `MultilineTextField` (and `AbstractTextAreaWidget`'s
|
||||
//! frame and scroll bar): a text area that wraps its text to the box, with a
|
||||
//! caret and selection that move by line, a character limit, a line limit and
|
||||
//! a scroll bar. Indices count characters, as `EditBox`'s do.
|
||||
use crate::{
|
||||
clipboard::{Clipboard, EditShortcut, filter_text},
|
||||
font::Font,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
text::Style,
|
||||
ui::{self, Scroll, java_whitespace},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `AbstractTextAreaWidget.INNER_PADDING`.
|
||||
const PADDING: f32 = 4.0;
|
||||
/// `Font.lineHeight`.
|
||||
const LINE: f32 = 9.0;
|
||||
/// `AbstractScrollArea.defaultSettings((int) (9.0 / 2.0))`'s scroll rate.
|
||||
const SCROLL_RATE: f32 = 4.0;
|
||||
|
||||
/// `StringSplitter.splitLines(value, width, Style.EMPTY, false, ..)`: each
|
||||
/// line's `[begin, end)`. A line ends at a `\n`, or once a character overflows
|
||||
/// the width (never the first visible one) at the last space so far, or before
|
||||
/// that character; the `\n` or space at the end is skipped.
|
||||
pub fn split_ranges(font: &Font, chars: &[char], width: f32) -> Vec<(usize, usize)> {
|
||||
let max_width = width.max(1.0);
|
||||
let style = Style::default();
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < chars.len() {
|
||||
let (mut line_width, mut had_visible, mut last_space) = (0.0f32, false, None);
|
||||
let mut line_break = None;
|
||||
for (i, &c) in chars.iter().enumerate().skip(start) {
|
||||
if c == '\n' {
|
||||
line_break = Some(i);
|
||||
break;
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
let advance = font.advance(c, &style);
|
||||
line_width += advance;
|
||||
if !had_visible || line_width <= max_width {
|
||||
had_visible |= advance != 0.0;
|
||||
} else {
|
||||
line_break = Some(last_space.unwrap_or(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(at) = line_break else {
|
||||
out.push((start, chars.len()));
|
||||
break;
|
||||
};
|
||||
out.push((start, at));
|
||||
start = if matches!(chars[at], '\n' | ' ') {
|
||||
at + 1
|
||||
} else {
|
||||
at
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `MultiLineEditBox` with its `MultilineTextField`.
|
||||
pub struct MultiLineEdit {
|
||||
chars: Vec<char>,
|
||||
cursor: usize,
|
||||
select_cursor: usize,
|
||||
selecting: bool,
|
||||
/// `characterLimit` (`MultilineTextField.NO_LIMIT` is `None`).
|
||||
character_limit: Option<usize>,
|
||||
line_limit: Option<usize>,
|
||||
/// `displayLines`.
|
||||
lines: Vec<(usize, usize)>,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub focused: bool,
|
||||
focused_at: Instant,
|
||||
pub scroll: Scroll,
|
||||
}
|
||||
|
||||
impl MultiLineEdit {
|
||||
pub fn new(font: &Font, x: f32, y: f32, width: f32, height: f32) -> Self {
|
||||
let mut edit = Self {
|
||||
chars: Vec::new(),
|
||||
cursor: 0,
|
||||
select_cursor: 0,
|
||||
selecting: false,
|
||||
character_limit: None,
|
||||
line_limit: None,
|
||||
lines: Vec::new(),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
focused: false,
|
||||
focused_at: Instant::now(),
|
||||
scroll: Scroll::default(),
|
||||
};
|
||||
edit.reflow(font);
|
||||
edit
|
||||
}
|
||||
|
||||
/// `AbstractTextAreaWidget.totalInnerPadding`.
|
||||
fn text_width(&self) -> f32 {
|
||||
self.width - 2.0 * PADDING
|
||||
}
|
||||
pub fn set_character_limit(&mut self, limit: usize) {
|
||||
self.character_limit = Some(limit);
|
||||
}
|
||||
pub fn set_line_limit(&mut self, limit: usize) {
|
||||
self.line_limit = Some(limit);
|
||||
}
|
||||
pub fn value(&self) -> String {
|
||||
self.chars.iter().collect()
|
||||
}
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
pub fn lines(&self) -> &[(usize, usize)] {
|
||||
&self.lines
|
||||
}
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.select_cursor != self.cursor
|
||||
}
|
||||
/// `getSelected`.
|
||||
fn selected(&self) -> (usize, usize) {
|
||||
(
|
||||
self.select_cursor.min(self.cursor),
|
||||
self.select_cursor.max(self.cursor),
|
||||
)
|
||||
}
|
||||
pub fn selected_text(&self) -> String {
|
||||
let (begin, end) = self.selected();
|
||||
self.chars[begin..end].iter().collect()
|
||||
}
|
||||
|
||||
/// `setValue(value, false)`: kept as it was when the text has more lines
|
||||
/// than the limit allows.
|
||||
pub fn set_value(&mut self, font: &Font, value: &str) {
|
||||
let mut chars: Vec<char> = value.chars().collect();
|
||||
if let Some(limit) = self.character_limit {
|
||||
chars.truncate(limit);
|
||||
}
|
||||
if !self.overflows_line_limit(font, &chars) {
|
||||
self.chars = chars;
|
||||
self.cursor = self.chars.len();
|
||||
self.select_cursor = self.cursor;
|
||||
self.on_value_change(font);
|
||||
}
|
||||
}
|
||||
|
||||
/// `overflowsLineLimit`.
|
||||
fn overflows_line_limit(&self, font: &Font, value: &[char]) -> bool {
|
||||
let Some(limit) = self.line_limit else {
|
||||
return false;
|
||||
};
|
||||
let lines = split_ranges(font, value, self.text_width()).len();
|
||||
// `StringUtil.endsWithNewLine`.
|
||||
lines + usize::from(value.last().is_some_and(|c| matches!(c, '\n' | '\r'))) > limit
|
||||
}
|
||||
|
||||
/// `insertText`.
|
||||
pub fn insert_text(&mut self, font: &Font, input: &str) {
|
||||
if input.is_empty() && !self.has_selection() {
|
||||
return;
|
||||
}
|
||||
let mut text: Vec<char> = filter_text(input, true).chars().collect();
|
||||
if let Some(limit) = self.character_limit {
|
||||
text.truncate(limit.saturating_sub(self.chars.len()));
|
||||
}
|
||||
let (begin, end) = self.selected();
|
||||
let mut value = self.chars[..begin].to_vec();
|
||||
value.extend_from_slice(&text);
|
||||
value.extend_from_slice(&self.chars[end..]);
|
||||
if !self.overflows_line_limit(font, &value) {
|
||||
self.chars = value;
|
||||
self.cursor = begin + text.len();
|
||||
self.select_cursor = self.cursor;
|
||||
self.on_value_change(font);
|
||||
}
|
||||
}
|
||||
|
||||
/// `deleteText`.
|
||||
pub fn delete_text(&mut self, font: &Font, dir: isize) {
|
||||
if !self.has_selection() {
|
||||
self.select_cursor = self.cursor.saturating_add_signed(dir).min(self.chars.len());
|
||||
}
|
||||
self.insert_text(font, "");
|
||||
}
|
||||
|
||||
/// `onValueChange`: the lines again, and the cursor in view.
|
||||
fn on_value_change(&mut self, font: &Font) {
|
||||
self.reflow(font);
|
||||
self.scroll_to_cursor();
|
||||
}
|
||||
|
||||
/// `reflowDisplayLines`.
|
||||
fn reflow(&mut self, font: &Font) {
|
||||
self.lines = if self.chars.is_empty() {
|
||||
vec![(0, 0)]
|
||||
} else {
|
||||
let mut lines = split_ranges(font, &self.chars, self.text_width());
|
||||
if self.chars.last() == Some(&'\n') {
|
||||
lines.push((self.chars.len(), self.chars.len()));
|
||||
}
|
||||
lines
|
||||
};
|
||||
}
|
||||
|
||||
/// `getLineAtCursor`: -1 when no line holds the cursor.
|
||||
fn line_at_cursor(&self) -> isize {
|
||||
self.lines
|
||||
.iter()
|
||||
.position(|&(begin, end)| self.cursor >= begin && self.cursor <= end)
|
||||
.map_or(-1, |i| i as isize)
|
||||
}
|
||||
/// `getLineView`: the index clamped to the lines.
|
||||
fn line_view(&self, index: isize) -> (usize, usize) {
|
||||
self.lines[index.clamp(0, self.lines.len() as isize - 1) as usize]
|
||||
}
|
||||
/// `getCursorLineView(offset)`.
|
||||
fn cursor_line_view(&self, offset: isize) -> (usize, usize) {
|
||||
match self.line_at_cursor() {
|
||||
-1 => *self.lines.last().expect("a text field has a line"),
|
||||
line => self.line_view(line + offset),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractScrollArea.contentHeight` of the text area.
|
||||
fn content_height(&self) -> f32 {
|
||||
LINE * self.lines.len() as f32 + 2.0 * PADDING
|
||||
}
|
||||
/// `setScrollAmount`.
|
||||
fn set_scroll(&mut self, amount: f32) {
|
||||
self.scroll.amount = amount;
|
||||
self.scroll.clamp(0.0, self.height, self.content_height());
|
||||
}
|
||||
|
||||
/// `MultiLineEditBox.scrollToCursor`, the text field's cursor listener.
|
||||
fn scroll_to_cursor(&mut self) {
|
||||
let mut amount = self.scroll.amount;
|
||||
let first = self.line_view((amount / LINE) as isize);
|
||||
let line = self.line_at_cursor() as f32;
|
||||
if self.cursor <= first.0 {
|
||||
amount = line * LINE;
|
||||
} else {
|
||||
let last = self.line_view(((amount + self.height) / LINE) as isize - 1);
|
||||
if self.cursor > last.1 {
|
||||
amount = line * LINE - self.height + LINE + 2.0 * PADDING;
|
||||
}
|
||||
}
|
||||
self.set_scroll(amount);
|
||||
}
|
||||
|
||||
/// `seekCursor` to `cursor`, then the cursor listener.
|
||||
fn seek_cursor(&mut self, cursor: usize) {
|
||||
self.cursor = cursor.min(self.chars.len());
|
||||
self.scroll_to_cursor();
|
||||
if !self.selecting {
|
||||
self.select_cursor = self.cursor;
|
||||
}
|
||||
}
|
||||
|
||||
/// `seekCursorLine`.
|
||||
fn seek_cursor_line(&mut self, font: &Font, offset: isize) {
|
||||
if offset == 0 {
|
||||
return;
|
||||
}
|
||||
let (begin, _) = self.cursor_line_view(0);
|
||||
let before: String = self.chars[begin..self.cursor].iter().collect();
|
||||
let old_left = font.width(&before) + 2.0;
|
||||
let (begin, end) = self.cursor_line_view(offset);
|
||||
let line: String = self.chars[begin..end].iter().collect();
|
||||
self.seek_cursor(begin + font.fit(&line, old_left));
|
||||
}
|
||||
|
||||
/// `seekCursorToPoint`: `x` and `y` from the top left of the text.
|
||||
fn seek_cursor_to_point(&mut self, font: &Font, x: f32, y: f32) {
|
||||
let left = x.floor();
|
||||
let top = (y / LINE).floor() as isize;
|
||||
let (begin, end) = self.line_view(top);
|
||||
let line: String = self.chars[begin..end].iter().collect();
|
||||
self.seek_cursor(begin + font.fit(&line, left));
|
||||
}
|
||||
|
||||
/// `getPreviousWord`: where the word starts and ends.
|
||||
fn previous_word(&self) -> (usize, usize) {
|
||||
if self.chars.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut start = self.cursor.min(self.chars.len() - 1);
|
||||
while start > 0 && java_whitespace(self.chars[start - 1]) {
|
||||
start -= 1;
|
||||
}
|
||||
while start > 0 && !java_whitespace(self.chars[start - 1]) {
|
||||
start -= 1;
|
||||
}
|
||||
(start, self.word_end(start))
|
||||
}
|
||||
/// `getNextWord`.
|
||||
fn next_word(&self) -> (usize, usize) {
|
||||
if self.chars.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut start = self.cursor.min(self.chars.len() - 1);
|
||||
while start < self.chars.len() && !java_whitespace(self.chars[start]) {
|
||||
start += 1;
|
||||
}
|
||||
while start < self.chars.len() && java_whitespace(self.chars[start]) {
|
||||
start += 1;
|
||||
}
|
||||
(start, self.word_end(start))
|
||||
}
|
||||
fn word_end(&self, from: usize) -> usize {
|
||||
let mut end = from;
|
||||
while end < self.chars.len() && !java_whitespace(self.chars[end]) {
|
||||
end += 1;
|
||||
}
|
||||
end
|
||||
}
|
||||
/// `selectWordAtCursor`.
|
||||
fn select_word_at_cursor(&mut self) {
|
||||
let (begin, end) = self.previous_word();
|
||||
self.seek_cursor(begin);
|
||||
self.selecting = true;
|
||||
self.seek_cursor(end);
|
||||
}
|
||||
|
||||
/// `MultilineTextField.keyPressed` for the keys that are not shortcuts.
|
||||
/// True when the key was used.
|
||||
pub fn key(&mut self, font: &Font, key: KeyCode, ctrl: bool, shift: bool) -> bool {
|
||||
self.selecting = shift;
|
||||
match key {
|
||||
KeyCode::Backspace if ctrl => {
|
||||
let (begin, _) = self.previous_word();
|
||||
self.delete_text(font, begin as isize - self.cursor as isize);
|
||||
}
|
||||
KeyCode::Backspace => self.delete_text(font, -1),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.insert_text(font, "\n"),
|
||||
KeyCode::Delete if ctrl => {
|
||||
let (begin, _) = self.next_word();
|
||||
self.delete_text(font, begin as isize - self.cursor as isize);
|
||||
}
|
||||
KeyCode::Delete => self.delete_text(font, 1),
|
||||
KeyCode::Home if ctrl => self.seek_cursor(0),
|
||||
KeyCode::Home => self.seek_cursor(self.cursor_line_view(0).0),
|
||||
KeyCode::PageUp => self.seek_cursor(0),
|
||||
KeyCode::End if ctrl => self.seek_cursor(self.chars.len()),
|
||||
KeyCode::End => self.seek_cursor(self.cursor_line_view(0).1),
|
||||
KeyCode::PageDown => self.seek_cursor(self.chars.len()),
|
||||
KeyCode::ArrowRight if ctrl => self.seek_cursor(self.next_word().0),
|
||||
KeyCode::ArrowRight => self.seek_cursor(self.cursor + 1),
|
||||
KeyCode::ArrowLeft if ctrl => self.seek_cursor(self.previous_word().0),
|
||||
KeyCode::ArrowLeft => self.seek_cursor(self.cursor.saturating_sub(1)),
|
||||
KeyCode::ArrowDown => {
|
||||
if !ctrl {
|
||||
self.seek_cursor_line(font, 1);
|
||||
}
|
||||
}
|
||||
KeyCode::ArrowUp => {
|
||||
if !ctrl {
|
||||
self.seek_cursor_line(font, -1);
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Ctrl+A/C/V/X. True when the value changed.
|
||||
pub fn shortcut(
|
||||
&mut self,
|
||||
font: &Font,
|
||||
shortcut: EditShortcut,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
) -> bool {
|
||||
let before = self.chars.len();
|
||||
match shortcut {
|
||||
EditShortcut::SelectAll => {
|
||||
self.cursor = self.chars.len();
|
||||
self.select_cursor = 0;
|
||||
}
|
||||
EditShortcut::Copy => clipboard.set(&self.selected_text()),
|
||||
EditShortcut::Paste => self.insert_text(font, &clipboard.get()),
|
||||
EditShortcut::Cut => {
|
||||
clipboard.set(&self.selected_text());
|
||||
self.insert_text(font, "");
|
||||
}
|
||||
}
|
||||
self.focused_at = Instant::now();
|
||||
self.chars.len() != before
|
||||
}
|
||||
|
||||
/// `AbstractWidget.isMouseOver`: the box and its scroll bar.
|
||||
pub fn hovered(&self, (x, y): (f32, f32)) -> bool {
|
||||
x >= self.x && y >= self.y && x < self.x + self.width + 6.0 && y < self.y + self.height
|
||||
}
|
||||
|
||||
/// `AbstractTextAreaWidget.mouseClicked`: a press on the scroll bar
|
||||
/// holds it, else `onClick` places the cursor (`double`: selects the word).
|
||||
pub fn click(&mut self, font: &Font, cursor: (f32, f32), shift: bool, double: bool) {
|
||||
let bar = self.x + self.width;
|
||||
if self.scroll.press(
|
||||
cursor,
|
||||
bar,
|
||||
self.y,
|
||||
self.y + self.height,
|
||||
self.content_height(),
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if double {
|
||||
self.select_word_at_cursor();
|
||||
} else {
|
||||
self.selecting = shift;
|
||||
self.seek_screen(font, cursor);
|
||||
}
|
||||
}
|
||||
/// `onDrag`: the selection follows the pointer, or the held scroll bar does.
|
||||
pub fn drag(&mut self, font: &Font, cursor: (f32, f32), shift: bool) {
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(
|
||||
cursor.1,
|
||||
self.y,
|
||||
self.y + self.height,
|
||||
self.content_height(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.selecting = true;
|
||||
self.seek_screen(font, cursor);
|
||||
self.selecting = shift;
|
||||
}
|
||||
/// `onRelease`.
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
}
|
||||
/// `mouseScrolled`.
|
||||
pub fn wheel(&mut self, delta: f32) {
|
||||
let amount = self.scroll.amount - delta * SCROLL_RATE;
|
||||
self.set_scroll(amount);
|
||||
}
|
||||
/// `seekCursorScreen`.
|
||||
fn seek_screen(&mut self, font: &Font, (x, y): (f32, f32)) {
|
||||
let mouse_x = x - self.x - PADDING;
|
||||
let mouse_y = y - self.y - PADDING + self.scroll.amount;
|
||||
self.seek_cursor_to_point(font, mouse_x, mouse_y);
|
||||
}
|
||||
|
||||
/// `setFocused`: the caret starts to blink.
|
||||
pub fn set_focused(&mut self, focused: bool) {
|
||||
if focused && !self.focused {
|
||||
self.focused_at = Instant::now();
|
||||
}
|
||||
self.focused = focused;
|
||||
}
|
||||
|
||||
/// `AbstractTextAreaWidget.withinContentAreaTopBottom`.
|
||||
fn within(&self, top: f32, bottom: f32) -> bool {
|
||||
bottom - self.scroll.amount >= self.y && top - self.scroll.amount <= self.y + self.height
|
||||
}
|
||||
|
||||
/// `extractWidgetRenderState`: the frame, the text under a scissor, the
|
||||
/// scroll bar and the character count.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
crate::widgets::target_as(
|
||||
[self.x, self.y, self.width, self.height],
|
||||
crate::widgets::Kind::Field,
|
||||
);
|
||||
let sprite = if self.focused {
|
||||
"gui/sprites/widget/text_field_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/text_field"
|
||||
};
|
||||
ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
sprite,
|
||||
self.x,
|
||||
self.y,
|
||||
self.width,
|
||||
self.height,
|
||||
u32::MAX,
|
||||
);
|
||||
let start = batch.vertices.len();
|
||||
self.draw_contents(batch, atlas);
|
||||
batch.clip_since(
|
||||
start,
|
||||
self.x + 1.0,
|
||||
self.y + 1.0,
|
||||
self.x + self.width - 1.0,
|
||||
self.y + self.height - 1.0,
|
||||
);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
self.x + self.width,
|
||||
self.y,
|
||||
self.y + self.height,
|
||||
self.content_height(),
|
||||
self.scroll.amount,
|
||||
);
|
||||
if let Some(limit) = self.character_limit {
|
||||
let count = crate::lang::tr(
|
||||
"gui.multiLineEditBox.character_limit",
|
||||
&[&self.chars.len(), &limit],
|
||||
);
|
||||
let width = atlas.font.width(&count);
|
||||
batch.text(
|
||||
atlas,
|
||||
&count,
|
||||
self.x + self.width - width,
|
||||
self.y + self.height + 4.0,
|
||||
rgba(160, 160, 160, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MultiLineEditBox.extractContents` (the placeholder is empty).
|
||||
fn draw_contents(&self, batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
if self.chars.is_empty() && !self.focused {
|
||||
return;
|
||||
}
|
||||
let font = &atlas.font;
|
||||
let text_color = rgba(224, 224, 224, 255);
|
||||
let cursor_color = rgba(208, 208, 208, 255);
|
||||
let slice =
|
||||
|begin: usize, end: usize| -> String { self.chars[begin..end].iter().collect() };
|
||||
let show_cursor =
|
||||
self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2);
|
||||
let needs_valid_cursor_pos = self.focused;
|
||||
let insert_cursor = self.cursor < self.chars.len();
|
||||
let (inner_left, mut draw_top) = (self.x + PADDING, self.y + PADDING - self.scroll.amount);
|
||||
let (mut cursor_x, mut cursor_y) = (0.0f32, 0.0f32);
|
||||
let mut has_drawn_cursor = false;
|
||||
// The content is translated by the scroll amount; `within` compares
|
||||
// untranslated tops, so it is given the line's top before the shift.
|
||||
let shift = self.scroll.amount;
|
||||
for &(begin, end) in &self.lines {
|
||||
let within = self.within(draw_top + shift, draw_top + shift + LINE);
|
||||
if !has_drawn_cursor
|
||||
&& (needs_valid_cursor_pos || show_cursor)
|
||||
&& insert_cursor
|
||||
&& self.cursor >= begin
|
||||
&& self.cursor <= end
|
||||
{
|
||||
let before = slice(begin, self.cursor);
|
||||
let right = inner_left + font.width(&before);
|
||||
(cursor_x, cursor_y) = (right, draw_top);
|
||||
if within {
|
||||
let after = slice(self.cursor, end);
|
||||
batch.text(atlas, &before, inner_left, draw_top, text_color, true);
|
||||
batch.text(atlas, &after, right, draw_top, text_color, true);
|
||||
if show_cursor {
|
||||
batch.fill(atlas, cursor_x, cursor_y - 1.0, 1.0, 11.0, cursor_color);
|
||||
}
|
||||
}
|
||||
has_drawn_cursor = true;
|
||||
} else {
|
||||
let line = slice(begin, end);
|
||||
if within {
|
||||
batch.text(atlas, &line, inner_left, draw_top, text_color, true);
|
||||
}
|
||||
if (needs_valid_cursor_pos || show_cursor) && !insert_cursor {
|
||||
cursor_x = inner_left + font.width(&line);
|
||||
cursor_y = draw_top;
|
||||
}
|
||||
}
|
||||
draw_top += LINE;
|
||||
}
|
||||
if show_cursor && !insert_cursor && self.within(cursor_y + shift, cursor_y + shift + LINE) {
|
||||
batch.text(atlas, "_", cursor_x, cursor_y, cursor_color, true);
|
||||
}
|
||||
if self.has_selection() {
|
||||
let (sel_begin, sel_end) = self.selected();
|
||||
let draw_x = inner_left;
|
||||
let mut draw_top = self.y + PADDING - self.scroll.amount;
|
||||
for &(begin, end) in &self.lines {
|
||||
if sel_begin > end {
|
||||
draw_top += LINE;
|
||||
continue;
|
||||
}
|
||||
if begin > sel_end {
|
||||
break;
|
||||
}
|
||||
if self.within(draw_top + shift, draw_top + shift + LINE) {
|
||||
let draw_begin = font.width(&slice(begin, sel_begin.max(begin)));
|
||||
let draw_end = if sel_end > end {
|
||||
self.width - PADDING
|
||||
} else {
|
||||
font.width(&slice(begin, sel_end))
|
||||
};
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(draw_x + draw_begin, draw_top, draw_end - draw_begin, LINE),
|
||||
true,
|
||||
);
|
||||
}
|
||||
draw_top += LINE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hud::Rect;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
Path::new("/nonexistent-horizon-multiline-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A box whose text is `chars` characters wide and `lines` lines tall.
|
||||
fn edit(font: &Font, chars: usize, lines: usize) -> MultiLineEdit {
|
||||
let advance = font.advance('a', &Style::default());
|
||||
MultiLineEdit::new(
|
||||
font,
|
||||
0.0,
|
||||
0.0,
|
||||
chars as f32 * advance + 8.0,
|
||||
LINE * lines as f32 + 8.0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_break_at_newlines_and_at_the_last_space_that_fits() {
|
||||
let font = font();
|
||||
let split = |text: &str, chars: usize| {
|
||||
let advance = font.advance('a', &Style::default());
|
||||
let text: Vec<char> = text.chars().collect();
|
||||
split_ranges(&font, &text, chars as f32 * advance)
|
||||
};
|
||||
assert_eq!(split("abcd efgh", 4), [(0, 4), (5, 9)]);
|
||||
// No space to break at: before the character that overflows.
|
||||
assert_eq!(split("abcdefgh", 4), [(0, 4), (4, 8)]);
|
||||
assert_eq!(split("ab\ncd", 4), [(0, 2), (3, 5)]);
|
||||
// A trailing newline starts no line here; the text field adds it.
|
||||
assert_eq!(split("ab\n", 4), [(0, 2)]);
|
||||
assert_eq!(split("", 4), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_newline_gets_an_empty_last_line() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
assert_eq!(edit.lines(), [(0, 0)]);
|
||||
edit.insert_text(&font, "ab\n");
|
||||
assert_eq!(edit.lines(), [(0, 2), (3, 3)]);
|
||||
assert_eq!(edit.cursor(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_character_limit_cuts_what_is_typed_and_set() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_character_limit(5);
|
||||
edit.set_value(&font, "abcdefgh");
|
||||
assert_eq!(edit.value(), "abcde");
|
||||
edit.key(&font, KeyCode::Backspace, false, false);
|
||||
edit.insert_text(&font, "wxyz");
|
||||
assert_eq!(edit.value(), "abcdw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_limit_refuses_an_edit_that_adds_a_line() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_line_limit(2);
|
||||
edit.set_value(&font, "a\nb");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
// A third line, by a newline or by wrapping, changes nothing.
|
||||
edit.key(&font, KeyCode::Enter, false, false);
|
||||
edit.insert_text(&font, "\nc");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
edit.insert_text(&font, " abcdefghijkl");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
// A value over the limit is not set at all.
|
||||
edit.set_value(&font, "1\n2\n3");
|
||||
assert_eq!(edit.value(), "a\nb");
|
||||
// A trailing newline counts as a line.
|
||||
edit.set_value(&font, "a\n");
|
||||
edit.insert_text(&font, "\n");
|
||||
assert_eq!(edit.value(), "a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_keep_the_column_and_home_and_end_stay_on_the_line() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_value(&font, "abcd\nef\nghij");
|
||||
// From the end of `ghij` up to `ef`: the end of the shorter line.
|
||||
edit.key(&font, KeyCode::ArrowUp, false, false);
|
||||
assert_eq!(edit.cursor(), 7);
|
||||
edit.key(&font, KeyCode::ArrowUp, false, false);
|
||||
assert_eq!(edit.cursor(), 2);
|
||||
edit.key(&font, KeyCode::Home, false, false);
|
||||
assert_eq!(edit.cursor(), 0);
|
||||
edit.key(&font, KeyCode::End, false, false);
|
||||
assert_eq!(edit.cursor(), 4);
|
||||
edit.key(&font, KeyCode::End, true, false);
|
||||
assert_eq!(edit.cursor(), 12);
|
||||
edit.key(&font, KeyCode::PageUp, false, false);
|
||||
assert_eq!(edit.cursor(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_selects_and_typing_replaces_the_selection() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_value(&font, "one two");
|
||||
edit.key(&font, KeyCode::ArrowLeft, true, true);
|
||||
assert_eq!(edit.selected_text(), "two");
|
||||
edit.insert_text(&font, "2");
|
||||
assert_eq!(edit.value(), "one 2");
|
||||
edit.set_value(&font, "one two");
|
||||
edit.key(&font, KeyCode::Backspace, true, false);
|
||||
assert_eq!(edit.value(), "one ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_scrolls_to_keep_the_cursor_in_view() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 2);
|
||||
edit.set_value(&font, "1\n2\n3\n4\n5");
|
||||
// Five lines in two lines' height: the end shows.
|
||||
assert_eq!(edit.scroll.amount, 9.0 * 5.0 + 8.0 - edit.height);
|
||||
edit.key(&font, KeyCode::PageUp, false, false);
|
||||
assert_eq!(edit.scroll.amount, 0.0);
|
||||
edit.wheel(-1.0);
|
||||
assert_eq!(edit.scroll.amount, SCROLL_RATE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_puts_the_cursor_on_the_line_and_character_under_it() {
|
||||
let font = font();
|
||||
let mut edit = edit(&font, 8, 4);
|
||||
edit.set_value(&font, "abcd\nefgh");
|
||||
let advance = font.advance('a', &Style::default());
|
||||
// The second line, after two characters.
|
||||
edit.click(
|
||||
&font,
|
||||
(4.0 + 2.0 * advance + 1.0, 4.0 + 9.0 + 1.0),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(edit.cursor(), 7);
|
||||
// Shift extends the selection.
|
||||
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), true, false);
|
||||
assert_eq!(edit.selected_text(), "abcd\nef");
|
||||
// A double click, after the plain click that starts it, selects the word.
|
||||
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), false, false);
|
||||
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), false, true);
|
||||
assert_eq!(edit.selected_text(), "abcd");
|
||||
}
|
||||
}
|
||||
@@ -141,10 +141,10 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
// Saved, but the terrain sampler has no RGSS or anisotropic filtering yet.
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
// Saved, but the mouse doesn't read it yet.
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
@@ -204,6 +204,11 @@ impl Opt {
|
||||
Opt::MaxFps => "options.framerateLimit",
|
||||
Opt::Vsync => "options.vsync",
|
||||
Opt::Fullscreen => "options.fullscreen",
|
||||
Opt::ExclusiveFullscreen => "options.exclusiveFullscreen",
|
||||
Opt::ExclusiveMode => "options.fullscreen.exclusive.mode",
|
||||
Opt::GraphicsBackend => "options.graphicsApi",
|
||||
Opt::TextureFiltering => "options.textureFiltering",
|
||||
Opt::MaxAnisotropy => "options.maxAnisotropy",
|
||||
Opt::SmoothLighting => "options.ao",
|
||||
Opt::Clouds => "options.renderClouds",
|
||||
Opt::CloudRange => "options.renderCloudsDistance",
|
||||
@@ -335,6 +340,15 @@ impl Opt {
|
||||
Opt::BiomeBlend => unit(s.biome_blend as f32, 0.0, 7.0),
|
||||
Opt::Preset => unit(preset_index(&s.graphics_preset) as f32, 0.0, 3.0),
|
||||
Opt::Mipmaps => unit(s.mipmap_levels as f32, 0.0, 4.0),
|
||||
Opt::MaxAnisotropy => unit(s.file.int("maxAnisotropyBit") as f32, 1.0, 3.0),
|
||||
Opt::ExclusiveMode => {
|
||||
let last = s.video_modes.len() as f32 - 1.0;
|
||||
if last < 0.0 {
|
||||
Kind::Slider(0.0)
|
||||
} else {
|
||||
unit(s.video_mode_index() as f32, -1.0, last)
|
||||
}
|
||||
}
|
||||
Opt::WeatherRadius => unit(s.weather_radius as f32, 3.0, 10.0),
|
||||
Opt::ChunkFade => unit(s.chunk_fade, 0.0, 2.0),
|
||||
Opt::MenuBlur => unit(s.menu_blur as f32, 0.0, 10.0),
|
||||
@@ -368,6 +382,19 @@ impl Opt {
|
||||
"options.prioritizeChunkUpdates.byPlayer",
|
||||
"options.prioritizeChunkUpdates.nearby",
|
||||
][usize::from(s.prioritize_chunk_updates.min(2))],
|
||||
// Options.exclusiveFullscreen: a tooltip per value.
|
||||
Opt::ExclusiveFullscreen => {
|
||||
if s.file.bool("exclusiveFullscreen") {
|
||||
"options.exclusiveFullscreen.on"
|
||||
} else {
|
||||
"options.exclusiveFullscreen.off"
|
||||
}
|
||||
}
|
||||
Opt::TextureFiltering => [
|
||||
"options.textureFiltering.none",
|
||||
"options.textureFiltering.rgss",
|
||||
"options.textureFiltering.anisotropic",
|
||||
][s.file.int("textureFiltering").clamp(0, 2) as usize],
|
||||
_ => self.caption(),
|
||||
};
|
||||
format!("{key}.tooltip")
|
||||
@@ -472,6 +499,26 @@ impl Opt {
|
||||
)),
|
||||
),
|
||||
Opt::Preset => generic(c, tr(&format!("options.graphics.{}", s.graphics_preset))),
|
||||
Opt::MaxAnisotropy => match s.file.int("maxAnisotropyBit") {
|
||||
0 => generic(c, tr("options.off")),
|
||||
bit => generic(c, Text::tr("options.multiplier", &[&(1 << bit)])),
|
||||
},
|
||||
Opt::ExclusiveMode => match s.video_modes.get(s.video_mode_index().max(0) as usize) {
|
||||
None if s.video_modes.is_empty() => tr("options.fullscreen.unavailable"),
|
||||
Some(mode) if s.video_mode_index() >= 0 => generic(
|
||||
c,
|
||||
Text::tr(
|
||||
"options.fullscreen.entry",
|
||||
&[
|
||||
&mode.width,
|
||||
&mode.height,
|
||||
&mode.refresh_rate_label(),
|
||||
&mode.bits(),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ => generic(c, tr("options.fullscreen.current")),
|
||||
},
|
||||
Opt::Mipmaps => {
|
||||
if s.mipmap_levels == 0 {
|
||||
generic(c, tr("options.off"))
|
||||
@@ -582,6 +629,17 @@ impl Opt {
|
||||
Opt::ViewBobbing => on_off(s.view_bobbing),
|
||||
Opt::Vsync => on_off(s.vsync),
|
||||
Opt::Fullscreen => on_off(s.fullscreen),
|
||||
Opt::ExclusiveFullscreen => on_off(s.file.bool("exclusiveFullscreen")),
|
||||
Opt::TextureFiltering => tr([
|
||||
"options.textureFiltering.none",
|
||||
"options.textureFiltering.rgss",
|
||||
"options.textureFiltering.anisotropic",
|
||||
][s.file.int("textureFiltering").clamp(0, 2) as usize]),
|
||||
Opt::GraphicsBackend => tr(match s.horizon.api {
|
||||
crate::graphics::GraphicsApi::OpenGl => "options.graphicsApi.opengl",
|
||||
crate::graphics::GraphicsApi::Vulkan => "options.graphicsApi.vulkan",
|
||||
_ => "options.graphicsApi.default",
|
||||
}),
|
||||
Opt::SmoothLighting => on_off(s.smooth_lighting),
|
||||
Opt::Clouds => tr(match s.clouds.as_str() {
|
||||
"false" => "options.off",
|
||||
@@ -733,6 +791,20 @@ impl Opt {
|
||||
Opt::BiomeBlend => s.biome_blend = step(0.0, 7.0) as i32,
|
||||
Opt::Preset => s.graphics_preset = PRESETS[step(0.0, 3.0) as usize].into(),
|
||||
Opt::Mipmaps => s.mipmap_levels = step(0.0, 4.0) as i32,
|
||||
Opt::MaxAnisotropy => s.file.set(
|
||||
"maxAnisotropyBit",
|
||||
crate::options_file::V::Int(step(1.0, 3.0) as i32),
|
||||
),
|
||||
Opt::ExclusiveMode => {
|
||||
// Window.setPreferredFullscreenVideoMode: -1 is the desktop's own mode.
|
||||
let Some(last) = s.video_modes.len().checked_sub(1) else {
|
||||
return;
|
||||
};
|
||||
let index = step(-1.0, last as f32) as i32;
|
||||
s.file.fullscreen_resolution = usize::try_from(index)
|
||||
.ok()
|
||||
.map(|i| s.video_modes[i].write());
|
||||
}
|
||||
Opt::WeatherRadius => s.weather_radius = step(3.0, 10.0) as i32,
|
||||
Opt::ChunkFade => s.chunk_fade = step(0.0, 40.0) / 20.0,
|
||||
Opt::MenuBlur => s.menu_blur = step(0.0, 10.0) as i32,
|
||||
@@ -772,6 +844,8 @@ impl Opt {
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::MenuBlur
|
||||
| Opt::ChunkBuilder
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
)
|
||||
}
|
||||
/// Advance a cycle button (CycleButton.cycleValue).
|
||||
@@ -821,6 +895,45 @@ impl Opt {
|
||||
Opt::ViewBobbing => s.view_bobbing = !s.view_bobbing,
|
||||
Opt::Vsync => s.vsync = !s.vsync,
|
||||
Opt::Fullscreen => s.fullscreen = !s.fullscreen,
|
||||
Opt::ExclusiveFullscreen => {
|
||||
let on = s.file.bool("exclusiveFullscreen");
|
||||
s.file
|
||||
.set("exclusiveFullscreen", crate::options_file::V::Bool(!on));
|
||||
}
|
||||
Opt::TextureFiltering => {
|
||||
let n = next(s.file.int("textureFiltering").clamp(0, 2) as u8, 3);
|
||||
s.file.set(
|
||||
"textureFiltering",
|
||||
crate::options_file::V::Int(i32::from(n)),
|
||||
);
|
||||
}
|
||||
Opt::GraphicsBackend => {
|
||||
// PreferredGraphicsApi: Default, OpenGL, Vulkan; a platform lacking one skips it.
|
||||
use crate::graphics::GraphicsApi::{Auto, OpenGl, Vulkan};
|
||||
let offered: Vec<_> = [Auto, OpenGl, Vulkan]
|
||||
.into_iter()
|
||||
.filter(|a| crate::graphics::GraphicsApi::choices().contains(a))
|
||||
.collect();
|
||||
let at = offered
|
||||
.iter()
|
||||
.position(|a| *a == s.horizon.api)
|
||||
.unwrap_or(0);
|
||||
let count = offered.len();
|
||||
let api = offered[if back {
|
||||
(at + count - 1) % count
|
||||
} else {
|
||||
(at + 1) % count
|
||||
}];
|
||||
s.horizon.api = api;
|
||||
s.file.set_name(
|
||||
"preferredGraphicsBackend",
|
||||
match api {
|
||||
OpenGl => "opengl",
|
||||
Vulkan => "vulkan",
|
||||
_ => "default",
|
||||
},
|
||||
);
|
||||
}
|
||||
Opt::SmoothLighting => s.smooth_lighting = !s.smooth_lighting,
|
||||
Opt::Clouds => {
|
||||
let order = ["false", "fast", "true"];
|
||||
@@ -952,8 +1065,25 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
|
||||
apply_preset(s);
|
||||
}
|
||||
}
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
|
||||
/// filtering and anisotropy have no option here yet).
|
||||
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
|
||||
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
|
||||
/// callback, which `OptionInstance.set` runs only for a new value).
|
||||
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
|
||||
let volume = |s: &Settings| match opt {
|
||||
Opt::Volume(0) => Some(s.master_volume),
|
||||
Opt::Volume(i) => Some(s.volumes[i]),
|
||||
_ => None,
|
||||
};
|
||||
let before = volume(s);
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
|
||||
&& before != Some(after)
|
||||
{
|
||||
events.push(Event::Volume(source, after));
|
||||
}
|
||||
}
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
|
||||
pub fn apply_preset(s: &mut Settings) {
|
||||
let fabulous = s.graphics_preset == "fabulous";
|
||||
match s.graphics_preset.as_str() {
|
||||
@@ -973,6 +1103,7 @@ pub fn apply_preset(s: &mut Settings) {
|
||||
s.cutout_leaves = false;
|
||||
s.improved_transparency = false;
|
||||
s.weather_radius = 5;
|
||||
set_filtering(s, 1, 0);
|
||||
}
|
||||
"fancy" | "fabulous" => {
|
||||
s.biome_blend = 2;
|
||||
@@ -990,10 +1121,40 @@ pub fn apply_preset(s: &mut Settings) {
|
||||
s.cutout_leaves = true;
|
||||
s.improved_transparency = fabulous;
|
||||
s.weather_radius = 10;
|
||||
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
|
||||
// wgpu does not report).
|
||||
set_filtering(
|
||||
s,
|
||||
if fabulous { 2 } else { 1 },
|
||||
if fabulous { 2 } else { 1 },
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
|
||||
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
|
||||
use crate::options_file::V;
|
||||
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
|
||||
s.file.set("textureFiltering", V::Int(filtering));
|
||||
}
|
||||
|
||||
/// `Options.preferredGraphicsBackend`'s tooltip: the restart note once the choice differs from
|
||||
/// the startup one, the description, and Vulkan's warning.
|
||||
fn graphics_api_tooltip(s: &Settings) -> Text {
|
||||
let line = |key: &str| lang::get_or(key, key);
|
||||
let mut lines = Vec::new();
|
||||
if s.horizon.api != s.startup_api {
|
||||
lines.push(line("options.needsRestart"));
|
||||
lines.push(String::new());
|
||||
}
|
||||
lines.push(line("options.graphicsApi.tooltip"));
|
||||
if s.horizon.api == crate::graphics::GraphicsApi::Vulkan {
|
||||
lines.push(String::new());
|
||||
lines.push(line("options.graphicsApi.tooltip.vulkan"));
|
||||
}
|
||||
Text::literal(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// `selected_key` while a native mod's key waits (`selected_native_key`).
|
||||
const NATIVE_KEY: &str = "horizon.nativeModKey";
|
||||
@@ -1106,6 +1267,9 @@ pub enum Event {
|
||||
Changed,
|
||||
/// A slider is being dragged: apply settings live but defer saving.
|
||||
SliderDrag,
|
||||
/// A category volume slider (`SoundSource` index) moved to a new value,
|
||||
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
|
||||
Volume(usize, f32),
|
||||
/// The language changed (reload translations).
|
||||
Language(String),
|
||||
/// The pack selection changed (reload resources).
|
||||
@@ -1629,13 +1793,16 @@ impl OptionsUi {
|
||||
)),
|
||||
)];
|
||||
rows.push(Row::Header("options.video.display.header"));
|
||||
rows.push(Row::Big(Opt::ExclusiveMode));
|
||||
rows.extend(pairs(&[
|
||||
Opt::MaxFps,
|
||||
Opt::Vsync,
|
||||
Opt::InactivityFps,
|
||||
Opt::GuiScale,
|
||||
Opt::Fullscreen,
|
||||
Opt::ExclusiveFullscreen,
|
||||
Opt::Gamma,
|
||||
Opt::GraphicsBackend,
|
||||
]));
|
||||
rows.push(Row::Header("options.video.quality.header"));
|
||||
rows.push(Row::Big(Opt::Preset));
|
||||
@@ -1654,6 +1821,8 @@ impl OptionsUi {
|
||||
Opt::CloudRange,
|
||||
Opt::CutoutLeaves,
|
||||
Opt::ImprovedTransparency,
|
||||
Opt::TextureFiltering,
|
||||
Opt::MaxAnisotropy,
|
||||
Opt::WeatherRadius,
|
||||
]));
|
||||
rows.push(Row::Header("options.video.preferences.header"));
|
||||
@@ -1982,6 +2151,21 @@ impl OptionsUi {
|
||||
});
|
||||
let row_left = cx - 170.0;
|
||||
let scrollbar_x = row_left + 340.0 + 6.0 + 2.0;
|
||||
// VideoSettingsScreen.addTitle: a changed graphics API needs a restart.
|
||||
if page == Page::Video && s.horizon.api != s.startup_api {
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: 15.0,
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr("options.restartRequired"),
|
||||
color: rgba(223, 80, 80, 255),
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
});
|
||||
}
|
||||
let list_start = out.len();
|
||||
let mut y = top + 2.0 - self.scroll;
|
||||
let mut last_category = "";
|
||||
@@ -2471,6 +2655,13 @@ impl OptionsUi {
|
||||
if let Some(key) = key.filter(|key| lang::has(key)) {
|
||||
tooltip = Some(atlas.font.split(&tr(&key), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(Opt::GraphicsBackend),
|
||||
..
|
||||
} = &wd.kind
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&graphics_api_tooltip(s), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
@@ -2532,6 +2723,7 @@ impl OptionsUi {
|
||||
shown,
|
||||
hover || dragging,
|
||||
true,
|
||||
dragging,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
@@ -2747,8 +2939,7 @@ impl OptionsUi {
|
||||
{
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
drag_slider(opt, s, v, &mut events);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -2861,8 +3052,7 @@ impl OptionsUi {
|
||||
self.dragging = Some(opt);
|
||||
let v = ui::slider_value(wd.x, wd.w, x);
|
||||
self.slider_value = Some((opt, v, Instant::now()));
|
||||
set_slider(opt, s, v);
|
||||
events.push(Event::SliderDrag);
|
||||
drag_slider(opt, s, v, &mut events);
|
||||
}
|
||||
WKind::LanguageRow { code, .. } => {
|
||||
if self.language_choice.as_deref() == Some(code.as_str()) {
|
||||
@@ -3415,6 +3605,27 @@ mod tests {
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_shortcuts_option_can_be_switched() {
|
||||
assert!(Opt::QuitShortcuts.available());
|
||||
let s = Settings::default();
|
||||
assert_eq!(Opt::QuitShortcuts.caption(), "options.quitShortcuts");
|
||||
assert_eq!(
|
||||
Opt::QuitShortcuts.tooltip_key(&s),
|
||||
"options.quitShortcuts.tooltip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_items_tab_option_can_be_switched() {
|
||||
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
|
||||
assert!(Opt::OperatorItemsTab.available());
|
||||
let mut s = Settings::default();
|
||||
assert!(!s.operator_items_tab);
|
||||
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.operator_items_tab);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
@@ -3645,6 +3856,234 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_native_mods_key_shows_and_keeps_the_key_pressed() {
|
||||
// #3539: Zoom's key set to R showed another key.
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
let size = (480.0, 240.0);
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
id: "zoom".into(),
|
||||
title: "Zoom 2.0.0".into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(crate::mod_list::NativePage {
|
||||
keys: vec![["zoom".into(), "Zoom key".into(), "key.keyboard.z".into()]],
|
||||
buttons: Vec::new(),
|
||||
}),
|
||||
});
|
||||
ui.show(Page::NativeMod(0));
|
||||
let key_label = |ui: &mut OptionsUi, s: &Settings| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
widgets.into_iter().find_map(|w| match w.kind {
|
||||
WKind::Button {
|
||||
label,
|
||||
act: Act::NativeKey(0, 0),
|
||||
..
|
||||
} => Some((label.plain(), w.x, w.y)),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let (label, x, y) = key_label(&mut ui, &s).expect("the key's row");
|
||||
assert_eq!(label, "Z");
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
x + 1.0,
|
||||
y + 1.0,
|
||||
size,
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyR, Some("r")),
|
||||
vec![Event::Changed]
|
||||
);
|
||||
assert_eq!(key_label(&mut ui, &s).unwrap().0, "R");
|
||||
let saved = crate::mod_list::ModList::parse(&s.mods.serialize());
|
||||
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
|
||||
}
|
||||
|
||||
fn mode(width: i32, rate: f32) -> crate::window::VideoMode {
|
||||
crate::window::VideoMode {
|
||||
width,
|
||||
height: width * 9 / 16,
|
||||
red_bits: 8,
|
||||
green_bits: 8,
|
||||
blue_bits: 8,
|
||||
refresh_rate: rate,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_page_lists_vanilla_display_and_quality_options_in_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let rows = ui.list_rows(&Settings::default());
|
||||
let opts: Vec<Opt> = rows
|
||||
.iter()
|
||||
.flat_map(|r| match r {
|
||||
Row::Big(o) => vec![*o],
|
||||
Row::Pair(a, b) => std::iter::once(*a).chain(*b).collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
// VideoSettingsScreen.addOptions: the exclusive mode row, displayOptions, the preset,
|
||||
// qualityOptions, then the preferences.
|
||||
assert_eq!(
|
||||
opts[..9],
|
||||
[
|
||||
Opt::ExclusiveMode,
|
||||
Opt::MaxFps,
|
||||
Opt::Vsync,
|
||||
Opt::InactivityFps,
|
||||
Opt::GuiScale,
|
||||
Opt::Fullscreen,
|
||||
Opt::ExclusiveFullscreen,
|
||||
Opt::Gamma,
|
||||
Opt::GraphicsBackend,
|
||||
]
|
||||
);
|
||||
let at = |o| opts.iter().position(|x| *x == o).unwrap();
|
||||
assert_eq!(at(Opt::ImprovedTransparency) + 1, at(Opt::TextureFiltering));
|
||||
assert_eq!(at(Opt::TextureFiltering) + 1, at(Opt::MaxAnisotropy));
|
||||
assert_eq!(at(Opt::MaxAnisotropy) + 1, at(Opt::WeatherRadius));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_mode_picks_a_monitor_mode_or_the_current_one() {
|
||||
let mut s = Settings::default();
|
||||
// No monitor mode: the slider changes nothing.
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.file.fullscreen_resolution, None);
|
||||
s.video_modes = vec![mode(1280, 60.0), mode(1920, 144.0), mode(2560, 59.94)];
|
||||
assert_eq!(s.video_mode_index(), -1);
|
||||
// IntRange(-1, 2): the slider's thirds are -1, 0, 1, 2.
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 2.0 / 3.0);
|
||||
assert_eq!(s.video_mode_index(), 1);
|
||||
assert_eq!(Opt::ExclusiveMode.slider_position(&s), Some(2.0 / 3.0));
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.video_mode_index(), 2);
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 0.0);
|
||||
assert_eq!(s.file.fullscreen_resolution, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
|
||||
let mut s = Settings::default();
|
||||
assert!(!s.file.bool("exclusiveFullscreen"));
|
||||
assert_eq!(
|
||||
Opt::ExclusiveFullscreen.tooltip_key(&s),
|
||||
"options.exclusiveFullscreen.off.tooltip"
|
||||
);
|
||||
Opt::ExclusiveFullscreen.cycle(&mut s, false, [854, 480]);
|
||||
assert!(s.file.bool("exclusiveFullscreen"));
|
||||
assert_eq!(
|
||||
Opt::ExclusiveFullscreen.tooltip_key(&s),
|
||||
"options.exclusiveFullscreen.on.tooltip"
|
||||
);
|
||||
// The mode row's own tooltip says it only matters with Exclusive Fullscreen.
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.tooltip_key(&s),
|
||||
"options.fullscreen.exclusive.mode.tooltip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_graphics_api_option_cycles_default_opengl_vulkan_and_asks_for_a_restart() {
|
||||
use crate::graphics::GraphicsApi;
|
||||
let mut s = Settings::default();
|
||||
s.startup_api = s.horizon.api;
|
||||
let offered = GraphicsApi::choices();
|
||||
let mut seen = vec![s.horizon.api];
|
||||
for _ in 1..offered.len() {
|
||||
Opt::GraphicsBackend.cycle(&mut s, false, [854, 480]);
|
||||
seen.push(s.horizon.api);
|
||||
}
|
||||
// Dx12 is Horizon's own Graphics page's; this option has the three of vanilla.
|
||||
assert!(!seen.contains(&GraphicsApi::Dx12));
|
||||
assert_eq!(seen[0], GraphicsApi::Auto);
|
||||
if offered.len() == 3 {
|
||||
assert_eq!(seen[1..], [GraphicsApi::OpenGl, GraphicsApi::Vulkan]);
|
||||
assert_eq!(s.file.name("preferredGraphicsBackend"), "vulkan");
|
||||
Opt::GraphicsBackend.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(s.horizon.api, GraphicsApi::Auto);
|
||||
assert_eq!(s.file.name("preferredGraphicsBackend"), "default");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_filtering_rows_exist_and_the_presets_set_them() {
|
||||
let mut s = Settings::default();
|
||||
for (preset, bit, filtering) in [("fast", 1, 0), ("fancy", 1, 1), ("fabulous", 2, 2)] {
|
||||
s.graphics_preset = preset.into();
|
||||
apply_preset(&mut s);
|
||||
assert_eq!(s.file.int("maxAnisotropyBit"), bit, "{preset}");
|
||||
assert_eq!(s.file.int("textureFiltering"), filtering, "{preset}");
|
||||
}
|
||||
// Nothing samples the terrain atlas with them yet, so the buttons stay greyed out.
|
||||
assert!(!Opt::TextureFiltering.available());
|
||||
assert!(!Opt::MaxAnisotropy.available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared language data in HORIZON_TEST_RESOURCES; run alone"]
|
||||
fn the_new_video_options_read_as_vanilla_words_them() {
|
||||
use crate::graphics::GraphicsApi;
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
lang::set(lang::Language::load(&[&root], "en_us"));
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(Opt::ExclusiveMode.label(&s).plain(), "Setting unavailable");
|
||||
s.video_modes = vec![mode(1280, 60.0), mode(1920, 144.0), mode(2560, 59.94)];
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.label(&s).plain(),
|
||||
"Exclusive Fullscreen Mode: Current"
|
||||
);
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 2.0 / 3.0);
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.label(&s).plain(),
|
||||
"Exclusive Fullscreen Mode: 1920x1080@144 (24bit)"
|
||||
);
|
||||
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
|
||||
assert_eq!(
|
||||
Opt::ExclusiveMode.label(&s).plain(),
|
||||
"Exclusive Fullscreen Mode: [email protected] (24bit)"
|
||||
);
|
||||
s.horizon.api = GraphicsApi::Vulkan;
|
||||
assert_eq!(
|
||||
Opt::GraphicsBackend.label(&s).plain(),
|
||||
"Graphics API: Prefer Vulkan (Experimental)"
|
||||
);
|
||||
assert!(
|
||||
graphics_api_tooltip(&s)
|
||||
.plain()
|
||||
.starts_with("Requires a game restart to take effect.\n\n")
|
||||
);
|
||||
s.startup_api = GraphicsApi::Vulkan;
|
||||
assert!(
|
||||
graphics_api_tooltip(&s)
|
||||
.plain()
|
||||
.starts_with("Which graphics API")
|
||||
);
|
||||
s.file
|
||||
.set("maxAnisotropyBit", crate::options_file::V::Int(2));
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(2));
|
||||
assert_eq!(
|
||||
Opt::MaxAnisotropy.label(&s).plain(),
|
||||
"Anisotropic Filtering: 4x"
|
||||
);
|
||||
assert_eq!(
|
||||
Opt::TextureFiltering.label(&s).plain(),
|
||||
"Texture Filtering: Anisotropic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_tooltip_keys_follow_value_dependent_descriptions() {
|
||||
let mut s = Settings::default();
|
||||
@@ -4093,7 +4532,7 @@ mod tests {
|
||||
for cx in [-100.0, 250.0, 1000.0] {
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
|
||||
vec![Event::SliderDrag]
|
||||
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
|
||||
);
|
||||
}
|
||||
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
|
||||
@@ -4103,6 +4542,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_volume_slider_reports_its_category_and_value() {
|
||||
let atlas = atlas();
|
||||
let size = (480.0, 320.0);
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
|
||||
.unwrap();
|
||||
let y = slider.y + 1.0;
|
||||
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
let volume = s.volumes[hostile];
|
||||
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
|
||||
);
|
||||
// OptionInstance.set: an unchanged value runs no callback.
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
|
||||
vec![Event::SliderDrag]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -156,60 +156,223 @@ fn format_of(v: &serde_json::Value) -> Option<(i64, i64)> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// PackCompatibility: inside [min_format, max_format] (or the legacy
|
||||
/// `pack_format`/`supported_formats`).
|
||||
/// `PackFormat`: major and minor.
|
||||
type Format = (i64, i64);
|
||||
/// `PackFormat.lastPreMinorVersion`: the last major without minor versions,
|
||||
/// for the resource pack format (64) or the data pack one (81).
|
||||
fn last_pre_minor(current: Format) -> i64 {
|
||||
if current.0 == horizon_core::version::DATA_PACK_FORMAT.0 {
|
||||
81
|
||||
} else {
|
||||
64
|
||||
}
|
||||
}
|
||||
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
|
||||
/// non-negative ints whose second is the minor. None: it does not parse.
|
||||
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
|
||||
let non_negative =
|
||||
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
|
||||
match v {
|
||||
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
|
||||
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
|
||||
let list = list?;
|
||||
Some((list[0], list.get(1).copied().unwrap_or(default_minor)))
|
||||
}
|
||||
serde_json::Value::Array(_) => None,
|
||||
v => Some((non_negative(v)?, default_minor)),
|
||||
}
|
||||
}
|
||||
/// `InclusiveRange.codec(Codec.INT)`: `[min, max]` or
|
||||
/// `{min_inclusive, max_inclusive}`, min not above max.
|
||||
fn int_range_codec(v: &serde_json::Value) -> Option<(i64, i64)> {
|
||||
let int = |v: &serde_json::Value| v.as_i64().filter(|n| i32::try_from(*n).is_ok());
|
||||
let (min, max) = match v {
|
||||
serde_json::Value::Array(a) if a.len() == 2 => (int(&a[0])?, int(&a[1])?),
|
||||
serde_json::Value::Object(o) => {
|
||||
(int(o.get("min_inclusive")?)?, int(o.get("max_inclusive")?)?)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(min <= max).then_some((min, max))
|
||||
}
|
||||
/// An `optionalFieldOf`: absent is `Ok(None)`, one that does not parse an error.
|
||||
fn optional<T>(
|
||||
v: &serde_json::Value,
|
||||
parse: impl Fn(&serde_json::Value) -> Option<T>,
|
||||
) -> Result<Option<T>, ()> {
|
||||
if v.is_null() {
|
||||
Ok(None)
|
||||
} else {
|
||||
parse(v).map(Some).ok_or(())
|
||||
}
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validatePackFormatForRange`.
|
||||
fn validate_pack_format_for_range(main_format: i64, min: i64, max: i64) -> Option<String> {
|
||||
if main_format < min || main_format > max {
|
||||
Some(format!(
|
||||
"Pack declared support for versions {min} to {max} but declared main format is {main_format}"
|
||||
))
|
||||
} else if main_format < 15 {
|
||||
Some("Multi-version packs cannot support minimum version of less than 15, since this will leave versions in range unable to load pack.".into())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// `PackFormat` as `toString` prints it.
|
||||
fn format_text(f: Format) -> String {
|
||||
if f.1 == i64::from(i32::MAX) {
|
||||
format!("{}.*", f.0)
|
||||
} else {
|
||||
format!("{}.{}", f.0, f.1)
|
||||
}
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validate` for a pack's `pack` section
|
||||
/// (`hasPackFormatField`, no `requireOldField`): the supported range, or why
|
||||
/// vanilla refuses the metadata (it then falls back to a pack of unknown
|
||||
/// format).
|
||||
pub fn validate_pack_range(
|
||||
pack: &serde_json::Value,
|
||||
last_pre_minor: i64,
|
||||
) -> Result<(Format, Format), String> {
|
||||
const CONTEXT: &str = "Pack";
|
||||
const OLD: &str = "supported_formats";
|
||||
let bad = |field: &str| format!("{CONTEXT} field {field} does not parse");
|
||||
let min =
|
||||
optional(&pack["min_format"], |v| format_codec(v, 0)).map_err(|()| bad("min_format"))?;
|
||||
let max = optional(&pack["max_format"], |v| {
|
||||
format_codec(v, i64::from(i32::MAX))
|
||||
})
|
||||
.map_err(|()| bad("max_format"))?;
|
||||
let format = optional(&pack["pack_format"], |v| {
|
||||
v.as_i64().filter(|n| i32::try_from(*n).is_ok())
|
||||
})
|
||||
.map_err(|()| bad("pack_format"))?;
|
||||
let supported = optional(&pack[OLD], int_range_codec).map_err(|()| bad(OLD))?;
|
||||
if min.is_some() != max.is_some() {
|
||||
return Err(format!(
|
||||
"{CONTEXT} missing field, must declare both min_format and max_format"
|
||||
));
|
||||
}
|
||||
if let (Some(min), Some(max)) = (min, max) {
|
||||
return validate_new_format(min, max, format, supported, last_pre_minor);
|
||||
}
|
||||
if let Some(supported) = supported {
|
||||
return validate_old_format(supported, format, last_pre_minor);
|
||||
}
|
||||
match format {
|
||||
Some(main) if main > last_pre_minor => Err(format!(
|
||||
"{CONTEXT} declares support for version newer than {last_pre_minor}, but is missing mandatory fields min_format and max_format"
|
||||
)),
|
||||
Some(main) => Ok(((main, 0), (main, 0))),
|
||||
None => Err(format!(
|
||||
"{CONTEXT} could not be parsed, missing format version information"
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validateNewFormat`.
|
||||
fn validate_new_format(
|
||||
min: Format,
|
||||
max: Format,
|
||||
format: Option<i64>,
|
||||
supported: Option<(i64, i64)>,
|
||||
last_pre_minor: i64,
|
||||
) -> Result<(Format, Format), String> {
|
||||
const OLD: &str = "supported_formats";
|
||||
let (major_min, major_max) = (min.0, max.0);
|
||||
if min > max {
|
||||
return Err(format!(
|
||||
"Pack min_format ({}) is greater than max_format ({})",
|
||||
format_text(min),
|
||||
format_text(max)
|
||||
));
|
||||
}
|
||||
if major_min > last_pre_minor {
|
||||
if supported.is_some() {
|
||||
return Err(format!(
|
||||
"Pack key {OLD} is deprecated starting from pack format {}. Remove {OLD} from your pack.mcmeta.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
}
|
||||
if let Some(main) = format
|
||||
&& let Some(error) = validate_pack_format_for_range(main, major_min, major_max)
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
let Some(old) = supported else {
|
||||
return Err(format!(
|
||||
"Pack declares support for format {major_min}, but game versions supporting formats 15 to {last_pre_minor} require a {OLD} field. Add \"{OLD}\": [{major_min}, {last_pre_minor}] or require a version greater or equal to {}.0.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
};
|
||||
if old.0 != major_min {
|
||||
return Err(format!(
|
||||
"Pack version declaration mismatch between {OLD} (from {}) and min_format ({})",
|
||||
old.0,
|
||||
format_text(min)
|
||||
));
|
||||
}
|
||||
if old.1 != major_max && old.1 != last_pre_minor {
|
||||
return Err(format!(
|
||||
"Pack version declaration mismatch between {OLD} (up to {}) and max_format ({})",
|
||||
old.1,
|
||||
format_text(max)
|
||||
));
|
||||
}
|
||||
let Some(main) = format else {
|
||||
return Err(format!(
|
||||
"Pack declares support for formats up to {last_pre_minor}, but game versions supporting formats 15 to {last_pre_minor} require a pack_format field. Add \"pack_format\": {major_min} or require a version greater or equal to {}.0.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
};
|
||||
if let Some(error) = validate_pack_format_for_range(main, major_min, major_max) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok((min, max))
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validateOldFormat`.
|
||||
fn validate_old_format(
|
||||
supported: (i64, i64),
|
||||
format: Option<i64>,
|
||||
last_pre_minor: i64,
|
||||
) -> Result<(Format, Format), String> {
|
||||
let (min, max) = supported;
|
||||
if max > last_pre_minor {
|
||||
return Err(format!(
|
||||
"Pack declares support for version newer than {last_pre_minor}, but is missing mandatory fields min_format and max_format"
|
||||
));
|
||||
}
|
||||
let Some(main) = format else {
|
||||
return Err(format!(
|
||||
"Pack declares support for formats up to {last_pre_minor}, but game versions supporting formats 15 to {last_pre_minor} require a pack_format field. Add \"pack_format\": {min} or require a version greater or equal to {}.0.",
|
||||
last_pre_minor + 1
|
||||
));
|
||||
};
|
||||
if let Some(error) = validate_pack_format_for_range(main, min, max) {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(((min, 0), (max, 0)))
|
||||
}
|
||||
/// PackCompatibility: inside the pack's declared range.
|
||||
pub fn compatible(meta: &serde_json::Value) -> bool {
|
||||
let pack = &meta["pack"];
|
||||
let current = RESOURCE_FORMAT;
|
||||
if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
) {
|
||||
return min <= current
|
||||
&& current <= (max.0, max.1.max(if max.1 == 0 { i64::MAX } else { max.1 }));
|
||||
}
|
||||
if let Some(range) = pack["supported_formats"].as_array()
|
||||
&& let (Some(lo), Some(hi)) = (
|
||||
range.first().and_then(|v| v.as_i64()),
|
||||
range.get(1).and_then(|v| v.as_i64()),
|
||||
)
|
||||
{
|
||||
return lo <= current.0 && current.0 <= hi;
|
||||
}
|
||||
format_of(&pack["pack_format"]).is_some_and(|f| f.0 == current.0)
|
||||
compatibility(meta) == crate::pack_screen::Compat::Compatible
|
||||
}
|
||||
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
|
||||
/// when the metadata names no format.
|
||||
/// when the metadata names no (valid) format.
|
||||
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
|
||||
compatibility_for(meta, RESOURCE_FORMAT)
|
||||
}
|
||||
/// `compatibility` against `current`, the resource or the data pack format.
|
||||
pub fn compatibility_for(
|
||||
meta: &serde_json::Value,
|
||||
current: (i64, i64),
|
||||
) -> crate::pack_screen::Compat {
|
||||
/// Metadata that fails `PackFormat.packCodec` parses as the fallback type,
|
||||
/// whose format is `Integer.MAX_VALUE`: unknown.
|
||||
pub fn compatibility_for(meta: &serde_json::Value, current: Format) -> crate::pack_screen::Compat {
|
||||
use crate::pack_screen::Compat;
|
||||
let pack = &meta["pack"];
|
||||
let range = if let (Some(min), Some(max)) = (
|
||||
format_of(&pack["min_format"]),
|
||||
format_of(&pack["max_format"]),
|
||||
) {
|
||||
Some((min, (max.0, if max.1 == 0 { i64::MAX } else { max.1 })))
|
||||
} else if let Some(r) = pack["supported_formats"].as_array()
|
||||
&& let (Some(lo), Some(hi)) = (
|
||||
r.first().and_then(|v| v.as_i64()),
|
||||
r.get(1).and_then(|v| v.as_i64()),
|
||||
)
|
||||
{
|
||||
Some(((lo, 0), (hi, i64::MAX)))
|
||||
} else {
|
||||
format_of(&pack["pack_format"]).map(|f| ((f.0, 0), (f.0, i64::MAX)))
|
||||
};
|
||||
match range {
|
||||
None => Compat::Unknown,
|
||||
Some((_, max)) if max < current => Compat::TooOld,
|
||||
Some((min, _)) if current < min => Compat::TooNew,
|
||||
_ => Compat::Compatible,
|
||||
match validate_pack_range(&meta["pack"], last_pre_minor(current)) {
|
||||
Err(_) => Compat::Unknown,
|
||||
Ok((_, max)) if max < current => Compat::TooOld,
|
||||
Ok((min, _)) if current < min => Compat::TooNew,
|
||||
Ok(_) => Compat::Compatible,
|
||||
}
|
||||
}
|
||||
/// Whether an `overlays.entries[]` item applies to the current format
|
||||
@@ -278,7 +441,8 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
|
||||
Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?,
|
||||
};
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
// Gson reads past a UTF-8 byte order mark; serde_json rejects it.
|
||||
serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok()
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
@@ -738,6 +902,12 @@ pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
|
||||
.filter_map(|id| all.iter().find(|p| &p.id == id).cloned())
|
||||
.collect()
|
||||
}
|
||||
/// `PackRepository.isAbleToClearAnyPack`: clearing the selection
|
||||
/// (`rebuildSelected([])`, which keeps only the required vanilla pack)
|
||||
/// changes it, that is, some selected pack still exists.
|
||||
pub fn able_to_clear_any_pack(all: &[Pack], selected: &[String]) -> bool {
|
||||
!enabled(all, selected).is_empty()
|
||||
}
|
||||
/// Resource root for a pack selection: vanilla itself when no pack is on.
|
||||
pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result<PathBuf> {
|
||||
if packs.is_empty() {
|
||||
@@ -938,6 +1108,153 @@ impl SelectionModel {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn mcmeta_with_utf8_bom_is_read() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let mut meta = b"\xEF\xBB\xBF".to_vec();
|
||||
meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#);
|
||||
fs::write(dir.join("pack.mcmeta"), &meta).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
// Without a BOM the same JSON still reads; other leading bytes still fail.
|
||||
fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_some());
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
[b"\xEF\xBB".as_slice(), &meta[3..]].concat(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
/// `PackFormat.IntermediaryFormat.validate` and its three helpers, with
|
||||
/// the resource pack's last pre-minor version, 64.
|
||||
#[test]
|
||||
fn pack_format_validation() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
let check = |s: &str| validate_pack_range(&v(s), 64);
|
||||
// validate: both min_format and max_format, or neither.
|
||||
assert!(
|
||||
check(r#"{"min_format":97}"#)
|
||||
.unwrap_err()
|
||||
.contains("both min_format and max_format")
|
||||
);
|
||||
assert!(check(r#"{"max_format":97}"#).is_err());
|
||||
assert!(check("{}").unwrap_err().contains("missing format version"));
|
||||
// A pack_format alone: fine up to 64, not above.
|
||||
assert_eq!(check(r#"{"pack_format":34}"#), Ok(((34, 0), (34, 0))));
|
||||
assert!(
|
||||
check(r#"{"pack_format":97}"#)
|
||||
.unwrap_err()
|
||||
.contains("newer than 64")
|
||||
);
|
||||
// validateNewFormat: min above max, the deprecated key above 64.
|
||||
assert!(
|
||||
check(r#"{"min_format":98,"max_format":97}"#)
|
||||
.unwrap_err()
|
||||
.contains("greater than max_format")
|
||||
);
|
||||
assert!(check(r#"{"min_format":[97,2],"max_format":[97,1]}"#).is_err());
|
||||
assert!(
|
||||
check(r#"{"min_format":97,"max_format":98,"supported_formats":[97,98]}"#)
|
||||
.unwrap_err()
|
||||
.contains("deprecated")
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97,"max_format":98}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":[97,0],"max_format":[97,1]}"#),
|
||||
Ok(((97, 0), (97, 1)))
|
||||
);
|
||||
// validatePackFormatForRange: a pack_format outside the range.
|
||||
assert!(
|
||||
check(r#"{"min_format":97,"max_format":98,"pack_format":99}"#)
|
||||
.unwrap_err()
|
||||
.contains("declared main format is 99")
|
||||
);
|
||||
assert!(check(r#"{"min_format":97,"max_format":98,"pack_format":98}"#).is_ok());
|
||||
// Below 65 the old fields are required and must agree.
|
||||
assert!(
|
||||
check(r#"{"min_format":34,"max_format":70}"#)
|
||||
.unwrap_err()
|
||||
.contains("require a supported_formats")
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[35,64],"pack_format":35}"#
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("mismatch")
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[34,60],"pack_format":35}"#
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("mismatch")
|
||||
);
|
||||
assert!(
|
||||
check(r#"{"min_format":34,"max_format":70,"supported_formats":[34,64]}"#)
|
||||
.unwrap_err()
|
||||
.contains("require a pack_format")
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[34,64],"pack_format":33}"#
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":34,"max_format":70,"supported_formats":[34,64],"pack_format":34}"#
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
// Multi-version packs below 15 cannot work.
|
||||
assert!(
|
||||
check(
|
||||
r#"{"min_format":10,"max_format":70,"supported_formats":[10,64],"pack_format":10}"#
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("less than 15")
|
||||
);
|
||||
// validateOldFormat: a range above 64, or no pack_format.
|
||||
assert!(
|
||||
check(r#"{"supported_formats":[34,70],"pack_format":34}"#)
|
||||
.unwrap_err()
|
||||
.contains("newer than 64")
|
||||
);
|
||||
assert!(
|
||||
check(r#"{"supported_formats":[34,60]}"#)
|
||||
.unwrap_err()
|
||||
.contains("require a pack_format")
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"supported_formats":[34,60],"pack_format":40}"#),
|
||||
Ok(((34, 0), (60, 0)))
|
||||
);
|
||||
assert!(
|
||||
check(
|
||||
r#"{"supported_formats":{"min_inclusive":34,"max_inclusive":60},"pack_format":40}"#
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(check(r#"{"supported_formats":[60,34],"pack_format":40}"#).is_err());
|
||||
// A refused pack is of unknown format.
|
||||
use crate::pack_screen::Compat;
|
||||
assert_eq!(
|
||||
compatibility(&v(r#"{"pack":{"pack_format":97}}"#)),
|
||||
Compat::Unknown
|
||||
);
|
||||
assert_eq!(
|
||||
compatibility(&v(
|
||||
r#"{"pack":{"min_format":97,"max_format":98,"pack_format":99}}"#
|
||||
)),
|
||||
Compat::Unknown
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compatibility_ranges() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
assert!(compatible(&v(
|
||||
@@ -949,7 +1266,6 @@ mod tests {
|
||||
assert!(!compatible(&v(
|
||||
r#"{"pack":{"min_format":[97,2],"max_format":99}}"#
|
||||
)));
|
||||
assert!(compatible(&v(r#"{"pack":{"pack_format":97}}"#)));
|
||||
assert!(!compatible(&v(r#"{"pack":{"pack_format":34}}"#)));
|
||||
assert_eq!(
|
||||
component(&v(r#"["a",{"text":"b","color":"red"}]"#)).plain(),
|
||||
|
||||
@@ -1042,6 +1042,8 @@ pub struct ParticleEngine {
|
||||
level_random: LegacyRandom,
|
||||
/// `SpellParticle.RANDOM`.
|
||||
spell_random: LegacyRandom,
|
||||
/// The local player's eye while it looks through a spyglass in first person.
|
||||
scope_eye: Option<[f64; 3]>,
|
||||
/// Seeds for each particle's own random (`RandomSource.create()`).
|
||||
seeds: LegacyRandom,
|
||||
/// When set, particles ask the level for other particles here instead
|
||||
@@ -1080,6 +1082,7 @@ impl ParticleEngine {
|
||||
random: LegacyRandom::new(0x7061_7274),
|
||||
level_random: LegacyRandom::new(0x6c65_7665),
|
||||
spell_random: LegacyRandom::new(0x7370_656c),
|
||||
scope_eye: None,
|
||||
seeds: LegacyRandom::new(0x7365_6564),
|
||||
requested: None,
|
||||
names: Vec::new(),
|
||||
@@ -1437,6 +1440,16 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Set each frame to the local player's eye while it scopes in first person.
|
||||
pub fn set_scope_eye(&mut self, eye: Option<[f64; 3]>) {
|
||||
self.scope_eye = eye;
|
||||
}
|
||||
/// `SpellParticle.isCloseToScopingPlayer`: within 3 blocks of that eye.
|
||||
fn close_to_scoping_player(&self, pos: [f64; 3]) -> bool {
|
||||
self.scope_eye.is_some_and(|eye| {
|
||||
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
|
||||
})
|
||||
}
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
@@ -2636,6 +2649,12 @@ impl ParticleEngine {
|
||||
q.size *= 0.75;
|
||||
q.lifetime = (8.0 / (f64::from(r.next_float()) * 0.8 + 0.2)) as i32;
|
||||
q.has_physics = false;
|
||||
// SpellParticle's constructor: setAlpha(0.0F), which also sets originalAlpha.
|
||||
// MobEffectProvider sets the option's alpha after it, so an entity
|
||||
// effect starts visible and the first tick hides it.
|
||||
if self.close_to_scoping_player(q.position) {
|
||||
q.rgba[3] = 0.0;
|
||||
}
|
||||
match ty {
|
||||
T::Witch => {
|
||||
let b = self.random.next_float() * 0.5 + 0.35;
|
||||
@@ -3743,7 +3762,12 @@ impl ParticleEngine {
|
||||
Behaviour::Spell { original_alpha } => {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
|
||||
// SpellParticle.tick: alpha 0 while close, else eased back to originalAlpha.
|
||||
if self.close_to_scoping_player(p.position) {
|
||||
p.rgba[3] = 0.0;
|
||||
} else {
|
||||
p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05;
|
||||
}
|
||||
}
|
||||
Behaviour::Cloud => {
|
||||
p.base_tick(level);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
// The built-in stand-ins for the vanilla post shaders a `post_effect` chain can
|
||||
// name (`core/screenquad`, `post/rotscale`, `post/invert`, `post/blit`,
|
||||
// `post/bits`, `post/color_convolve`, `post/box_blur`, `post/spiderclip`).
|
||||
// Coordinates stay in the chain's own convention (v up from the bottom edge) and
|
||||
// are flipped only where a texture is read.
|
||||
|
||||
// OutSize, InSize | BlurSize, menu blur radius.
|
||||
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
|
||||
// The vertex and fragment stage's uniform block, packed as std140.
|
||||
@group(0) @binding(1) var<uniform> vs_cfg: array<vec4<f32>, 8>;
|
||||
@group(0) @binding(2) var<uniform> fs_cfg: array<vec4<f32>, 8>;
|
||||
@group(0) @binding(3) var in_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var in_smp: sampler;
|
||||
@group(0) @binding(5) var blur_tex: texture_2d<f32>;
|
||||
@group(0) @binding(6) var blur_smp: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) scaled: vec2<f32>,
|
||||
}
|
||||
|
||||
fn vcf(i: u32) -> f32 {
|
||||
return vs_cfg[i / 4u][i % 4u];
|
||||
}
|
||||
fn fcf(i: u32) -> f32 {
|
||||
return fs_cfg[i / 4u][i % 4u];
|
||||
}
|
||||
fn read_in(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(in_tex, in_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
}
|
||||
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
}
|
||||
|
||||
fn corner(i: u32) -> vec2<f32> {
|
||||
return vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
}
|
||||
|
||||
@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
out.scaled = p;
|
||||
return out;
|
||||
}
|
||||
|
||||
@vertex fn vs_rotscale(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
let radians = vcf(4u) * 0.0174532925;
|
||||
let c = cos(radians);
|
||||
let s = sin(radians);
|
||||
let rotated = vec2(p.x * c - p.y * s, p.y * c + p.x * s);
|
||||
out.scaled = rotated * vec2(vcf(0u), vcf(1u)) + vec2(vcf(2u), vcf(3u));
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment fn fs_invert(in: Out) -> @location(0) vec4<f32> {
|
||||
let d = read_in(in.uv);
|
||||
let mixed = mix(d, 1.0 - d, fcf(0u));
|
||||
return vec4(mixed.rgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_blit(in: Out) -> @location(0) vec4<f32> {
|
||||
return read_in(in.uv) * vec4(fcf(0u), fcf(1u), fcf(2u), fcf(3u));
|
||||
}
|
||||
|
||||
@fragment fn fs_bits(in: Out) -> @location(0) vec4<f32> {
|
||||
let resolution = fcf(0u);
|
||||
let mosaic_in = info[0].zw / fcf(1u);
|
||||
let fract_pix = fract(in.uv * mosaic_in) / mosaic_in;
|
||||
var base = read_in(in.uv - fract_pix);
|
||||
let quantised = base.rgb - fract(base.rgb * resolution) / resolution;
|
||||
let luma = dot(quantised, vec3(0.3, 0.59, 0.11));
|
||||
let chroma = (quantised - luma) * 1.5;
|
||||
return vec4(luma + chroma, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_color_convolve(in: Out) -> @location(0) vec4<f32> {
|
||||
let t = read_in(in.uv).rgb;
|
||||
let red = dot(t, vec3(fcf(0u), fcf(1u), fcf(2u)));
|
||||
let green = dot(t, vec3(fcf(4u), fcf(5u), fcf(6u)));
|
||||
let blue = dot(t, vec3(fcf(8u), fcf(9u), fcf(10u)));
|
||||
let rgb = vec3(red, green, blue);
|
||||
let luma = dot(rgb, vec3(0.3, 0.59, 0.11));
|
||||
return vec4((rgb - luma) * 1.8 + luma, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_box_blur(in: Out) -> @location(0) vec4<f32> {
|
||||
let step = (1.0 / info[0].zw) * vec2(fcf(0u), fcf(1u));
|
||||
let configured = fcf(2u);
|
||||
var radius = info[1].z;
|
||||
if configured >= 0.5 {
|
||||
radius = round(configured);
|
||||
}
|
||||
var blurred = vec4(0.0);
|
||||
for (var a = -radius + 0.5; a <= radius; a += 2.0) {
|
||||
blurred += read_in(in.uv + step * a);
|
||||
}
|
||||
blurred += read_in(in.uv + step * radius) / 2.0;
|
||||
return blurred / (radius + 0.5);
|
||||
}
|
||||
|
||||
@fragment fn fs_spiderclip(in: Out) -> @location(0) vec4<f32> {
|
||||
let scaled_texel = read_in(in.scaled);
|
||||
let blur_texel = read_blur(in.uv);
|
||||
let scissor = vec4(fcf(0u), fcf(1u), fcf(2u), fcf(3u));
|
||||
let vignette = vec4(fcf(4u), fcf(5u), fcf(6u), fcf(7u));
|
||||
let s = in.scaled;
|
||||
var o = scaled_texel;
|
||||
if s.x < scissor.x { o = blur_texel; }
|
||||
if s.y < scissor.y { o = blur_texel; }
|
||||
if s.x > scissor.z { o = blur_texel; }
|
||||
if s.y > scissor.w { o = blur_texel; }
|
||||
if s.x < vignette.x { o = mix(blur_texel, o, (scissor.x - s.x) / (scissor.x - vignette.x)); }
|
||||
if s.y < vignette.y { o = mix(blur_texel, o, (scissor.y - s.y) / (scissor.y - vignette.y)); }
|
||||
if s.x > vignette.z { o = mix(blur_texel, o, (scissor.z - s.x) / (scissor.z - vignette.z)); }
|
||||
if s.y > vignette.w { o = mix(blur_texel, o, (scissor.w - s.y) / (scissor.w - vignette.w)); }
|
||||
return vec4(o.rgb, 1.0);
|
||||
}
|
||||
@@ -50,6 +50,9 @@ pub fn sound_source(event: &str) -> usize {
|
||||
index("ambient")
|
||||
} else if e.starts_with("music_disc.") {
|
||||
index("record")
|
||||
} else if e.starts_with("item.goat_horn.") {
|
||||
// InstrumentItem.play: SoundSource.RECORDS.
|
||||
index("record")
|
||||
} else if e.starts_with("music.") {
|
||||
index("music")
|
||||
} else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") {
|
||||
@@ -186,6 +189,10 @@ pub struct Settings {
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// The graphics API the game started with: `Options.preferredGraphicsBackendFromStartup`.
|
||||
pub startup_api: crate::graphics::GraphicsApi,
|
||||
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
|
||||
pub video_modes: Vec<crate::window::VideoMode>,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
pub controller: crate::gamepad::PadSettings,
|
||||
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
|
||||
@@ -272,6 +279,8 @@ impl Default for Settings {
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -305,6 +314,16 @@ fn put_s(f: &mut OptionsFile, key: &str, v: &str) {
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
/// The index in `video_modes` of the preferred fullscreen mode, -1 (the desktop's own mode)
|
||||
/// when none is set or the monitor lacks it: `Monitor.indexOfMode`.
|
||||
pub fn video_mode_index(&self) -> i32 {
|
||||
self.file
|
||||
.fullscreen_resolution
|
||||
.as_deref()
|
||||
.and_then(crate::window::VideoMode::read)
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let horizon = path
|
||||
.parent()
|
||||
@@ -339,7 +358,11 @@ impl Settings {
|
||||
},
|
||||
};
|
||||
settings.describe_for_crashes();
|
||||
settings
|
||||
// Options.load: the backend asked for at startup, what a restart is measured from.
|
||||
Self {
|
||||
startup_api: settings.horizon.api,
|
||||
..settings
|
||||
}
|
||||
}
|
||||
/// The render settings and resource packs a crash report lists.
|
||||
fn describe_for_crashes(&self) {
|
||||
@@ -438,6 +461,8 @@ impl Settings {
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
startup_api: Default::default(),
|
||||
video_modes: Vec::new(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
mods: Default::default(),
|
||||
@@ -764,6 +789,16 @@ pub enum Opt {
|
||||
SharePresence,
|
||||
DirectionalAudio,
|
||||
MusicToast,
|
||||
/// `Options.exclusiveFullscreen`.
|
||||
ExclusiveFullscreen,
|
||||
/// VideoSettingsScreen's "Exclusive Fullscreen Mode": the monitor's mode to take.
|
||||
ExclusiveMode,
|
||||
/// `Options.preferredGraphicsBackend`.
|
||||
GraphicsBackend,
|
||||
/// `Options.textureFiltering`.
|
||||
TextureFiltering,
|
||||
/// `Options.maxAnisotropyBit`.
|
||||
MaxAnisotropy,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -832,6 +867,17 @@ mod tests {
|
||||
assert_eq!(again.keys.get("key.jump"), "key.keyboard.j");
|
||||
}
|
||||
#[test]
|
||||
fn client_information_maps_the_stored_main_hand() {
|
||||
// The Skin Customization option is stored by name; ClientInformation
|
||||
// sends HumanoidArm (LEFT = 0, RIGHT = 1).
|
||||
for (stored, arm) in [("left", 0), ("right", 1)] {
|
||||
let s = Settings::parse(&format!("mainHand:{stored}\n"));
|
||||
assert_eq!(s.client_information(10).main_hand, arm);
|
||||
assert!(s.serialize().contains(&format!("mainHand:\"{stored}\"")));
|
||||
}
|
||||
assert_eq!(Settings::default().client_information(10).main_hand, 1);
|
||||
}
|
||||
#[test]
|
||||
fn no_options_txt_paces_frames_like_an_empty_one() {
|
||||
let (fresh, empty) = (Settings::default(), Settings::parse(""));
|
||||
assert_eq!(fresh.max_fps, 120);
|
||||
|
||||
@@ -32,45 +32,14 @@ pub const TEXTURE_URIS: [&str; 2] = [
|
||||
|
||||
/// `java.util.Base64.getDecoder().decode`.
|
||||
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(text.len() / 4 * 3);
|
||||
let (mut bits, mut shift) = (0u32, 18i32);
|
||||
let bytes: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i];
|
||||
i += 1;
|
||||
let b = match c {
|
||||
'A'..='Z' => c as u32 - 'A' as u32,
|
||||
'a'..='z' => c as u32 - 'a' as u32 + 26,
|
||||
'0'..='9' => c as u32 - '0' as u32 + 52,
|
||||
'+' => 62,
|
||||
'/' => 63,
|
||||
'=' => {
|
||||
if shift == 6 && (i == bytes.len() || bytes[i] != '=') || shift == 18 {
|
||||
return None;
|
||||
}
|
||||
if shift == 6 {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bits |= b << shift;
|
||||
shift -= 6;
|
||||
if shift < 0 {
|
||||
out.extend([(bits >> 16) as u8, (bits >> 8) as u8, bits as u8]);
|
||||
shift = 18;
|
||||
bits = 0;
|
||||
}
|
||||
}
|
||||
match shift {
|
||||
6 => out.push((bits >> 16) as u8),
|
||||
0 => out.extend([(bits >> 16) as u8, (bits >> 8) as u8]),
|
||||
12 => return None,
|
||||
_ => {}
|
||||
}
|
||||
(i >= bytes.len()).then_some(out)
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
||||
};
|
||||
STANDARD
|
||||
.decode(text)
|
||||
.or_else(|_| STANDARD_NO_PAD.decode(text))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// `unpackTextures(property)`: the textures of a `textures` property's
|
||||
@@ -557,6 +526,39 @@ pub fn offline_cape() -> Option<&'static [u8]> {
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// The skin the launcher gives an offline name (`HORIZON_OFFLINE_SKIN`, a
|
||||
/// PNG's path, and `HORIZON_OFFLINE_SKIN_MODEL`, `classic` or `slim`;
|
||||
/// launcher/src/skins.rs): its 64x64 RGBA as `process_legacy_skin` lays it
|
||||
/// out, and whether it is slim. Read once.
|
||||
pub fn offline_skin() -> Option<&'static (Vec<u8>, bool)> {
|
||||
static SKIN: std::sync::OnceLock<Option<(Vec<u8>, bool)>> = std::sync::OnceLock::new();
|
||||
SKIN.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_OFFLINE_SKIN").filter(|p| !p.is_empty())?;
|
||||
let model = std::env::var("HORIZON_OFFLINE_SKIN_MODEL").unwrap_or_default();
|
||||
read_offline_skin(std::path::Path::new(&path), &model)
|
||||
.inspect_err(|e| eprintln!("Horizon: no offline skin ({}): {e}", path.display()))
|
||||
.ok()
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
fn read_offline_skin(path: &std::path::Path, model: &str) -> Result<(Vec<u8>, bool), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
let skin = process_legacy_skin(w, h, &rgba).ok_or("not a 64x64 or 64x32 skin")?;
|
||||
Ok((skin, model == "slim"))
|
||||
}
|
||||
|
||||
/// The signed-in account's own profile properties (`Minecraft.profileFuture`:
|
||||
/// `fetchProfile(id, true)`, so its `textures` property is signed), or empty
|
||||
/// without an answer. Blocks on the network.
|
||||
pub fn fetch_own_properties(id: u128) -> Vec<(String, String, Option<String>)> {
|
||||
crate::auth::discovery()
|
||||
.ok()
|
||||
.and_then(|doc| resolve_profile(&NameOrId::Id(id), &doc))
|
||||
.map(|p| p.properties)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where a texture download stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Download {
|
||||
@@ -902,60 +904,49 @@ fn http_get(url: &str) -> Option<Vec<u8>> {
|
||||
|
||||
/// SHA-1 (FIPS 180-4).
|
||||
pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
let mut h: [u32; 5] = [
|
||||
0x6745_2301,
|
||||
0xEFCD_AB89,
|
||||
0x98BA_DCFE,
|
||||
0x1032_5476,
|
||||
0xC3D2_E1F0,
|
||||
];
|
||||
let mut msg = data.to_vec();
|
||||
msg.push(0x80);
|
||||
while msg.len() % 64 != 56 {
|
||||
msg.push(0);
|
||||
}
|
||||
msg.extend((data.len() as u64 * 8).to_be_bytes());
|
||||
for block in msg.as_chunks::<64>().0 {
|
||||
let mut w = [0u32; 80];
|
||||
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
|
||||
w[i] = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..80 {
|
||||
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
||||
}
|
||||
let [mut a, mut b, mut c, mut d, mut e] = h;
|
||||
for (i, wi) in w.iter().enumerate() {
|
||||
let (f, k) = match i {
|
||||
0..=19 => ((b & c) | (!b & d), 0x5A82_7999),
|
||||
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
|
||||
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
|
||||
_ => (b ^ c ^ d, 0xCA62_C1D6),
|
||||
};
|
||||
let t = a
|
||||
.rotate_left(5)
|
||||
.wrapping_add(f)
|
||||
.wrapping_add(e)
|
||||
.wrapping_add(k)
|
||||
.wrapping_add(*wi);
|
||||
e = d;
|
||||
d = c;
|
||||
c = b.rotate_left(30);
|
||||
b = a;
|
||||
a = t;
|
||||
}
|
||||
for (x, y) in h.iter_mut().zip([a, b, c, d, e]) {
|
||||
*x = x.wrapping_add(y);
|
||||
}
|
||||
}
|
||||
let mut out = [0u8; 20];
|
||||
for (i, x) in h.iter().enumerate() {
|
||||
out[i * 4..i * 4 + 4].copy_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
out
|
||||
use sha1::{Digest, Sha1};
|
||||
Sha1::digest(data).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The launcher's offline skin: a 64x64 or legacy 64x32 PNG laid out as
|
||||
/// a downloaded skin, slim only for the `slim` model; anything else is
|
||||
/// refused and the player keeps their default skin.
|
||||
#[test]
|
||||
fn offline_skins_read_like_downloaded_ones() {
|
||||
let png = |w: u32, h: u32| {
|
||||
let rgba: Vec<u8> = (0..w * h * 4).map(|i| (i % 251) as u8).collect();
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&rgba)
|
||||
.unwrap();
|
||||
(out, rgba)
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-offline-skin-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("steve.png");
|
||||
for (h, model, slim) in [(64, "slim", true), (32, "classic", false), (64, "", false)] {
|
||||
let (bytes, rgba) = png(64, h);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (skin, is_slim) = super::read_offline_skin(&file, model).unwrap();
|
||||
assert_eq!(skin, super::process_legacy_skin(64, h, &rgba).unwrap());
|
||||
assert_eq!(skin.len(), 64 * 64 * 4);
|
||||
assert_eq!(is_slim, slim);
|
||||
}
|
||||
std::fs::write(&file, png(32, 32).0).unwrap();
|
||||
assert!(super::read_offline_skin(&file, "slim").is_err());
|
||||
std::fs::write(&file, b"not a png").unwrap();
|
||||
assert!(super::read_offline_skin(&file, "slim").is_err());
|
||||
assert!(super::read_offline_skin(&dir.join("missing.png"), "").is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
|
||||
/// skin; no metadata is the wide (classic) model.
|
||||
#[test]
|
||||
|
||||
@@ -58,8 +58,13 @@ pub enum Ticking {
|
||||
Bee { bee: u32, aggressive: bool },
|
||||
/// `GuardianAttackSoundInstance`.
|
||||
GuardianAttack { guardian: u32 },
|
||||
/// `SnifferSoundInstance`.
|
||||
Sniffer { sniffer: u32 },
|
||||
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
|
||||
Elytra { time: i32 },
|
||||
/// `RidingEntitySoundInstance` for the player riding a happy ghast
|
||||
/// (`underwater_sound` false) or a nautilus (true).
|
||||
RidingEntity { entity: u32, underwater_sound: bool },
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -83,6 +88,19 @@ pub struct MobSound {
|
||||
pub silent: bool,
|
||||
/// `Guardian.getAttackAnimationScale(0)`.
|
||||
pub attack_scale: f32,
|
||||
/// `getTarget() == null && Sniffer.canPlayDiggingSound()`.
|
||||
pub sniffer_digging: bool,
|
||||
}
|
||||
/// What a `RidingEntitySoundInstance` reads about the mob the player rides.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RiddenSound {
|
||||
pub id: u32,
|
||||
/// `getDeltaMovement().length()`.
|
||||
pub speed: f64,
|
||||
/// `isSilent`.
|
||||
pub silent: bool,
|
||||
/// `Entity.isUnderWater`.
|
||||
pub underwater: bool,
|
||||
}
|
||||
/// What tickable instances read about the player and the entities they follow.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -103,6 +121,8 @@ pub struct TickContext {
|
||||
pub player_speed_sqr: f64,
|
||||
/// `isFallFlying()` of the player.
|
||||
pub player_fall_flying: bool,
|
||||
/// The mob the player rides, when it is not a minecart.
|
||||
pub ridden: Option<RiddenSound>,
|
||||
}
|
||||
/// `AbstractSoundInstance` and its subclasses' fields.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -345,6 +365,43 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new RidingEntitySoundInstance(player, happyGhast, false,
|
||||
/// HAPPY_GHAST_RIDING, happyGhast.getSoundSource(), 0, 1, 5)`, from
|
||||
/// `LocalPlayer.startRiding`.
|
||||
pub fn riding_happy_ghast(ghast: &RiddenSound, random: LegacyRandom) -> Self {
|
||||
Self::riding_entity("minecraft:entity.happy_ghast.riding", ghast, false, random)
|
||||
}
|
||||
/// `new RidingEntitySoundInstance(player, nautilus, true,
|
||||
/// NAUTILUS_RIDING, nautilus.getSoundSource(), 0, 1, 5)`, from
|
||||
/// `LocalPlayer.startRiding`.
|
||||
pub fn riding_nautilus(nautilus: &RiddenSound, random: LegacyRandom) -> Self {
|
||||
Self::riding_entity("minecraft:entity.nautilus.riding", nautilus, true, random)
|
||||
}
|
||||
fn riding_entity(
|
||||
id: &str,
|
||||
entity: &RiddenSound,
|
||||
underwater_sound: bool,
|
||||
random: LegacyRandom,
|
||||
) -> Self {
|
||||
let mut s = Self::new(
|
||||
id,
|
||||
NEUTRAL,
|
||||
0.0,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
s.can_start_silent = true;
|
||||
s.ticking = Ticking::RidingEntity {
|
||||
entity: entity.id,
|
||||
underwater_sound,
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new BeeFlyingSoundInstance(bee)` (or `BeeAggressiveSoundInstance`):
|
||||
/// silent until the bee moves.
|
||||
pub fn bee(bee: &MobSound, aggressive: bool, random: LegacyRandom) -> Self {
|
||||
@@ -398,6 +455,25 @@ impl SoundInstance {
|
||||
};
|
||||
s
|
||||
}
|
||||
/// `new SnifferSoundInstance(sniffer)`: the digging sound, which sits at
|
||||
/// the origin (`AbstractSoundInstance` leaves x, y, z at 0) until its
|
||||
/// first tick moves it to the sniffer.
|
||||
pub fn sniffer(sniffer: u32, random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
"minecraft:entity.sniffer.digging",
|
||||
NEUTRAL,
|
||||
1.0,
|
||||
1.0,
|
||||
random,
|
||||
false,
|
||||
0,
|
||||
Attenuation::Linear,
|
||||
[0.0; 3],
|
||||
false,
|
||||
);
|
||||
s.ticking = Ticking::Sniffer { sniffer };
|
||||
s
|
||||
}
|
||||
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
|
||||
/// its first tick.
|
||||
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
|
||||
@@ -551,6 +627,27 @@ impl SoundInstance {
|
||||
1.0
|
||||
};
|
||||
}
|
||||
Ticking::Sniffer { sniffer } => {
|
||||
// canPlaySound (`!isSilent`), then `tick`: follow the sniffer
|
||||
// while it is alive, has no target and still digs or searches.
|
||||
let id = *sniffer;
|
||||
let Some(sniffer) = ctx
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && !m.silent && m.sniffer_digging)
|
||||
else {
|
||||
self.stop();
|
||||
return;
|
||||
};
|
||||
let [x, y, z] = sniffer.position;
|
||||
self.position = [
|
||||
f64::from(x as f32),
|
||||
f64::from(y as f32),
|
||||
f64::from(z as f32),
|
||||
];
|
||||
self.volume = 1.0;
|
||||
self.pitch = 1.0;
|
||||
}
|
||||
Ticking::GuardianAttack { guardian } => {
|
||||
let id = *guardian;
|
||||
// The client's guardian never has a target of its own, so
|
||||
@@ -569,6 +666,26 @@ impl SoundInstance {
|
||||
self.volume = 0.0 + 1.0 * scale * scale;
|
||||
self.pitch = 0.7 + 0.5 * scale;
|
||||
}
|
||||
Ticking::RidingEntity {
|
||||
entity,
|
||||
underwater_sound,
|
||||
} => {
|
||||
// RidingEntitySoundInstance.tick: canPlaySound, then while the
|
||||
// player still rides it, volumeMin (0) when
|
||||
// `underwaterSound != entity.isUnderWater()` and else
|
||||
// `volumeAmplifier * Mth.clampedLerp(speed, volumeMin, volumeMax)`.
|
||||
let (id, underwater_sound) = (*entity, *underwater_sound);
|
||||
let Some(entity) = ctx.ridden.filter(|r| r.id == id && !r.silent) else {
|
||||
self.stop();
|
||||
return;
|
||||
};
|
||||
let speed = entity.speed as f32;
|
||||
self.volume = if underwater_sound != entity.underwater || speed < 0.01 {
|
||||
0.0
|
||||
} else {
|
||||
5.0 * speed.clamp(0.0, 1.0)
|
||||
};
|
||||
}
|
||||
Ticking::RidingMinecart { cart, underwater } => {
|
||||
let (id, underwater) = (*cart, *underwater);
|
||||
let found = ctx.carts.iter().find(|c| c.id == id);
|
||||
@@ -1328,3 +1445,108 @@ pub mod test_backend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
|
||||
let ghast = RiddenSound {
|
||||
id: 7,
|
||||
speed: 0.1,
|
||||
silent: false,
|
||||
underwater: false,
|
||||
};
|
||||
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
|
||||
assert_eq!(s.volume, 0.0);
|
||||
let mut ctx = TickContext {
|
||||
ridden: Some(ghast),
|
||||
..TickContext::default()
|
||||
};
|
||||
// 5 * clampedLerp(speed, 0, 1).
|
||||
s.tick(&ctx);
|
||||
assert!((s.volume - 0.5).abs() < 1e-6, "{}", s.volume);
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
speed: 3.0,
|
||||
..ghast
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 5.0);
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
speed: 0.005,
|
||||
..ghast
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
// Under water the ghast's sound is not the underwater one.
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
underwater: true,
|
||||
..ghast
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
assert!(!s.stopped);
|
||||
// Off the ghast, it stops.
|
||||
ctx.ridden = None;
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffer_digging_sound_follows_the_sniffer_and_stops() {
|
||||
let sniffer = MobSound {
|
||||
id: 3,
|
||||
position: [10.0, 64.0, -5.0],
|
||||
speed: 0.0,
|
||||
baby: false,
|
||||
angry: false,
|
||||
silent: false,
|
||||
attack_scale: 0.0,
|
||||
sniffer_digging: true,
|
||||
};
|
||||
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
|
||||
assert_eq!(s.position, [0.0; 3]);
|
||||
assert!(!s.looping);
|
||||
let mut ctx = TickContext {
|
||||
mobs: vec![sniffer],
|
||||
..TickContext::default()
|
||||
};
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.position, [10.0, 64.0, -5.0]);
|
||||
assert!(!s.stopped);
|
||||
// A target, a state other than digging or searching: stop.
|
||||
ctx.mobs[0].sniffer_digging = false;
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
// Removed (missing): stop.
|
||||
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
|
||||
ctx.mobs.clear();
|
||||
s.tick(&ctx);
|
||||
assert!(s.stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nautilus_riding_sound_plays_only_under_water() {
|
||||
let nautilus = RiddenSound {
|
||||
id: 9,
|
||||
speed: 0.2,
|
||||
silent: false,
|
||||
underwater: true,
|
||||
};
|
||||
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
|
||||
let mut ctx = TickContext {
|
||||
ridden: Some(nautilus),
|
||||
..TickContext::default()
|
||||
};
|
||||
s.tick(&ctx);
|
||||
assert!((s.volume - 1.0).abs() < 1e-6, "{}", s.volume);
|
||||
ctx.ridden = Some(RiddenSound {
|
||||
underwater: false,
|
||||
..nautilus
|
||||
});
|
||||
s.tick(&ctx);
|
||||
assert_eq!(s.volume, 0.0);
|
||||
assert!(!s.stopped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
|
||||
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
|
||||
//! calls `preview` when there is no level).
|
||||
use crate::settings::SOUND_SOURCES;
|
||||
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
|
||||
|
||||
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
|
||||
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
|
||||
pub fn preview_event(source: usize) -> Option<&'static str> {
|
||||
Some(match *SOUND_SOURCES.get(source)? {
|
||||
"record" => "minecraft:block.note_block.guitar",
|
||||
"weather" => "minecraft:entity.lightning_bolt.thunder",
|
||||
"block" => "minecraft:block.grass.place",
|
||||
"hostile" => "minecraft:entity.zombie.ambient",
|
||||
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
|
||||
"neutral" => "minecraft:entity.cow.ambient",
|
||||
"player" => "minecraft:entity.generic.eat",
|
||||
"ambient" => "minecraft:ambient.cave",
|
||||
"ui" => "minecraft:ui.button.click",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SoundPreview {
|
||||
active: Option<InstanceId>,
|
||||
previous: Option<usize>,
|
||||
}
|
||||
|
||||
impl SoundPreview {
|
||||
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
|
||||
/// preview of another category, then, unless the last preview still
|
||||
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
|
||||
/// pitch 1 and the slider's volume).
|
||||
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
|
||||
self.stop_other_category_preview(sounds, source);
|
||||
if !self.can_play_sound(sounds) {
|
||||
return;
|
||||
}
|
||||
let Some(event) = preview_event(source) else {
|
||||
return;
|
||||
};
|
||||
if let Some(old) = self.active.take() {
|
||||
sounds.release(old);
|
||||
}
|
||||
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
|
||||
self.active = Some(sounds.play_instance(instance, true).0);
|
||||
}
|
||||
|
||||
/// The preview playing now, if any.
|
||||
pub fn active(&self) -> Option<InstanceId> {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// `stopOtherCategoryPreview`.
|
||||
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
|
||||
if self.previous != Some(source) {
|
||||
self.previous = Some(source);
|
||||
if let Some(active) = self.active {
|
||||
sounds.stop_instance(active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `canPlaySound`: no preview yet, or the last one has finished.
|
||||
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
|
||||
self.active.is_none_or(|active| !sounds.is_active(active))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
|
||||
use crate::sound_events::SoundRegistry;
|
||||
|
||||
fn engine() -> SoundEngine {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
|
||||
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
|
||||
"ui.button.click": {"sounds": ["random/click"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
SoundEngine::new(registry)
|
||||
}
|
||||
|
||||
fn source(name: &str) -> usize {
|
||||
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_category_previews_its_vanilla_sound() {
|
||||
let expected = [
|
||||
("master", None),
|
||||
("music", None),
|
||||
("record", Some("minecraft:block.note_block.guitar")),
|
||||
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
|
||||
("block", Some("minecraft:block.grass.place")),
|
||||
("hostile", Some("minecraft:entity.zombie.ambient")),
|
||||
("neutral", Some("minecraft:entity.cow.ambient")),
|
||||
("player", Some("minecraft:entity.generic.eat")),
|
||||
("ambient", Some("minecraft:ambient.cave")),
|
||||
("voice", None),
|
||||
("ui", Some("minecraft:ui.button.click")),
|
||||
];
|
||||
for (name, event) in expected {
|
||||
assert_eq!(preview_event(source(name)), event, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn slide(
|
||||
preview: &mut SoundPreview,
|
||||
engine: &mut SoundEngine,
|
||||
backend: &mut Null,
|
||||
name: &str,
|
||||
volume: f32,
|
||||
) {
|
||||
preview.preview(&mut Bound { engine, backend }, source(name), volume);
|
||||
}
|
||||
|
||||
/// Ticks enough for the engine to drop finished or stopped channels.
|
||||
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
|
||||
for _ in 0..40 {
|
||||
engine.tick(false, &TickContext::default(), backend);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
|
||||
let (mut engine, mut backend) = (engine(), Null::default());
|
||||
let mut preview = SoundPreview::default();
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
|
||||
let first = preview
|
||||
.active()
|
||||
.expect("the hostile slider plays a preview");
|
||||
let instance = engine.instance(first).unwrap();
|
||||
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
|
||||
assert_eq!(instance.source, UI);
|
||||
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
|
||||
assert!(instance.relative);
|
||||
assert!(engine.is_active(first));
|
||||
// canPlaySound: still playing, so dragging further does not restart it.
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
|
||||
assert_eq!(preview.active(), Some(first));
|
||||
assert_eq!(engine.active_instances(), vec![first]);
|
||||
// Once it has finished, the next move plays it again.
|
||||
for stopped in backend.stopped.values_mut() {
|
||||
*stopped = true;
|
||||
}
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(!engine.is_active(first));
|
||||
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
|
||||
let second = preview.active().unwrap();
|
||||
assert_ne!(second, first);
|
||||
assert!(engine.is_active(second));
|
||||
// Another category stops the playing preview (SoundManager.stop); the
|
||||
// engine lets its channel go on the next ticks, then that category's
|
||||
// own sound plays.
|
||||
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
|
||||
assert!(backend.stopped.values().all(|stopped| *stopped));
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
|
||||
let third = preview.active().unwrap();
|
||||
assert_eq!(
|
||||
engine.instance(third).unwrap().identifier,
|
||||
"minecraft:entity.cow.ambient"
|
||||
);
|
||||
// Master has no sound but still stops the other category's preview.
|
||||
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
|
||||
settle(&mut engine, &mut backend);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
|
||||
assert!(engine.active_instances().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ pub struct Span {
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Whether spans are being recorded.
|
||||
pub fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
|
||||
//! so stale work is dropped.
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
@@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
|
||||
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
|
||||
None
|
||||
}
|
||||
/// A decorated proto chunk's column sections (`Chunk::sections`
|
||||
/// indexes) holding a block other than air: what its neighbours' light
|
||||
/// layers count once it is at INITIALIZE_LIGHT.
|
||||
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
|
||||
None
|
||||
}
|
||||
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
|
||||
None
|
||||
}
|
||||
@@ -275,6 +282,7 @@ impl Mailbox {
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -305,6 +313,9 @@ pub struct Streaming {
|
||||
decorates: bool,
|
||||
lighting: FxHashMap<(i32, i32), u64>,
|
||||
meshing: FxHashMap<(i32, i32, i32), u64>,
|
||||
/// The sequence each section's latest accepted mesh was dispatched at
|
||||
/// (`CompiledSectionMesh.compileTaskStartTimeNs`).
|
||||
compile_started: FxHashMap<(i32, i32, i32), u64>,
|
||||
/// Chunks whose light is current.
|
||||
lit: FxHashSet<(i32, i32)>,
|
||||
/// Columns whose sections have been meshed at least once.
|
||||
@@ -653,9 +664,14 @@ impl Streaming {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.light_view(p))
|
||||
};
|
||||
let non_air_sections = {
|
||||
let generator = generator.clone();
|
||||
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
||||
};
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
non_air_sections,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -674,6 +690,7 @@ impl Streaming {
|
||||
decorates: generator.decorates(),
|
||||
lighting: Default::default(),
|
||||
meshing: Default::default(),
|
||||
compile_started: Default::default(),
|
||||
lit: Default::default(),
|
||||
meshed: Default::default(),
|
||||
waiting: Default::default(),
|
||||
@@ -717,6 +734,7 @@ impl Streaming {
|
||||
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
|
||||
*self.shared.context.write().unwrap() = context;
|
||||
self.meshing.clear();
|
||||
self.compile_started.clear();
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
@@ -780,6 +798,7 @@ impl Streaming {
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.compile_started.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
}
|
||||
world.dirty_sections.take_column(x, z);
|
||||
@@ -956,6 +975,15 @@ impl Streaming {
|
||||
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
||||
self.lit.contains(&(x, z))
|
||||
}
|
||||
/// The non-air sections of a column that is a decorated proto chunk
|
||||
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
|
||||
/// chunk's light layers count them (#4294).
|
||||
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
|
||||
if !self.decorated.contains(&(x, z)) {
|
||||
return None;
|
||||
}
|
||||
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
|
||||
}
|
||||
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
|
||||
/// furthest step this column has reached, `None` before it is asked for.
|
||||
pub fn latest_status(
|
||||
@@ -980,6 +1008,17 @@ impl Streaming {
|
||||
return None;
|
||||
})
|
||||
}
|
||||
/// The sequence the next mesh job will be dispatched after: a clock for
|
||||
/// `compile_start`, whose jobs all snapshot the world as they are
|
||||
/// dispatched (`Util.getNanos()` at the start of a compile task).
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.sequence
|
||||
}
|
||||
/// When the section's latest accepted mesh started compiling, on the
|
||||
/// `sequence` clock (`CompiledSectionMesh.getCompileTaskStartTime`).
|
||||
pub fn compile_start(&self, section: (i32, i32, i32)) -> Option<u64> {
|
||||
self.compile_started.get(§ion).copied()
|
||||
}
|
||||
pub fn is_meshing(&self, section: (i32, i32, i32)) -> bool {
|
||||
self.meshing.contains_key(§ion)
|
||||
}
|
||||
@@ -1103,6 +1142,7 @@ impl Streaming {
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.compile_started.insert((x, sy, z), seq);
|
||||
self.meshed_total += 1;
|
||||
uploads.push(((x, sy, z), mesh));
|
||||
}
|
||||
@@ -1134,6 +1174,8 @@ impl Streaming {
|
||||
if !far.is_empty() {
|
||||
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
||||
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
self.compile_started
|
||||
.retain(|k, _| !far.contains(&(k.0, k.2)));
|
||||
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
|
||||
world
|
||||
.dirty_sections
|
||||
@@ -1475,6 +1517,7 @@ impl Streaming {
|
||||
&mut changed,
|
||||
);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
Self::relight_beside_unlit(world, &changed);
|
||||
if !self.light_snapshots.is_empty() {
|
||||
for p in positions {
|
||||
self.light_edit_sequence += 1;
|
||||
@@ -1504,6 +1547,7 @@ impl Streaming {
|
||||
&mut changed,
|
||||
);
|
||||
Self::mark_light_changes(world, &changed);
|
||||
Self::relight_beside_unlit(world, &changed);
|
||||
}
|
||||
let oldest = self.light_snapshots.values().copied().min();
|
||||
match oldest {
|
||||
@@ -1511,6 +1555,14 @@ impl Streaming {
|
||||
None => self.light_log.clear(),
|
||||
}
|
||||
}
|
||||
/// A column whose light an edit changed beside a column not lit yet gets
|
||||
/// a full light job ([`crate::light::beside_unlit`]).
|
||||
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
|
||||
let columns = crate::light::beside_unlit(changed, |x, z| {
|
||||
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
|
||||
});
|
||||
world.dirty_light.extend(columns);
|
||||
}
|
||||
/// Sections whose meshes sample a changed light value (faces and corners
|
||||
/// read light from neighbouring blocks).
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
//! `StructureBlockEntity`: the structure block's name, offset, size, mode and
|
||||
//! options, with its save format (`saveAdditional` / `loadAdditional`), the
|
||||
//! corner scan (`getEnclosingBoundingBox`) and the box the editor shows
|
||||
//! (`getRenderableBox`).
|
||||
use horizon_client_core::nbt_value::{float, number, string, text};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::structure::{Mirror, Rotation},
|
||||
};
|
||||
|
||||
pub const MAX_OFFSET_PER_AXIS: i32 = 48;
|
||||
pub const MAX_SIZE_PER_AXIS: i32 = 48;
|
||||
/// `SCAN_CORNER_BLOCKS_RANGE`.
|
||||
pub const SCAN_CORNER_BLOCKS_RANGE: i32 = 80;
|
||||
|
||||
/// `StructureMode`, in declaration order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StructureMode {
|
||||
Save,
|
||||
Load,
|
||||
Corner,
|
||||
Data,
|
||||
}
|
||||
|
||||
impl StructureMode {
|
||||
pub const ALL: [Self; 4] = [Self::Save, Self::Load, Self::Corner, Self::Data];
|
||||
/// `StructureMode.getSerializedName`: the block state's `mode` value.
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "save",
|
||||
Self::Load => "load",
|
||||
Self::Corner => "corner",
|
||||
Self::Data => "data",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.serialized_name() == name)
|
||||
}
|
||||
/// `StructureMode.LEGACY_CODEC`: `Enum.name()`.
|
||||
pub fn legacy_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "SAVE",
|
||||
Self::Load => "LOAD",
|
||||
Self::Corner => "CORNER",
|
||||
Self::Data => "DATA",
|
||||
}
|
||||
}
|
||||
pub fn from_legacy_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.legacy_name() == name)
|
||||
}
|
||||
pub fn ordinal(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
pub fn from_ordinal(ordinal: i32) -> Option<Self> {
|
||||
usize::try_from(ordinal)
|
||||
.ok()
|
||||
.and_then(|i| Self::ALL.get(i).copied())
|
||||
}
|
||||
/// `StructureMode.getDisplayName`'s lang key.
|
||||
pub fn info_key(self) -> String {
|
||||
format!("structure_block.mode_info.{}", self.serialized_name())
|
||||
}
|
||||
/// The mode button's label (`structure_block.mode.<name>`).
|
||||
pub fn label_key(self) -> String {
|
||||
format!("structure_block.mode.{}", self.serialized_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.UpdateType`, in declaration order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum UpdateType {
|
||||
UpdateData,
|
||||
SaveArea,
|
||||
LoadArea,
|
||||
ScanArea,
|
||||
}
|
||||
|
||||
impl UpdateType {
|
||||
pub fn ordinal(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
pub fn from_ordinal(ordinal: i32) -> Option<Self> {
|
||||
Some(match ordinal {
|
||||
0 => Self::UpdateData,
|
||||
1 => Self::SaveArea,
|
||||
2 => Self::LoadArea,
|
||||
3 => Self::ScanArea,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rotation_from_ordinal(ordinal: i32) -> Option<Rotation> {
|
||||
usize::try_from(ordinal)
|
||||
.ok()
|
||||
.and_then(|i| Rotation::ALL.get(i).copied())
|
||||
}
|
||||
pub fn rotation_ordinal(rotation: Rotation) -> i32 {
|
||||
rotation as i32
|
||||
}
|
||||
pub fn rotation_from_legacy_name(name: &str) -> Option<Rotation> {
|
||||
Rotation::ALL.into_iter().find(|r| r.saved_name() == name)
|
||||
}
|
||||
pub fn mirror_from_ordinal(ordinal: i32) -> Option<Mirror> {
|
||||
Some(match ordinal {
|
||||
0 => Mirror::None,
|
||||
1 => Mirror::LeftRight,
|
||||
2 => Mirror::FrontBack,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub fn mirror_ordinal(mirror: Mirror) -> i32 {
|
||||
mirror as i32
|
||||
}
|
||||
pub fn mirror_from_legacy_name(name: &str) -> Option<Mirror> {
|
||||
[Mirror::None, Mirror::LeftRight, Mirror::FrontBack]
|
||||
.into_iter()
|
||||
.find(|m| m.saved_name() == name)
|
||||
}
|
||||
|
||||
/// `BoundingBoxRenderable.Mode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderMode {
|
||||
None,
|
||||
Box,
|
||||
BoxAndInvisibleBlocks,
|
||||
}
|
||||
|
||||
/// `BoundingBoxRenderable.RenderableBox`: an offset from the block and a
|
||||
/// size, in blocks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RenderableBox {
|
||||
pub offset: [i32; 3],
|
||||
pub size: [i32; 3],
|
||||
}
|
||||
|
||||
impl RenderableBox {
|
||||
/// `RenderableBox.fromCorners`.
|
||||
pub fn from_corners(x0: i32, y0: i32, z0: i32, x1: i32, y1: i32, z1: i32) -> Self {
|
||||
let (min_x, min_y, min_z) = (x0.min(x1), y0.min(y1), z0.min(z1));
|
||||
Self {
|
||||
offset: [min_x, min_y, min_z],
|
||||
size: [x0.max(x1) - min_x, y0.max(y1) - min_y, z0.max(z1) - min_z],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StructureBlock {
|
||||
/// The structure's id; an unparsable or empty name is none.
|
||||
pub name: Option<String>,
|
||||
pub author: String,
|
||||
pub metadata: String,
|
||||
pub pos: [i32; 3],
|
||||
pub size: [i32; 3],
|
||||
pub mirror: Mirror,
|
||||
pub rotation: Rotation,
|
||||
pub mode: StructureMode,
|
||||
pub ignore_entities: bool,
|
||||
pub strict: bool,
|
||||
pub powered: bool,
|
||||
pub show_air: bool,
|
||||
pub show_bounding_box: bool,
|
||||
pub integrity: f32,
|
||||
pub seed: i64,
|
||||
}
|
||||
|
||||
impl StructureBlock {
|
||||
/// `new StructureBlockEntity(pos, state)`: the mode is the state's.
|
||||
pub fn new(mode: StructureMode) -> Self {
|
||||
Self {
|
||||
name: None,
|
||||
author: String::new(),
|
||||
metadata: String::new(),
|
||||
pos: [0, 1, 0],
|
||||
size: [0; 3],
|
||||
mirror: Mirror::None,
|
||||
rotation: Rotation::None,
|
||||
mode,
|
||||
ignore_entities: true,
|
||||
strict: false,
|
||||
powered: false,
|
||||
show_air: false,
|
||||
show_bounding_box: true,
|
||||
integrity: 1.0,
|
||||
seed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.getStructureName`.
|
||||
pub fn structure_name(&self) -> &str {
|
||||
self.name.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
/// `setStructureName(String)`: empty or unparsable is none (`Identifier.tryParse`).
|
||||
pub fn set_structure_name(&mut self, name: &str) {
|
||||
self.name = if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_identifier(name)
|
||||
};
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.saveAdditional`.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let byte = |b: bool| Tag::Byte(i8::from(b));
|
||||
vec![
|
||||
("name", string(self.structure_name())),
|
||||
("author", string(&self.author)),
|
||||
("metadata", string(&self.metadata)),
|
||||
("posX", Tag::Int(self.pos[0])),
|
||||
("posY", Tag::Int(self.pos[1])),
|
||||
("posZ", Tag::Int(self.pos[2])),
|
||||
("sizeX", Tag::Int(self.size[0])),
|
||||
("sizeY", Tag::Int(self.size[1])),
|
||||
("sizeZ", Tag::Int(self.size[2])),
|
||||
("rotation", string(self.rotation.saved_name())),
|
||||
("mirror", string(self.mirror.saved_name())),
|
||||
("mode", string(self.mode.legacy_name())),
|
||||
("ignoreEntities", byte(self.ignore_entities)),
|
||||
("strict", byte(self.strict)),
|
||||
("powered", byte(self.powered)),
|
||||
("showair", byte(self.show_air)),
|
||||
("showboundingbox", byte(self.show_bounding_box)),
|
||||
("integrity", Tag::Float(self.integrity.to_bits())),
|
||||
("seed", Tag::Long(self.seed)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.loadAdditional`.
|
||||
pub fn load(tag: &Tag) -> Self {
|
||||
let int = |key: &str, default: i32, lo: i32, hi: i32| {
|
||||
(number(tag.get(key)).unwrap_or(i64::from(default)) as i32).clamp(lo, hi)
|
||||
};
|
||||
let flag = |key: &str, default: bool| number(tag.get(key)).map_or(default, |v| v != 0);
|
||||
let o = MAX_OFFSET_PER_AXIS;
|
||||
let s = MAX_SIZE_PER_AXIS;
|
||||
let mut block = Self::new(
|
||||
text(tag.get("mode"))
|
||||
.and_then(|m| StructureMode::from_legacy_name(&m))
|
||||
.unwrap_or(StructureMode::Data),
|
||||
);
|
||||
block.set_structure_name(&text(tag.get("name")).unwrap_or_default());
|
||||
block.author = text(tag.get("author")).unwrap_or_default();
|
||||
block.metadata = text(tag.get("metadata")).unwrap_or_default();
|
||||
block.pos = [
|
||||
int("posX", 0, -o, o),
|
||||
int("posY", 1, -o, o),
|
||||
int("posZ", 0, -o, o),
|
||||
];
|
||||
block.size = [
|
||||
int("sizeX", 0, 0, s),
|
||||
int("sizeY", 0, 0, s),
|
||||
int("sizeZ", 0, 0, s),
|
||||
];
|
||||
block.rotation = text(tag.get("rotation"))
|
||||
.and_then(|r| rotation_from_legacy_name(&r))
|
||||
.unwrap_or(Rotation::None);
|
||||
block.mirror = text(tag.get("mirror"))
|
||||
.and_then(|m| mirror_from_legacy_name(&m))
|
||||
.unwrap_or(Mirror::None);
|
||||
block.ignore_entities = flag("ignoreEntities", true);
|
||||
block.strict = flag("strict", false);
|
||||
block.powered = flag("powered", false);
|
||||
block.show_air = flag("showair", false);
|
||||
block.show_bounding_box = flag("showboundingbox", true);
|
||||
block.integrity = float(tag.get("integrity")).map_or(1.0, |f| f as f32);
|
||||
block.seed = number(tag.get("seed")).unwrap_or(0);
|
||||
block
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.renderMode`.
|
||||
pub fn render_mode(&self) -> RenderMode {
|
||||
if self.mode != StructureMode::Save && self.mode != StructureMode::Load {
|
||||
RenderMode::None
|
||||
} else if self.mode == StructureMode::Save && self.show_air {
|
||||
RenderMode::BoxAndInvisibleBlocks
|
||||
} else if self.mode != StructureMode::Save && !self.show_bounding_box {
|
||||
RenderMode::None
|
||||
} else {
|
||||
RenderMode::Box
|
||||
}
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.getRenderableBox`.
|
||||
pub fn renderable_box(&self) -> RenderableBox {
|
||||
let [x_origin, y0, z_origin] = self.pos;
|
||||
let size = self.size;
|
||||
let y1 = y0 + size[1];
|
||||
let (x_diff, z_diff) = match self.mirror {
|
||||
Mirror::LeftRight => (size[0], -size[2]),
|
||||
Mirror::FrontBack => (-size[0], size[2]),
|
||||
Mirror::None => (size[0], size[2]),
|
||||
};
|
||||
let (x0, z0, x1, z1);
|
||||
match self.rotation {
|
||||
Rotation::Clockwise90 => {
|
||||
x0 = if z_diff < 0 { x_origin } else { x_origin + 1 };
|
||||
z0 = if x_diff < 0 { z_origin + 1 } else { z_origin };
|
||||
x1 = x0 - z_diff;
|
||||
z1 = z0 + x_diff;
|
||||
}
|
||||
Rotation::Clockwise180 => {
|
||||
x0 = if x_diff < 0 { x_origin } else { x_origin + 1 };
|
||||
z0 = if z_diff < 0 { z_origin } else { z_origin + 1 };
|
||||
x1 = x0 - x_diff;
|
||||
z1 = z0 - z_diff;
|
||||
}
|
||||
Rotation::Counterclockwise90 => {
|
||||
x0 = if z_diff < 0 { x_origin + 1 } else { x_origin };
|
||||
z0 = if x_diff < 0 { z_origin } else { z_origin + 1 };
|
||||
x1 = x0 + z_diff;
|
||||
z1 = z0 - x_diff;
|
||||
}
|
||||
Rotation::None => {
|
||||
x0 = if x_diff < 0 { x_origin + 1 } else { x_origin };
|
||||
z0 = if z_diff < 0 { z_origin + 1 } else { z_origin };
|
||||
x1 = x0 + x_diff;
|
||||
z1 = z0 + z_diff;
|
||||
}
|
||||
}
|
||||
RenderableBox::from_corners(x0, y0, z0, x1, y1, z1)
|
||||
}
|
||||
|
||||
/// `StructureBlockEntity.getEnclosingBoundingBox` over the corner blocks
|
||||
/// of this structure's name found in range (`corners`: every position of
|
||||
/// a corner-mode structure block with the same name), with `at` the block
|
||||
/// asking. Returns the inclusive min and max, none without a corner.
|
||||
pub fn enclosing_box(corners: &[[i32; 3]], at: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
|
||||
let mut found: Option<([i32; 3], [i32; 3])> = None;
|
||||
for c in corners {
|
||||
found = Some(match found {
|
||||
None => (*c, *c),
|
||||
Some((min, max)) => (
|
||||
[min[0].min(c[0]), min[1].min(c[1]), min[2].min(c[2])],
|
||||
[max[0].max(c[0]), max[1].max(c[1]), max[2].max(c[2])],
|
||||
),
|
||||
});
|
||||
}
|
||||
let (min, max) = found?;
|
||||
if corners.len() == 1 {
|
||||
return Some((
|
||||
[min[0].min(at[0]), min[1].min(at[1]), min[2].min(at[2])],
|
||||
[max[0].max(at[0]), max[1].max(at[1]), max[2].max(at[2])],
|
||||
));
|
||||
}
|
||||
Some((min, max))
|
||||
}
|
||||
|
||||
/// `detectSize`'s result for the box: the offset and size the block takes
|
||||
/// when every side is wider than the corner blocks themselves.
|
||||
pub fn detected(min: [i32; 3], max: [i32; 3], at: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
|
||||
let delta = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
||||
if delta.iter().all(|d| *d > 1) {
|
||||
Some((
|
||||
[min[0] - at[0] + 1, min[1] - at[1] + 1, min[2] - at[2] + 1],
|
||||
[delta[0] - 1, delta[1] - 1, delta[2] - 1],
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Identifier.tryParse`: `namespace:path` (the namespace defaults to
|
||||
/// `minecraft`) with only legal characters, as its string form.
|
||||
pub fn parse_identifier(text: &str) -> Option<String> {
|
||||
let (namespace, path) = text.split_once(':').unwrap_or(("minecraft", text));
|
||||
let namespace = if namespace.is_empty() {
|
||||
"minecraft"
|
||||
} else {
|
||||
namespace
|
||||
};
|
||||
let namespace_ok = namespace
|
||||
.chars()
|
||||
.all(|c| matches!(c, '_' | '-' | '.' | '0'..='9' | 'a'..='z'));
|
||||
let path_ok = !path.is_empty()
|
||||
&& path
|
||||
.chars()
|
||||
.all(|c| matches!(c, '_' | '-' | '.' | '/' | '0'..='9' | 'a'..='z'));
|
||||
(namespace_ok && path_ok).then(|| format!("{namespace}:{path}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_structure_block_entity() {
|
||||
let mut block = StructureBlock::new(StructureMode::Save);
|
||||
block.set_structure_name("house");
|
||||
block.author = "levzzz".into();
|
||||
block.pos = [1, -2, 3];
|
||||
block.size = [4, 5, 6];
|
||||
block.rotation = Rotation::Clockwise90;
|
||||
block.mirror = Mirror::FrontBack;
|
||||
block.ignore_entities = false;
|
||||
block.strict = true;
|
||||
block.integrity = 0.25;
|
||||
block.seed = -9;
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(text(tag.get("name")).as_deref(), Some("minecraft:house"));
|
||||
assert_eq!(text(tag.get("mode")).as_deref(), Some("SAVE"));
|
||||
assert_eq!(text(tag.get("rotation")).as_deref(), Some("CLOCKWISE_90"));
|
||||
assert_eq!(StructureBlock::load(&tag), block.clone());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_clamps_and_defaults() {
|
||||
let tag = Tag::compound(vec![
|
||||
("posX", Tag::Int(99)),
|
||||
("posY", Tag::Int(-99)),
|
||||
("sizeX", Tag::Int(-4)),
|
||||
("sizeY", Tag::Int(100)),
|
||||
("name", string("Bad Name")),
|
||||
]);
|
||||
let block = StructureBlock::load(&tag);
|
||||
assert_eq!(block.pos, [48, -48, 0]);
|
||||
assert_eq!(block.size, [0, 48, 0]);
|
||||
assert_eq!(block.name, None);
|
||||
assert_eq!(block.mode, StructureMode::Data);
|
||||
assert!(block.ignore_entities && block.show_bounding_box && !block.strict);
|
||||
assert_eq!(block.integrity, 1.0);
|
||||
// Pos defaults to (0, 1, 0).
|
||||
assert_eq!(
|
||||
StructureBlock::load(&Tag::compound(Vec::<(&str, Tag)>::new())).pos,
|
||||
[0, 1, 0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_mode_and_box_follow_get_renderable_box() {
|
||||
let mut block = StructureBlock::new(StructureMode::Save);
|
||||
block.pos = [0, 1, 0];
|
||||
block.size = [3, 2, 4];
|
||||
assert_eq!(block.render_mode(), RenderMode::Box);
|
||||
assert_eq!(
|
||||
block.renderable_box(),
|
||||
RenderableBox {
|
||||
offset: [0, 1, 0],
|
||||
size: [3, 2, 4]
|
||||
}
|
||||
);
|
||||
block.rotation = Rotation::Clockwise90;
|
||||
assert_eq!(
|
||||
block.renderable_box(),
|
||||
RenderableBox {
|
||||
offset: [-3, 1, 0],
|
||||
size: [4, 2, 3]
|
||||
}
|
||||
);
|
||||
block.rotation = Rotation::None;
|
||||
block.mirror = Mirror::LeftRight;
|
||||
assert_eq!(
|
||||
block.renderable_box(),
|
||||
RenderableBox {
|
||||
offset: [0, 1, -3],
|
||||
size: [3, 2, 4]
|
||||
}
|
||||
);
|
||||
block.show_air = true;
|
||||
assert_eq!(block.render_mode(), RenderMode::BoxAndInvisibleBlocks);
|
||||
block.mode = StructureMode::Load;
|
||||
block.show_bounding_box = false;
|
||||
assert_eq!(block.render_mode(), RenderMode::None);
|
||||
block.mode = StructureMode::Corner;
|
||||
assert_eq!(block.render_mode(), RenderMode::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_scan_matches_detect_size() {
|
||||
// Two corners: their box; one corner: the box with the asking block.
|
||||
let (min, max) = StructureBlock::enclosing_box(&[[0, 0, 0], [5, 4, 6]], [2, 0, 2]).unwrap();
|
||||
assert_eq!((min, max), ([0, 0, 0], [5, 4, 6]));
|
||||
assert_eq!(
|
||||
StructureBlock::detected(min, max, [2, 0, 2]),
|
||||
Some(([-1, 1, -1], [4, 3, 5]))
|
||||
);
|
||||
let one = StructureBlock::enclosing_box(&[[0, 0, 0]], [3, 3, 3]).unwrap();
|
||||
assert_eq!(one, ([0, 0, 0], [3, 3, 3]));
|
||||
assert_eq!(StructureBlock::enclosing_box(&[], [0, 0, 0]), None);
|
||||
// A box of one block thickness is no structure.
|
||||
assert_eq!(
|
||||
StructureBlock::detected([0, 0, 0], [1, 5, 5], [0, 0, 0]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_parse_like_try_parse() {
|
||||
assert_eq!(parse_identifier("a/b").as_deref(), Some("minecraft:a/b"));
|
||||
assert_eq!(parse_identifier("my:house").as_deref(), Some("my:house"));
|
||||
assert_eq!(parse_identifier("Bad"), None);
|
||||
assert_eq!(parse_identifier("ns:"), None);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,68 @@ struct TerrainOut {
|
||||
@location(4) cylindrical: f32,
|
||||
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
|
||||
@location(8) visibility: f32,
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
|
||||
// column of its own grid (`local`, the tile-relative position: 16 blocks,
|
||||
// 2^level for coarse tiles) starts in turn over the first half of the fade
|
||||
// and fades over the second, as a 4x4 ordered pattern in the face's plane,
|
||||
// its unit the power of two of a block nearest 1.5 pixels, so it stays on
|
||||
// the terrain as the camera moves. `dx` and `dy`: `local`'s screen
|
||||
// derivatives.
|
||||
fn faded(code: u32, local: vec3<f32>, dx: vec3<f32>, dy: vec3<f32>) -> bool {
|
||||
if code == 0u { return false; }
|
||||
// Sixty-fourths of the fade: 1 to 63 fading in, 65 to 128 fading out.
|
||||
let step = (code - 1u) % 129u;
|
||||
let out = step >= 64u;
|
||||
let progress = f32(select(step, step - 64u, out)) / 64.0;
|
||||
let size = exp2(f32(max((code - 1u) / 129u, 4u)));
|
||||
let cell = bitcast<vec2<u32>>(vec2<i32>(floor(local.xz / size)));
|
||||
var h = cell.x * 73856093u ^ cell.y * 19349663u;
|
||||
h = (h ^ (h >> 13u)) * 0x5bd1e995u;
|
||||
let start = f32((h ^ (h >> 15u)) & 1023u) / 2048.0;
|
||||
let ramp = clamp((progress - start) * 2.0, 0.0, 1.0);
|
||||
let shown = select(ramp, 1.0 - ramp, out);
|
||||
let n = abs(cross(dx, dy));
|
||||
var plane = local.xy;
|
||||
if n.x >= n.y && n.x >= n.z {
|
||||
plane = local.yz;
|
||||
} else if n.y >= n.z {
|
||||
plane = local.xz;
|
||||
}
|
||||
let pixel = max(length(dx), length(dy));
|
||||
let unit = exp2(ceil(log2(max(pixel * 1.5, 1.0 / 16.0))));
|
||||
let p = vec2<u32>(bitcast<vec2<u32>>(vec2<i32>(floor(plane / unit)))) & vec2(3u);
|
||||
let bayer = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
|
||||
return (f32(bayer[p.y * 4u + p.x]) + 0.5) / 16.0 >= shown;
|
||||
}
|
||||
// A fading tile or section is drawn a hair toward the camera (depth is
|
||||
// reversed: nearer is larger), the newer the more, so it wins where it draws
|
||||
// over the solid tiles it replaces. Distant tiles go back by 0.2% of their
|
||||
// distance, so near chunks win over tiles still under them (gfx/lod.rs's
|
||||
// `previous`, columns not complete yet): near water lies a little below a
|
||||
// tile's (vanilla's fluid corner heights), and under a smaller step the
|
||||
// tile's drew over it, two waters, opaque. A tile's UV step is 1/32, a
|
||||
// section's 1/32768 (`terrain_uv`).
|
||||
fn fade_depth(clip: vec4<f32>, w: f32) -> vec4<f32> {
|
||||
let code = fade_code(w);
|
||||
let tile = instance_step(w) > 0.001;
|
||||
var scale = select(1.0, 1.0 - 2e-3, tile);
|
||||
if code != 0u && (code - 1u) % 129u < 64u {
|
||||
scale *= 1.0 + 5e-6 * f32(64u - (code - 1u) % 129u);
|
||||
} else if code != 0u && !tile {
|
||||
// A section leaving (gfx/lod.rs's `HELD`, `fade_out_code`) goes 2% of
|
||||
// its distance toward the camera: far terrain's coarser blocks stand a
|
||||
// little out of the real ones, and the tile coming in under it hid it
|
||||
// at once, before its fade.
|
||||
scale *= 1.02;
|
||||
}
|
||||
return vec4(clip.xy, clip.z * scale, clip.w);
|
||||
}
|
||||
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
|
||||
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
|
||||
@@ -95,15 +157,36 @@ fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
|
||||
// mesher.rs's TerrainVertex holds the UV in fixed point; the instance's
|
||||
// origin.w is the step: 1/32768 per unit for sections (a power of two on an
|
||||
// integer below 2^24, so it decodes exactly), 1/32 per block for distant
|
||||
// terrain tiles (TerrainVertex::tiled).
|
||||
fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
|
||||
return vec2<f32>(packed) * step;
|
||||
// terrain tiles (TerrainVertex::tiled). A fading tile negates it past its
|
||||
// dither code (gfx/lod.rs's `instance`): the step is the fraction either way.
|
||||
fn terrain_uv(packed: vec2<u32>, w: f32) -> vec2<f32> {
|
||||
return vec2<f32>(packed) * instance_step(w);
|
||||
}
|
||||
// A whole section's step (a power of two) carries its fade-in in the mantissa's 23 bits, as
|
||||
// `1 - visibility` (gfx.rs's `section_instance`): masked off, the step; the rest is
|
||||
// `RenderSection.getVisibility`. A fading one (negative) is fully visible.
|
||||
fn instance_step(w: f32) -> f32 {
|
||||
if w > 0.0 {
|
||||
return bitcast<f32>(bitcast<u32>(w) & 0xff800000u);
|
||||
}
|
||||
return fract(abs(w));
|
||||
}
|
||||
fn instance_visibility(w: f32) -> f32 {
|
||||
if w > 0.0 {
|
||||
return 1.0 - f32(bitcast<u32>(w) & 0x7fffffu) / 8388608.0;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
// A fading distant tile's dither code (gfx/lod.rs's `fade_code`), 0 when whole.
|
||||
fn fade_code(w: f32) -> u32 {
|
||||
return u32(floor(max(-w, 0.0)));
|
||||
}
|
||||
@vertex fn vs_terrain(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
|
||||
// index list. A section's vertices fill whole 256-vertex blocks of its page:
|
||||
@@ -116,13 +199,15 @@ fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> TerrainOut {
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
let pos = position + origin;
|
||||
var out: TerrainOut;
|
||||
out.clip = globals.view_proj * vec4(pos, 1.0);
|
||||
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
|
||||
out.local = position;
|
||||
out.uv = uv;
|
||||
out.layer = layer;
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
@@ -130,6 +215,10 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
|
||||
out.spherical = length(pos);
|
||||
out.cylindrical = max(length(pos.xz), abs(pos.y));
|
||||
out.fade = fade_code(w);
|
||||
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
|
||||
out.visibility = mix(1.0, instance_visibility(w),
|
||||
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
|
||||
return out;
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
@@ -143,8 +232,13 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
// terrain.fsh: the fade-in mixes toward the fog colour.
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
|
||||
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
|
||||
@@ -152,8 +246,12 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
|
||||
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
|
||||
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
|
||||
}
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
|
||||
// pixels showed the layer behind through the one in front, twice the water.
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! `TestBlockEntity`: the test block's mode, message and powered flag, with
|
||||
//! its save format (`saveAdditional` / `loadAdditional`).
|
||||
use horizon_client_core::nbt_value::{number, string, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `TestBlockMode`, in id order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TestBlockMode {
|
||||
Start,
|
||||
Log,
|
||||
Fail,
|
||||
Accept,
|
||||
}
|
||||
|
||||
impl TestBlockMode {
|
||||
pub const ALL: [Self; 4] = [Self::Start, Self::Log, Self::Fail, Self::Accept];
|
||||
/// `getSerializedName`: the block state's `mode` value.
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
Self::Log => "log",
|
||||
Self::Fail => "fail",
|
||||
Self::Accept => "accept",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.serialized_name() == name)
|
||||
}
|
||||
pub fn id(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
/// `ByIdMap.continuous(.., OutOfBoundsStrategy.ZERO)`.
|
||||
pub fn by_id(id: i32) -> Self {
|
||||
usize::try_from(id)
|
||||
.ok()
|
||||
.and_then(|i| Self::ALL.get(i).copied())
|
||||
.unwrap_or(Self::Start)
|
||||
}
|
||||
/// `getDisplayName`'s lang key.
|
||||
pub fn label_key(self) -> String {
|
||||
format!("test_block.mode.{}", self.serialized_name())
|
||||
}
|
||||
/// `getDetailedMessage`'s lang key.
|
||||
pub fn info_key(self) -> String {
|
||||
format!("test_block.mode_info.{}", self.serialized_name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TestBlock {
|
||||
pub mode: TestBlockMode,
|
||||
pub message: String,
|
||||
pub powered: bool,
|
||||
/// `triggered`: not saved; the game test reads and clears it.
|
||||
pub triggered: bool,
|
||||
}
|
||||
|
||||
/// What a `TestBlockEntity` call asks of the level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Effects {
|
||||
/// `level.updateNeighborsAt(pos, block)`.
|
||||
pub update_neighbors: bool,
|
||||
/// `log()`.
|
||||
pub log: bool,
|
||||
}
|
||||
|
||||
impl TestBlock {
|
||||
/// `new TestBlockEntity(pos, state)`: the mode is the state's.
|
||||
pub fn new(mode: TestBlockMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
message: String::new(),
|
||||
powered: false,
|
||||
triggered: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `reset`.
|
||||
pub fn reset(&mut self) -> Effects {
|
||||
self.triggered = false;
|
||||
let start = self.mode == TestBlockMode::Start;
|
||||
if start {
|
||||
self.powered = false;
|
||||
}
|
||||
Effects {
|
||||
update_neighbors: start,
|
||||
log: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `trigger`: a start block powers up and logs; the others log (LOG
|
||||
/// only) and remember they were triggered.
|
||||
pub fn trigger(&mut self) -> Effects {
|
||||
if self.mode == TestBlockMode::Start {
|
||||
self.powered = true;
|
||||
return Effects {
|
||||
update_neighbors: true,
|
||||
log: true,
|
||||
};
|
||||
}
|
||||
self.triggered = true;
|
||||
Effects {
|
||||
update_neighbors: false,
|
||||
log: self.mode == TestBlockMode::Log,
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestBlock.neighborChanged`: `signal` is `hasNeighborSignal`; a start
|
||||
/// block ignores it, the others trigger on a rising signal.
|
||||
pub fn neighbor_changed(&mut self, signal: bool) -> Option<Effects> {
|
||||
if self.mode == TestBlockMode::Start {
|
||||
return None;
|
||||
}
|
||||
if signal && !self.powered {
|
||||
self.powered = true;
|
||||
Some(self.trigger())
|
||||
} else {
|
||||
if !signal && self.powered {
|
||||
self.powered = false;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestBlock.ownSignal`: a powered start block gives 15.
|
||||
pub fn own_signal(&self) -> i32 {
|
||||
if self.mode == TestBlockMode::Start && self.powered {
|
||||
15
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
vec![
|
||||
("mode", string(self.mode.serialized_name())),
|
||||
("message", string(&self.message)),
|
||||
("powered", Tag::Byte(i8::from(self.powered))),
|
||||
]
|
||||
}
|
||||
|
||||
/// `loadAdditional`: a missing or unknown mode is FAIL.
|
||||
pub fn load(tag: &Tag) -> Self {
|
||||
Self {
|
||||
mode: text(tag.get("mode"))
|
||||
.and_then(|m| TestBlockMode::of_serialized_name(&m))
|
||||
.unwrap_or(TestBlockMode::Fail),
|
||||
message: text(tag.get("message")).unwrap_or_default(),
|
||||
powered: number(tag.get("powered")).is_some_and(|v| v != 0),
|
||||
triggered: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_test_block_entity() {
|
||||
let block = TestBlock {
|
||||
mode: TestBlockMode::Accept,
|
||||
message: "all done".into(),
|
||||
powered: true,
|
||||
triggered: false,
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(text(tag.get("mode")).as_deref(), Some("accept"));
|
||||
assert_eq!(TestBlock::load(&tag), block);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_blocks_power_up_and_the_others_trigger_on_a_rising_signal() {
|
||||
let mut start = TestBlock::new(TestBlockMode::Start);
|
||||
assert_eq!(start.neighbor_changed(true), None);
|
||||
assert_eq!(start.own_signal(), 0);
|
||||
let effects = start.trigger();
|
||||
assert!(effects.update_neighbors && effects.log && start.own_signal() == 15);
|
||||
assert!(start.reset().update_neighbors && start.own_signal() == 0);
|
||||
|
||||
let mut log = TestBlock::new(TestBlockMode::Log);
|
||||
let effects = log.neighbor_changed(true).unwrap();
|
||||
assert!(effects.log && !effects.update_neighbors && log.triggered && log.powered);
|
||||
// Still powered: no second trigger; a fall clears the flag.
|
||||
assert_eq!(log.neighbor_changed(true), None);
|
||||
assert_eq!(log.neighbor_changed(false), None);
|
||||
assert!(!log.powered && log.triggered);
|
||||
assert!(!log.reset().update_neighbors && !log.triggered);
|
||||
let mut accept = TestBlock::new(TestBlockMode::Accept);
|
||||
assert!(!accept.neighbor_changed(true).unwrap().log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_defaults_to_fail_and_ids_wrap_to_start() {
|
||||
let block = TestBlock::load(&Tag::compound(Vec::<(&str, Tag)>::new()));
|
||||
assert_eq!(block, TestBlock::new(TestBlockMode::Fail));
|
||||
assert_eq!(TestBlockMode::by_id(3), TestBlockMode::Accept);
|
||||
assert_eq!(TestBlockMode::by_id(9), TestBlockMode::Start);
|
||||
assert_eq!(TestBlockMode::Log.id(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! `TestInstanceBlockEntity`: the test instance block's test, size, rotation
|
||||
//! and status, its error markers, and the boxes derived from them.
|
||||
use horizon_client_core::nbt_value::{number, string, text};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
worldgen::structure::{BoundingBox, Rotation},
|
||||
};
|
||||
|
||||
/// `STRUCTURE_OFFSET`: where the structure starts from the block.
|
||||
pub const STRUCTURE_OFFSET: [i32; 3] = [0, 1, 1];
|
||||
|
||||
/// `TestInstanceBlockEntity.Status`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Cleared,
|
||||
Running,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn serialized_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cleared => "cleared",
|
||||
Self::Running => "running",
|
||||
Self::Finished => "finished",
|
||||
}
|
||||
}
|
||||
pub fn of_serialized_name(name: &str) -> Option<Self> {
|
||||
[Self::Cleared, Self::Running, Self::Finished]
|
||||
.into_iter()
|
||||
.find(|s| s.serialized_name() == name)
|
||||
}
|
||||
pub fn index(self) -> i32 {
|
||||
self as i32
|
||||
}
|
||||
/// `Status.byIndex`: out of range is CLEARED.
|
||||
pub fn by_index(index: i32) -> Self {
|
||||
match index {
|
||||
1 => Self::Running,
|
||||
2 => Self::Finished,
|
||||
_ => Self::Cleared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestInstanceBlockEntity.Data`. Components are kept as the NBT they are
|
||||
/// sent and saved as.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Data {
|
||||
/// The test's registry id.
|
||||
pub test: Option<String>,
|
||||
pub size: [i32; 3],
|
||||
pub rotation: Rotation,
|
||||
pub ignore_entities: bool,
|
||||
pub status: Status,
|
||||
pub error_message: Option<Tag>,
|
||||
}
|
||||
|
||||
impl Default for Data {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
test: None,
|
||||
size: [0; 3],
|
||||
rotation: Rotation::None,
|
||||
ignore_entities: false,
|
||||
status: Status::Cleared,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Data {
|
||||
/// `Data.CODEC` encoded.
|
||||
pub fn save(&self) -> Tag {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(test) = &self.test {
|
||||
entries.push(("test", string(test)));
|
||||
}
|
||||
entries.push(("size", Tag::IntArray(self.size.to_vec())));
|
||||
entries.push(("rotation", string(self.rotation.serialized_name())));
|
||||
entries.push(("ignore_entities", Tag::Byte(i8::from(self.ignore_entities))));
|
||||
entries.push(("status", string(self.status.serialized_name())));
|
||||
if let Some(error) = &self.error_message {
|
||||
entries.push(("error_message", error.clone()));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
/// `Data.CODEC` decoded: size, rotation, ignore_entities and status are
|
||||
/// required, or the whole value is dropped.
|
||||
pub fn load(tag: &Tag) -> Option<Self> {
|
||||
let size = match tag.get("size")? {
|
||||
Tag::IntArray(v) if v.len() == 3 => [v[0], v[1], v[2]],
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
test: text(tag.get("test")),
|
||||
size,
|
||||
rotation: Rotation::from_name(&text(tag.get("rotation"))?)?,
|
||||
ignore_entities: number(tag.get("ignore_entities")).map(|v| v != 0)?,
|
||||
status: Status::of_serialized_name(&text(tag.get("status"))?)?,
|
||||
error_message: tag.get("error_message").cloned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestInstanceBlockEntity.ErrorMarker`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ErrorMarker {
|
||||
pub pos: [i32; 3],
|
||||
pub text: Tag,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TestInstanceBlock {
|
||||
pub data: Data,
|
||||
pub error_markers: Vec<ErrorMarker>,
|
||||
}
|
||||
|
||||
impl TestInstanceBlock {
|
||||
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut entries = vec![("data", self.data.save())];
|
||||
if !self.error_markers.is_empty() {
|
||||
let markers = self
|
||||
.error_markers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
Tag::compound([
|
||||
("pos", Tag::IntArray(m.pos.to_vec())),
|
||||
("text", m.text.clone()),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
entries.push(("errors", Tag::List(markers)));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// `loadAdditional`: the data is replaced only when it decodes, and the
|
||||
/// markers are always replaced.
|
||||
pub fn load(&mut self, tag: &Tag) {
|
||||
if let Some(data) = tag.get("data").and_then(Data::load) {
|
||||
self.data = data;
|
||||
}
|
||||
self.error_markers.clear();
|
||||
if let Some(Tag::List(markers)) = tag.get("errors") {
|
||||
for m in markers {
|
||||
if let (Some(Tag::IntArray(p)), Some(text)) = (m.get("pos"), m.get("text"))
|
||||
&& p.len() == 3
|
||||
{
|
||||
self.error_markers.push(ErrorMarker {
|
||||
pos: [p[0], p[1], p[2]],
|
||||
text: text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `getStructurePos(blockPos)`.
|
||||
pub fn structure_pos_of(block: [i32; 3]) -> [i32; 3] {
|
||||
[0, 1, 2].map(|i| block[i] + STRUCTURE_OFFSET[i])
|
||||
}
|
||||
|
||||
/// `getTransformedSize`: the size with the axes swapped for a quarter turn.
|
||||
pub fn transformed_size(size: [i32; 3], rotation: Rotation) -> [i32; 3] {
|
||||
if matches!(
|
||||
rotation,
|
||||
Rotation::Clockwise90 | Rotation::Counterclockwise90
|
||||
) {
|
||||
[size[2], size[1], size[0]]
|
||||
} else {
|
||||
size
|
||||
}
|
||||
}
|
||||
|
||||
/// `getRenderableBox`: the offset from the block and the size to draw,
|
||||
/// given the test's `padding` and rotation.
|
||||
pub fn renderable_box(&self, padding: i32, rotation: Rotation) -> ([i32; 3], [i32; 3]) {
|
||||
let offset = STRUCTURE_OFFSET.map(|o| o + padding);
|
||||
(offset, Self::transformed_size(self.data.size, rotation))
|
||||
}
|
||||
|
||||
/// `getStructureBoundingBox`.
|
||||
pub fn structure_box(&self, block: [i32; 3], padding: i32, rotation: Rotation) -> BoundingBox {
|
||||
let corner = Self::structure_pos_of([0, 1, 2].map(|i| block[i] + padding));
|
||||
let size = Self::transformed_size(self.data.size, rotation);
|
||||
BoundingBox::from_corners(corner, [0, 1, 2].map(|i| corner[i] + size[i] - 1))
|
||||
}
|
||||
|
||||
/// `getStartCorner`: the corner the template is placed from.
|
||||
pub fn start_corner(&self, block: [i32; 3], padding: i32, rotation: Rotation) -> [i32; 3] {
|
||||
let [x, _, z] = self.data.size;
|
||||
let c = Self::structure_pos_of([0, 1, 2].map(|i| block[i] + padding));
|
||||
match rotation {
|
||||
Rotation::None => c,
|
||||
Rotation::Clockwise90 => [c[0] + z - 1, c[1], c[2]],
|
||||
Rotation::Clockwise180 => [c[0] + x - 1, c[1], c[2] + z - 1],
|
||||
Rotation::Counterclockwise90 => [c[0], c[1], c[2] + x - 1],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> TestInstanceBlock {
|
||||
TestInstanceBlock {
|
||||
data: Data {
|
||||
test: Some("minecraft:always_pass".into()),
|
||||
size: [3, 4, 5],
|
||||
rotation: Rotation::Clockwise180,
|
||||
ignore_entities: true,
|
||||
status: Status::Finished,
|
||||
error_message: Some(string("boom")),
|
||||
},
|
||||
error_markers: vec![ErrorMarker {
|
||||
pos: [1, 2, 3],
|
||||
text: string("here"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_and_loads_like_test_instance_block_entity() {
|
||||
let block = sample();
|
||||
let tag = Tag::compound(block.save());
|
||||
assert_eq!(
|
||||
text(tag.get("data").unwrap().get("rotation")).as_deref(),
|
||||
Some("180")
|
||||
);
|
||||
let mut loaded = TestInstanceBlock::default();
|
||||
loaded.load(&tag);
|
||||
assert_eq!(loaded, block);
|
||||
// No markers, no `errors` key; a bad `data` leaves the old one.
|
||||
let mut plain = block.clone();
|
||||
plain.error_markers.clear();
|
||||
assert!(Tag::compound(plain.save()).get("errors").is_none());
|
||||
let mut kept = block;
|
||||
kept.load(&Tag::compound([(
|
||||
"data",
|
||||
Tag::compound(Vec::<(&str, Tag)>::new()),
|
||||
)]));
|
||||
assert_eq!(kept.data.test.as_deref(), Some("minecraft:always_pass"));
|
||||
assert!(kept.error_markers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_follow_padding_and_rotation() {
|
||||
let block = sample();
|
||||
// A quarter turn swaps the horizontal sizes.
|
||||
let (offset, size) = block.renderable_box(2, Rotation::Clockwise90);
|
||||
assert_eq!((offset, size), ([2, 3, 3], [5, 4, 3]));
|
||||
let bounds = block.structure_box([10, 64, 10], 2, Rotation::None);
|
||||
assert_eq!(bounds.min, [12, 67, 13]);
|
||||
assert_eq!(bounds.max, [14, 70, 17]);
|
||||
assert_eq!(
|
||||
block.start_corner([10, 64, 10], 2, Rotation::Counterclockwise90),
|
||||
[12, 67, 15]
|
||||
);
|
||||
assert_eq!(Status::by_index(7), Status::Cleared);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,7 @@ pub fn slider(
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
dragging: bool,
|
||||
) {
|
||||
let kind = crate::widgets::Kind::Slider {
|
||||
value,
|
||||
@@ -401,17 +402,8 @@ pub fn slider(
|
||||
// A controller's D-pad can focus it (Horizon's controller support).
|
||||
crate::widgets::target_as([x, y, w, 20.0], kind);
|
||||
}
|
||||
let track = if hover && active {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let (track, handle) = slider_sprites(hover, active, dragging);
|
||||
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
|
||||
let handle = if hover && active {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
};
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -429,6 +421,21 @@ pub fn slider(
|
||||
};
|
||||
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
|
||||
}
|
||||
/// Track and handle sprites: the track outline shows only while the slider is
|
||||
/// dragged, hovering highlights just the handle.
|
||||
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
|
||||
let track = if dragging {
|
||||
"gui/sprites/widget/slider_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider"
|
||||
};
|
||||
let handle = if hover && active {
|
||||
"gui/sprites/widget/slider_handle_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/slider_handle"
|
||||
};
|
||||
(track, handle)
|
||||
}
|
||||
/// Slider value for a click at `cx` (AbstractSliderButton.setValueFromMouse).
|
||||
pub fn slider_value(x: f32, w: f32, cx: f32) -> f32 {
|
||||
((cx - (x + 4.0)) / (w - 8.0)).clamp(0.0, 1.0)
|
||||
@@ -921,7 +928,7 @@ pub fn multiline_word_position(value: &str, dir: isize, from: usize) -> usize {
|
||||
}
|
||||
/// `Character.isWhitespace`: Unicode spaces and line breaks but not the no-break spaces, plus
|
||||
/// the ASCII separators U+001C to U+001F.
|
||||
fn java_whitespace(c: char) -> bool {
|
||||
pub(crate) fn java_whitespace(c: char) -> bool {
|
||||
(c.is_whitespace() && !matches!(c, '\u{85}' | '\u{a0}' | '\u{2007}' | '\u{202f}'))
|
||||
|| ('\u{1c}'..='\u{1f}').contains(&c)
|
||||
}
|
||||
@@ -1344,6 +1351,18 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
|
||||
let (track, handle) = slider_sprites(true, true, true);
|
||||
assert_eq!(track, "gui/sprites/widget/slider_highlighted");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted");
|
||||
let (track, handle) = slider_sprites(false, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
assert_eq!(handle, "gui/sprites/widget/slider_handle");
|
||||
}
|
||||
#[test]
|
||||
fn separator_tiles_sample_both_rows_and_clip_the_last_column() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
tile(
|
||||
|
||||
@@ -93,6 +93,10 @@ pub struct World {
|
||||
/// Every block position written since the log was last drained, kept
|
||||
/// only while the world is open to the network (see `lan`).
|
||||
pub block_log: Option<Vec<(i32, i32, i32)>>,
|
||||
/// Every write since the mobs last ticked (position, old and new state),
|
||||
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
|
||||
/// the mobs of this world tick (`game/creatures.rs`).
|
||||
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
|
||||
/// The sections whose light changed since the log was last drained, as
|
||||
/// a bit per section of each column, kept while `block_log` is
|
||||
/// (`ChunkHolder.sectionLightChanged`).
|
||||
@@ -195,6 +199,7 @@ impl World {
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
path_writes: None,
|
||||
light_log: None,
|
||||
moving_piston_fallback: Vec::new(),
|
||||
moving_pistons: Vec::new(),
|
||||
@@ -262,6 +267,14 @@ impl World {
|
||||
pub fn layout(&self) -> ColumnLayout {
|
||||
self.shape.column()
|
||||
}
|
||||
/// The lowest block Y the level's chunks hold.
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
}
|
||||
/// Resident storage write; marks the touched sections (and neighbours on
|
||||
/// section borders) for remeshing and the surrounding chunks for relighting.
|
||||
/// Neighbour updates, drops and block entities belong to the gameplay layer.
|
||||
@@ -285,6 +298,9 @@ impl World {
|
||||
if let Some(log) = &mut self.block_log {
|
||||
log.push((x, y, z));
|
||||
}
|
||||
if let Some(writes) = &mut self.path_writes {
|
||||
writes.push(((x, y, z), previous, state.0));
|
||||
}
|
||||
self.modified.insert((x >> 4, z >> 4));
|
||||
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
|
||||
// the sections it can reach: this section, the one above/below
|
||||
|
||||
@@ -22,6 +22,8 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
@@ -65,7 +67,8 @@ unicode-bidi = "0.3.18"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
|
||||
@@ -12,6 +12,7 @@ use horizon_client::{
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
crafting::{Recipe, Recipes},
|
||||
enchant::Enchantments,
|
||||
items::{ItemStack, Items},
|
||||
mod_content::{self, ContentMod},
|
||||
stations::StationRecipes,
|
||||
@@ -81,6 +82,7 @@ fn main() -> Result<()> {
|
||||
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
|
||||
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
|
||||
let enchantments = Enchantments::load(&root);
|
||||
let collisions =
|
||||
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
|
||||
let loaded = began.elapsed().as_secs_f64() - built;
|
||||
@@ -198,7 +200,35 @@ fn main() -> Result<()> {
|
||||
item_rows.insert(
|
||||
name.clone(),
|
||||
match items.items.get(name.as_str()) {
|
||||
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
|
||||
Some(i) => {
|
||||
let props = enchantments.props(name);
|
||||
let repairable: Vec<String> = props
|
||||
.into_iter()
|
||||
.flat_map(|p| &p.repairable)
|
||||
.flat_map(|r| match r.strip_prefix('#') {
|
||||
Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(),
|
||||
None => vec![r.clone()],
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"max_stack_size": i.max_stack,
|
||||
"block": i.block.map(|b| b.0),
|
||||
"max_damage": i.max_damage,
|
||||
"modifiers": i.modifiers.iter().map(|(attribute, id, amount, operation, slot)| {
|
||||
json!({"type": attribute, "id": id, "amount": amount, "operation": operation, "slot": slot})
|
||||
}).collect::<Vec<_>>(),
|
||||
"tool": i.tool_damage_per_block.map(|per_block| json!({
|
||||
"rules": i.tool.iter().map(|r| json!({"blocks": r.blocks, "speed": r.speed, "correct_for_drops": r.correct})).collect::<Vec<_>>(),
|
||||
"default_mining_speed": i.tool_default_speed,
|
||||
"damage_per_block": per_block,
|
||||
})),
|
||||
"enchantable": props.map_or(0, |p| p.enchantable),
|
||||
"repairable": repairable,
|
||||
"equip_slot": i.equip_slot,
|
||||
"equip_asset": i.equip_asset,
|
||||
"food": i.food.map(|(nutrition, saturation)| json!({"nutrition": nutrition, "saturation": saturation})),
|
||||
})
|
||||
}
|
||||
None => Value::Null,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,6 +153,9 @@ pub fn main() -> Result<()> {
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)),
|
||||
ArrowEvent::PotionPuff { id, color } => {
|
||||
events.push(format!("potion-puff {id} {color:08x}"))
|
||||
}
|
||||
ArrowEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! The creative inventory, for tools/test_creative_parity.py: loads an
|
||||
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
|
||||
//! and prints the tabs it shows (canonical stacks) and the search tab's
|
||||
//! results for every query of the vanilla oracle's output.
|
||||
//! results for every query of the vanilla oracle's output; with `permissions`,
|
||||
//! as an operator with the Operator Items Tab option on.
|
||||
//!
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
|
||||
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
|
||||
use horizon_client::{
|
||||
creative::{Creative, Names},
|
||||
item_data::canon_stack,
|
||||
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
|
||||
let usage =
|
||||
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
|
||||
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
|
||||
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
|
||||
args.get(2).ok_or(usage)?,
|
||||
)?)?)?;
|
||||
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
|
||||
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
|
||||
let tabs: Vec<Value> = creative
|
||||
.tabs
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
//!
|
||||
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
|
||||
use horizon_client::{
|
||||
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
|
||||
assets::Assets,
|
||||
blocks::BlockFacts,
|
||||
items::Items,
|
||||
mobs::{
|
||||
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
|
||||
},
|
||||
random_sequences::RandomSequences,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
@@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
|
||||
let types = MobTypes::load(root)?;
|
||||
let loot = LootData::load(root, &PackOverlay::NONE)?;
|
||||
// Block loot for an enderman's carried block (BlockState.getDrops).
|
||||
let assets = Assets::load(root)?;
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(root, registry)?;
|
||||
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
|
||||
// The stand-in level's random (RandomSource.create(0)), never reset.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
let mut out = String::new();
|
||||
for raw in input.lines() {
|
||||
if raw.trim().is_empty() || raw.starts_with('#') {
|
||||
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
|
||||
}
|
||||
m.extra.sheared = get("sheared", "0") == "1";
|
||||
if let Some(block) = c.get("carried") {
|
||||
m.extra.carried = Some(
|
||||
registry
|
||||
.default_state(block)
|
||||
.ok_or_else(|| format!("unknown block {block}"))?,
|
||||
);
|
||||
}
|
||||
if get("leader", "0") == "1" {
|
||||
raider::state_mut(&mut m).patrol_leader = true;
|
||||
}
|
||||
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
for d in items {
|
||||
let _ = match d {
|
||||
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
|
||||
drops::Drop::CarriedBlock(state) => {
|
||||
let stacks = enderman::carried_block_drops(
|
||||
&block_items,
|
||||
registry,
|
||||
state,
|
||||
&mut level_random,
|
||||
Some((&mut sequences, world_seed)),
|
||||
);
|
||||
for s in stacks {
|
||||
let _ = write!(out, "drop {} {}", s.item, s.count);
|
||||
if s.damage != 0 {
|
||||
let _ = write!(out, " damage={}", s.damage);
|
||||
}
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ mod recipe_book_fixture;
|
||||
mod recipe_book_render_fixture;
|
||||
mod recipe_book_view_fixture;
|
||||
mod recipe_fixture;
|
||||
mod remote_attack_probe;
|
||||
mod scene_collision_probe;
|
||||
mod scene_motion_probe;
|
||||
mod scoreboard_fixture;
|
||||
@@ -99,6 +100,7 @@ mod toggle_keys_probe;
|
||||
mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
mod translucency_fixture;
|
||||
mod trident_probe;
|
||||
mod weather_fixture;
|
||||
mod world_border_render_fixture;
|
||||
mod world_options_fixture;
|
||||
@@ -170,6 +172,7 @@ probes! {
|
||||
recipe_book_render_fixture,
|
||||
recipe_book_view_fixture,
|
||||
recipe_fixture,
|
||||
remote_attack_probe,
|
||||
scene_collision_probe,
|
||||
scene_motion_probe,
|
||||
scoreboard_fixture,
|
||||
@@ -191,6 +194,7 @@ probes! {
|
||||
text_display_fixture,
|
||||
text_display_render_fixture,
|
||||
trading_fixture,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
weather_fixture,
|
||||
world_border_render_fixture,
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
|
||||
raining_at: &|_| false,
|
||||
height_no_leaves: &|_| 0,
|
||||
firefly_bush_sounds: false,
|
||||
creaking_active: false,
|
||||
sky_darken: 0,
|
||||
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
|
||||
model_offset: &|_, _, _| None,
|
||||
|
||||
@@ -25,7 +25,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::LegacyRandom,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
|
||||
@@ -59,6 +59,8 @@ struct Scenario {
|
||||
creative: bool,
|
||||
/// The tick from which the player faces another yaw.
|
||||
turn: Option<(i32, f32)>,
|
||||
/// The player rides a boat that moves this far each tick (x, z).
|
||||
boat: Option<[f64; 2]>,
|
||||
/// Creaking hearts placed when the scenario starts.
|
||||
hearts: Vec<Vec<String>>,
|
||||
/// `ServerLevel.isSpawningMonsters`.
|
||||
@@ -132,6 +134,9 @@ fn parse(text: &str) -> Vec<Scenario> {
|
||||
s.turn = Some((tick.parse().unwrap(), yaw.parse().unwrap()));
|
||||
} else if let Some(item) = opt.strip_prefix("hand=") {
|
||||
s.hand = Some(item.to_owned());
|
||||
} else if let Some(v) = opt.strip_prefix("boat=") {
|
||||
let (x, z) = v.split_once(',').unwrap();
|
||||
s.boat = Some([x.parse().unwrap(), z.parse().unwrap()]);
|
||||
} else {
|
||||
panic!("unknown player option {opt}");
|
||||
}
|
||||
@@ -1042,6 +1047,15 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
}
|
||||
}
|
||||
m.random = LegacyRandom::new(spec.seed);
|
||||
if matches!(
|
||||
m.species(),
|
||||
Species::Cod | Species::Salmon | Species::TropicalFish
|
||||
) {
|
||||
// The oracle draws FollowFlockLeaderGoal's first delay again once seeded.
|
||||
let delay =
|
||||
horizon_client::mobs::aquatic::FollowFlockLeader::next_start_tick(&mut m);
|
||||
m.ai.goals.set_start_delay("FollowFlockLeaderGoal", delay);
|
||||
}
|
||||
if m.species() == Species::Bee {
|
||||
// The oracle draws the constructor's cooldowns again once seeded.
|
||||
horizon_client::mobs::bee::draw_cooldowns(&mut m);
|
||||
@@ -1115,10 +1129,11 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
sleeping: false,
|
||||
sleep_timer: 0,
|
||||
passenger: false,
|
||||
vehicle: None,
|
||||
}
|
||||
});
|
||||
let mut scratch = LegacyRandom::new(0);
|
||||
// The level's random for eating sounds: only goats and sniffers draw
|
||||
// The level random for eating sounds: only goats and sniffers draw
|
||||
// from it, and the scenarios feed neither.
|
||||
let mut level_random = LegacyRandom::new(0);
|
||||
for (i, m) in mobs.iter().enumerate() {
|
||||
@@ -1144,6 +1159,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
player: player.clone(),
|
||||
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
|
||||
others: $snapshot,
|
||||
leash_boats: &[],
|
||||
others_grid: $grid,
|
||||
others_index: None,
|
||||
daylight: (world.day_time.rem_euclid(24000)) < 12000,
|
||||
@@ -1182,6 +1198,30 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
BlockEntity::CreakingHeart(Box::new(h.clone())),
|
||||
);
|
||||
}
|
||||
if let (Some(p), Some([vx, vz]), Some(start)) = (player.as_mut(), s.boat, s.player) {
|
||||
// The boat the player rides, moved by the script (the oracle's does not tick).
|
||||
let at = [
|
||||
start[0] + vx * f64::from(t),
|
||||
start[1],
|
||||
start[2] + vz * f64::from(t),
|
||||
];
|
||||
p.position = Vec3::new(at[0], at[1], at[2]);
|
||||
p.bounds = Aabb::new(
|
||||
Vec3::new(at[0] - 0.3, at[1], at[2] - 0.3),
|
||||
Vec3::new(at[0] + 0.3, at[1] + 1.8, at[2] + 0.3),
|
||||
);
|
||||
p.eye = [at[0], at[1] + f64::from(1.62_f32), at[2]];
|
||||
p.passenger = true;
|
||||
p.riding = true;
|
||||
p.vehicle = Some(horizon_client::mobs::Vehicle {
|
||||
nautilus: false,
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(at[0] - 0.6875, at[1], at[2] - 0.6875),
|
||||
Vec3::new(at[0] + 0.6875, at[1] + 0.5625, at[2] + 0.6875),
|
||||
),
|
||||
velocity: Vec3::new(vx, 0.0, vz),
|
||||
});
|
||||
}
|
||||
if let (Some(p), Some((tick, yaw))) = (player.as_mut(), s.turn)
|
||||
&& t >= tick
|
||||
{
|
||||
@@ -1478,6 +1518,24 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
})
|
||||
.collect();
|
||||
for (at, open) in doors {
|
||||
// DoorBlock.setOpen: the sound (a wooden door's), its pitch from the level's
|
||||
// random, when the door was not already so.
|
||||
let was = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.is_some_and(|s| s.properties.get("open").map(String::as_str) == Some("true"));
|
||||
if was != open {
|
||||
let pitch = scratch.next_float() * 0.1 + 0.9;
|
||||
events.push(MobEvent::Sound(
|
||||
if open {
|
||||
"block.wooden_door.open"
|
||||
} else {
|
||||
"block.wooden_door.close"
|
||||
},
|
||||
[0.0; 3],
|
||||
1.0,
|
||||
pitch,
|
||||
));
|
||||
}
|
||||
let Some(half) = registry
|
||||
.state(world.block(at[0], at[1], at[2]))
|
||||
.and_then(|s| s.properties.get("half").cloned())
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The attacking client's side of a hit, as `Game::remote_player_attack` decides it, against
|
||||
//! VanillaRemoteAttackOracle's real `Player.attack` (tools/test_remote_attack_parity.py).
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result,
|
||||
attack_strength::{ATTACKER_SLOWDOWN, AttackStrength, attacker_slows_after_hit},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
fn double(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().skip(1).collect();
|
||||
let mut out = BufWriter::new(File::create(&args[1])?);
|
||||
for line in BufReader::new(File::open(&args[0])?).lines() {
|
||||
let c: Value = serde_json::from_str(&line?)?;
|
||||
let mut attack = AttackStrength::default();
|
||||
attack.ticker = c["ticker"].as_i64().unwrap() as i32;
|
||||
let strength = attack.scale(0.5, double(&c["speed"]));
|
||||
let damage = match c["spin"].as_f64() {
|
||||
Some(spin) => spin as f32,
|
||||
None => double(&c["damage"]) as f32,
|
||||
};
|
||||
let mut sprinting = c["sprint"].as_bool().unwrap();
|
||||
let velocity: Vec<f64> = c["vel"].as_array().unwrap().iter().map(double).collect();
|
||||
let (mut x, y, mut z) = (velocity[0], velocity[1], velocity[2]);
|
||||
// `level_players` keeps a mob out of remote_player_attack: Entity.hurtClient is false.
|
||||
if c["target"] == "player"
|
||||
&& attacker_slows_after_hit(damage, double(&c["knockback"]) as f32, strength, sprinting)
|
||||
{
|
||||
x *= ATTACKER_SLOWDOWN;
|
||||
z *= ATTACKER_SLOWDOWN;
|
||||
sprinting = false;
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"{:08x} {:016x} {:016x} {:016x} {}",
|
||||
strength.to_bits(),
|
||||
x.to_bits(),
|
||||
y.to_bits(),
|
||||
z.to_bits(),
|
||||
sprinting
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
|
||||
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
|
||||
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
|
||||
#![forbid(unsafe_code)]
|
||||
use horizon_client::{
|
||||
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufRead, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
fn number(v: &Value) -> f64 {
|
||||
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
|
||||
}
|
||||
fn vector(v: &Value) -> Vec3 {
|
||||
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
|
||||
}
|
||||
fn bits(v: Vec3) -> String {
|
||||
format!(
|
||||
"{:016x},{:016x},{:016x}",
|
||||
v.x.to_bits(),
|
||||
v.y.to_bits(),
|
||||
v.z.to_bits()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<_> = crate::args().skip(1).collect();
|
||||
if args.len() != 3 {
|
||||
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
|
||||
}
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let dir = PathBuf::from(&args[1]);
|
||||
let assets = Assets::load(&root)?;
|
||||
let render = assets.reload(&root)?;
|
||||
let mut scene = World::test_scene(&assets, &render, 1)?;
|
||||
fs::write(
|
||||
dir.join("scene.json"),
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
|
||||
)?,
|
||||
)?;
|
||||
if args[2] == "--describe" {
|
||||
return Ok(());
|
||||
}
|
||||
let binding = collisions::load(&root, assets.registry)?;
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let side = &args[2];
|
||||
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
|
||||
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
|
||||
let f: Value = serde_json::from_str(&line?)?;
|
||||
let mut position = vector(&f["position"]);
|
||||
let mut velocity = vector(&f["velocity"]);
|
||||
let mut in_ground = false;
|
||||
let mut shake = 0u32;
|
||||
for op in f["ops"].as_array().unwrap() {
|
||||
match op["mode"].as_str().unwrap() {
|
||||
"block" => {
|
||||
let p = &op["position"];
|
||||
let mut state = assets
|
||||
.registry
|
||||
.default_state(op["name"].as_str().unwrap())
|
||||
.ok_or("missing fixture block")?;
|
||||
if let Some(properties) = op["properties"].as_object() {
|
||||
let mut definition = assets.registry.state(state).unwrap().clone();
|
||||
for (name, value) in properties {
|
||||
definition
|
||||
.properties
|
||||
.insert(name.clone(), value.as_str().unwrap().into());
|
||||
}
|
||||
state = assets
|
||||
.registry
|
||||
.id(&definition)
|
||||
.ok_or("missing fixture state")?;
|
||||
}
|
||||
scene.set(
|
||||
p[0].as_i64().unwrap() as i32,
|
||||
p[1].as_i64().unwrap() as i32,
|
||||
p[2].as_i64().unwrap() as i32,
|
||||
state,
|
||||
)?;
|
||||
}
|
||||
// Ops only the vanilla oracle needs.
|
||||
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
|
||||
"tick" => {
|
||||
// AbstractArrow.tick: shakeTime down, then the block step.
|
||||
shake = shake.saturating_sub(1);
|
||||
if in_ground {
|
||||
continue;
|
||||
}
|
||||
if let Some((stop, _)) =
|
||||
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
|
||||
{
|
||||
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
|
||||
position = stop;
|
||||
velocity = Vec3::ZERO;
|
||||
in_ground = true;
|
||||
shake = 7;
|
||||
} else {
|
||||
// A MISS moves the whole movement, then air drag and gravity.
|
||||
position = Vec3::new(
|
||||
position.x + velocity.x,
|
||||
position.y + velocity.y,
|
||||
position.z + velocity.z,
|
||||
);
|
||||
velocity = Vec3::new(
|
||||
velocity.x * f64::from(0.99f32),
|
||||
velocity.y * f64::from(0.99f32) - 0.05,
|
||||
velocity.z * f64::from(0.99f32),
|
||||
);
|
||||
}
|
||||
}
|
||||
other => return Err(format!("unknown operation {other}").into()),
|
||||
}
|
||||
}
|
||||
out.write_all(
|
||||
format!(
|
||||
"trident {} {} {in_ground} {shake}\n",
|
||||
bits(position),
|
||||
bits(velocity)
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -479,6 +479,8 @@ pub struct EntityView {
|
||||
/// mainhand, offhand, head, chest, legs, feet, body.
|
||||
pub equipment: [Option<ItemView>; 7],
|
||||
pub vehicle: Option<Box<EntityView>>,
|
||||
/// `Entity.getPassengers`, for `PassengerPredicate`.
|
||||
pub passengers: Vec<EntityView>,
|
||||
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
|
||||
pub looking_at: Option<Box<EntityView>>,
|
||||
pub effects: Vec<(String, i32, i32)>,
|
||||
@@ -763,6 +765,11 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
.vehicle
|
||||
.as_ref()
|
||||
.is_some_and(|veh| entity(p, veh, w, origin)),
|
||||
// PassengerPredicate.matches: any passenger matches the nested predicate.
|
||||
"minecraft:passenger" | "passenger" => e
|
||||
.passengers
|
||||
.iter()
|
||||
.any(|passenger| entity(p, passenger, w, origin)),
|
||||
"minecraft:effects" | "effects" => p.as_object().is_some_and(|m| {
|
||||
m.iter().all(|(id, want)| {
|
||||
e.effects.iter().any(|(eid, amp, dur)| {
|
||||
@@ -1401,6 +1408,24 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// `PassengerPredicate.matches`: ride_a_boat_with_a_goat's vehicle holds a goat.
|
||||
#[test]
|
||||
fn passenger_predicate_needs_a_matching_passenger() {
|
||||
let want = serde_json::json!({
|
||||
"minecraft:entity_type": "minecraft:oak_boat",
|
||||
"minecraft:passenger": {"minecraft:entity_type": "minecraft:goat"}
|
||||
});
|
||||
let entity = |kind: &str| EntityView {
|
||||
kind: kind.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut boat = entity("minecraft:oak_boat");
|
||||
assert!(!super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
boat.passengers = vec![entity("minecraft:player")];
|
||||
assert!(!super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
boat.passengers.push(entity("minecraft:goat"));
|
||||
assert!(super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
}
|
||||
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
|
||||
/// projectile, kills it for the advancement; a sword or an arrow does not.
|
||||
#[test]
|
||||
|
||||
@@ -101,6 +101,8 @@ enum Animate {
|
||||
DeadBush,
|
||||
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
|
||||
DryGrass,
|
||||
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
|
||||
CreakingHeart,
|
||||
}
|
||||
fn step(facing: &str) -> [i32; 3] {
|
||||
match facing {
|
||||
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
|
||||
"firefly_bush" => Animate::FireflyBush,
|
||||
"open_eyeblossom" => Animate::OpenEyeblossom,
|
||||
"pale_hanging_moss" => Animate::PaleHangingMoss,
|
||||
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
|
||||
Animate::CreakingHeart
|
||||
}
|
||||
"dead_bush" => Animate::DeadBush,
|
||||
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
|
||||
"sculk_sensor" | "calibrated_sculk_sensor"
|
||||
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
|
||||
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
|
||||
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
|
||||
pub firefly_bush_sounds: bool,
|
||||
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
|
||||
pub creaking_active: bool,
|
||||
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
|
||||
pub sky_darken: i32,
|
||||
/// Creative main-hand marker block and a resolver for each placed state's sprite.
|
||||
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
|
||||
}
|
||||
}
|
||||
}
|
||||
Animate::CreakingHeart => {
|
||||
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
|
||||
// the attribute is on, then needs pale oak logs on all six sides
|
||||
// (`CreakingHeartBlock.isSurroundedByLogs`).
|
||||
if ctx.creaking_active
|
||||
&& next_int(engine.random(), 16) == 0
|
||||
&& [
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
]
|
||||
.iter()
|
||||
.all(|d| {
|
||||
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
|
||||
tables
|
||||
.pale_oak_logs
|
||||
.get(ctx.state(p).0 as usize)
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
sound(
|
||||
engine,
|
||||
"minecraft:block.creaking_heart.idle",
|
||||
[fx, fy, fz],
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
Animate::DeadBush => {
|
||||
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
|
||||
if next_int(engine.random(), 130) == 0 {
|
||||
|
||||
@@ -94,6 +94,13 @@ pub enum ArrowEvent {
|
||||
position: Vec3,
|
||||
velocity: Vec3,
|
||||
},
|
||||
/// `Level.broadcastEntityEvent(arrow, 0)`: a tipped arrow stuck for 600
|
||||
/// ticks. The clients burst its particles (`Arrow.handleEntityEvent`);
|
||||
/// the arrow itself is a plain one from now on.
|
||||
PotionPuff {
|
||||
id: u32,
|
||||
color: u32,
|
||||
},
|
||||
Sound {
|
||||
sound: ArrowSound,
|
||||
volume: f32,
|
||||
@@ -186,6 +193,8 @@ pub struct ArrowState {
|
||||
/// The arrow's own random (`Entity.random`).
|
||||
pub random: LegacyRandom,
|
||||
pub events: Vec<ArrowEvent>,
|
||||
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
|
||||
pub hit_speed: f64,
|
||||
}
|
||||
impl fmt::Debug for ArrowState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
@@ -230,8 +239,15 @@ impl ArrowState {
|
||||
removed: false,
|
||||
random,
|
||||
events: Vec::new(),
|
||||
hit_speed: 0.0,
|
||||
}
|
||||
}
|
||||
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
|
||||
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
|
||||
pub fn potion_color(&self) -> Option<u32> {
|
||||
let potion = self.pickup_item.potion()?;
|
||||
Some(crate::brewing::potion_color(Some(potion)))
|
||||
}
|
||||
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
|
||||
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
|
||||
/// makes the arrow CREATIVE_ONLY; vanilla takes the component off the
|
||||
@@ -375,6 +391,61 @@ impl ArrowScene<'_, '_> {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
|
||||
/// `level().clip(new ClipContext(position(), position().add(movement),
|
||||
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
|
||||
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
|
||||
/// the position to stop at (pull-back included) and the block that was hit.
|
||||
pub fn clip_block_stop(
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
) -> Option<(Vec3, BlockPos)> {
|
||||
let to = Vec3::new(
|
||||
from.x + movement.x,
|
||||
from.y + movement.y,
|
||||
from.z + movement.z,
|
||||
);
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
|
||||
let state = world.block(p.x, p.y, p.z);
|
||||
if facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(from, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some((back_off(hit.location, movement), hit.block))
|
||||
}
|
||||
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
|
||||
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
|
||||
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
|
||||
fn signum(v: f64) -> f64 {
|
||||
if v > 0.0 {
|
||||
1.0
|
||||
} else if v < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
let offset = f64::from(0.05f32);
|
||||
Vec3::new(
|
||||
location.x - signum(movement.x) * offset,
|
||||
location.y - signum(movement.y) * offset,
|
||||
location.z - signum(movement.z) * offset,
|
||||
)
|
||||
}
|
||||
fn bounds(at: Vec3) -> Aabb {
|
||||
// EntityDimensions.makeBoundingBox: half the float width.
|
||||
let w = f64::from(WIDTH / 2.0);
|
||||
@@ -576,6 +647,19 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
let at = self.position();
|
||||
self.apply_effects_from_blocks(at, at);
|
||||
}
|
||||
// Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends
|
||||
// entity event 0 (the clients' burst of particles in its colour,
|
||||
// `handleEntityEvent`) and turns into a plain arrow. The flight
|
||||
// and ground particles are the clients' own (`Level.isClientSide`).
|
||||
if self.st.in_ground_time >= 600
|
||||
&& let Some(color) = self.st.potion_color()
|
||||
{
|
||||
self.st.events.push(ArrowEvent::PotionPuff {
|
||||
id: self.p.id,
|
||||
color,
|
||||
});
|
||||
self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1);
|
||||
}
|
||||
return Hit::None;
|
||||
}
|
||||
self.st.in_ground_time = 0;
|
||||
@@ -664,7 +748,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(crate::chunk::MIN_Y - 64) {
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
@@ -848,6 +932,8 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// it moved on, stop, and stick.
|
||||
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
|
||||
self.st.last_state = Some(state);
|
||||
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
|
||||
self.st.hit_speed = self.p.velocity.length();
|
||||
// Projectile.onHitBlock: BlockState.onProjectileHit.
|
||||
if self.st.on_fire()
|
||||
&& let Some(reaction) = self.block_reaction(state)
|
||||
@@ -1159,10 +1245,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
|
||||
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
}
|
||||
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
|
||||
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
|
||||
let layout = self.scene.world.layout();
|
||||
Ok(FluidSections {
|
||||
minimum_section_y: crate::chunk::MIN_Y >> 4,
|
||||
has_fluid: &SECTIONS,
|
||||
minimum_section_y: layout.min_y() >> 4,
|
||||
has_fluid: crate::chunk::every_section(layout),
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
|
||||
@@ -68,6 +68,42 @@ impl AttackStrength {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.attack`'s extra knockback: `getKnockback` (`weapon`, halved: the
|
||||
/// ATTACK_KNOCKBACK value, plus on a server the weapon's knockback enchantment)
|
||||
/// and a sprinting full-strength hit (0.5), and whether the hit is that
|
||||
/// sprinting knockback attack.
|
||||
pub fn extra_knockback(weapon: f32, strength: f32, sprinting: bool) -> (f32, bool) {
|
||||
let knockback_attack = strength > 0.9 && sprinting;
|
||||
let bonus = if knockback_attack { 0.5 } else { 0.0 };
|
||||
(weapon / 2.0 + bonus, knockback_attack)
|
||||
}
|
||||
|
||||
/// `causeExtraKnockback`'s cut of the attacker's horizontal speed.
|
||||
pub const ATTACKER_SLOWDOWN: f64 = 0.6;
|
||||
|
||||
/// What the attacking client's own `Player.attack` does to itself on a hit
|
||||
/// at another player, whose `RemotePlayer.hurtClient` lets the hit through:
|
||||
/// whether `causeExtraKnockback` cuts its horizontal speed and stops its
|
||||
/// sprint. This runs on the client's player, not the server's: held items and
|
||||
/// effects only change attributes on a server and neither of these two is
|
||||
/// synced, so `attack_damage` and `knockback` are the client's own ATTACK_DAMAGE
|
||||
/// (or a riptide spin's damage) and ATTACK_KNOCKBACK, whatever the weapon or
|
||||
/// Weakness. Enchantments don't count either: only `ServerPlayer` overrides
|
||||
/// `getEnchantedDamage`, and `getKnockback` asks for a `ServerLevel`.
|
||||
/// `strength` is `getAttackStrengthScale(0.5)`.
|
||||
pub fn attacker_slows_after_hit(
|
||||
attack_damage: f32,
|
||||
knockback: f32,
|
||||
strength: f32,
|
||||
sprinting: bool,
|
||||
) -> bool {
|
||||
let base = attack_damage * (0.2 + strength * strength * 0.8);
|
||||
let (knockback, _) = extra_knockback(knockback, strength, sprinting);
|
||||
// Player.attack stops before the hurt and the knockback when no damage is
|
||||
// left (`magicBoost` is 0 here).
|
||||
base > 0.0 && knockback > 0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,4 +143,55 @@ mod tests {
|
||||
s.reset_attack();
|
||||
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
|
||||
}
|
||||
/// A client's own ATTACK_DAMAGE: Player's base value, as no item or effect
|
||||
/// changes it there.
|
||||
const CLIENT_DAMAGE: f32 = 1.0;
|
||||
#[test]
|
||||
fn a_charged_sprint_hit_slows_the_attacker() {
|
||||
assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, true));
|
||||
assert_eq!(extra_knockback(0.0, 1.0, true), (0.5, true));
|
||||
}
|
||||
#[test]
|
||||
fn an_uncharged_sprint_hit_does_not() {
|
||||
assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.5, true));
|
||||
assert!(
|
||||
!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.9, true),
|
||||
"the bonus needs more than 0.9"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_hit_without_sprinting_or_knockback_does_not() {
|
||||
assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, false));
|
||||
}
|
||||
#[test]
|
||||
fn a_synced_knockback_attribute_slows_even_uncharged() {
|
||||
assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 1.0, 0.2, false));
|
||||
}
|
||||
#[test]
|
||||
fn a_hit_that_does_no_damage_stops_first() {
|
||||
// Only a server that sends ATTACK_DAMAGE can leave a client without any.
|
||||
assert!(!attacker_slows_after_hit(0.0, 0.0, 1.0, true));
|
||||
assert!(!attacker_slows_after_hit(0.0, 1.0, 1.0, true));
|
||||
}
|
||||
#[test]
|
||||
fn a_server_attack_speed_charges_at_once() {
|
||||
// 1.8-style servers raise ATTACK_SPEED: with 1024 the delay is under
|
||||
// a tick, so getAttackStrengthScale(0.5) is full right after a hit.
|
||||
let mut s = AttackStrength::default();
|
||||
s.reset_attack();
|
||||
assert_eq!(s.scale(0.5, 1024.0), 1.0);
|
||||
assert!(attacker_slows_after_hit(
|
||||
CLIENT_DAMAGE,
|
||||
0.0,
|
||||
s.scale(0.5, 1024.0),
|
||||
true
|
||||
));
|
||||
// A sword's own 1.6 leaves it uncharged.
|
||||
assert!(!attacker_slows_after_hit(
|
||||
CLIENT_DAMAGE,
|
||||
0.0,
|
||||
s.scale(0.5, 1.6),
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `IntegratedServer.reportChunkLoadFailure` / `reportChunkSaveFailure` /
|
||||
//! `sendLowDiskSpaceWarning`: the region storage runs on the server thread and
|
||||
//! its writer thread, and the toasts are the window's. Each failure is logged,
|
||||
//! checked for low disk space (`MinecraftServer.warnOnLowDiskSpace`) and queued
|
||||
//! here; the window drains the queue every frame (`Minecraft.execute`).
|
||||
//! `SystemToast.addOrUpdate` makes a repeat reset the toast it already shows,
|
||||
//! so only the newest chunk of each kind is kept.
|
||||
use crate::{lang, save, toasts::SystemToast};
|
||||
use std::{path::Path, sync::Mutex};
|
||||
|
||||
/// What the window has still to toast.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Pending {
|
||||
/// `SystemToast.onLowDiskSpace`.
|
||||
pub low_disk_space: bool,
|
||||
/// `SystemToast.onChunkLoadFailure`: the newest chunk.
|
||||
pub load_failure: Option<(i32, i32)>,
|
||||
/// `SystemToast.onChunkSaveFailure`: the newest chunk.
|
||||
pub save_failure: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
static PENDING: Mutex<Pending> = Mutex::new(Pending {
|
||||
low_disk_space: false,
|
||||
load_failure: None,
|
||||
save_failure: None,
|
||||
});
|
||||
|
||||
fn with(update: impl FnOnce(&mut Pending)) {
|
||||
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
update(&mut pending);
|
||||
}
|
||||
|
||||
/// `MinecraftServer.warnOnLowDiskSpace` for the world folder `dir`.
|
||||
fn warn_on_low_disk_space(dir: &Path) {
|
||||
if save::low_disk_space(dir) {
|
||||
// `MinecraftServer.sendLowDiskSpaceWarning`.
|
||||
log::warn!("Low disk space! Might not be able to save the world.");
|
||||
with(|p| p.low_disk_space = true);
|
||||
}
|
||||
}
|
||||
|
||||
/// `reportChunkLoadFailure` for the chunk at (`x`, `z`) of the dimension in `dir`.
|
||||
pub fn report_load_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to load chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.load_failure = Some((x, z)));
|
||||
}
|
||||
|
||||
/// `reportChunkSaveFailure`.
|
||||
pub fn report_save_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to save chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.save_failure = Some((x, z)));
|
||||
}
|
||||
|
||||
/// `Minecraft.sendLowDiskSpaceWarning`: a server's `ClientboundLowDiskSpaceWarningPacket`
|
||||
/// (`ClientPacketListener.handleLowDiskSpaceWarning`) toasts like the local report.
|
||||
pub fn report_low_disk_space() {
|
||||
with(|p| p.low_disk_space = true);
|
||||
}
|
||||
|
||||
/// The reports since the last call.
|
||||
pub fn take() -> Pending {
|
||||
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::mem::take(&mut *pending)
|
||||
}
|
||||
|
||||
/// `ChatFormatting.RED`.
|
||||
fn red() -> u32 {
|
||||
crate::hud::rgba(255, 85, 85, 255)
|
||||
}
|
||||
|
||||
/// `SystemToast.onLowDiskSpace` (`SystemToastId.LOW_DISK_SPACE`: ten seconds).
|
||||
pub fn low_disk_space_toast() -> SystemToast {
|
||||
SystemToast::new(
|
||||
lang::tr("chunk.toast.lowDiskSpace", &[]),
|
||||
Some(lang::tr("chunk.toast.lowDiskSpace.description", &[])),
|
||||
)
|
||||
.with_display_time(SystemToast::LONG_DISPLAY_TIME)
|
||||
}
|
||||
|
||||
fn chunk_toast(key: &str, x: i32, z: i32) -> SystemToast {
|
||||
// `Component.translationArg(ChunkPos)` is `ChunkPos.toString`.
|
||||
let pos = format!("[{x}, {z}]");
|
||||
SystemToast::new(
|
||||
lang::tr(key, &[&pos]),
|
||||
Some(lang::tr("chunk.toast.checkLog", &[])),
|
||||
)
|
||||
.with_title_color(red())
|
||||
}
|
||||
|
||||
/// `SystemToast.onChunkLoadFailure`.
|
||||
pub fn load_failure_toast(x: i32, z: i32) -> SystemToast {
|
||||
chunk_toast("chunk.toast.loadFailure", x, z)
|
||||
}
|
||||
|
||||
/// `SystemToast.onChunkSaveFailure`.
|
||||
pub fn save_failure_toast(x: i32, z: i32) -> SystemToast {
|
||||
chunk_toast("chunk.toast.saveFailure", x, z)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failures_keep_the_newest_chunk_of_each_kind() {
|
||||
let _ = take();
|
||||
let missing = Path::new("/nonexistent-horizon-world");
|
||||
report_load_failure(missing, 1, 2, &"a");
|
||||
report_load_failure(missing, 3, 4, &"b");
|
||||
report_save_failure(missing, 5, 6, &"c");
|
||||
// A folder that cannot be asked has no low-space warning.
|
||||
assert_eq!(
|
||||
take(),
|
||||
Pending {
|
||||
low_disk_space: false,
|
||||
load_failure: Some((3, 4)),
|
||||
save_failure: Some((5, 6)),
|
||||
}
|
||||
);
|
||||
assert_eq!(take(), Pending::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_servers_warning_packet_toasts_low_disk_space() {
|
||||
let _ = take();
|
||||
report_low_disk_space();
|
||||
assert!(take().low_disk_space);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_disk_space_toast_stays_ten_seconds() {
|
||||
let toast = low_disk_space_toast();
|
||||
assert_eq!(toast.display_time(), 10000.0);
|
||||
assert_eq!(load_failure_toast(0, 0).display_time(), 5000.0);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,31 @@ pub struct Tab {
|
||||
/// tab is selected (`set_saved_hotbars`).
|
||||
pub hotbar: bool,
|
||||
}
|
||||
impl Tab {
|
||||
/// The tab without its stacks.
|
||||
fn header(&self) -> Self {
|
||||
Self {
|
||||
id: self.id.clone(),
|
||||
title: self.title.clone(),
|
||||
icon: self.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: self.top,
|
||||
column: self.column,
|
||||
search: self.search,
|
||||
inventory: self.inventory,
|
||||
hotbar: self.hotbar,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A tab as exported, built with permissions: `operator` marks the stacks a
|
||||
/// build without them leaves out (the export's `permissions_only`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Exported {
|
||||
tab: Tab,
|
||||
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
|
||||
category: bool,
|
||||
operator: Vec<bool>,
|
||||
}
|
||||
/// What the client's creative search trees (SessionSearchTrees) index for one
|
||||
/// stack of the search tab: its creative tooltip lines and its item tags.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -37,6 +62,8 @@ struct SearchEntry {
|
||||
tags: Vec<(String, String)>,
|
||||
}
|
||||
pub struct Creative {
|
||||
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
|
||||
/// permissions put in them.
|
||||
pub tabs: Vec<Tab>,
|
||||
pub selected: usize,
|
||||
/// Scroll position 0..1 (scrollOffs).
|
||||
@@ -48,6 +75,13 @@ pub struct Creative {
|
||||
/// The search tab's stacks, in its display order.
|
||||
search_items: Vec<ItemStack>,
|
||||
search_index: Vec<SearchEntry>,
|
||||
/// Every exported tab, and the search tab's stacks with their entries and
|
||||
/// whether only permissions list them: `rebuild` keeps what is shown.
|
||||
exported: Vec<Exported>,
|
||||
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
|
||||
/// The `hasPermissions` the tabs were last built with
|
||||
/// (`CreativeModeTabs.tryRebuildTabContents`).
|
||||
permissions: bool,
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
@@ -202,14 +236,22 @@ impl Creative {
|
||||
}
|
||||
pub fn from_json(raw: &Value) -> Result<Self> {
|
||||
let mut tabs = Vec::new();
|
||||
let mut search_items = Vec::new();
|
||||
let mut search_index = Vec::new();
|
||||
let mut search = Vec::new();
|
||||
for t in raw["tabs"].as_array().into_iter().flatten() {
|
||||
// The stacks only a build with permissions lists, by index.
|
||||
let only: Vec<u64> = t["permissions_only"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.collect();
|
||||
let mut items = Vec::new();
|
||||
for v in t["items"].as_array().into_iter().flatten() {
|
||||
let mut operator = Vec::new();
|
||||
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
|
||||
let snbt = v.as_str().unwrap_or("-");
|
||||
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
|
||||
items.push(stack);
|
||||
operator.push(only.contains(&(i as u64)));
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
@@ -223,34 +265,35 @@ impl Creative {
|
||||
};
|
||||
for (i, stack) in items.iter().enumerate() {
|
||||
let (namespace, path) = split_id(&java_lower(&stack.item));
|
||||
search_index.push(SearchEntry {
|
||||
let entry = SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
lines: strings(&t["lines"][i]),
|
||||
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
|
||||
});
|
||||
};
|
||||
search.push((stack.clone(), entry, operator[i]));
|
||||
}
|
||||
search_items = items.clone();
|
||||
}
|
||||
if t["visible"] == false {
|
||||
continue;
|
||||
}
|
||||
let icon = t["icon"]
|
||||
.as_str()
|
||||
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
tabs.push(Exported {
|
||||
tab: Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: kind == "HOTBAR",
|
||||
},
|
||||
category: kind == "CATEGORY",
|
||||
operator,
|
||||
});
|
||||
}
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
Ok(Self::new(tabs, search))
|
||||
}
|
||||
/// The old export: item ids per tab, no components, tooltips or tags.
|
||||
fn from_legacy(raw: &Value) -> Result<Self> {
|
||||
@@ -272,34 +315,39 @@ impl Creative {
|
||||
}
|
||||
}
|
||||
let kind = t["type"].as_str().unwrap_or("");
|
||||
tabs.push(Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
tabs.push(Exported {
|
||||
operator: vec![false; items.len()],
|
||||
tab: Tab {
|
||||
id: t["id"].as_str().unwrap_or("").to_owned(),
|
||||
title: t["title"].as_str().unwrap_or("").to_owned(),
|
||||
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
|
||||
items,
|
||||
top: t["row"] == "TOP",
|
||||
column: t["column"].as_u64().unwrap_or(0) as u32,
|
||||
search: kind == "SEARCH",
|
||||
inventory: kind == "INVENTORY",
|
||||
hotbar: false,
|
||||
},
|
||||
category: false,
|
||||
});
|
||||
}
|
||||
let search_index = search_items
|
||||
.iter()
|
||||
let search = search_items
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let (namespace, path) = split_id(&java_lower(&s.item));
|
||||
SearchEntry {
|
||||
let entry = SearchEntry {
|
||||
namespace,
|
||||
path,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
(s, entry, false)
|
||||
})
|
||||
.collect();
|
||||
Ok(Self::new(tabs, search_items, search_index))
|
||||
Ok(Self::new(tabs, search))
|
||||
}
|
||||
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
|
||||
Self {
|
||||
tabs,
|
||||
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
|
||||
let mut creative = Self {
|
||||
tabs: Vec::new(),
|
||||
selected: 0,
|
||||
scroll: 0.0,
|
||||
scrolling: false,
|
||||
@@ -310,10 +358,81 @@ impl Creative {
|
||||
search.text_color = u32::MAX;
|
||||
search
|
||||
},
|
||||
search_items,
|
||||
search_index,
|
||||
search_items: Vec::new(),
|
||||
search_index: Vec::new(),
|
||||
exported,
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
}
|
||||
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
|
||||
/// tab's display items generator lists its stacks only with them
|
||||
/// (`hasPermissions`), and the search tab with it; a category tab is
|
||||
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
|
||||
fn rebuild(&mut self) {
|
||||
let permissions = self.permissions;
|
||||
let keep = |operator: bool| permissions || !operator;
|
||||
self.tabs = self
|
||||
.exported
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
|
||||
.filter(|(_, o)| keep(**o))
|
||||
.map(|(s, _)| s.clone())
|
||||
.collect();
|
||||
(!e.category || !items.is_empty()).then(|| Tab {
|
||||
items,
|
||||
..e.tab.header()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
.filter(|(_, _, o)| keep(*o))
|
||||
.map(|(s, e, _)| (s.clone(), e.clone()))
|
||||
.unzip();
|
||||
self.forget_items();
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
|
||||
/// constructor and `containerTick` run with `hasPermissions`
|
||||
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
|
||||
/// when that changed, the tabs are built again. A selected tab left
|
||||
/// without stacks (the operator tab) gives way to the default tab; any
|
||||
/// other keeps its first row (`refreshCurrentTabContents`). True when the
|
||||
/// tabs were rebuilt.
|
||||
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
|
||||
if permissions == self.permissions {
|
||||
return false;
|
||||
}
|
||||
let selected = self.tab().id.clone();
|
||||
let row = self.first_row(names);
|
||||
self.permissions = permissions;
|
||||
self.rebuild();
|
||||
match self.tabs.iter().position(|t| t.id == selected) {
|
||||
Some(tab) => {
|
||||
self.selected = tab;
|
||||
// ItemPickerMenu.getScrollForRowIndex.
|
||||
let hidden = self.hidden_rows(names);
|
||||
self.scroll = if hidden > 0 {
|
||||
(row as f32 / hidden as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
None => {
|
||||
// CreativeModeTabs.getDefaultTab.
|
||||
let default = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|t| t.id == "minecraft:building_blocks");
|
||||
self.select(default.unwrap_or(0));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Where a stack comes in the creative tabs (the search tab lists them
|
||||
/// all, in tab order): the same stack's place, else its item's first.
|
||||
@@ -327,20 +446,7 @@ impl Creative {
|
||||
}
|
||||
/// The tabs without their stacks: what the screen draws of them each frame.
|
||||
pub fn tab_headers(&self) -> Vec<Tab> {
|
||||
self.tabs
|
||||
.iter()
|
||||
.map(|t| Tab {
|
||||
id: t.id.clone(),
|
||||
title: t.title.clone(),
|
||||
icon: t.icon.clone(),
|
||||
items: Vec::new(),
|
||||
top: t.top,
|
||||
column: t.column,
|
||||
search: t.search,
|
||||
inventory: t.inventory,
|
||||
hotbar: t.hotbar,
|
||||
})
|
||||
.collect()
|
||||
self.tabs.iter().map(Tab::header).collect()
|
||||
}
|
||||
pub fn tab(&self) -> &Tab {
|
||||
&self.tabs[self.selected.min(self.tabs.len() - 1)]
|
||||
@@ -601,7 +707,7 @@ impl Creative {
|
||||
pub fn tab_position(tab: &Tab) -> (f32, f32) {
|
||||
let mut x = tab.column as f32 * 27.0;
|
||||
if tab.column >= 5 {
|
||||
// Right-aligned tabs (search, inventory).
|
||||
// Right-aligned tabs (search, operator, inventory).
|
||||
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
|
||||
}
|
||||
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
|
||||
@@ -1083,6 +1189,84 @@ mod tests {
|
||||
assert_eq!(c.search.value, "");
|
||||
}
|
||||
|
||||
/// An export built with permissions: the operator tab's stacks, and their
|
||||
/// place at the end of the search tab, only with them.
|
||||
fn with_operator_tab() -> Creative {
|
||||
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
|
||||
let raw = serde_json::json!({"schema": 3, "tabs": [
|
||||
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
|
||||
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
|
||||
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
|
||||
"icon": stack("command_block"), "visible": false,
|
||||
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
|
||||
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
|
||||
"icon": stack("compass"), "visible": true,
|
||||
"items": [stack("stone"), stack("command_block"), stack("barrier")],
|
||||
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
|
||||
"permissions_only": [1, 2]},
|
||||
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
|
||||
"icon": stack("chest"), "visible": true, "items": []}
|
||||
]});
|
||||
Creative::from_json(&raw).unwrap()
|
||||
}
|
||||
|
||||
fn tab_ids(c: &Creative) -> Vec<&str> {
|
||||
c.tabs.iter().map(|t| t.id.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_stacks_stay_out_without_permissions() {
|
||||
let c = with_operator_tab();
|
||||
let names = Names::default();
|
||||
assert_eq!(
|
||||
tab_ids(&c),
|
||||
[
|
||||
"minecraft:building_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
assert_eq!(c.search_items().len(), 1);
|
||||
assert!(c.search_results("command", &names).is_empty());
|
||||
assert_eq!(c.search_results("", &names), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_tab_comes_and_goes_with_permissions() {
|
||||
let mut c = with_operator_tab();
|
||||
let names = Names::default();
|
||||
assert!(!c.set_permissions(false, &names));
|
||||
assert!(c.set_permissions(true, &names));
|
||||
assert!(!c.set_permissions(true, &names));
|
||||
assert_eq!(
|
||||
tab_ids(&c),
|
||||
[
|
||||
"minecraft:building_blocks",
|
||||
"minecraft:op_blocks",
|
||||
"minecraft:search",
|
||||
"minecraft:inventory"
|
||||
]
|
||||
);
|
||||
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
|
||||
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
|
||||
assert_eq!(c.search_results("command", &names), [1]);
|
||||
assert_eq!(c.search_results("", &names), [0, 1, 2]);
|
||||
// The search tab keeps its place across the rebuild.
|
||||
c.select(2);
|
||||
c.search.set_value("block");
|
||||
assert!(c.set_permissions(false, &names));
|
||||
assert!(c.tab().search);
|
||||
assert_eq!(c.search.value, "block");
|
||||
assert!(c.items(&names).is_empty());
|
||||
// The selected operator tab is left empty: the default tab instead.
|
||||
c.set_permissions(true, &names);
|
||||
c.select(1);
|
||||
assert_eq!(c.items(&names).len(), 2);
|
||||
c.set_permissions(false, &names);
|
||||
assert_eq!(c.tab().id, "minecraft:building_blocks");
|
||||
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
|
||||
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
|
||||
|
||||
@@ -158,6 +158,18 @@ fn item(
|
||||
}
|
||||
}
|
||||
|
||||
/// `TextInputHandler.addControl`: the 20 high `EditBox`, or the
|
||||
/// `MultiLineEditBox` of the options' height, by default as tall as their most
|
||||
/// lines (4 without a limit) need, at most 512.
|
||||
pub fn text_height(multiline: Option<(Option<i32>, Option<i32>)>) -> f32 {
|
||||
match multiline {
|
||||
None => 20.0,
|
||||
Some((max_lines, height)) => {
|
||||
height.unwrap_or_else(|| (9 * max_lines.unwrap_or(4) + 8).min(512)) as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `InputControlHandlers`: the control for input `i`.
|
||||
fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
match control {
|
||||
@@ -165,16 +177,18 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
width,
|
||||
label,
|
||||
label_visible,
|
||||
multiline,
|
||||
..
|
||||
} => {
|
||||
let w = *width as f32;
|
||||
let h = text_height(*multiline);
|
||||
if !label_visible {
|
||||
return Element {
|
||||
w,
|
||||
h: 20.0,
|
||||
h,
|
||||
parts: vec![Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 0.0, w, 20.0],
|
||||
rect: [0.0, 0.0, w, h],
|
||||
}],
|
||||
};
|
||||
}
|
||||
@@ -187,12 +201,12 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
|
||||
}
|
||||
Element {
|
||||
w: w.max(font.width_text(label)),
|
||||
h: 9.0 + 4.0 + 20.0,
|
||||
h: 9.0 + 4.0 + h,
|
||||
parts: vec![
|
||||
Part::Line(shown, 0.0, 0.0),
|
||||
Part::Field {
|
||||
input: i,
|
||||
rect: [0.0, 13.0, w, 20.0],
|
||||
rect: [0.0, 13.0, w, h],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -439,6 +453,17 @@ mod tests {
|
||||
assert_eq!(one.w, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multiline_input_is_as_tall_as_its_lines_or_its_height() {
|
||||
assert_eq!(text_height(None), 20.0);
|
||||
// No options: four lines and the padding.
|
||||
assert_eq!(text_height(Some((None, None))), 44.0);
|
||||
assert_eq!(text_height(Some((Some(10), None))), 98.0);
|
||||
// Never over 512; a height wins over the lines.
|
||||
assert_eq!(text_height(Some((Some(100), None))), 512.0);
|
||||
assert_eq!(text_height(Some((Some(2), Some(70)))), 70.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_waiting_screens_button_counts_down() {
|
||||
assert!(!waiting_button(0).1);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use crate::projectiles::{Projectile, ProjectileKind};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
@@ -65,6 +66,35 @@ pub fn swept_gateway(
|
||||
found
|
||||
}
|
||||
|
||||
/// The End gateway a thrown ender pearl passed through on its last move, from
|
||||
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
|
||||
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
|
||||
/// are not looked at.
|
||||
pub fn pearl_gateway(
|
||||
pearl: &Projectile,
|
||||
is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
|
||||
return None;
|
||||
}
|
||||
let size = pearl.kind.size() as f32;
|
||||
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
|
||||
}
|
||||
|
||||
/// A thrown ender pearl at its gateway's exit `at`
|
||||
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
|
||||
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
|
||||
/// It falls from there and takes its owner where it lands.
|
||||
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
|
||||
pearl.position = at;
|
||||
pearl.previous = at;
|
||||
pearl.velocity = Vec3::ZERO;
|
||||
pearl.yaw = 0.0;
|
||||
pearl.pitch = 0.0;
|
||||
// A server tells its clients of the jump at once.
|
||||
pearl.needs_sync = true;
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
@@ -150,6 +180,75 @@ mod tests {
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
/// A world with no blocks in the way and nothing to hit: the gateway
|
||||
/// block has no collision shape, so a projectile flies into it.
|
||||
struct Open;
|
||||
impl crate::projectiles::ProjectileWorld for Open {
|
||||
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
|
||||
None
|
||||
}
|
||||
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
|
||||
false
|
||||
}
|
||||
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// The ticks a projectile flies, thrown east at the gateway, until its
|
||||
/// move goes through one (`None` if it never does in 5 ticks).
|
||||
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
|
||||
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
|
||||
shot.velocity = at(1.5, 0.0, 0.0);
|
||||
for _ in 0..5 {
|
||||
shot.tick(&Open);
|
||||
if let Some(g) = pearl_gateway(&shot, is_gateway) {
|
||||
return (shot, Some(g));
|
||||
}
|
||||
}
|
||||
(shot, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
|
||||
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
|
||||
// takes it in, and it goes through on the next tick.
|
||||
assert_eq!(found, Some(gateway));
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// getPortalDestination: at the exit, no motion and rotation 0.
|
||||
let exit = bottom_center([8, 100, 0]);
|
||||
place_pearl(&mut pearl, exit);
|
||||
assert_eq!(pearl.position, exit);
|
||||
assert_eq!(pearl.previous, exit);
|
||||
assert_eq!(pearl.velocity, Vec3::ZERO);
|
||||
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
|
||||
// It falls straight down from the exit (ThrowableProjectile gravity,
|
||||
// then inertia), where it lands and takes its owner along.
|
||||
pearl.tick(&Open);
|
||||
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
|
||||
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
|
||||
// The pearl is out of the gateway: it does not go back at once.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
|
||||
// Thrown one block higher, over the gateway block.
|
||||
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
|
||||
pearl.velocity = at(1.5, 0.0, 0.0);
|
||||
for _ in 0..5 {
|
||||
pearl.tick(&Open);
|
||||
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
|
||||
}
|
||||
// Only the pearl travels: a snowball on the same path is not taken in.
|
||||
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
|
||||
Loaded 100 of 592 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user