mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
1456
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
976e3f3858 | ||
|
|
af55a73486 | ||
|
|
125875b844 | ||
|
|
68ac6f647e | ||
|
|
57aa58c8f9 | ||
|
|
d9c733419c | ||
|
|
c50f97ec8f | ||
|
|
27cb8f96bf | ||
|
|
c7dcaf2b7d | ||
|
|
bd541458cd | ||
|
|
01bbb4c654 | ||
|
|
38a97ea64e | ||
|
|
2abd1ac013 | ||
|
|
36f4bc00d6 | ||
|
|
3c68c05d0a | ||
|
|
7f99b2e8cc | ||
|
|
c556f5b5b8 | ||
|
|
b546ffb877 | ||
|
|
a6f30aa763 | ||
|
|
bcbf367fc6 | ||
|
|
65d4d06ec5 | ||
|
|
d7c6e36cb9 | ||
|
|
a672a51ebd | ||
|
|
13fd2ee724 | ||
|
|
c3905131bf | ||
|
|
d7d0ead29d | ||
|
|
13ce6bcc64 | ||
|
|
807a818395 | ||
|
|
51c9565411 | ||
|
|
71d7921132 | ||
|
|
9d979caa48 | ||
|
|
09a4cd9baf | ||
|
|
c18ed2db67 | ||
|
|
c41a8d0d96 | ||
|
|
fc680ec318 | ||
|
|
c535285302 | ||
|
|
763528025d | ||
|
|
5bd13dad80 | ||
|
|
03fc2d3f2d | ||
|
|
1dfea0b84e | ||
|
|
eae1e21bd0 | ||
|
|
97b2e10df5 | ||
|
|
5bda60aa5d | ||
|
|
3bffd9b52e | ||
|
|
a648bef38c | ||
|
|
e3b213f8f9 | ||
|
|
530a1253e6 | ||
|
|
ae45f040b7 | ||
|
|
3d1b94f97c | ||
|
|
57bee439bb | ||
|
|
367d519e3c | ||
|
|
13aecbe1e5 | ||
|
|
7aa0e28fae | ||
|
|
202e37981f | ||
|
|
6ee48d0e47 | ||
|
|
836ea22364 | ||
|
|
877d0dac10 | ||
|
|
94b3b4aca5 | ||
|
|
73958d8397 | ||
|
|
37ad1f5c9e | ||
|
|
73db376775 | ||
|
|
84f633b56b | ||
|
|
a3c5fe138e | ||
|
|
4db1495bfb | ||
|
|
ed5565713e | ||
|
|
90994feaca | ||
|
|
f2a99d6238 | ||
|
|
e776e8e94a | ||
|
|
3d0512446d | ||
|
|
a595a30164 | ||
|
|
dffc94645e | ||
|
|
3edd56c5e4 | ||
|
|
b526d836a0 | ||
|
|
0b82e9f872 | ||
|
|
3f722b80a8 | ||
|
|
f554e16b02 | ||
|
|
92415234d9 | ||
|
|
f96b795bf6 | ||
|
|
2d6d006d39 | ||
|
|
6eca577874 | ||
|
|
c559d125b5 | ||
|
|
819cd2eebc | ||
|
|
1e62ad7914 | ||
|
|
57d8a70a2c | ||
|
|
0fb65197a7 | ||
|
|
d4dff9534c | ||
|
|
a73db6ad95 | ||
|
|
6718c7dd9a | ||
|
|
d331af9962 | ||
|
|
389a918c16 | ||
|
|
bdebd8103c | ||
|
|
5e7b9a5075 | ||
|
|
dd94337c2e | ||
|
|
7f969cb3a5 | ||
|
|
0c5074bba1 | ||
|
|
fcaaed088b | ||
|
|
4de90a13d4 | ||
|
|
cbda897a5e | ||
|
|
86a40451e8 | ||
|
|
017afee3a1 | ||
|
|
b2f0b58fc9 | ||
|
|
7f884ad64b | ||
|
|
568159fa1e | ||
|
|
6bbc5cb3b0 | ||
|
|
6e263d79f6 | ||
|
|
2959076c75 | ||
|
|
ff5d14d4c2 | ||
|
|
c39db2247c | ||
|
|
e3cba0ac6f | ||
|
|
e91d4e3f01 | ||
|
|
eac39ece47 | ||
|
|
b251abc575 | ||
|
|
09e315cb24 | ||
|
|
48709c8091 | ||
|
|
f8b5fed2f0 | ||
|
|
4141a127fb | ||
|
|
1ba05096b8 | ||
|
|
19021623be | ||
|
|
d27ec41434 | ||
|
|
5993df106f | ||
|
|
81b30898d7 | ||
|
|
4fc225edb9 | ||
|
|
28754451ea | ||
|
|
142424b58f | ||
|
|
1ad4d8633b | ||
|
|
600058493e | ||
|
|
fdd6f507ec | ||
|
|
7e170c6a64 | ||
|
|
0291f63727 | ||
|
|
27c38eb27b | ||
|
|
2ed80412b1 | ||
|
|
a49b9a6b88 | ||
|
|
53e565516f | ||
|
|
9a4eb4add8 | ||
|
|
c58859aedf | ||
|
|
0d327cc093 | ||
|
|
cffc83bb8b | ||
|
|
f253ba22d4 | ||
|
|
78858b1d42 | ||
|
|
cb59fcd162 | ||
|
|
eda9860e1d | ||
|
|
5b492b240f | ||
|
|
0a28a10e93 | ||
|
|
9e06cafb83 | ||
|
|
7110152122 | ||
|
|
6c78d82c5d | ||
|
|
a82a28179d | ||
|
|
44d3e49db8 | ||
|
|
a3ba9256a4 | ||
|
|
a00475b910 | ||
|
|
e5c713c00b | ||
|
|
8b1758dc73 | ||
|
|
623e7b03dc | ||
|
|
a2bc79274b | ||
|
|
8d9d4d94e3 | ||
|
|
809dd639e2 | ||
|
|
970376b6c5 | ||
|
|
ffb3d2d1ea | ||
|
|
0d1e3dfd41 | ||
|
|
e5dde40e72 | ||
|
|
ff5db37b0e | ||
|
|
37fd66562f | ||
|
|
07042859e1 | ||
|
|
b499251187 | ||
|
|
6197116a9e | ||
|
|
ad531e6374 | ||
|
|
dfb6d1f0fb | ||
|
|
9f2a4433ec | ||
|
|
7c9efae60a | ||
|
|
c829600ac6 | ||
|
|
f4088409ee | ||
|
|
3063237a06 | ||
|
|
e75d4bc1b6 | ||
|
|
ceca6301a6 | ||
|
|
1b62d8d1f2 | ||
|
|
52e186749d | ||
|
|
ba40840da7 | ||
|
|
c97b85dc0b | ||
|
|
fd4a625f7e | ||
|
|
bceac87629 | ||
|
|
e005da2cc0 | ||
|
|
406648ebe2 | ||
|
|
477ae00887 | ||
|
|
3c481aa243 | ||
|
|
ddd00efcbb | ||
|
|
c6cf721a91 | ||
|
|
85c0d5fe3c | ||
|
|
048dafddad | ||
|
|
d33d377090 | ||
|
|
f651954913 | ||
|
|
8f91f896a5 | ||
|
|
27888dc18d | ||
|
|
16fd80bdcf | ||
|
|
b4da90182f | ||
|
|
87a9d03fd8 | ||
|
|
43e29d4556 | ||
|
|
480f6b3d1b | ||
|
|
d4c60e0ac4 | ||
|
|
4af27b50a8 | ||
|
|
0b83386c7e | ||
|
|
a10b26b9ef | ||
|
|
8acefbec58 | ||
|
|
da2acdad41 | ||
|
|
d020b5f93c | ||
|
|
1641b3ecc0 | ||
|
|
e8b180b01e | ||
|
|
c84b15d426 | ||
|
|
23f917e309 | ||
|
|
8ba17d98d9 | ||
|
|
e51c33efdd | ||
|
|
388bdfe072 | ||
|
|
e2b0b817d7 | ||
|
|
678f0ac6f4 | ||
|
|
de76c32fe3 | ||
|
|
af2df073d7 | ||
|
|
b7fef4337b | ||
|
|
465a8fda1e | ||
|
|
69ba6c5645 | ||
|
|
61883f815e | ||
|
|
a25e830b28 | ||
|
|
8bb6e0dc11 | ||
|
|
4bbb1d3b42 | ||
|
|
3dc5b432fe | ||
|
|
b043f3977b | ||
|
|
cb8feeeff9 | ||
|
|
d454cd53fb | ||
|
|
e5089d3fe4 | ||
|
|
dd68ab987a | ||
|
|
3bd2b8b820 | ||
|
|
d46cb2ca35 | ||
|
|
e3a24beec6 | ||
|
|
587b85d675 | ||
|
|
deff92036a | ||
|
|
230d0d5911 | ||
|
|
fcd9f1b401 | ||
|
|
68f554e7dc | ||
|
|
ee637b2567 | ||
|
|
cc6bfe2bc3 | ||
|
|
57b2aa470b | ||
|
|
c8ad6a8840 | ||
|
|
57064f15d0 | ||
|
|
21f460c630 | ||
|
|
d05e95409d | ||
|
|
f4852b0660 | ||
|
|
9304a5387e | ||
|
|
6b25a75238 | ||
|
|
36e376591a | ||
|
|
926875ace4 | ||
|
|
1b4ef1de92 | ||
|
|
91e1f8a8cd | ||
|
|
575c9b20df | ||
|
|
e65680f925 | ||
|
|
e24e645156 | ||
|
|
d5ff486d31 | ||
|
|
b477ff2689 | ||
|
|
8a65204b3a | ||
|
|
8115a3a4b1 | ||
|
|
d26c8fba0a | ||
|
|
65bc3d80b5 | ||
|
|
5c5107b72d | ||
|
|
0a6d8aa90a | ||
|
|
497e13808f | ||
|
|
90ca4e0491 | ||
|
|
23d5461fe0 | ||
|
|
65688ae0f0 | ||
|
|
8b718c5d5e | ||
|
|
c5d7bae5fe | ||
|
|
b296f7e2ea | ||
|
|
0f4db0c569 | ||
|
|
f541e240fb | ||
|
|
0ba58d9229 | ||
|
|
450d8c35dc | ||
|
|
e280d96cf2 | ||
|
|
241684332d | ||
|
|
59ec871592 | ||
|
|
b4a85e32e3 | ||
|
|
cbf9b8d5ab | ||
|
|
00643bca10 | ||
|
|
7ac8e3a30a | ||
|
|
1fdcf6b385 | ||
|
|
6d669b0a59 | ||
|
|
93b9775439 | ||
|
|
c723e0894c | ||
|
|
6f1e0a2463 | ||
|
|
08191e2f40 | ||
|
|
439e0ee259 | ||
|
|
a18e7d5d1f | ||
|
|
ec27a1e0b0 | ||
|
|
1d4b04ceb3 | ||
|
|
07c8bdbc0b | ||
|
|
adfda4bd26 | ||
|
|
502a3026aa | ||
|
|
8c7779e17f | ||
|
|
9d9fe2298e | ||
|
|
717b49cbc9 | ||
|
|
3839d80076 | ||
|
|
26752debe1 | ||
|
|
7dd10acb86 | ||
|
|
2a8ae891ae | ||
|
|
65d68266e2 | ||
|
|
c418e970be | ||
|
|
8930bb3faf | ||
|
|
d01ff3b8a4 | ||
|
|
cd9867da5c | ||
|
|
1ee73d821d | ||
|
|
7680fee325 | ||
|
|
860dbf51e1 | ||
|
|
3ef3272ee2 | ||
|
|
9cc33bdda8 | ||
|
|
2d621405c6 | ||
|
|
a0b0a5ba50 | ||
|
|
5fbb18a139 | ||
|
|
1fdd195790 | ||
|
|
95fe89ca0d | ||
|
|
cfb61d623a | ||
|
|
711f099ac8 | ||
|
|
4f43f49cf7 | ||
|
|
ad7b45d3b6 | ||
|
|
35fbfb00dd | ||
|
|
9ca4d10841 | ||
|
|
9e2c0dfde2 | ||
|
|
7b52aa1f85 | ||
|
|
05a7663aab | ||
|
|
2f82051fb4 | ||
|
|
f1b2d7b86f | ||
|
|
223a97787d | ||
|
|
e5e27a7aac | ||
|
|
a65989354d | ||
|
|
83c79c4d4f | ||
|
|
5b372e2f35 | ||
|
|
e3eb634eba | ||
|
|
c00e5801aa | ||
|
|
b1f7c78d54 | ||
|
|
f9676a54ce | ||
|
|
b9f5a45c62 | ||
|
|
b357010174 | ||
|
|
9b611a200b | ||
|
|
2fc88ea632 | ||
|
|
10a4ec4e2d | ||
|
|
5ddb4828b5 | ||
|
|
9bb2c8818d | ||
|
|
40f8bd80da | ||
|
|
e44f25cae1 | ||
|
|
65d2b5e8fe | ||
|
|
c620da0f19 | ||
|
|
e1093d6800 | ||
|
|
148b33f41d | ||
|
|
b80768cd4a | ||
|
|
c8a8bf357b | ||
|
|
d914ceeb53 | ||
|
|
b213e5346b | ||
|
|
2509598c13 | ||
|
|
f0d20beff8 | ||
|
|
934136e813 | ||
|
|
b2bf03c749 | ||
|
|
22b8af021c | ||
|
|
111081b1bc | ||
|
|
1d383e73b3 | ||
|
|
bb674debd6 | ||
|
|
32b24a9e8e | ||
|
|
ee837e88be | ||
|
|
470fb25a89 | ||
|
|
40fee24b86 | ||
|
|
29c318b541 | ||
|
|
a01203c3b2 | ||
|
|
a8f57b1997 | ||
|
|
94e2666542 | ||
|
|
2541cba005 | ||
|
|
9c54d6b294 | ||
|
|
6eac540995 | ||
|
|
93892c4049 | ||
|
|
067b5877d2 | ||
|
|
52890d2f8f | ||
|
|
6521dffcee | ||
|
|
cbc12f96bf | ||
|
|
b1c5484243 | ||
|
|
59dc4a6f14 | ||
|
|
f1ed2f36e6 | ||
|
|
1028b8f19c | ||
|
|
c832b61617 | ||
|
|
4cc2631bc0 | ||
|
|
4e8bdad871 | ||
|
|
f77d6da3bd | ||
|
|
46a3761a41 | ||
|
|
8b4aa65bbd | ||
|
|
8676990b18 | ||
|
|
6f899414a7 | ||
|
|
e3bcd0fdba | ||
|
|
a52f904ff5 | ||
|
|
f4d91f2fbb | ||
|
|
cf8cab121c | ||
|
|
7bcd15e22d | ||
|
|
c17233b793 | ||
|
|
c644282b7b | ||
|
|
704dff73bd | ||
|
|
2fb0d5b493 | ||
|
|
b8511b4888 | ||
|
|
88fd421269 | ||
|
|
ce1500dbbb | ||
|
|
3811132bd2 | ||
|
|
4c6d49a02f | ||
|
|
4f17e42f93 | ||
|
|
ddd3d4d0e3 | ||
|
|
12e5483917 | ||
|
|
d7ff063035 | ||
|
|
c597f06d65 | ||
|
|
c904d2fa1c | ||
|
|
094a083540 | ||
|
|
5c188c619b | ||
|
|
cce03e9367 | ||
|
|
c08119ea7a | ||
|
|
088390b4fe | ||
|
|
4f5c8307e8 | ||
|
|
b3516f66e2 | ||
|
|
db83571428 | ||
|
|
c58580ead2 | ||
|
|
1aae74b8b1 | ||
|
|
270431ec55 | ||
|
|
6c9ab657fd | ||
|
|
4851a7ab4d | ||
|
|
7570dcff24 | ||
|
|
9e2440253f | ||
|
|
ee4108fdaf | ||
|
|
5d41303466 | ||
|
|
0e515dbfd3 | ||
|
|
5747b52d0c | ||
|
|
bc105393f0 | ||
|
|
5fe97cd6d1 | ||
|
|
e1c4d5dc88 | ||
|
|
49b4dbec3d | ||
|
|
2e1da6db52 | ||
|
|
1f2ebdbd91 | ||
|
|
0820541751 | ||
|
|
cd53a3c0dd | ||
|
|
c39b4ed4b8 | ||
|
|
9fd99d5a4d | ||
|
|
4ecf521195 | ||
|
|
adf54b0c68 | ||
|
|
f554aa5a6c | ||
|
|
9510efd2fe | ||
|
|
747ccd0b5b | ||
|
|
11eecf5a6a | ||
|
|
396ff1e5f9 | ||
|
|
cb93abfe66 | ||
|
|
70ede6a0ef | ||
|
|
7a6dd1ffc9 | ||
|
|
656b7c7c9b | ||
|
|
e0cee924f0 | ||
|
|
061a7dda03 | ||
|
|
baae358894 | ||
|
|
83b29e43ff | ||
|
|
f472da81d1 | ||
|
|
d35bf3ba33 | ||
|
|
6667abcb04 | ||
|
|
9b5e0026f3 | ||
|
|
d9517672ef | ||
|
|
12eb38e131 | ||
|
|
3c1d4b39c6 | ||
|
|
c5abd34fb1 | ||
|
|
434577c913 | ||
|
|
a55abdfe89 | ||
|
|
5a70e0ba0d | ||
|
|
56dcdc8464 | ||
|
|
2a009ad16f | ||
|
|
0b912926a3 | ||
|
|
37b1f605d5 | ||
|
|
d65cd8af19 | ||
|
|
a6cf9d0370 | ||
|
|
68e9da706c | ||
|
|
15dce73eb4 | ||
|
|
f98890b386 | ||
|
|
f2f768bb36 | ||
|
|
dd4f61225b | ||
|
|
1b99614b7b | ||
|
|
2789409634 | ||
|
|
9f4f39addc | ||
|
|
50cd60645b | ||
|
|
d9cbb0f4e6 | ||
|
|
3bfec3857d | ||
|
|
37aa729526 | ||
|
|
1c6992a28c | ||
|
|
b57d440412 | ||
|
|
253b1f8780 | ||
|
|
d671006122 | ||
|
|
bddb7c34be | ||
|
|
23bceb746d | ||
|
|
0c66d4678d | ||
|
|
7e902a6886 | ||
|
|
9ad0a3abf1 | ||
|
|
101280753b | ||
|
|
90e269dc36 | ||
|
|
814a8592da | ||
|
|
d5c4248f62 | ||
|
|
0580d262da | ||
|
|
43f142115b | ||
|
|
d3a5cf3925 | ||
|
|
4bbd8f3e8e | ||
|
|
a71733bfe7 | ||
|
|
79c76034a0 | ||
|
|
1af0acfc37 | ||
|
|
e8897035e4 | ||
|
|
0bfa991471 | ||
|
|
a26ebcb37e | ||
|
|
8fc8f02820 | ||
|
|
d3fd453cfa | ||
|
|
be529ea12d | ||
|
|
3275d82094 | ||
|
|
ed7f8b3998 | ||
|
|
2dcafe3ee3 | ||
|
|
429fda5680 | ||
|
|
fe4b750827 | ||
|
|
a87db94c53 | ||
|
|
f15483df83 | ||
|
|
33d8b6ebeb | ||
|
|
917e58b2da | ||
|
|
c830a170ad | ||
|
|
5eb665b1c3 | ||
|
|
e35be2e5bc | ||
|
|
d72c67d81a | ||
|
|
672b204c0a | ||
|
|
a79436fb97 | ||
|
|
3da2b20cc8 | ||
|
|
4b461a9aae | ||
|
|
9990eabc7f | ||
|
|
2d0c6e984f | ||
|
|
d9e7cae928 | ||
|
|
dc29fd238b | ||
|
|
393a461720 | ||
|
|
973eeadcfd | ||
|
|
a5ff7b1b31 | ||
|
|
f82149a3dd | ||
|
|
0dc793fdb5 | ||
|
|
9054950e06 | ||
|
|
84ebab8a78 | ||
|
|
45f155e801 | ||
|
|
b307359409 | ||
|
|
bcb2087d2f | ||
|
|
424dc918e7 | ||
|
|
ce12bd5b6d | ||
|
|
0d04976fa1 | ||
|
|
303e418b08 | ||
|
|
337692aab3 | ||
|
|
403df829f3 | ||
|
|
8e40d11b27 | ||
|
|
c95194b676 | ||
|
|
e921ddb7c5 | ||
|
|
a4fec395ee | ||
|
|
d8d6dd15aa | ||
|
|
e825d36af8 | ||
|
|
9bab876eb4 | ||
|
|
2f41aedb39 | ||
|
|
ae19796505 | ||
|
|
ec156a856f | ||
|
|
7192a53328 | ||
|
|
d49bcab591 | ||
|
|
230ae976e1 | ||
|
|
5f5acece3f | ||
|
|
51119592e2 | ||
|
|
f4a81d27ee | ||
|
|
1f4dba50e3 | ||
|
|
7a09947e46 | ||
|
|
6034bab012 | ||
|
|
1b06b53500 | ||
|
|
e5f3507585 | ||
|
|
1f3ac390da | ||
|
|
6907fe0a79 | ||
|
|
a12b5151e1 | ||
|
|
65b312814b | ||
|
|
a834bdb7cb | ||
|
|
a01414e035 | ||
|
|
771394b76d | ||
|
|
5f06dbbc16 | ||
|
|
ef7ef9959c | ||
|
|
a1f56c2f2e | ||
|
|
09ecc06126 | ||
|
|
fbb8f4e6d7 | ||
|
|
c3c2ffc43e | ||
|
|
0ec594d9d3 | ||
|
|
98b6a442cf | ||
|
|
96caf0bf88 | ||
|
|
31192110c6 | ||
|
|
00b1e2c12a | ||
|
|
b0cd391031 | ||
|
|
22c09057fc | ||
|
|
7366ff29b5 | ||
|
|
fd92adb803 | ||
|
|
b914de7e69 | ||
|
|
c5d4217dbb | ||
|
|
53aa834866 | ||
|
|
3c5dd6b03b | ||
|
|
c4c4b63aba | ||
|
|
56f9a17682 | ||
|
|
35212f09a5 | ||
|
|
19de197927 | ||
|
|
ce208cac3a | ||
|
|
98ecd84b54 | ||
|
|
99050f7ac2 | ||
|
|
e017850f93 | ||
|
|
0d1dccd5ff | ||
|
|
e73e31c709 | ||
|
|
6307b54dea | ||
|
|
51629f9085 | ||
|
|
b74a78c970 | ||
|
|
890e625fc7 | ||
|
|
e694791bb5 | ||
|
|
65030881fc | ||
|
|
c094313726 | ||
|
|
ceb5791a69 | ||
|
|
703f9ec127 | ||
|
|
cd52b16c8f | ||
|
|
4fa552591a | ||
|
|
f935ff68a5 | ||
|
|
699afa8007 | ||
|
|
dc35e78173 | ||
|
|
49db9b276b | ||
|
|
0b95a7a921 | ||
|
|
d9d8f5e2e8 | ||
|
|
524071f9cf | ||
|
|
5d3a45f09f | ||
|
|
ab92024350 | ||
|
|
5168e4db30 | ||
|
|
5e2e5b4291 | ||
|
|
05d810136c | ||
|
|
b91f0330f4 | ||
|
|
4b8f735c7b | ||
|
|
dc6e548538 | ||
|
|
95f097fd06 | ||
|
|
c6dd5e2205 | ||
|
|
8036e0de7f | ||
|
|
1604f8abee | ||
|
|
f9d0a88d27 | ||
|
|
874c4d1cdb | ||
|
|
c739b7af73 | ||
|
|
177aff1240 | ||
|
|
88a19fab33 | ||
|
|
062d66e495 | ||
|
|
7fb5b6f214 | ||
|
|
aa44f715e6 | ||
|
|
ffe68c936f | ||
|
|
d2fe5c0768 | ||
|
|
0d0e9d4213 | ||
|
|
f788711fe1 | ||
|
|
c6132e2b9b | ||
|
|
4c88da0096 | ||
|
|
6830f201d5 | ||
|
|
e1a530a71e | ||
|
|
b5b451de7c | ||
|
|
1498477aec | ||
|
|
8b23993cfb | ||
|
|
c527d60b75 | ||
|
|
16cb6a1204 | ||
|
|
3c257d870c | ||
|
|
0a825b42a2 | ||
|
|
75867c69b9 | ||
|
|
475d096fe2 | ||
|
|
c0f09fed88 | ||
|
|
04c7cfc1af | ||
|
|
907ae1bbb1 | ||
|
|
70436b6f7d | ||
|
|
ecd85c497f | ||
|
|
fd16400b91 | ||
|
|
8bad582185 | ||
|
|
963b7d8bd9 | ||
|
|
ee7d443c8f | ||
|
|
3554efb04f | ||
|
|
94f48bbf6a | ||
|
|
ada4e6567d | ||
|
|
fb9db82d1b | ||
|
|
8922550fdf | ||
|
|
23f553e285 | ||
|
|
1085b42fc5 | ||
|
|
2e321e385a | ||
|
|
9c471a85ce | ||
|
|
25950c64ad | ||
|
|
31054f520e | ||
|
|
0bb993afd7 | ||
|
|
a62fa7375d | ||
|
|
6f26c1f355 | ||
|
|
2052e55b0b | ||
|
|
8975278e5e | ||
|
|
c4c7df589a | ||
|
|
fb4fcb04ec | ||
|
|
23200373f6 | ||
|
|
b6c585706f | ||
|
|
daa6d3b1ca | ||
|
|
f387c1d78f | ||
|
|
468dd3c24b | ||
|
|
571e0e5341 | ||
|
|
68b90d860b | ||
|
|
53ba582419 | ||
|
|
c995c9b76e | ||
|
|
ab2ab8ef61 | ||
|
|
9e267fbf9e | ||
|
|
618469513b | ||
|
|
0c9b29d20a | ||
|
|
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 | ||
|
|
9b6a9a6b55 | ||
|
|
f4da935e0f | ||
|
|
1698ff41f8 | ||
|
|
7ee905eab2 | ||
|
|
2977badbb8 | ||
|
|
bfeb1e69a8 | ||
|
|
fc4641bc8e | ||
|
|
066eb31ad5 | ||
|
|
563ce7338e | ||
|
|
0d9dcfd247 | ||
|
|
073afd484e | ||
|
|
e10efca1bc | ||
|
|
8ae79aaad4 | ||
|
|
2bc03baa6b | ||
|
|
3f38f88bee | ||
|
|
88e36f9220 | ||
|
|
3a5ac7ffed | ||
|
|
355b5f099e | ||
|
|
0a7ad01ed6 | ||
|
|
f8f1679153 | ||
|
|
be0445848d | ||
|
|
d049c7a175 | ||
|
|
f5c79065e9 | ||
|
|
a14cac7f7c | ||
|
|
08918a8a7f | ||
|
|
34118b285f | ||
|
|
b680d71bdc | ||
|
|
1eedd39c76 | ||
|
|
0c1b92ce82 | ||
|
|
d2839ff264 | ||
|
|
74351c8057 | ||
|
|
8486a32926 | ||
|
|
31300e6846 | ||
|
|
cf6e5c2474 | ||
|
|
0a568f21aa | ||
|
|
deec9041ac | ||
|
|
c822f6eb82 | ||
|
|
e1314bbcc9 | ||
|
|
8d82e986f9 | ||
|
|
d51a2eb268 | ||
|
|
b9f756ebad | ||
|
|
cf38f00b54 | ||
|
|
76ab943b40 | ||
|
|
6f846cf6a5 | ||
|
|
d0fbd3f638 | ||
|
|
a14c9886ce | ||
|
|
07641bb0b2 | ||
|
|
bf29bd9166 | ||
|
|
820913dc4c | ||
|
|
e021395210 | ||
|
|
e311967b29 | ||
|
|
5edf728ee3 | ||
|
|
6e8e088c25 | ||
|
|
24e36962aa | ||
|
|
da4ded1d7c | ||
|
|
669aa04e69 | ||
|
|
c27092a567 | ||
|
|
17b1f8fd19 | ||
|
|
6b3aacf619 | ||
|
|
6c023bdca0 | ||
|
|
f2d9c04794 | ||
|
|
19ca1c8b33 | ||
|
|
83e8e0ba1f | ||
|
|
b781de899d | ||
|
|
af8f2fb7c2 | ||
|
|
04687c99c6 | ||
|
|
b90b7d8aac | ||
|
|
03331b3d52 | ||
|
|
bf4e738618 | ||
|
|
1de9f9fc69 | ||
|
|
2b85acfeaf | ||
|
|
e74907f551 | ||
|
|
85e7517b76 | ||
|
|
faf7d52f20 | ||
|
|
9765a0341d | ||
|
|
21ec14c0b2 | ||
|
|
cb2a7fb1b6 | ||
|
|
7857967863 | ||
|
|
4fabf2025c | ||
|
|
3c580814c8 | ||
|
|
876d0003d5 | ||
|
|
d63d89bb86 | ||
|
|
af5528ef25 | ||
|
|
f4c1010c64 | ||
|
|
72f66bb71c | ||
|
|
f969e62a56 | ||
|
|
edbeffbcf3 | ||
|
|
8a2e5cb55f | ||
|
|
1c7eb12800 | ||
|
|
02c48952b8 | ||
|
|
574c600551 | ||
|
|
f7dc8dd714 | ||
|
|
6fa052b4e0 | ||
|
|
0ddaf69abc | ||
|
|
0e060bb2ac | ||
|
|
754440dd55 | ||
|
|
cd79f2e326 | ||
|
|
aced527885 | ||
|
|
bec89a8927 | ||
|
|
648531c6da | ||
|
|
b4ad459449 | ||
|
|
c08c051a92 | ||
|
|
46c09f1c70 | ||
|
|
ae9a990929 | ||
|
|
3b284f173f | ||
|
|
18a900a360 | ||
|
|
a502eccc92 | ||
|
|
a4a90ad0a1 | ||
|
|
e06a5ced37 | ||
|
|
8dff50318a | ||
|
|
a3726f4bf8 | ||
|
|
d2c4e9ac87 | ||
|
|
d17163d417 | ||
|
|
29b58bb0fc | ||
|
|
c8c02381b5 | ||
|
|
e7fae3d587 | ||
|
|
a83ac6ac1c | ||
|
|
701168f1e5 | ||
|
|
2e52f01d71 | ||
|
|
15a18c3a5e | ||
|
|
c1a5052c43 | ||
|
|
7af000900b | ||
|
|
fd855f8815 | ||
|
|
c1db3bad48 | ||
|
|
f7ce36c8e1 | ||
|
|
4cbfa78bee | ||
|
|
f6154f9ebb | ||
|
|
f5eb5a18f3 | ||
|
|
3fa621d254 | ||
|
|
a9b932e4c2 | ||
|
|
71e6b60d96 | ||
|
|
0c9ac0cfae | ||
|
|
f533c3af7d | ||
|
|
25e03754e2 | ||
|
|
e51783d5bd | ||
|
|
00c9b04674 | ||
|
|
20703366f9 | ||
|
|
ddf1be8050 | ||
|
|
9b47769757 | ||
|
|
d7cb3537ea | ||
|
|
32e8e51cfc | ||
|
|
5f0ac95135 | ||
|
|
ca2955ef0c | ||
|
|
59e71f00ef | ||
|
|
fcf76afc05 | ||
|
|
b12a499a9a | ||
|
|
59526900a0 | ||
|
|
b6e7d3e7c0 | ||
|
|
0d31254815 | ||
|
|
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 | ||
|
|
d848b1e05d | ||
|
|
c727c26612 | ||
|
|
9e6453c457 | ||
|
|
af84c3a547 | ||
|
|
04f73f422a | ||
|
|
7886e3f5d7 | ||
|
|
e8e251df67 | ||
|
|
1fb244e0a5 | ||
|
|
0bc5e0de9c | ||
|
|
6557c0604c | ||
|
|
f3a05ae1a3 | ||
|
|
6f0c7b1441 | ||
|
|
bb1007fae9 | ||
|
|
d5a8d23be6 | ||
|
|
b5dace7edd | ||
|
|
e39901dc6f | ||
|
|
963f465dc6 | ||
|
|
0ada161e69 | ||
|
|
ca5315dc0d | ||
|
|
536fe04942 | ||
|
|
14b5b031d3 | ||
|
|
397e947fac | ||
|
|
db2c7b799d | ||
|
|
bc005e8ac2 | ||
|
|
be75260bc0 | ||
|
|
f5f968df5c | ||
|
|
fd4c3e01e9 | ||
|
|
bd19371acf | ||
|
|
4f3a68c450 | ||
|
|
2e97b2f5f9 | ||
|
|
78c2b92c5e | ||
|
|
fcfe236b92 | ||
|
|
ed6d97cbbb | ||
|
|
d52d37f944 | ||
|
|
8373160c35 | ||
|
|
d39eb5d638 | ||
|
|
91352551d6 | ||
|
|
ddbaa5a64c | ||
|
|
01e9ac5702 | ||
|
|
f429f93420 | ||
|
|
848c0b10d4 | ||
|
|
c96733f3c6 | ||
|
|
5459f6e232 | ||
|
|
e23b4b7d8f | ||
|
|
396f456556 | ||
|
|
52a0865aac | ||
|
|
19eeab4a2a | ||
|
|
714f7a76da | ||
|
|
e0cb05bfce | ||
|
|
6f6650a68f | ||
|
|
ca27b2de97 | ||
|
|
1d4c9fa4db | ||
|
|
864f314abf | ||
|
|
a02d0c4a1a | ||
|
|
971e8a90f0 | ||
|
|
46dfd693f5 | ||
|
|
768d20d998 | ||
|
|
b070f41dda | ||
|
|
8f376a4712 | ||
|
|
5a7cfb443f | ||
|
|
63db88b844 | ||
|
|
076c41fc27 | ||
|
|
b947048ad6 | ||
|
|
3f1a45313e | ||
|
|
9d0473d748 | ||
|
|
f6a757ac2e | ||
|
|
511b974107 | ||
|
|
ca9195fb02 | ||
|
|
c1c646a326 | ||
|
|
fca2169de9 | ||
|
|
aa906ecbe7 | ||
|
|
4cf3a14ba0 | ||
|
|
c505204c24 | ||
|
|
fbac0ae2d9 | ||
|
|
234d5e1cd6 | ||
|
|
eeb1d3773d | ||
|
|
bdc422ad8c | ||
|
|
91c77af9ae | ||
|
|
594b50b510 | ||
|
|
0416fe600c | ||
|
|
69d6254b70 | ||
|
|
fc485bb996 | ||
|
|
9ea981a2f3 | ||
|
|
548fca6308 | ||
|
|
1da354b67a | ||
|
|
d0a7c52171 | ||
|
|
92f69c1fca | ||
|
|
7c6bbc1cfd | ||
|
|
12299b07c9 | ||
|
|
0e780d404f | ||
|
|
4ee9816b82 | ||
|
|
d4740f8621 | ||
|
|
20b0fd4eca | ||
|
|
a78bee4642 | ||
|
|
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 | ||
|
|
65b0155b8e | ||
|
|
702f03a4b9 | ||
|
|
6a066d9076 | ||
|
|
f83505a484 | ||
|
|
9a644f3e72 | ||
|
|
a2d6d33d30 | ||
|
|
1ab895ea01 | ||
|
|
f646dc1e0d | ||
|
|
41d70dc6c5 | ||
|
|
da5895b445 | ||
|
|
7cee64a389 | ||
|
|
1bb3647ed6 | ||
|
|
f47cec589b | ||
|
|
6286de3abd | ||
|
|
17e5640d94 | ||
|
|
c30f519c16 | ||
|
|
11d1936a5e | ||
|
|
865ad0b89d | ||
|
|
93bab2181c | ||
|
|
6e5d821eeb | ||
|
|
bf016d1c54 | ||
|
|
b5ad463d2d | ||
|
|
83f723f666 | ||
|
|
bcdcc9a460 | ||
|
|
6ca4b32d06 | ||
|
|
d8b0934f65 | ||
|
|
a2429b8864 | ||
|
|
06460237d9 | ||
|
|
22263abf91 | ||
|
|
83a84b2637 | ||
|
|
2fa88ac250 | ||
|
|
8682bd5781 | ||
|
|
0da29407d1 | ||
|
|
2e2c11005a | ||
|
|
bf5b9b089e | ||
|
|
6b5428915b | ||
|
|
320778a3d2 | ||
|
|
3ecec2ca7b | ||
|
|
6b44f2ba99 | ||
|
|
f8f276748b | ||
|
|
736d22009e | ||
|
|
32bb54e90c | ||
|
|
b46c30af80 | ||
|
|
94a93ab7aa | ||
|
|
ef4f8d3edc | ||
|
|
8a806d23e0 | ||
|
|
ea678bc6e6 | ||
|
|
85dbd835d6 | ||
|
|
fd21d6ee82 | ||
|
|
c274dad88d | ||
|
|
95d06ac8cd | ||
|
|
69ec4ccdd6 | ||
|
|
217fb7af0d | ||
|
|
8a196eeb77 | ||
|
|
1e14e33704 | ||
|
|
59a1f51d34 | ||
|
|
7ee787f8de | ||
|
|
737d74f592 | ||
|
|
1a37932efd | ||
|
|
5866611176 | ||
|
|
fe8c64382d | ||
|
|
71571ed2cc | ||
|
|
9d23d4e542 | ||
|
|
2c39ef7cbf | ||
|
|
21ecb7f80b | ||
|
|
e2dbf8a03f | ||
|
|
21bdf4f9a3 | ||
|
|
5f7338178c | ||
|
|
dec8429c3d | ||
|
|
1c27a0c66d | ||
|
|
6a03966205 | ||
|
|
cfcd6c9112 | ||
|
|
c172ab7dae | ||
|
|
d3d714e9eb | ||
|
|
cf5290e835 | ||
|
|
b3f866c5a6 | ||
|
|
a1dc00d466 | ||
|
|
fb51619741 | ||
|
|
2c74f84b97 | ||
|
|
cc1e38ef22 | ||
|
|
1bea267634 | ||
|
|
7090cd2d38 | ||
|
|
66cc52fd9d | ||
|
|
adc6e8626e | ||
|
|
3bf35d81c8 | ||
|
|
61066655fe | ||
|
|
1338b93815 | ||
|
|
5f72e129b2 | ||
|
|
191fc3302d | ||
|
|
5a453aac0f | ||
|
|
4e86e200a5 | ||
|
|
3df5f5d5c4 | ||
|
|
5455bc98f4 | ||
|
|
7c5de46635 | ||
|
|
c0f16e65e2 | ||
|
|
835733c7fc | ||
|
|
5d4016ca61 | ||
|
|
48055aa98f | ||
|
|
dd0a1e4972 | ||
|
|
57eac62cd2 | ||
|
|
18f79e7683 | ||
|
|
a24fe033cd | ||
|
|
525d7366eb | ||
|
|
a2d7a9f6c5 | ||
|
|
0c9468753d | ||
|
|
0887fd0c69 | ||
|
|
01c6de15a3 | ||
|
|
50fd82435c | ||
|
|
ca04a6de62 | ||
|
|
d3be73f9d0 | ||
|
|
dab1861c21 | ||
|
|
4d42e8b877 | ||
|
|
c7cfd91f14 | ||
|
|
d12fd7341c | ||
|
|
9ca7f51d05 | ||
|
|
e17aabe322 | ||
|
|
8e10fc9924 | ||
|
|
bf103fc383 | ||
|
|
fb2fb00ec4 | ||
|
|
f69bf3deb5 | ||
|
|
c24f9a1693 | ||
|
|
393d90d870 | ||
|
|
e66a24edd4 | ||
|
|
df71f965b0 | ||
|
|
b91fb97b1c | ||
|
|
7255ecd851 | ||
|
|
a1e033003a | ||
|
|
a3aa60d7bc | ||
|
|
0e573b8e25 | ||
|
|
b4181cf43a | ||
|
|
14d7f036c3 | ||
|
|
c89bf6241d | ||
|
|
0271c62825 | ||
|
|
0bebf89220 | ||
|
|
3c2a49c4ef | ||
|
|
1eeda33ae5 | ||
|
|
aba4ee3d56 | ||
|
|
803bff885b | ||
|
|
51b39ee118 | ||
|
|
4459fe3989 | ||
|
|
027aef4b3b | ||
|
|
64b7e511b1 | ||
|
|
2de673c4f8 | ||
|
|
d0c5f220bc | ||
|
|
dc3d702e01 | ||
|
|
912a9febc7 | ||
|
|
558a9a13e9 | ||
|
|
e3e2404a1f | ||
|
|
6f42cbb13f | ||
|
|
37f449f91f | ||
|
|
9432147489 | ||
|
|
824953813a | ||
|
|
04cbf91533 | ||
|
|
3b584979a8 | ||
|
|
d5fd62c298 | ||
|
|
6ffe36494e | ||
|
|
350d5dbe2d | ||
|
|
91aa9c790f | ||
|
|
3e536cb541 | ||
|
|
c1c7cdb3c1 | ||
|
|
8880df58c1 | ||
|
|
ccb85be48e | ||
|
|
85b5ff7294 | ||
|
|
6b0d88bf88 | ||
|
|
0917285a33 | ||
|
|
b886586ca6 | ||
|
|
2061f797ba | ||
|
|
3f690a811c | ||
|
|
381e2e9a84 | ||
|
|
f4b7a899b8 | ||
|
|
dff7cc1dda | ||
|
|
fa27fb4088 | ||
|
|
d59d8e7147 | ||
|
|
b2ba13377b | ||
|
|
3379afe807 | ||
|
|
f83be65d79 | ||
|
|
1a4ee0e7ee | ||
|
|
360582703d | ||
|
|
cb0be4adbe | ||
|
|
531cb06614 | ||
|
|
58cf8aa10c | ||
|
|
6f654f9180 | ||
|
|
b636b37e9a | ||
|
|
effec4887d | ||
|
|
e8c32f6610 | ||
|
|
d87ead957e | ||
|
|
56402b95f3 | ||
|
|
7037d586ca | ||
|
|
73632dae7e | ||
|
|
8ec6e3d9f4 | ||
|
|
a97afdc93f | ||
|
|
3acfebb2fe | ||
|
|
77738c6356 | ||
|
|
557c4c5cf9 | ||
|
|
300d048a1c | ||
|
|
fc26a5f40a | ||
|
|
4aa8663777 | ||
|
|
29363ee3e9 | ||
|
|
7cb03beeed | ||
|
|
6aff9307fe | ||
|
|
d14b3e27ba | ||
|
|
5c4eb67ba2 | ||
|
|
7fe6f090e1 | ||
|
|
7aaa68865f | ||
|
|
723a2a44cb | ||
|
|
d2e26c32d4 | ||
|
|
7df9ed0545 | ||
|
|
2c803ae875 | ||
|
|
baa05a30ec | ||
|
|
94e167b796 | ||
|
|
dc925bf620 | ||
|
|
6297ea09f6 | ||
|
|
b916a49fbf | ||
|
|
99791ea225 | ||
|
|
807901034b | ||
|
|
3cb4177026 | ||
|
|
24c323960e | ||
|
|
e4b5b0f07f | ||
|
|
0b0a91e4d4 | ||
|
|
5ee04257c5 | ||
|
|
8c1f50f616 | ||
|
|
12e23c20a8 | ||
|
|
df19d46a5a | ||
|
|
691f958bc4 | ||
|
|
8c079ed9d7 | ||
|
|
d9d0bd3289 | ||
|
|
b246a02f55 | ||
|
|
def1dbef33 | ||
|
|
c449d797bd | ||
|
|
3813fba81c | ||
|
|
93f42ead5b | ||
|
|
05f68f3fdc | ||
|
|
f57fe836c4 | ||
|
|
152258bb83 | ||
|
|
51cd5409da | ||
|
|
ef61ac7c6c | ||
|
|
0a72d58c25 | ||
|
|
0239b3c97f | ||
|
|
decdf49937 | ||
|
|
a8b5151ce5 | ||
|
|
27c3c0be31 | ||
|
|
52399eb28a | ||
|
|
f3a3c1291b | ||
|
|
da98be3276 | ||
|
|
acce84e884 | ||
|
|
e9b2bdef24 | ||
|
|
967ec25579 | ||
|
|
18da9c988a | ||
|
|
86ebc77e25 | ||
|
|
90823415c3 | ||
|
|
2017653aff | ||
|
|
4b004cf7d6 | ||
|
|
79360c5867 | ||
|
|
7b98a22fe1 | ||
|
|
0a8ac2e89c | ||
|
|
89b737d88a | ||
|
|
f4c6f8dc29 | ||
|
|
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 | ||
|
|
e0a1b471ff | ||
|
|
90db82ba0e | ||
|
|
f250e84ecf | ||
|
|
a1454e00a2 | ||
|
|
994820e697 | ||
|
|
75b37506ab | ||
|
|
8ef51c4b69 | ||
|
|
c2bd40f270 | ||
|
|
245ea982b9 | ||
|
|
6528d566a1 | ||
|
|
89d4b1ce31 | ||
|
|
2010ef412f | ||
|
|
0e42a29fda | ||
|
|
880eb7350f | ||
|
|
15a22366e3 | ||
|
|
a554d6e7c4 | ||
|
|
32cce66761 | ||
|
|
f2a3a76c07 | ||
|
|
8ee30558da | ||
|
|
d9a2f157a3 | ||
|
|
e716347329 | ||
|
|
abeb5845dd | ||
|
|
df440f90b2 | ||
|
|
791ac3d928 | ||
|
|
6774e47aae | ||
|
|
aba29be6af | ||
|
|
647ec89bfc | ||
|
|
e855ded821 | ||
|
|
4264d34f1c | ||
|
|
d169963556 | ||
|
|
573f398a2a | ||
|
|
37de786282 | ||
|
|
f4bdc66781 | ||
|
|
e4c1af1fff | ||
|
|
d36f45e213 | ||
|
|
0503ee0cbf | ||
|
|
6b01498127 | ||
|
|
5983bae947 | ||
|
|
ab82d71a96 | ||
|
|
c3daaa8762 | ||
|
|
a91763379f | ||
|
|
a9d14449e8 | ||
|
|
d39715cca0 | ||
|
|
f904f44047 | ||
|
|
3b3560d71d | ||
|
|
26fa54369c | ||
|
|
88e7afbedf | ||
|
|
29d24f48df | ||
|
|
ae72e1f6ca | ||
|
|
164cfd6b11 | ||
|
|
09a9735630 | ||
|
|
f86c946a3c | ||
|
|
c4f7b4f964 | ||
|
|
0e4c964d0a | ||
|
|
2228a7456e | ||
|
|
a7771347e6 | ||
|
|
83b958a824 | ||
|
|
148d592567 | ||
|
|
0a72ffbf5c | ||
|
|
72317bb0c6 | ||
|
|
8e445b4c3d | ||
|
|
f5f5532c19 | ||
|
|
cb5743dcc8 | ||
|
|
eea99bb586 | ||
|
|
295871fcab | ||
|
|
ae43424882 | ||
|
|
d5ae9945fa | ||
|
|
c20247d708 | ||
|
|
08947cd922 | ||
|
|
0e18c68b4d | ||
|
|
1b0b0a8149 | ||
|
|
ac22c4db04 | ||
|
|
e204d3c028 | ||
|
|
3246732087 | ||
|
|
c215a48c91 | ||
|
|
6aabf3f618 | ||
|
|
36ba5c65a6 | ||
|
|
a3489d33b4 | ||
|
|
9c16d40db1 | ||
|
|
730184c64f | ||
|
|
75267d2fc6 | ||
|
|
9b88df32d3 | ||
|
|
67591edf8f | ||
|
|
e2ca936487 | ||
|
|
cc2a2a8741 | ||
|
|
4eb85dbdf9 | ||
|
|
1102fa42df |
No files matched your search
@@ -21,6 +21,12 @@
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Other projects' code
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
+70
-12
@@ -1,7 +1,7 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
|
||||
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
@@ -24,7 +24,10 @@ env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
|
||||
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
|
||||
# verify runs always() and fails unless all three passed.
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -34,6 +37,28 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
fi
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
@@ -41,22 +66,16 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Keep game artifacts out of Git
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
python tools/check_public_tree.py --history
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
shared-key: verify-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -65,8 +84,35 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
@@ -75,6 +121,18 @@ jobs:
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
verify:
|
||||
if: always()
|
||||
needs: [python, clippy, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Did python, clippy and tests pass?
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
echo "python, clippy, tests: $RESULTS"
|
||||
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
# which never runs what this one built. See docs/release-flow.md.
|
||||
@@ -104,6 +105,13 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
|
||||
# programs under Rosetta 2.
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
dir: horizon-macos-x64
|
||||
asset: horizon-macos-x64.zip
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
@@ -114,6 +122,8 @@ jobs:
|
||||
HORIZON_VERSION: ${{ inputs.version }}
|
||||
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
|
||||
HORIZON_NIGHTLY: ${{ inputs.nightly }}
|
||||
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -128,6 +138,8 @@ jobs:
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -138,11 +150,11 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
@@ -157,15 +169,15 @@ jobs:
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
|
||||
codesign --verify --strict --verbose=2 "$BIN/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
file="$BIN/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
@@ -176,7 +188,7 @@ jobs:
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
bin=$BIN
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
@@ -202,7 +214,7 @@ jobs:
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
client="$PWD/$BIN/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
@@ -217,8 +229,9 @@ jobs:
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
@@ -231,7 +244,7 @@ jobs:
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -8,7 +8,8 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
@@ -105,6 +106,13 @@ jobs:
|
||||
mac: true
|
||||
asset: horizon-launcher-macos-arm64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: arm64
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
asset: horizon-launcher-macos-x64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: x64
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
@@ -117,6 +125,8 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
|
||||
# variable, not a secret. Unset, the launcher says sign-in is not configured.
|
||||
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
|
||||
@@ -140,6 +150,8 @@ jobs:
|
||||
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -160,17 +172,17 @@ jobs:
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$(target/release/${{ matrix.binary }} --version)
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
echo "$out"
|
||||
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
|
||||
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
|
||||
- name: Only system libraries
|
||||
run: |
|
||||
file=target/release/${{ matrix.binary }}
|
||||
file="$BIN/${{ matrix.binary }}"
|
||||
case "$RUNNER_OS" in
|
||||
Windows) python ../tools/check_imports.py "$file" ;;
|
||||
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
@@ -185,13 +197,28 @@ jobs:
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
launcher="$PWD/$BIN/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
retry "$launcher" versions
|
||||
"$launcher" links --json
|
||||
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
|
||||
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
|
||||
# (exit 3); a launcher or a script that fails still fails the step.
|
||||
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
|
||||
versions=$(retry "$launcher" --json versions)
|
||||
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
|
||||
status=0
|
||||
python3 -c "$no_build" <<<"$versions" || status=$?
|
||||
if [[ $status == 3 ]]; then
|
||||
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
exit 0
|
||||
fi
|
||||
[[ $status == 0 ]] || exit $status
|
||||
fi
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
@@ -224,11 +251,11 @@ jobs:
|
||||
# Developer ID): packaging/macos.
|
||||
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
else
|
||||
|
||||
@@ -172,7 +172,8 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -233,9 +234,10 @@ 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 on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.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` |
|
||||
|
||||
+26
-8
@@ -47,14 +47,31 @@ them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code
|
||||
## Only original code, or code whose license allows it
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
|
||||
another project's source may also be read and used, **only when that project's license allows it**:
|
||||
|
||||
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
|
||||
public-domain dedication. That code may be read, copied or adapted.
|
||||
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
|
||||
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
|
||||
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
|
||||
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
|
||||
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
|
||||
at your commit counts. In doubt, don't: ask in #contributors first.
|
||||
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
|
||||
pull request that used another project's source without saying so is closed.
|
||||
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
|
||||
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
|
||||
was changed.
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
|
||||
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
|
||||
another project's source; they come back only as a clean-room rewrite from a written description of the pack
|
||||
format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
@@ -82,7 +99,8 @@ claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
|
||||
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
|
||||
pull requests already fix. Agents claim the same way.
|
||||
|
||||
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
|
||||
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
|
||||
|
||||
Generated
+23
@@ -830,6 +830,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -1074,6 +1084,14 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-assets"
|
||||
version = "0.1.0"
|
||||
@@ -1109,8 +1127,10 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-client-core",
|
||||
@@ -1175,6 +1195,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1203,6 +1224,7 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"symphonia",
|
||||
"unicode-bidi",
|
||||
@@ -1350,6 +1372,7 @@ name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
|
||||
+5
-1
@@ -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",
|
||||
@@ -52,7 +53,10 @@ large_enum_variant = "allow"
|
||||
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
|
||||
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
|
||||
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
|
||||
# Drop this and the folder once a wgpu release has both fixes.
|
||||
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
|
||||
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
|
||||
# frame wait for the GPU to finish the one before (#4604).
|
||||
# Drop this and the folder once a wgpu release has these fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,34 +24,32 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## What works today
|
||||
|
||||
The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
|
||||
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
|
||||
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
|
||||
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
In the Rust client, with vanilla assets and no mods or shaders:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -69,15 +67,15 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
|
||||
## Host a server
|
||||
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
|
||||
## Modpacks
|
||||
|
||||
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
|
||||
## Agent control
|
||||
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
|
||||
|
||||
```sh
|
||||
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
|
||||
@@ -88,16 +86,14 @@ horizon-ctl break -1 65 -3 # mines it; replies with
|
||||
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
|
||||
```
|
||||
|
||||
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
|
||||
## Build from source
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
|
||||
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
|
||||
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/horizon-rust/horizon.git
|
||||
@@ -107,62 +103,49 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
|
||||
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). Before a pull request, run the self-check and paste its output:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
python3 tools/pr_ready.py
|
||||
```
|
||||
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
|
||||
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
|
||||
and verified against vanilla by differential tests). The percentage is the
|
||||
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
|
||||
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
|
||||
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
|
||||
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
|
||||
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
|
||||
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
|
||||
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
|
||||
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
|
||||
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
|
||||
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
|
||||
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
|
||||
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
|
||||
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
|
||||
|
||||
### Known issues
|
||||
|
||||
@@ -170,9 +153,7 @@ Skins served over https show the default skin unless already cached; pack icons
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
|
||||
|
||||
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
|
||||
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
@@ -326,11 +326,11 @@ A mapping table from intermediary and SRG names so pre-26.1 jars can be classifi
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
|
||||
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
|
||||
Imported modpacks carry their resource packs into a profile of their own. Shader-pack conversion is still pending.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
|
||||
Resource-pack carry-over is implemented locally: Modrinth common/client overrides, CurseForge's override folder, installed instances (ZIP and directory packs), and hash-checked indexed ZIP downloads. The launcher keeps profile edits and lets resource-only profiles play; packs are enabled in Resource Packs. Synthetic import and profile tests cover the file pipeline; in-game rendering has not been verified. Shader-pack conversion is still pending, so this combined item remains partial ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</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>
|
||||
|
||||
|
||||
+42
-2
@@ -3,8 +3,9 @@
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with one upstream fix applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
|
||||
developers, with upstream fixes applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## Pixelify Sans
|
||||
@@ -19,3 +20,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.
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
|
||||
|
||||
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
|
||||
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
|
||||
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
|
||||
|
||||
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
|
||||
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
|
||||
|
||||
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
|
||||
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
|
||||
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
|
||||
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
|
||||
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
|
||||
|
||||
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
|
||||
|
||||
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
|
||||
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
|
||||
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
|
||||
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
|
||||
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EXE = '.exe' if os.name == 'nt' else ''
|
||||
SERVERS = ('horizon', 'vanilla', 'pumpkin')
|
||||
|
||||
PROPERTIES = {
|
||||
'level-seed': '12345',
|
||||
'view-distance': '10',
|
||||
'simulation-distance': '10',
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'gamemode': 'creative',
|
||||
'force-gamemode': 'true',
|
||||
'spawn-protection': '0',
|
||||
'max-players': '200',
|
||||
'difficulty': 'easy',
|
||||
'server-ip': '127.0.0.1',
|
||||
'enable-query': 'false',
|
||||
'enable-rcon': 'false',
|
||||
}
|
||||
|
||||
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
|
||||
PUMPKIN_CONFIGURATION = '''\
|
||||
server_address = "127.0.0.1:{port}"
|
||||
seed = "{seed}"
|
||||
max_players = 200
|
||||
view_distance = {view}
|
||||
simulation_distance = {view}
|
||||
online_mode = false
|
||||
default_difficulty = "Easy"
|
||||
allow_chat_reports = false
|
||||
encryption = false
|
||||
lan_broadcast = false
|
||||
|
||||
[networking.bedrock]
|
||||
enabled = false
|
||||
|
||||
[networking.lan_broadcast]
|
||||
enabled = false
|
||||
|
||||
[networking.query]
|
||||
enabled = false
|
||||
|
||||
[networking.rcon]
|
||||
enabled = false
|
||||
'''
|
||||
PUMPKIN_FEATURES = '''\
|
||||
[telemetry]
|
||||
enabled = false
|
||||
|
||||
[plugins]
|
||||
enabled = false
|
||||
'''
|
||||
|
||||
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
|
||||
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
|
||||
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
|
||||
GAMETIME = re.compile(r'The time is (-?\d+)')
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def parse_tick_query(lines):
|
||||
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
|
||||
average = percentiles = None
|
||||
for line in lines:
|
||||
if (m := TICK_AVERAGE.search(line)) and average is None:
|
||||
average = float(m.group(1))
|
||||
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
|
||||
percentiles = tuple(float(g) for g in m.groups())
|
||||
if average is None or percentiles is None:
|
||||
return None
|
||||
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
|
||||
|
||||
|
||||
def parse_gametime(lines):
|
||||
"""The game time `time query gametime` printed, or None."""
|
||||
for line in lines:
|
||||
if m := GAMETIME.search(line):
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def tps(start_ticks, end_ticks, seconds):
|
||||
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
|
||||
if start_ticks is None or end_ticks is None or seconds <= 0:
|
||||
return None
|
||||
return round((end_ticks - start_ticks) / seconds, 2)
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
|
||||
|
||||
|
||||
def cpu_seconds(text):
|
||||
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
|
||||
days, _, rest = text.rpartition('-')
|
||||
seconds = 0.0
|
||||
for part in rest.split(':'):
|
||||
seconds = seconds * 60 + float(part)
|
||||
return seconds + (float(days) * 86400 if days else 0)
|
||||
|
||||
|
||||
def tree_usage(root):
|
||||
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
|
||||
try:
|
||||
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
|
||||
except OSError:
|
||||
return None
|
||||
rows = {}
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 4:
|
||||
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
|
||||
tree, frontier = {root}, [root]
|
||||
while frontier:
|
||||
parent = frontier.pop()
|
||||
for pid, (ppid, _, _) in rows.items():
|
||||
if ppid == parent and pid not in tree:
|
||||
tree.add(pid)
|
||||
frontier.append(pid)
|
||||
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
|
||||
|
||||
|
||||
def prepare(kind, directory, port, args):
|
||||
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
|
||||
directory = Path(directory)
|
||||
if args.pre_generated:
|
||||
shutil.copytree(args.pre_generated, directory / 'world')
|
||||
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
|
||||
'view-distance': str(args.view_distance),
|
||||
'simulation-distance': str(args.view_distance)})
|
||||
if kind == 'pumpkin':
|
||||
config = directory / 'config'
|
||||
config.mkdir()
|
||||
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
|
||||
port=port, seed=args.seed, view=args.view_distance))
|
||||
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
|
||||
if args.pumpkin_config:
|
||||
with (config / 'configuration.toml').open('a') as f:
|
||||
f.write('\n' + Path(args.pumpkin_config).read_text())
|
||||
return [Path(args.exe).resolve()]
|
||||
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
(directory / 'eula.txt').write_text('eula=true\n')
|
||||
if kind == 'vanilla':
|
||||
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
|
||||
server = Path(args.exe).resolve()
|
||||
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
|
||||
capture_output=True)
|
||||
toml = directory / 'server.toml'
|
||||
# No web panel in a bench.
|
||||
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
|
||||
return [server, '--dir', directory, 'run']
|
||||
|
||||
|
||||
class Server:
|
||||
"""A server with its console on stdin and its output collected."""
|
||||
|
||||
def __init__(self, command, directory, log_path):
|
||||
self.lines = []
|
||||
self.lock = threading.Lock()
|
||||
self.log = log_path.open('w', encoding='utf-8')
|
||||
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
|
||||
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
encoding='utf-8', errors='replace', env=env)
|
||||
threading.Thread(target=self._read, daemon=True).start()
|
||||
|
||||
def _read(self):
|
||||
for line in self.process.stdout:
|
||||
with self.lock:
|
||||
self.lines.append(line.rstrip('\n'))
|
||||
self.log.write(line)
|
||||
self.log.flush()
|
||||
|
||||
def count(self):
|
||||
with self.lock:
|
||||
return len(self.lines)
|
||||
|
||||
def since(self, n):
|
||||
with self.lock:
|
||||
return self.lines[n:]
|
||||
|
||||
def wait_for(self, regex, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if any(regex.search(line) for line in self.since(0)):
|
||||
return
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f'the server was not up within {timeout} s')
|
||||
|
||||
def ask(self, command, parse, timeout=5):
|
||||
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
|
||||
start = self.count()
|
||||
try:
|
||||
self.process.stdin.write(command + '\n')
|
||||
self.process.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
return None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if (value := parse(self.since(start))) is not None:
|
||||
return value
|
||||
time.sleep(0.05)
|
||||
return None
|
||||
|
||||
def stop(self):
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
self.process.stdin.write('stop\n')
|
||||
self.process.stdin.flush()
|
||||
self.process.wait(timeout=60)
|
||||
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self.log.close()
|
||||
|
||||
|
||||
def bots_command(args, port, total):
|
||||
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
|
||||
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
|
||||
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
|
||||
|
||||
|
||||
def split_bot_output(lines):
|
||||
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
|
||||
samples, rest = [], []
|
||||
for line in lines:
|
||||
try:
|
||||
sample = json.loads(line)
|
||||
except ValueError:
|
||||
sample = None
|
||||
if isinstance(sample, dict) and 't' in sample:
|
||||
samples.append(sample)
|
||||
else:
|
||||
rest.append(line)
|
||||
text = ''.join(rest)
|
||||
return samples, (json.loads(text) if text.strip() else {})
|
||||
|
||||
|
||||
def bytes_per_player_second(per_bot, run_seconds, join_every):
|
||||
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
|
||||
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
|
||||
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
|
||||
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
|
||||
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
|
||||
if not counted or online <= 0:
|
||||
return None
|
||||
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
|
||||
|
||||
|
||||
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
|
||||
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
|
||||
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
|
||||
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
|
||||
return {
|
||||
'server': kind,
|
||||
'bots': args.bots,
|
||||
'behaviour': args.behaviour,
|
||||
'start_seconds': start_seconds,
|
||||
'tps': tps(*gametime, window_seconds),
|
||||
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
|
||||
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
|
||||
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
|
||||
'cpu_percent': mean(cpu),
|
||||
'bytes_per_player_second': bandwidth,
|
||||
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
|
||||
'bots_joined': summary.get('joined'),
|
||||
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
|
||||
'chunks_total': summary.get('chunks'),
|
||||
'tick_queries': len(ticks),
|
||||
}
|
||||
|
||||
|
||||
def run_once(kind, args, out):
|
||||
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
|
||||
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
|
||||
server = None
|
||||
try:
|
||||
port = free_port()
|
||||
command = prepare(kind, scratch, port, args)
|
||||
started = time.time()
|
||||
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
|
||||
server.wait_for(DONE, args.start_timeout)
|
||||
start_seconds = round(time.time() - started, 2)
|
||||
total = args.bots * args.join_every + args.warmup + args.seconds
|
||||
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
bot_lines = []
|
||||
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
|
||||
time.sleep(args.bots * args.join_every + args.warmup)
|
||||
first_time = server.ask('time query gametime', parse_gametime)
|
||||
began = time.time()
|
||||
rss, cpu, ticks = [], [], []
|
||||
usage = tree_usage(server.process.pid)
|
||||
last_cpu, last_time = (usage[1] if usage else 0), time.time()
|
||||
next_query = time.time()
|
||||
while time.time() - began < args.seconds:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
if usage and (u := tree_usage(server.process.pid)):
|
||||
rss.append(u[0])
|
||||
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
|
||||
last_cpu, last_time = u[1], now
|
||||
if now >= next_query:
|
||||
if (t := server.ask('tick query', parse_tick_query)) is not None:
|
||||
ticks.append(t)
|
||||
next_query = now + 5
|
||||
window = time.time() - began
|
||||
last = server.ask('time query gametime', parse_gametime)
|
||||
bot_process.wait(timeout=total + 120)
|
||||
time.sleep(0.5)
|
||||
_, summary = split_bot_output(list(bot_lines))
|
||||
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
|
||||
total))
|
||||
except Exception as e: # a failed run is reported, and the next one still runs
|
||||
result['error'] = str(e)
|
||||
finally:
|
||||
if server:
|
||||
server.stop()
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
return result
|
||||
|
||||
|
||||
def table(runs):
|
||||
"""The Markdown table of the design: one column per server, one row per measure."""
|
||||
ok = [r for r in runs if 'error' not in r]
|
||||
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
|
||||
def cell(r, path):
|
||||
value = r
|
||||
for key in path:
|
||||
value = value.get(key) if isinstance(value, dict) else None
|
||||
return '-' if value is None else str(value)
|
||||
rows = [
|
||||
('Players', ('bots',)),
|
||||
('TPS', ('tps',)),
|
||||
('MSPT average', ('mspt', 'average')),
|
||||
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
|
||||
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
|
||||
('RSS mean MB', ('rss_mb', 'mean')),
|
||||
('CPU %', ('cpu_percent',)),
|
||||
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
|
||||
('Join p50 ms', ('join_ms', 'p50')),
|
||||
('Join worst ms', ('join_ms', 'worst')),
|
||||
('Start s', ('start_seconds',)),
|
||||
]
|
||||
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
|
||||
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
|
||||
return head + body + failed
|
||||
|
||||
|
||||
def check(args):
|
||||
for kind in args.server:
|
||||
if kind not in SERVERS:
|
||||
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
|
||||
if 'vanilla' in args.server and not args.jar:
|
||||
sys.exit('vanilla needs --jar, your own server.jar')
|
||||
if any(k != 'vanilla' for k in args.server) and not args.exe:
|
||||
sys.exit('horizon and pumpkin need --exe, the server binary')
|
||||
if not Path(args.bots_bin).exists():
|
||||
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
|
||||
if args.pre_generated and not Path(args.pre_generated).is_dir():
|
||||
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
|
||||
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
|
||||
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
|
||||
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
|
||||
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
|
||||
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
|
||||
parser.add_argument('--bots', type=int, default=50)
|
||||
parser.add_argument('--behaviour', default='mixed')
|
||||
parser.add_argument('--join-every', type=float, default=0.5)
|
||||
parser.add_argument('--warmup', type=int, default=30)
|
||||
parser.add_argument('--seconds', type=int, default=60)
|
||||
parser.add_argument('--seed', type=int, default=12345)
|
||||
parser.add_argument('--view-distance', type=int, default=10)
|
||||
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
|
||||
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
|
||||
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
|
||||
parser.add_argument('--start-timeout', type=int, default=600)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
|
||||
args = parser.parse_args(argv)
|
||||
check(args)
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
runs = []
|
||||
for kind in args.server:
|
||||
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
|
||||
runs.append(run_once(kind, args, args.out))
|
||||
print(json.dumps(runs[-1]), flush=True)
|
||||
report = {
|
||||
'kind': 'horizon-bench',
|
||||
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
|
||||
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
|
||||
'runs': runs,
|
||||
}
|
||||
path = args.out / 'report.json'
|
||||
path.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print('\n' + table(runs) + f'\nWrote {path}')
|
||||
return 1 if any('error' in r for r in runs) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -3,8 +3,8 @@ name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks and items to your worlds".
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -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,326 @@
|
||||
//! 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 prediction;
|
||||
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] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
|
||||
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again.
|
||||
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
));
|
||||
}
|
||||
}
|
||||
let tolerance = self.prediction.tolerance;
|
||||
if !tolerance.is_finite() || tolerance <= 0.0 {
|
||||
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
|
||||
}
|
||||
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)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
}
|
||||
|
||||
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 zero = "[anticheat]\n[anticheat.prediction]\ntolerance = 0\n";
|
||||
assert!(Config::from_server_toml(zero).is_err());
|
||||
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,404 @@
|
||||
//! The movement prediction's record: each of a player's client ticks as the server heard them, so
|
||||
//! the server can play the tick again with vanilla's movement code and compare (docs/anticheat.md).
|
||||
//!
|
||||
//! A vanilla client ends every tick with ServerboundClientTickEndPacket, after the tick's input
|
||||
//! (ServerboundPlayerInputPacket, sent when the keys change) and its move (ServerboundMovePlayerPacket,
|
||||
//! when it moved or turned). Those packets arrive in order, so the packets between two tick ends are one
|
||||
//! client tick.
|
||||
//!
|
||||
//! What the server changes about the player (a push, a teleport, an effect) takes effect on the client
|
||||
//! when it handles the packet, which is some client ticks after the server sent it. At the end of each of
|
||||
//! its ticks the server sends a ClientboundPingPacket whose id is that server tick; the client answers
|
||||
//! with ServerboundPongPacket as soon as it handles it, before its next tick runs. A client tick knows
|
||||
//! the last server tick it had handled: every change of that server tick or an earlier one was in effect
|
||||
//! during the client tick, none of a later one.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Client ticks kept: two seconds at 20 a second.
|
||||
pub const KEPT: usize = 40;
|
||||
|
||||
/// Server changes kept, the oldest dropped first.
|
||||
const CHANGES_KEPT: usize = 64;
|
||||
|
||||
/// Changes near the player kept until every kept tick has seen them.
|
||||
const NEAR_KEPT: usize = 1024;
|
||||
|
||||
/// Something the server sent the player that changes how its client moves. The client applies it when
|
||||
/// it handles the packet, before its next tick.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Change {
|
||||
/// ClientboundSetEntityMotionPacket for the player itself: its new velocity, as the client
|
||||
/// decodes it.
|
||||
Velocity([f64; 3]),
|
||||
/// ClientboundExplodePacket's player knockback, added to the velocity.
|
||||
Knockback([f64; 3]),
|
||||
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
|
||||
/// attributes and effects, as they are when the replay reaches it.
|
||||
State,
|
||||
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
|
||||
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
|
||||
/// tick played before the client had them may be off.
|
||||
Abilities,
|
||||
}
|
||||
|
||||
/// A teleport the client accepted: the replay starts again from it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Restart {
|
||||
/// Where it put the player.
|
||||
pub at: [f64; 3],
|
||||
/// It kept the player's motion (an End gateway's).
|
||||
pub kept_motion: bool,
|
||||
/// Client ticks ended and changes heard before it: the ones after are the replay's.
|
||||
pub ended: u64,
|
||||
pub changed: u64,
|
||||
}
|
||||
|
||||
/// One client tick.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ClientTick {
|
||||
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
|
||||
/// shift, sprint), as last sent.
|
||||
pub input: u8,
|
||||
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
|
||||
pub sprinting: bool,
|
||||
/// The rotation the client last sent.
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
/// Where the tick's move said the player is, if it said: a move with a position.
|
||||
pub position: Option<[f64; 3]>,
|
||||
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
|
||||
pub flags: Option<u8>,
|
||||
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
|
||||
pub seen: Option<u64>,
|
||||
}
|
||||
|
||||
/// A player's last client ticks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Record {
|
||||
/// The tick being heard.
|
||||
current: ClientTick,
|
||||
/// The ended ones, oldest first, at most [`KEPT`].
|
||||
ticks: VecDeque<ClientTick>,
|
||||
/// The last server tick the client answered for.
|
||||
seen: Option<u64>,
|
||||
/// Client ticks ended since the record began.
|
||||
ended: u64,
|
||||
/// Where an accepted teleport put the player, until the replay starts again from there.
|
||||
restart: Option<Restart>,
|
||||
/// What the server changed, with the server tick that sent it, oldest first.
|
||||
changes: VecDeque<(u64, Change)>,
|
||||
/// Changes heard since the record began.
|
||||
changed: u64,
|
||||
/// The blocks the server changed near the player (None: an entity it stopped showing the player
|
||||
/// from near it, which counts wherever the player has gone since), with the server tick that sent
|
||||
/// each, until every kept tick has seen them.
|
||||
near: VecDeque<(u64, Option<[i32; 3]>)>,
|
||||
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
|
||||
near_dropped: Option<u64>,
|
||||
}
|
||||
|
||||
impl Record {
|
||||
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
|
||||
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
|
||||
if position.is_some() {
|
||||
self.current.position = position;
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
(self.current.yaw, self.current.pitch) = (yaw, pitch);
|
||||
}
|
||||
self.current.flags = Some(flags);
|
||||
}
|
||||
|
||||
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
|
||||
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
|
||||
/// every ping, once, in order.
|
||||
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
|
||||
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
|
||||
return false;
|
||||
}
|
||||
self.seen = Some(server_tick);
|
||||
true
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
|
||||
/// next tick starts with the same keys and rotation, until the client says otherwise.
|
||||
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
|
||||
self.current.input = input;
|
||||
self.current.sprinting = sprinting;
|
||||
self.current.seen = self.seen;
|
||||
let next = ClientTick {
|
||||
yaw: self.current.yaw,
|
||||
pitch: self.current.pitch,
|
||||
..ClientTick::default()
|
||||
};
|
||||
let ended = std::mem::replace(&mut self.current, next);
|
||||
if self.ticks.len() == KEPT {
|
||||
self.ticks.pop_front();
|
||||
}
|
||||
self.ticks.push_back(ended);
|
||||
self.ended += 1;
|
||||
// What the oldest tick kept had seen, every later one had too.
|
||||
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
|
||||
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
|
||||
self.near.pop_front();
|
||||
}
|
||||
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
|
||||
}
|
||||
self.ticks.back().expect("just pushed")
|
||||
}
|
||||
|
||||
/// The ended ticks, oldest first.
|
||||
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
|
||||
self.ticks.iter()
|
||||
}
|
||||
|
||||
/// The last server tick the client answered for.
|
||||
pub fn seen(&self) -> Option<u64> {
|
||||
self.seen
|
||||
}
|
||||
|
||||
/// Client ticks ended since the record began.
|
||||
pub fn ended(&self) -> u64 {
|
||||
self.ended
|
||||
}
|
||||
|
||||
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
|
||||
/// kept.
|
||||
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
|
||||
let new = self.ended.checked_sub(played)?;
|
||||
let new = usize::try_from(new)
|
||||
.ok()
|
||||
.filter(|&n| n <= self.ticks.len())?;
|
||||
Some(self.ticks.iter().skip(self.ticks.len() - new))
|
||||
}
|
||||
|
||||
/// `change`, sent during server tick `server_tick` (before that tick's ping).
|
||||
pub fn changed(&mut self, server_tick: u64, change: Change) {
|
||||
if self.changes.len() == CHANGES_KEPT {
|
||||
self.changes.pop_front();
|
||||
}
|
||||
self.changes.push_back((server_tick, change));
|
||||
self.changed += 1;
|
||||
}
|
||||
|
||||
/// Changes heard since the record began.
|
||||
pub fn changes(&self) -> u64 {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
|
||||
pub fn changes_since(
|
||||
&self,
|
||||
applied: u64,
|
||||
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
|
||||
let new = self.changed.checked_sub(applied)?;
|
||||
let new = usize::try_from(new)
|
||||
.ok()
|
||||
.filter(|&n| n <= self.changes.len())?;
|
||||
Some(self.changes.iter().skip(self.changes.len() - new))
|
||||
}
|
||||
|
||||
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
|
||||
/// at `pos`.
|
||||
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
|
||||
self.note_near(server_tick, Some(pos));
|
||||
}
|
||||
|
||||
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
|
||||
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
|
||||
pub fn entity_gone(&mut self, server_tick: u64) {
|
||||
if self.near.back() != Some(&(server_tick, None)) {
|
||||
self.note_near(server_tick, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
|
||||
if self.near.len() == NEAR_KEPT {
|
||||
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
|
||||
}
|
||||
self.near.push_back((server_tick, pos));
|
||||
}
|
||||
|
||||
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
|
||||
/// not have had a change at a position for which `near` holds: it still had the old block, or the
|
||||
/// entity, where the server no longer has it.
|
||||
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
|
||||
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
|
||||
self.near_dropped.is_some_and(unseen)
|
||||
|| self
|
||||
.near
|
||||
.iter()
|
||||
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
|
||||
}
|
||||
|
||||
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
|
||||
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
|
||||
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
|
||||
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
|
||||
self.restart_tick();
|
||||
self.restart = Some(Restart {
|
||||
at,
|
||||
kept_motion,
|
||||
ended: self.ended,
|
||||
changed: self.changed,
|
||||
});
|
||||
}
|
||||
|
||||
/// The last teleport the client accepted, once.
|
||||
pub fn take_restart(&mut self) -> Option<Restart> {
|
||||
self.restart.take()
|
||||
}
|
||||
|
||||
/// The ticks before now say nothing about the ones after.
|
||||
pub fn clear(&mut self) {
|
||||
self.ticks.clear();
|
||||
self.restart_tick();
|
||||
}
|
||||
|
||||
fn restart_tick(&mut self) {
|
||||
self.current = ClientTick {
|
||||
yaw: self.current.yaw,
|
||||
pitch: self.current.pitch,
|
||||
..ClientTick::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
|
||||
let mut r = Record::default();
|
||||
r.changed_near(4, [1, 2, 3]);
|
||||
r.changed_near(5, [7, 2, 3]);
|
||||
let near = |p: [i32; 3]| p[0] < 5;
|
||||
assert!(r.unseen_near(None, near));
|
||||
assert!(r.unseen_near(Some(3), near));
|
||||
assert!(!r.unseen_near(Some(4), near));
|
||||
assert!(r.unseen_near(Some(4), |_| true));
|
||||
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
|
||||
r.tick_end(0, false);
|
||||
assert!(r.pong(5, 5));
|
||||
r.tick_end(0, false);
|
||||
assert!(r.unseen_near(Some(3), near));
|
||||
for _ in 0..KEPT {
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
assert!(!r.unseen_near(None, |_| true));
|
||||
// An entity gone counts anywhere.
|
||||
r.entity_gone(5);
|
||||
r.entity_gone(5);
|
||||
assert!(r.unseen_near(Some(4), |_| false));
|
||||
assert!(!r.unseen_near(Some(5), |_| false));
|
||||
// One dropped early counts for the ticks that hadn't seen it.
|
||||
for i in 0..=NEAR_KEPT as u64 {
|
||||
r.changed_near(6 + i, [100, 0, 0]);
|
||||
}
|
||||
assert!(
|
||||
r.unseen_near(Some(5), near),
|
||||
"a change dropped unseen counts"
|
||||
);
|
||||
assert!(!r.unseen_near(Some(6), near));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_is_the_packets_between_two_tick_ends() {
|
||||
let mut r = Record::default();
|
||||
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
|
||||
let t = *r.tick_end(0b1001, true);
|
||||
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
|
||||
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
|
||||
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
|
||||
// A tick without a move keeps the rotation and has no position.
|
||||
let t = *r.tick_end(0, false);
|
||||
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
|
||||
assert_eq!(r.ticks().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
|
||||
let mut r = Record::default();
|
||||
assert!(r.pong(5, 6));
|
||||
r.tick_end(0, false);
|
||||
assert!(r.pong(6, 6));
|
||||
r.tick_end(0, false);
|
||||
r.tick_end(0, false);
|
||||
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
|
||||
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
|
||||
// Never sent, answered twice, or out of order.
|
||||
assert!(!r.pong(9, 6));
|
||||
assert!(!r.pong(6, 6));
|
||||
assert!(!r.pong(4, 6));
|
||||
assert_eq!(r.seen(), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_two_seconds_are_kept() {
|
||||
let mut r = Record::default();
|
||||
for i in 0..KEPT + 5 {
|
||||
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
assert_eq!(r.ticks().len(), KEPT);
|
||||
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
|
||||
r.clear();
|
||||
assert_eq!(r.ticks().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_replay_takes_the_ticks_it_has_not_played() {
|
||||
let mut r = Record::default();
|
||||
for i in 0..3 {
|
||||
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
|
||||
r.since(played)
|
||||
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
|
||||
}
|
||||
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
|
||||
assert_eq!(x(&r, 3), Some(vec![]));
|
||||
// Played ahead of the record, or so far behind the ticks are gone.
|
||||
assert_eq!(x(&r, 4), None);
|
||||
r.clear();
|
||||
assert_eq!(x(&r, 2), None);
|
||||
assert_eq!(x(&r, 3), Some(vec![]));
|
||||
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
|
||||
r.teleported([5.0, 6.0, 7.0], false);
|
||||
// A tick that ends before the replay starts again is still one after the teleport.
|
||||
r.tick_end(0, false);
|
||||
let restart = r.take_restart().unwrap();
|
||||
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
|
||||
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
|
||||
// The tick ended before it stays for a replay that hasn't played it; the move of the one
|
||||
// under way is gone.
|
||||
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
|
||||
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
|
||||
assert_eq!(r.take_restart(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_keep_the_server_tick_that_sent_them() {
|
||||
let mut r = Record::default();
|
||||
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
|
||||
r.changed(5, Change::State);
|
||||
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
|
||||
assert_eq!(
|
||||
all,
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
|
||||
);
|
||||
assert_eq!(r.changes_since(1).unwrap().count(), 1);
|
||||
assert!(r.changes_since(3).is_none());
|
||||
for i in 0..CHANGES_KEPT as u64 {
|
||||
r.changed(6 + i, Change::State);
|
||||
}
|
||||
// The first two are gone.
|
||||
assert!(r.changes_since(1).is_none());
|
||||
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+305
-11
@@ -15,10 +15,13 @@
|
||||
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
|
||||
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
|
||||
|
||||
//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after
|
||||
//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds).
|
||||
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection},
|
||||
connection::{ConnWriter, Connection, Counters},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
@@ -33,7 +36,7 @@ use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, LazyLock, Mutex,
|
||||
Arc, LazyLock, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -46,6 +49,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 +63,11 @@ 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-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
--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 +76,24 @@ 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)
|
||||
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
|
||||
registry id, count), to test what a server takes
|
||||
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
|
||||
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
|
||||
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
|
||||
to test what a server takes
|
||||
--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 +112,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 +139,26 @@ 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,
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
/// --raw: (tick after joining, packet id, body).
|
||||
raw: Vec<(u64, i32, Vec<u8>)>,
|
||||
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
|
||||
give: Vec<(i16, i32, i32)>,
|
||||
/// --click: the block to open, then the slot, button and click type in its window.
|
||||
click: Option<([i32; 3], i16, i8, i32)>,
|
||||
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 +172,20 @@ 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,
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
raw: Vec::new(),
|
||||
give: Vec::new(),
|
||||
click: None,
|
||||
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 +197,14 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = 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 +223,74 @@ 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),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
let n: Vec<&str> = part.split(':').collect();
|
||||
let parsed = match n[..] {
|
||||
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
|
||||
_ => None,
|
||||
};
|
||||
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
|
||||
o.give.push((slot, item, count));
|
||||
}
|
||||
}
|
||||
"--click" => {
|
||||
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
|
||||
let n: Vec<&str> = value.split(':').collect();
|
||||
let [at, slot, button, mode] = n[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let at: Vec<i32> = at
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| bad())?;
|
||||
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
|
||||
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
|
||||
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
|
||||
}
|
||||
"--raw" => {
|
||||
for part in value.split(',') {
|
||||
let bad = || number("TICK:ID:HEX");
|
||||
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let body = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
hex.get(i..i + 2)
|
||||
.and_then(|b| u8::from_str_radix(b, 16).ok())
|
||||
})
|
||||
.collect::<Option<Vec<u8>>>()
|
||||
.ok_or_else(bad)?;
|
||||
o.raw.push((
|
||||
tick.parse().map_err(|_| bad())?,
|
||||
id.parse().map_err(|_| bad())?,
|
||||
body,
|
||||
));
|
||||
}
|
||||
}
|
||||
"--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",
|
||||
@@ -217,8 +331,48 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The connection's byte counters (wire bytes, after compression and encryption), set once it is open.
|
||||
counters: OnceLock<Arc<Counters>>,
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
fn bytes_in(&self) -> u64 {
|
||||
self.counters
|
||||
.get()
|
||||
.map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64)
|
||||
}
|
||||
fn bytes_out(&self) -> u64 {
|
||||
self.counters
|
||||
.get()
|
||||
.map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty.
|
||||
fn percentile(sorted: &[u64], p: u64) -> u64 {
|
||||
if sorted.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize;
|
||||
sorted[rank - 1]
|
||||
}
|
||||
|
||||
/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max.
|
||||
fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
times.sort_unstable();
|
||||
json!({
|
||||
"count": times.len(),
|
||||
"p50_ms": percentile(×, 50),
|
||||
"p99_ms": percentile(×, 99),
|
||||
"max_ms": times.last().copied().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
@@ -398,6 +552,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 +592,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 +699,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(())
|
||||
@@ -617,7 +800,9 @@ fn run_bot(
|
||||
}
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let _ = bot.counters.set(reader.counters.clone());
|
||||
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());
|
||||
@@ -640,7 +825,9 @@ fn run_bot(
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
}
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
@@ -648,6 +835,15 @@ fn run_bot(
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
for &(slot, item, count) in &o.give {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item: Some(Stack::new(item, count)),
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
@@ -661,10 +857,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 +872,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 +910,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);
|
||||
@@ -769,6 +968,43 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((at, slot, button, input)) = o.click {
|
||||
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
|
||||
if tick == 60 {
|
||||
sequence += 1;
|
||||
send(
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
} else if tick == 80 {
|
||||
// The first window a player opens is 1.
|
||||
send(
|
||||
play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 1,
|
||||
state: 0,
|
||||
slot,
|
||||
button,
|
||||
input,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (at, id, body) in &o.raw {
|
||||
if *at == tick {
|
||||
send(raw(*id, |w| w.bytes(body)));
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
@@ -776,12 +1012,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 +1034,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 +1050,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,
|
||||
@@ -907,10 +1153,20 @@ fn main() {
|
||||
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
|
||||
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
|
||||
"join_time": join_summary(
|
||||
bots.iter()
|
||||
.map(|b| b.joined_after_ms.load(Ordering::SeqCst))
|
||||
.filter(|&ms| ms > 0)
|
||||
.collect(),
|
||||
),
|
||||
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
|
||||
"name": format!("{}{i}", o.name),
|
||||
"behaviour": behaviour_of(&o.behaviour, i).name(),
|
||||
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
|
||||
"bytes_in": b.bytes_in(),
|
||||
"bytes_out": b.bytes_out(),
|
||||
"chunks": b.chunks.load(Ordering::Relaxed),
|
||||
"unique_chunks": b.unique.load(Ordering::Relaxed),
|
||||
"resent": b.resent.load(Ordering::Relaxed),
|
||||
@@ -918,6 +1174,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("-") {
|
||||
@@ -943,3 +1201,39 @@ fn main() {
|
||||
}
|
||||
std::process::exit(if errors.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
assert_eq!(percentile(&[7], 99), 7);
|
||||
let v: Vec<u64> = (1..=100).collect();
|
||||
assert_eq!(percentile(&v, 50), 50);
|
||||
assert_eq!(percentile(&v, 99), 99);
|
||||
assert_eq!(percentile(&[10, 20, 30, 40], 50), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_summary_sorts_and_reports_max() {
|
||||
let s = join_summary(vec![300, 100, 200]);
|
||||
assert_eq!(s["count"], 3);
|
||||
assert_eq!(s["p50_ms"], 200);
|
||||
assert_eq!(s["p99_ms"], 300);
|
||||
assert_eq!(s["max_ms"], 300);
|
||||
assert_eq!(join_summary(vec![])["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_bytes_come_from_the_connection_counters() {
|
||||
let bot = Bot::default();
|
||||
assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0));
|
||||
let counters = Arc::new(Counters::default());
|
||||
counters.bytes_in.store(1234, Ordering::Relaxed);
|
||||
counters.bytes_out.store(56, Ordering::Relaxed);
|
||||
let _ = bot.counters.set(counters);
|
||||
assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,73 @@ impl Parser<'_> {
|
||||
Err("unterminated string".into())
|
||||
}
|
||||
}
|
||||
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
|
||||
/// `hoverEvent` names SNBT and older chat JSON still use.
|
||||
fn style_event_object<'a>(
|
||||
o: &'a serde_json::Map<String, Value>,
|
||||
modern: &str,
|
||||
legacy: &str,
|
||||
) -> Option<&'a serde_json::Map<String, Value>> {
|
||||
o.get(modern)
|
||||
.or_else(|| o.get(legacy))
|
||||
.and_then(Value::as_object)
|
||||
}
|
||||
|
||||
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
|
||||
fn style_flag(v: Option<&Value>) -> Option<bool> {
|
||||
match v {
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn untrusted_http_url(url: &str) -> bool {
|
||||
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
|
||||
// https only, scheme case-insensitive.
|
||||
url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
}
|
||||
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
let legacy = || text("value");
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("open_url") => text("url")
|
||||
.or_else(legacy)
|
||||
.filter(|u| untrusted_http_url(u))
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| e.get("value").and_then(Value::as_i64))
|
||||
.or_else(|| {
|
||||
e.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse().ok())
|
||||
})
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command")
|
||||
.or_else(legacy)
|
||||
.map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command")
|
||||
.or_else(legacy)
|
||||
.map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The style fields of a component object.
|
||||
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let mut style = Style::default();
|
||||
@@ -196,58 +263,36 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.bold = style_flag(o.get("bold"));
|
||||
style.italic = style_flag(o.get("italic"));
|
||||
style.underlined = style_flag(o.get("underlined"));
|
||||
style.strikethrough = style_flag(o.get("strikethrough"));
|
||||
style.obfuscated = style_flag(o.get("obfuscated"));
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
// ClickEvent.CODEC: the actions a chat line acts on here. A component
|
||||
// from a server never opens a file.
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
|
||||
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
|
||||
Some("open_url") => text("url")
|
||||
.filter(|u| {
|
||||
u.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
})
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
|
||||
style.click = parse_click_event(e);
|
||||
}
|
||||
// HoverEvent.ShowText and ShowItem.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
if let Some(value) = e.get("value") {
|
||||
// Modern `value`, or the older `contents` / nested `text`.
|
||||
let value = e
|
||||
.get("value")
|
||||
.or_else(|| e.get("contents"))
|
||||
.or_else(|| e.get("text"));
|
||||
if let Some(value) = value {
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
}
|
||||
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())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -503,10 +548,43 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.plain(), "Hi there");
|
||||
assert_eq!(t.spans[1].style.color, named_color("red"));
|
||||
// SNBT `1b` is a number in JSON; style flags still take it as true.
|
||||
assert_eq!(t.spans[1].style.bold, Some(true));
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_click_event_open_url_uses_value_field() {
|
||||
use crate::text::ClickEvent;
|
||||
let modern = serde_json::json!({
|
||||
"text": "site",
|
||||
"click_event": {"action": "open_url", "url": "https://example.com"},
|
||||
"underlined": true
|
||||
});
|
||||
let legacy = serde_json::json!({
|
||||
"text": "site",
|
||||
"clickEvent": {"action": "open_url", "value": "https://example.com"},
|
||||
"underlined": 1
|
||||
});
|
||||
for component in [modern, legacy] {
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
assert_eq!(
|
||||
text.spans[0].style.click,
|
||||
Some(ClickEvent::OpenUrl("https://example.com".into()))
|
||||
);
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
|
||||
let (v, _) = parse_snbt(snbt).unwrap();
|
||||
let text = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(
|
||||
text.spans[0].style.click,
|
||||
Some(ClickEvent::OpenUrl("https://example.com".into()))
|
||||
);
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
|
||||
@@ -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(), "/");
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,15 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -287,6 +296,13 @@ mod tests {
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Teclado en pantalla")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,8 @@ pub enum Category {
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
|
||||
/// colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
@@ -84,11 +85,52 @@ pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
/// The effects content mods declare, registered after vanilla's.
|
||||
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
|
||||
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
|
||||
/// The list is set once per process: false when it already was, and the
|
||||
/// effects stay those of the first call until the game restarts.
|
||||
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
|
||||
if MOD_EFFECTS.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let defs: Vec<EffectDef> = effects
|
||||
.into_iter()
|
||||
.map(|(id, color, category)| {
|
||||
debug_assert!(id.contains(':'), "{id} has no namespace");
|
||||
fx(Box::leak(id.into_boxed_str()), color, category)
|
||||
})
|
||||
.collect();
|
||||
MOD_EFFECTS.set(defs).is_ok()
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
/// The content mods' effects, in registry order.
|
||||
pub fn mod_effects() -> &'static [EffectDef] {
|
||||
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
/// Every registered effect: vanilla's, then the content mods'.
|
||||
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
|
||||
EFFECTS.iter().chain(mod_effects())
|
||||
}
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let short = id.trim_start_matches("minecraft:");
|
||||
match EFFECTS.iter().find(|e| e.id == short) {
|
||||
Some(e) => Some(e),
|
||||
None => mod_effect(id),
|
||||
}
|
||||
}
|
||||
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
|
||||
mod_effects().iter().find(|e| e.id == id)
|
||||
}
|
||||
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
|
||||
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
|
||||
pub fn description_id(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("effect.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage; a mod's id already is.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
@@ -248,3 +290,40 @@ pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_ids_keep_the_namespace() {
|
||||
assert_eq!(description_id("speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
}
|
||||
|
||||
/// The only test that registers: the registry is set once per process.
|
||||
#[test]
|
||||
fn mod_effects_follow_vanilla_once_registered() {
|
||||
assert!(register_mod_effects([(
|
||||
"ruby:glow".to_owned(),
|
||||
0xFF0000,
|
||||
Category::Neutral
|
||||
)]));
|
||||
assert!(!register_mod_effects([(
|
||||
"ruby:late".to_owned(),
|
||||
1,
|
||||
Category::Harmful
|
||||
)]));
|
||||
let glow = effect("ruby:glow").unwrap();
|
||||
assert_eq!(
|
||||
(glow.color, glow.category, glow.instant),
|
||||
(0xFF0000, Category::Neutral, false)
|
||||
);
|
||||
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
|
||||
assert_eq!(namespaced(glow.id), "ruby:glow");
|
||||
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
|
||||
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
|
||||
assert_eq!(ids.len(), EFFECTS.len() + 1);
|
||||
assert_eq!(ids.last(), Some(&"ruby:glow"));
|
||||
}
|
||||
}
|
||||
@@ -3,179 +3,8 @@
|
||||
//! number formatting.
|
||||
use crate::text::{self, Style, Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Double.toString`.
|
||||
pub fn java_double(v: f64) -> String {
|
||||
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
|
||||
}
|
||||
/// `Float.toString`.
|
||||
pub fn java_float(v: f32) -> String {
|
||||
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
|
||||
}
|
||||
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
|
||||
if v.is_nan() {
|
||||
return "NaN".into();
|
||||
}
|
||||
if v.is_infinite() {
|
||||
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
|
||||
}
|
||||
if v == 0.0 {
|
||||
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
|
||||
}
|
||||
let a = v.abs();
|
||||
if (1.0e-3..1.0e7).contains(&a) {
|
||||
if plain.contains('.') {
|
||||
plain.to_owned()
|
||||
} else {
|
||||
format!("{plain}.0")
|
||||
}
|
||||
} else {
|
||||
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
|
||||
let mantissa = if mantissa.contains('.') {
|
||||
mantissa.to_owned()
|
||||
} else {
|
||||
format!("{mantissa}.0")
|
||||
};
|
||||
format!("{mantissa}E{exponent}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `SnbtGrammar.escapeControlCharacters`.
|
||||
fn escape_control(c: char) -> Option<&'static str> {
|
||||
Some(match c {
|
||||
'\u{8}' => "b",
|
||||
'\u{c}' => "f",
|
||||
'\n' => "n",
|
||||
'\r' => "r",
|
||||
'\t' => "t",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `StringTag.quoteAndEscape`.
|
||||
pub fn quote_and_escape(input: &str) -> String {
|
||||
let mut out = String::from(" ");
|
||||
let mut quote = '\0';
|
||||
for c in input.chars() {
|
||||
if c == '\\' {
|
||||
out.push_str("\\\\");
|
||||
} else if c != '"' && c != '\'' {
|
||||
match escape_control(c) {
|
||||
Some(e) => {
|
||||
out.push('\\');
|
||||
out.push_str(e);
|
||||
}
|
||||
None => out.push(c),
|
||||
}
|
||||
} else {
|
||||
if quote == '\0' {
|
||||
quote = if c == '"' { '\'' } else { '"' };
|
||||
}
|
||||
if quote == c {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if quote == '\0' {
|
||||
quote = '"';
|
||||
}
|
||||
out.replace_range(0..1, "e.to_string());
|
||||
out.push(quote);
|
||||
out
|
||||
}
|
||||
fn unquoted_key(key: &str) -> bool {
|
||||
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
|
||||
return false;
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let first_ok = chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
|
||||
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
|
||||
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
|
||||
}
|
||||
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
|
||||
t.to_string_lossy()
|
||||
}
|
||||
|
||||
/// `Tag.toString` (StringTagVisitor).
|
||||
pub fn to_snbt(tag: &Tag) -> String {
|
||||
let mut out = String::new();
|
||||
visit(tag, &mut out);
|
||||
out
|
||||
}
|
||||
fn visit(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::End => out.push_str("END"),
|
||||
Tag::Byte(v) => out.push_str(&format!("{v}b")),
|
||||
Tag::Short(v) => out.push_str(&format!("{v}s")),
|
||||
Tag::Int(v) => out.push_str(&v.to_string()),
|
||||
Tag::Long(v) => out.push_str(&format!("{v}L")),
|
||||
Tag::Float(v) => {
|
||||
out.push_str(&java_float(f32::from_bits(*v)));
|
||||
out.push('f');
|
||||
}
|
||||
Tag::Double(v) => {
|
||||
out.push_str(&java_double(f64::from_bits(*v)));
|
||||
out.push('d');
|
||||
}
|
||||
Tag::String(s) => out.push_str("e_and_escape(&string_of(s))),
|
||||
Tag::ByteArray(a) => {
|
||||
out.push_str("[B;");
|
||||
out.push_str(
|
||||
&a.iter()
|
||||
.map(|v| format!("{}B", *v as i8))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
out.push(']');
|
||||
}
|
||||
Tag::IntArray(a) => {
|
||||
out.push_str("[I;");
|
||||
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::LongArray(a) => {
|
||||
out.push_str("[L;");
|
||||
out.push_str(
|
||||
&a.iter()
|
||||
.map(|v| format!("{v}L"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
out.push(']');
|
||||
}
|
||||
Tag::List(l) => {
|
||||
out.push('[');
|
||||
for (i, t) in l.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
visit(t, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Tag::Compound(c) => {
|
||||
out.push('{');
|
||||
let mut entries: Vec<(String, &Tag)> =
|
||||
c.iter().map(|(k, v)| (string_of(k), v)).collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (i, (k, v)) in entries.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
if unquoted_key(k) {
|
||||
out.push_str(k);
|
||||
} else {
|
||||
out.push_str("e_and_escape(k));
|
||||
}
|
||||
out.push(':');
|
||||
visit(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
use horizon_world::snbt_text::string_of;
|
||||
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
|
||||
|
||||
// ---- the coloured form -----------------------------------------------------
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -567,6 +567,34 @@ fn length(v: [f32; 2]) -> f32 {
|
||||
(v[0] * v[0] + v[1] * v[1]).sqrt()
|
||||
}
|
||||
|
||||
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
|
||||
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
|
||||
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
|
||||
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
|
||||
|
||||
/// Stick axes after correcting pads whose right stick gilrs still exposes on
|
||||
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
|
||||
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
|
||||
pub fn stick_axes(
|
||||
left: [f32; 2],
|
||||
right: [f32; 2],
|
||||
left_z: f32,
|
||||
vendor: Option<u16>,
|
||||
product: Option<u16>,
|
||||
sdl_mapped: bool,
|
||||
) -> ([f32; 2], [f32; 2]) {
|
||||
if !sdl_mapped
|
||||
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
|
||||
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
|
||||
{
|
||||
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
|
||||
// is the right trigger and must not drive look.
|
||||
(left, [left_z, right[0]])
|
||||
} else {
|
||||
(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
|
||||
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
|
||||
/// clipped and the first movement past the deadzone is small.
|
||||
@@ -1178,6 +1206,38 @@ mod tests {
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultimate2_right_stick_on_z_without_sdl() {
|
||||
let (left, right) = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
-0.5,
|
||||
Some(BITDO_ULTIMATE2_VID_PID.0),
|
||||
Some(BITDO_ULTIMATE2_VID_PID.1),
|
||||
false,
|
||||
);
|
||||
assert_eq!(left, [0.2, 0.3]);
|
||||
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
|
||||
let kept = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
-0.5,
|
||||
Some(BITDO_ULTIMATE2_VID_PID.0),
|
||||
Some(BITDO_ULTIMATE2_VID_PID.1),
|
||||
true,
|
||||
);
|
||||
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
|
||||
let other = stick_axes(
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
0.5,
|
||||
Some(0x045e),
|
||||
Some(0x028e),
|
||||
false,
|
||||
);
|
||||
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_deadzone_rescales() {
|
||||
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,10 @@ pub struct KeyState {
|
||||
released_by_screen_when_down: bool,
|
||||
toggle: Option<bool>,
|
||||
keyboard: bool,
|
||||
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
|
||||
/// vanilla has no option for): until it lets go, the mapping toggles as
|
||||
/// if its vanilla option were on, so a screen releases and restores it.
|
||||
pad_toggled: bool,
|
||||
}
|
||||
|
||||
impl KeyState {
|
||||
@@ -20,6 +24,7 @@ impl KeyState {
|
||||
released_by_screen_when_down: false,
|
||||
toggle: None,
|
||||
keyboard: true,
|
||||
pad_toggled: false,
|
||||
}
|
||||
}
|
||||
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
|
||||
@@ -43,16 +48,28 @@ impl KeyState {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// The mapping toggles: its option is on, or a pad toggled it down.
|
||||
fn needs_toggle(&self, option: bool) -> bool {
|
||||
option || self.pad_toggled
|
||||
}
|
||||
/// `setDown`; `needs_toggle` is the mapping's option value.
|
||||
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
|
||||
if self.toggle.is_some() && needs_toggle {
|
||||
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
|
||||
if down {
|
||||
self.is_down = !self.is_down;
|
||||
// Toggled back up: the pad's toggle is over.
|
||||
self.pad_toggled &= self.is_down;
|
||||
}
|
||||
} else {
|
||||
self.is_down = down;
|
||||
}
|
||||
}
|
||||
/// A controller's toggle button: flips the mapping like `setDown(true)`
|
||||
/// with its option on, whatever the option says.
|
||||
pub fn pad_toggle(&mut self) {
|
||||
self.is_down = !self.is_down;
|
||||
self.pad_toggled = self.is_down;
|
||||
}
|
||||
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
|
||||
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
|
||||
self.keyboard && (self.toggle.is_none() || !needs_toggle)
|
||||
@@ -61,7 +78,8 @@ impl KeyState {
|
||||
pub fn release(&mut self, needs_toggle: bool) {
|
||||
if self.toggle.is_some() {
|
||||
// ToggleKeyMapping.release does not clear pending clicks.
|
||||
if needs_toggle && self.is_down || self.released_by_screen_when_down {
|
||||
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
|
||||
{
|
||||
self.released_by_screen_when_down = true;
|
||||
}
|
||||
self.reset();
|
||||
@@ -70,7 +88,8 @@ impl KeyState {
|
||||
self.set_down(false, needs_toggle);
|
||||
}
|
||||
}
|
||||
/// `ToggleKeyMapping.reset`.
|
||||
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
|
||||
/// that released it restores it.
|
||||
pub fn reset(&mut self) {
|
||||
self.is_down = false;
|
||||
}
|
||||
@@ -83,7 +102,10 @@ impl KeyState {
|
||||
let Some(restore) = self.toggle else {
|
||||
return false;
|
||||
};
|
||||
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
|
||||
let result = restore
|
||||
&& self.needs_toggle(needs_toggle)
|
||||
&& self.keyboard
|
||||
&& self.released_by_screen_when_down;
|
||||
self.released_by_screen_when_down = false;
|
||||
result
|
||||
}
|
||||
@@ -138,6 +160,15 @@ impl ToggleKeys {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
|
||||
/// a press with no screen open flips the mapping and clicks it, as a
|
||||
/// key press does with the mapping's toggle option on.
|
||||
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
|
||||
if !screen_open {
|
||||
self.0[slot].pad_toggle();
|
||||
self.0[slot].click();
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
|
||||
/// screen or overlay.
|
||||
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
|
||||
@@ -167,6 +198,9 @@ impl ToggleKeys {
|
||||
pub fn respawn(&mut self) {
|
||||
for key in &mut self.0 {
|
||||
key.reset();
|
||||
// As with the vanilla option, the death screen closing after
|
||||
// this still restores a toggle it released.
|
||||
key.pad_toggled &= key.released_by_screen_when_down;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +231,57 @@ mod tests {
|
||||
keys.respawn();
|
||||
assert!(!keys.is_down(SPRINT));
|
||||
}
|
||||
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
|
||||
/// back after a screen, as the vanilla option's toggle does; a
|
||||
/// keyboard shift-click in the inventory does not drop it.
|
||||
#[test]
|
||||
fn pad_toggle_is_restored_after_a_screen() {
|
||||
let off = [false; 4];
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
keys.screen_opened(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.key(SNEAK, KeyAction::Press, true, false);
|
||||
keys.key(SNEAK, KeyAction::Release, true, false);
|
||||
keys.screen_closed(off);
|
||||
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
|
||||
// Toggled back up, it stays up through the next screen.
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.screen_opened(off);
|
||||
keys.screen_closed(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
// A held key is not restored, as before.
|
||||
keys.key(SNEAK, KeyAction::Press, false, false);
|
||||
keys.screen_opened(off);
|
||||
keys.key(SNEAK, KeyAction::Release, true, false);
|
||||
keys.screen_closed(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
}
|
||||
/// After the pad's toggle is over (toggled up, or reset by a respawn
|
||||
/// with no screen to restore it), the shift key holds again.
|
||||
#[test]
|
||||
fn pad_toggle_ends_with_its_release() {
|
||||
let off = [false; 4];
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
keys.respawn();
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.key(SNEAK, KeyAction::Press, false, false);
|
||||
keys.key(SNEAK, KeyAction::Release, false, false);
|
||||
assert!(!keys.is_down(SNEAK), "held, not toggled");
|
||||
// The death screen releases it and closes after the respawn: the
|
||||
// toggle comes back, as with the vanilla option.
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
keys.screen_opened(off);
|
||||
keys.respawn();
|
||||
keys.screen_closed(off);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
// A press with a screen open does nothing.
|
||||
keys.pad_toggle(SNEAK, true);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
}
|
||||
#[test]
|
||||
fn screen_release_restores_toggled_state() {
|
||||
let mut k = KeyState::toggle(true, true);
|
||||
|
||||
@@ -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);
|
||||
@@ -428,6 +673,11 @@ mod tests {
|
||||
vsync_present_mode(&[PresentMode::Fifo], false),
|
||||
Some(PresentMode::Fifo)
|
||||
);
|
||||
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
|
||||
assert_eq!(
|
||||
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
|
||||
Some(PresentMode::Immediate)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
|
||||
|
||||
@@ -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 }
|
||||
@@ -31,11 +32,12 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
glam = "0.30"
|
||||
log = "0.4"
|
||||
miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
|
||||
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,9 +192,8 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
|
||||
/// layers, without the block or item models or the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
@@ -209,8 +208,13 @@ impl RenderAssets {
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
// A server draws no block and no item: its block models (about 24 MB
|
||||
// baked) and item model library (7 MB) stay empty.
|
||||
let parsed = if draws {
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
|
||||
@@ -47,6 +47,12 @@ struct Mixer {
|
||||
listener_yaw: f32,
|
||||
listener_pitch: f32,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_name: String,
|
||||
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_lost: bool,
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -167,6 +173,15 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
/// The other half of `lastSeenDevices`: every output device last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: Vec<String>,
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -506,19 +521,30 @@ fn open_device(
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
m.device_name = device.name().unwrap_or_default();
|
||||
m.device_lost = false;
|
||||
}
|
||||
Ok((stream, config))
|
||||
}
|
||||
/// The name of the system's default output device.
|
||||
#[cfg(feature = "audio")]
|
||||
fn default_output_name() -> Option<String> {
|
||||
cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
@@ -528,6 +554,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let on_error = mixer.clone();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
@@ -541,7 +568,14 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| log::warn!("Horizon: audio stream error: {e}"),
|
||||
move |e| {
|
||||
log::warn!("Horizon: audio stream error: {e}");
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable)
|
||||
&& let Ok(mut m) = on_error.lock()
|
||||
{
|
||||
m.device_lost = true;
|
||||
}
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
@@ -637,6 +671,12 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: if live { output_devices() } else { Vec::new() },
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -764,8 +804,64 @@ impl Audio {
|
||||
}
|
||||
self.engine.listening = self.record_heard;
|
||||
}
|
||||
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
|
||||
/// default changed (with no device chosen in the options), or the chosen
|
||||
/// device came back; the output is then opened again. The devices are looked
|
||||
/// at once a second.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
let (current, lost) = match self.backend.mixer.lock() {
|
||||
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
|
||||
Err(_) => return false,
|
||||
};
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.default_seen = default_output_name();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
self.device_ticks += 1;
|
||||
if self._stream.is_none() || self.device_ticks < 20 {
|
||||
return false;
|
||||
}
|
||||
self.device_ticks = 0;
|
||||
let default = default_output_name();
|
||||
let devices = output_devices();
|
||||
// Only a change in the device lists is looked at, as in vanilla: a
|
||||
// preferred device that is listed but cannot open is not retried.
|
||||
if default == self.default_seen && devices == self.devices_seen {
|
||||
return false;
|
||||
}
|
||||
self.default_seen = default.clone();
|
||||
let wanted = self.device_name.clone();
|
||||
let available = devices.contains(&wanted);
|
||||
self.devices_seen = devices;
|
||||
if wanted.is_empty() {
|
||||
let changed = default.as_deref().is_some_and(|d| d != current);
|
||||
if changed {
|
||||
log::info!("Horizon: system default audio device has changed");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
let changed = current != wanted && available;
|
||||
if changed {
|
||||
log::info!("Horizon: preferred audio device has become available");
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
if self.should_change_device() {
|
||||
// SoundEngine.reload: stop every sound, open the output again.
|
||||
self.stop_all();
|
||||
let device = self.device_name.clone();
|
||||
if let Err(e) = self.set_device(&device) {
|
||||
log::warn!("Horizon: could not reopen the audio output: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
@@ -801,6 +897,37 @@ impl Audio {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
|
||||
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
|
||||
pub fn play_distance_delayed(
|
||||
&mut self,
|
||||
event: &str,
|
||||
at: [f64; 3],
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
camera: [f64; 3],
|
||||
) {
|
||||
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
|
||||
if distance_sqr <= 100.0 {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
}
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
self.sync_volumes();
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let source = self
|
||||
.source_override
|
||||
.unwrap_or_else(|| crate::settings::sound_source(&event));
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine
|
||||
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
|
||||
@@ -203,6 +203,46 @@ pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<St
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// `Minecraft`'s constructor futures for the title screen: `UserApiService.fetchProperties`
|
||||
/// (the `player` service's `getAttributes`, with the bearer token) and
|
||||
/// `SessionService.fetchProfile(uuid, true)` (`getProfileById`). A failed
|
||||
/// attributes call falls back to the offline properties, a failed profile call
|
||||
/// to no actions, as vanilla does. Blocks: call it off the main thread.
|
||||
pub fn fetch_account_status(session: &AuthSession) -> crate::ban_notice::AccountStatus {
|
||||
use crate::ban_notice::{AccountStatus, parse_profile_actions, parse_properties};
|
||||
let Ok(doc) = discovery() else {
|
||||
return AccountStatus::default();
|
||||
};
|
||||
let properties = endpoint(&doc, "player", "getAttributes")
|
||||
.and_then(|url| {
|
||||
agent()
|
||||
.get(&url)
|
||||
.set("Authorization", &format!("Bearer {}", session.access_token))
|
||||
.call()
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
.and_then(|body| parse_properties(&body))
|
||||
.unwrap_or_default();
|
||||
let actions = endpoint(&doc, "session", "getProfileById")
|
||||
.and_then(|url| {
|
||||
let url = url.replace("{profileId}", &format!("{:032x}", session.uuid));
|
||||
agent()
|
||||
.get(&format!("{url}?unsigned=true"))
|
||||
.call()
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
.map(|body| parse_profile_actions(&body))
|
||||
.unwrap_or_default();
|
||||
AccountStatus {
|
||||
properties,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
|
||||
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
|
||||
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
|
||||
//! attributes call), the forced name change and the banned skin
|
||||
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
|
||||
//! The network calls are in `auth.rs`; this file parses what they return.
|
||||
use crate::text::Text;
|
||||
|
||||
/// `CommonLinks.SUSPENSION_HELP`.
|
||||
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
|
||||
|
||||
/// authlib's `BanDetails` (the id is never shown).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BanDetails {
|
||||
/// Epoch milliseconds the ban ends; `None` is permanent.
|
||||
pub expires_ms: Option<i64>,
|
||||
pub reason: Option<String>,
|
||||
pub reason_message: Option<String>,
|
||||
}
|
||||
|
||||
/// authlib's `UserProperties`, as far as the title screen reads it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProperties {
|
||||
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
|
||||
pub servers_allowed: bool,
|
||||
/// `bannedScopes.get("MULTIPLAYER")`.
|
||||
pub multiplayer_ban: Option<BanDetails>,
|
||||
}
|
||||
impl Default for UserProperties {
|
||||
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// authlib's `ProfileResult.actions()`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProfileActions {
|
||||
pub forced_name_change: bool,
|
||||
pub using_banned_skin: bool,
|
||||
}
|
||||
|
||||
/// What the title screen learns about the account.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AccountStatus {
|
||||
pub properties: UserProperties,
|
||||
pub actions: ProfileActions,
|
||||
}
|
||||
impl AccountStatus {
|
||||
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
|
||||
pub fn allows_multiplayer(&self) -> bool {
|
||||
self.properties.servers_allowed
|
||||
&& self.properties.multiplayer_ban.is_none()
|
||||
&& !self.actions.forced_name_change
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the screens `Gui.addInitialScreens` adds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Notice {
|
||||
/// `BanNoticeScreens.create`.
|
||||
Multiplayer(BanDetails),
|
||||
/// `BanNoticeScreens.createNameBan` with the profile's name.
|
||||
Name(String),
|
||||
/// `BanNoticeScreens.createSkinBan`.
|
||||
Skin,
|
||||
}
|
||||
|
||||
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
|
||||
/// name ban, the skin ban.
|
||||
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
|
||||
let mut notices = Vec::new();
|
||||
if let Some(ban) = &status.properties.multiplayer_ban {
|
||||
notices.push(Notice::Multiplayer(ban.clone()));
|
||||
}
|
||||
if status.actions.forced_name_change {
|
||||
notices.push(Notice::Name(profile_name.to_owned()));
|
||||
}
|
||||
if status.actions.using_banned_skin {
|
||||
notices.push(Notice::Skin);
|
||||
}
|
||||
notices
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
/// The screen's title (bold).
|
||||
pub fn title(&self) -> Text {
|
||||
let key = match self {
|
||||
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
|
||||
Self::Multiplayer(_) => "gui.banned.title.permanent",
|
||||
Self::Name(_) => "gui.banned.name.title",
|
||||
Self::Skin => "gui.banned.skin.title",
|
||||
};
|
||||
Text::translate(key, &[]).bold()
|
||||
}
|
||||
/// The screen's message; `now_ms` is `Instant.now()`.
|
||||
pub fn message(&self, now_ms: i64) -> Text {
|
||||
let link = || Text::literal(SUSPENSION_HELP);
|
||||
match self {
|
||||
Self::Multiplayer(ban) => Text::translate(
|
||||
"gui.banned.description",
|
||||
&[reason_text(ban), status_text(ban, now_ms), link()],
|
||||
),
|
||||
Self::Name(name) => Text::translate(
|
||||
"gui.banned.name.description",
|
||||
&[Text::colored(name.clone(), YELLOW), link()],
|
||||
),
|
||||
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatFormatting.YELLOW`.
|
||||
const YELLOW: u32 = 0xFFFF55;
|
||||
|
||||
/// `BanReason.byId`: the translation key suffix.
|
||||
fn ban_reason(id: i32) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
2 => "false_reporting",
|
||||
5 => "hate_speech",
|
||||
16 | 25 => "hate_terrorism_notorious_figure",
|
||||
17 | 19 | 23 | 31 => "generic_violation",
|
||||
21 => "harassment_or_bullying",
|
||||
27 => "defamation_impersonation_false_information",
|
||||
28 => "drugs",
|
||||
29 => "fraud",
|
||||
30 => "spam_or_advertising",
|
||||
32 => "nudity_or_pornography",
|
||||
33 | 35 | 36 => "sexually_inappropriate",
|
||||
34 => "extreme_violence_or_gore",
|
||||
53 => "imminent_harm_to_person_or_property",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanReasonText`.
|
||||
fn reason_text(ban: &BanDetails) -> Text {
|
||||
let numeric = ban
|
||||
.reason
|
||||
.as_deref()
|
||||
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
|
||||
let Some(reason) = numeric else {
|
||||
return Text::translate("gui.banned.description.unknownreason", &[]);
|
||||
};
|
||||
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
|
||||
let id = reason.parse::<i32>().ok();
|
||||
let text = if let Some(key) = id.and_then(ban_reason) {
|
||||
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
|
||||
} else if let Some(message) = &ban.reason_message {
|
||||
Text::translate(
|
||||
"gui.banned.description.reason_id_message",
|
||||
&[Text::literal(reason), Text::literal(message.clone())],
|
||||
)
|
||||
.bold()
|
||||
} else {
|
||||
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
|
||||
};
|
||||
Text::translate("gui.banned.description.reason", &[text])
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanStatusText`.
|
||||
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
|
||||
match ban.expires_ms {
|
||||
Some(expires) => {
|
||||
let duration = Text::translate(
|
||||
"gui.banned.description.temporary.duration",
|
||||
&[duration_text(expires - now_ms)],
|
||||
)
|
||||
.bold();
|
||||
Text::translate("gui.banned.description.temporary", &[duration])
|
||||
}
|
||||
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
|
||||
/// else minutes (`Duration.toHours` and friends truncate toward zero).
|
||||
fn duration_text(millis: i64) -> Text {
|
||||
let count = |n: i64| Text::literal(n.to_string());
|
||||
let hours = millis / 3_600_000;
|
||||
if hours > 72 {
|
||||
Text::translate("gui.days", &[count(millis / 86_400_000)])
|
||||
} else if hours < 1 {
|
||||
Text::translate("gui.minutes", &[count(millis / 60_000)])
|
||||
} else {
|
||||
Text::translate("gui.hours", &[count(hours)])
|
||||
}
|
||||
}
|
||||
|
||||
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
|
||||
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64(),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|t| t.timestamp_millis())
|
||||
.or_else(|| s.parse().ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
|
||||
/// is an error, which vanilla answers with the offline properties.
|
||||
pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let servers_allowed = v
|
||||
.pointer("/privileges/multiplayerServer/enabled")
|
||||
.and_then(|e| e.as_bool())
|
||||
.unwrap_or(false);
|
||||
let multiplayer_ban = v
|
||||
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
|
||||
.filter(|b| b.is_object())
|
||||
.map(|b| {
|
||||
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
|
||||
BanDetails {
|
||||
expires_ms: b.get("expires").and_then(parse_instant),
|
||||
reason: text("reason"),
|
||||
reason_message: text("reasonMessage"),
|
||||
}
|
||||
});
|
||||
Some(UserProperties {
|
||||
servers_allowed,
|
||||
multiplayer_ban,
|
||||
})
|
||||
}
|
||||
|
||||
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
|
||||
pub fn parse_profile_actions(body: &str) -> ProfileActions {
|
||||
let mut actions = ProfileActions::default();
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
for action in v
|
||||
.get("profileActions")
|
||||
.and_then(|a| a.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
match action.get("type").and_then(|t| t.as_str()) {
|
||||
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
|
||||
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
/// 2026-11-01T00:00:00Z.
|
||||
const EXPIRES: i64 = 1_793_491_200_000;
|
||||
|
||||
#[test]
|
||||
fn parses_a_multiplayer_ban() {
|
||||
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
|
||||
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
|
||||
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
|
||||
let p = parse_properties(body).unwrap();
|
||||
assert!(!p.servers_allowed);
|
||||
let ban = p.multiplayer_ban.unwrap();
|
||||
assert_eq!(ban.expires_ms, Some(EXPIRES));
|
||||
assert_eq!(ban.reason.as_deref(), Some("21"));
|
||||
let numeric =
|
||||
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
|
||||
assert!(parse_properties("nope").is_none());
|
||||
let clean = parse_properties(
|
||||
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profile_actions() {
|
||||
let a = parse_profile_actions(
|
||||
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
|
||||
);
|
||||
assert!(a.forced_name_change && !a.using_banned_skin);
|
||||
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
|
||||
assert!(!a.forced_name_change && a.using_banned_skin);
|
||||
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queues_in_vanilla_order() {
|
||||
let ban = BanDetails {
|
||||
expires_ms: None,
|
||||
reason: None,
|
||||
reason_message: None,
|
||||
};
|
||||
let status = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: Some(ban.clone()),
|
||||
},
|
||||
actions: ProfileActions {
|
||||
forced_name_change: true,
|
||||
using_banned_skin: true,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
initial_notices(&status, "Steve"),
|
||||
vec![
|
||||
Notice::Multiplayer(ban),
|
||||
Notice::Name("Steve".into()),
|
||||
Notice::Skin
|
||||
]
|
||||
);
|
||||
assert!(!status.allows_multiplayer());
|
||||
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
|
||||
assert!(AccountStatus::default().allows_multiplayer());
|
||||
let no_servers = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: false,
|
||||
multiplayer_ban: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!no_servers.allows_multiplayer());
|
||||
}
|
||||
|
||||
/// The message's component JSON: the keys and arguments, whatever language is loaded.
|
||||
fn json(notice: &Notice, now: i64) -> String {
|
||||
notice.message(now).component().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ban_text_follows_banned_screens() {
|
||||
let temp = |reason: Option<&str>, message: Option<&str>| {
|
||||
Notice::Multiplayer(BanDetails {
|
||||
expires_ms: Some(100 * HOUR),
|
||||
reason: reason.map(str::to_owned),
|
||||
reason_message: message.map(str::to_owned),
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
temp(None, None).title().translation_key(),
|
||||
Some("gui.banned.title.temporary")
|
||||
);
|
||||
let perm = Notice::Multiplayer(BanDetails {
|
||||
expires_ms: None,
|
||||
reason: Some("5".into()),
|
||||
reason_message: None,
|
||||
});
|
||||
assert_eq!(
|
||||
perm.title().translation_key(),
|
||||
Some("gui.banned.title.permanent")
|
||||
);
|
||||
let text = json(&perm, 0);
|
||||
assert!(text.contains("gui.banned.reason.hate_speech"));
|
||||
assert!(text.contains("gui.banned.description.permanent"));
|
||||
assert!(text.contains(SUSPENSION_HELP));
|
||||
// Unknown ids show their code, with the message when there is one.
|
||||
let with_message = json(&temp(Some("999"), Some("why")), 0);
|
||||
assert!(with_message.contains("gui.banned.description.reason_id_message"));
|
||||
assert!(with_message.contains("why"));
|
||||
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
|
||||
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
|
||||
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
|
||||
let duration = |now| json(&temp(None, None), now);
|
||||
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
|
||||
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
|
||||
let minutes = duration(100 * HOUR - 90_000);
|
||||
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
|
||||
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
|
||||
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -789,6 +789,8 @@ pub struct Scene<'a> {
|
||||
pub glyphs: u64,
|
||||
}
|
||||
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
kinds: Vec<Kind>,
|
||||
@@ -1114,7 +1116,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 +1129,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]))
|
||||
@@ -1477,6 +1492,7 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
@@ -1511,7 +1527,13 @@ impl BlockEntityRenderer {
|
||||
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
|
||||
_ => {}
|
||||
};
|
||||
let start = out.len();
|
||||
draw_part(model, "root", m, &anim, paint, out);
|
||||
// `SkullBlockRenderer.resolveSkullRenderType`: a head with a profile takes
|
||||
// the player skin's `entityTranslucent` render type.
|
||||
if has_profile {
|
||||
crate::gfx::mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
|
||||
@@ -24,6 +24,9 @@ struct Out {
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
|
||||
return border_color(in);
|
||||
}
|
||||
fn border_color(in: Out) -> vec4<f32> {
|
||||
let color = textureSample(forcefield, forcefield_sampler, in.uv);
|
||||
if color.a == 0.0 {
|
||||
discard;
|
||||
|
||||
@@ -12,8 +12,8 @@ use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
@@ -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>,
|
||||
@@ -64,8 +80,7 @@ pub fn effect_lines<'a>(
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
let mut line = translate(&description_id(e)).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_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,14 @@ 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;
|
||||
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
|
||||
const MODIFIED_INDICATOR: u32 = 0x60_6060;
|
||||
/// 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)]
|
||||
@@ -163,15 +171,56 @@ impl ChatOptions {
|
||||
struct Message {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
/// `GuiMessage.signature`: a signed player message's, for deletion.
|
||||
signature: Option<Box<Signature>>,
|
||||
}
|
||||
/// A wrapped line (`GuiMessage.Line`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Line {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
}
|
||||
|
||||
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
|
||||
/// over it, and for a modified message the icon after each line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub color: u32,
|
||||
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
|
||||
pub icon: bool,
|
||||
pub text: Text,
|
||||
}
|
||||
impl Tag {
|
||||
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
|
||||
pub fn system_single_player(color: u32) -> Self {
|
||||
Self {
|
||||
color,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.system_single_player", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatNotSecure`.
|
||||
pub fn not_secure() -> Self {
|
||||
Self {
|
||||
color: SYSTEM_INDICATOR,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.not_secure", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
|
||||
pub fn modified(original_content: &str) -> Self {
|
||||
Self {
|
||||
color: MODIFIED_INDICATOR,
|
||||
icon: true,
|
||||
text: Text::empty()
|
||||
.append(Text::translate("chat.tag.modified", &[]))
|
||||
.append(Text::literal("\n"))
|
||||
.append(Text::literal(original_content).with_color(text::GRAY)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
@@ -205,6 +254,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 {
|
||||
@@ -225,38 +277,99 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
|
||||
self.add_message(&atlas.font, content, tag, 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>,
|
||||
tag: Option<Tag>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn add_signed_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_player_message(font, content, signature, None, ticks, focused);
|
||||
}
|
||||
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
|
||||
fn add_player_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
tag: Option<Tag>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
tag: tag.map(std::sync::Arc::new),
|
||||
source: ChatSource::Player,
|
||||
signature: signature.map(Box::new),
|
||||
},
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn add_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
if !self.abilities.shows(source) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
tag,
|
||||
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);
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
|
||||
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
|
||||
9 + 4 + 2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let width = self.max_width() - icon;
|
||||
let content = self.displayed(&message.content);
|
||||
for line in font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
@@ -268,7 +381,7 @@ impl Chat {
|
||||
Line {
|
||||
added: message.added,
|
||||
content: line,
|
||||
indicator: message.indicator,
|
||||
tag: message.tag.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -319,8 +432,68 @@ 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(),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
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) {
|
||||
@@ -431,8 +604,8 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(indicator) = line.indicator {
|
||||
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -593,12 +766,91 @@ impl Chat {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether the cursor is on a line's indicator bar while chat is focused
|
||||
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
|
||||
/// message tag's text.
|
||||
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
|
||||
self.line_at(height, x, y)
|
||||
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
|
||||
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
|
||||
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
|
||||
/// the tag under it (its bar, or its icon) and the icons to draw, those
|
||||
/// under the cursor and those of the message the cursor is over.
|
||||
pub fn focused_tags(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> (Option<Text>, Vec<GuiDraw>) {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
|
||||
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
|
||||
mouse_x >= f64::from(left)
|
||||
&& mouse_x < f64::from(right)
|
||||
&& mouse_y >= f64::from(top)
|
||||
&& mouse_y < f64::from(bottom)
|
||||
};
|
||||
let visible = self
|
||||
.lines
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let mut tooltip = None;
|
||||
let mut icons = Vec::new();
|
||||
let mut hovered_message = false;
|
||||
for i in (0..visible).rev() {
|
||||
let index = i + self.scroll;
|
||||
let line = &self.lines[index];
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
let width = font.width_text(&line.content).ceil() as i32;
|
||||
hovered_message |= inside(0, top, width, bottom);
|
||||
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
|
||||
let end_of_entry = index == 0
|
||||
|| match (&line.tag, &self.lines[index - 1].tag) {
|
||||
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
let force_icon = end_of_entry && hovered_message;
|
||||
if end_of_entry {
|
||||
hovered_message = false;
|
||||
}
|
||||
let Some(tag) = &line.tag else { continue };
|
||||
if inside(-4, top, -2, bottom) {
|
||||
tooltip = Some(tag.text.clone());
|
||||
}
|
||||
if tag.icon {
|
||||
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
|
||||
let icon_top = text_bottom - 9 - 1;
|
||||
let over = inside(left, icon_top, left + 9, text_bottom);
|
||||
if over {
|
||||
tooltip = Some(tag.text.clone());
|
||||
}
|
||||
if force_icon || over {
|
||||
icons.push(GuiDraw::Sprite {
|
||||
sprite: "icon/chat_modified".into(),
|
||||
x: left,
|
||||
y: icon_top,
|
||||
w: 9,
|
||||
h: 9,
|
||||
region: None,
|
||||
color: hud_layout::WHITE,
|
||||
blend: hud_layout::Blend::Textured,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if icons.is_empty() {
|
||||
return (tooltip, icons);
|
||||
}
|
||||
let mut draws = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
draws.extend(icons);
|
||||
draws.push(GuiDraw::Pop);
|
||||
(tooltip, draws)
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
Line {
|
||||
added: 0,
|
||||
content,
|
||||
indicator: None,
|
||||
tag: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -519,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.tag.as_ref().map(|t| t.color), 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());
|
||||
}
|
||||
@@ -86,6 +86,43 @@ impl LevelShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (`LevelShape::column`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -206,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)]
|
||||
@@ -337,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.
|
||||
|
||||
@@ -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,13 +309,21 @@ 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(),
|
||||
}
|
||||
@@ -369,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(),
|
||||
},
|
||||
)
|
||||
@@ -480,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();
|
||||
@@ -500,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)
|
||||
);
|
||||
}
|
||||
@@ -552,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(),
|
||||
},
|
||||
);
|
||||
@@ -572,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(),
|
||||
},
|
||||
)
|
||||
@@ -672,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!(
|
||||
@@ -705,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.
|
||||
|
||||
@@ -223,6 +223,35 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// Re-Create World's packs from a saved world: a new folder holding a copy of
|
||||
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
|
||||
/// world has none), and the selection its `datapacks.json` makes, lowest
|
||||
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
|
||||
/// The built-in pack is selected unless the world turned it off.
|
||||
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
|
||||
let packs = discover(world);
|
||||
let mut selected = enabled_ids(world, &packs);
|
||||
let vanilla_off = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.and_then(|v| v["disabled"].as_array().cloned())
|
||||
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
|
||||
if !vanilla_off {
|
||||
selected.insert(0, "vanilla".into());
|
||||
}
|
||||
if packs.is_empty() {
|
||||
return (None, selected);
|
||||
}
|
||||
let dir = temp_folder().ok().and_then(|dir| {
|
||||
if copy(&world.join("datapacks"), &dir).is_ok() {
|
||||
Some(dir)
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
None
|
||||
}
|
||||
});
|
||||
(dir, selected)
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
@@ -618,6 +647,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 +696,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 +768,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 +964,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())
|
||||
|
||||
@@ -6,7 +6,8 @@ use horizon_client_core::potions;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectInstance {
|
||||
/// Effect id without namespace (`speed`, `poison`, ...).
|
||||
/// Effect id: vanilla's without namespace (`speed`, `poison`, ...), a
|
||||
/// content mod's `<ns>:<path>`.
|
||||
pub id: &'static str,
|
||||
/// Remaining ticks; -1 is infinite.
|
||||
pub duration: i32,
|
||||
@@ -148,6 +149,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 +172,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 +185,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();
|
||||
@@ -203,7 +220,7 @@ impl PlayerEffects {
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
format!("minecraft:{}", e.id),
|
||||
potions::namespaced(e.id).to_owned(),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
@@ -308,19 +325,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 +490,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();
|
||||
@@ -486,6 +526,30 @@ mod tests {
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
/// The only test here that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mod_effect_saves_under_its_namespace() {
|
||||
potions::register_mod_effects([(
|
||||
"ruby:glow".to_owned(),
|
||||
0xFF0000,
|
||||
potions::Category::Neutral,
|
||||
)]);
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new(
|
||||
potions::effect("ruby:glow").unwrap().id,
|
||||
600,
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
|
||||
// Without its mod the saved effect is dropped, as an unknown id is.
|
||||
assert!(
|
||||
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn raid_omen_acts_on_its_last_tick() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
|
||||
@@ -23,6 +23,7 @@ struct Globals {
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -131,7 +132,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -224,6 +225,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_color(in, front);
|
||||
}
|
||||
fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
@@ -271,6 +275,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
return shadow_color(in);
|
||||
}
|
||||
fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
@@ -306,21 +313,33 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
|
||||
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
|
||||
// geometry that sets none (the End sky, the End flash): it stays brightest.
|
||||
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
|
||||
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
|
||||
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
|
||||
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
|
||||
// as breaking::stage_light packs them.
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
fn energy_swirl_base(in: Out) -> vec4<f32> {
|
||||
let bytes = round(in.color * 255.0);
|
||||
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
|
||||
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
|
||||
if color.a < 0.1 { discard; }
|
||||
return vec4(color.rgb * (128.0 / 255.0), color.a);
|
||||
}
|
||||
fn energy_swirl_fog(in: Out) -> f32 {
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
return f * globals.fog_color.a;
|
||||
}
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@@ -449,6 +468,9 @@ struct WeatherTime { values: vec4<f32> }
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
|
||||
return weather_color(in);
|
||||
}
|
||||
fn weather_color(in: Out) -> vec4<f32> {
|
||||
// Repeat within this sprite, never across the atlas. The atlas uses nearest
|
||||
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
|
||||
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
|
||||
|
||||
@@ -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));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -491,10 +491,6 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn depth_test(&self) -> DepthTest {
|
||||
self.depth_test
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1 (`Early`): draw the tested boxes
|
||||
/// against its depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
|
||||
@@ -9,7 +9,24 @@ use super::Vertex;
|
||||
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
|
||||
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 9;
|
||||
///
|
||||
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
const MATERIALS: usize = 10;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
for vertex in quads {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
}
|
||||
}
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -30,7 +47,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer {
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
@@ -51,6 +71,10 @@ pub(super) struct EntityBatch {
|
||||
// frame, keeping their storage.
|
||||
runs: [Vec<(usize, usize)>; MATERIALS],
|
||||
pub(super) combined: Vec<Vertex>,
|
||||
/// Additive quads tagged `OIT_ENTITY` that the last `update` moved into the
|
||||
/// last material (improved transparency on): the dragon's eyes, which share
|
||||
/// the additive buffer with its rays.
|
||||
pub(super) moved_additive: u32,
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
@@ -60,6 +84,7 @@ impl EntityBatch {
|
||||
additive: &[Vertex],
|
||||
hand: &[Vertex],
|
||||
see_through: &[Vertex],
|
||||
oit: bool,
|
||||
) -> [u32; MATERIALS] {
|
||||
let quads = entities.as_chunks::<4>().0;
|
||||
for runs in &mut self.runs {
|
||||
@@ -68,10 +93,10 @@ impl EntityBatch {
|
||||
let mut counts = [0usize; MATERIALS];
|
||||
let mut start = 0;
|
||||
while start < quads.len() {
|
||||
let kind = material(quads[start][0].layer);
|
||||
let kind = material(quads[start][0].layer, oit);
|
||||
let end = quads[start + 1..]
|
||||
.iter()
|
||||
.position(|quad| material(quad[0].layer) != kind)
|
||||
.position(|quad| material(quad[0].layer, oit) != kind)
|
||||
.map_or(quads.len(), |n| start + 1 + n);
|
||||
self.runs[kind].push((start, end - start));
|
||||
counts[kind] += end - start;
|
||||
@@ -87,7 +112,32 @@ impl EntityBatch {
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(additive);
|
||||
// `RenderTypes.eyes` is blended like the other entities, not added: with
|
||||
// improved transparency on, tagged additive quads join the last
|
||||
// material, drawn without the additive overlay and the baked shading.
|
||||
self.moved_additive = 0;
|
||||
if oit {
|
||||
for quad in additive.as_chunks::<4>().0 {
|
||||
if quad[0].layer & OIT_ENTITY != 0 {
|
||||
self.combined.extend(quad.iter().map(|v| Vertex {
|
||||
layer: v.layer & !0xffff,
|
||||
normal: [0; 4],
|
||||
..*v
|
||||
}));
|
||||
counts[9] += 1;
|
||||
self.moved_additive += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
for quad in additive.as_chunks::<4>().0 {
|
||||
if quad[0].layer & OIT_ENTITY == 0 {
|
||||
self.combined.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(hand);
|
||||
self.combined.extend_from_slice(see_through);
|
||||
counts.map(|count| count as u32)
|
||||
@@ -156,13 +206,78 @@ mod tests {
|
||||
hand: &[Vertex],
|
||||
) {
|
||||
let (expected, counts) = baseline(entities, additive, hand);
|
||||
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
|
||||
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&batch.combined),
|
||||
bytemuck::cast_slice::<_, u8>(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oit_entity_quads_are_a_last_material_only_when_on() {
|
||||
let quad = |layer: u32, x: f32| {
|
||||
(0..4).map(move |_| Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
layer,
|
||||
..geometry(1, false)[0]
|
||||
})
|
||||
};
|
||||
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
|
||||
let entities: Vec<Vertex> = [
|
||||
(0, 0.0),
|
||||
(OIT_ENTITY | 3 << 16, 1.0),
|
||||
(1, 2.0),
|
||||
(SHADOW_OVERLAY, 3.0),
|
||||
(OIT_ENTITY | 1, 4.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_additive_quads_join_the_last_material_only_when_on() {
|
||||
let quad = |layer: u32, x: f32| {
|
||||
(0..4).map(move |_| Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
layer,
|
||||
normal: [1, 2, 3, 4],
|
||||
..geometry(1, false)[0]
|
||||
})
|
||||
};
|
||||
// The dragon's eyes (tagged, overlay 2) and a ray (plain).
|
||||
let additive: Vec<Vertex> = [(2 | OIT_ENTITY | 3 << 16, 1.0), (2, 2.0)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let entities: Vec<Vertex> = quad(0, 0.0).collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: untouched.
|
||||
let counts = batch.update(&entities, &additive, &[], &[], false);
|
||||
assert_eq!((counts[9], batch.moved_additive), (0, 0));
|
||||
assert_eq!(batch.combined.len(), 12);
|
||||
assert_eq!(batch.combined[4].layer, 2 | OIT_ENTITY | 3 << 16);
|
||||
// On: the eyes are an entity quad without the additive overlay, the ray stays.
|
||||
let counts = batch.update(&entities, &additive, &[], &[], true);
|
||||
assert_eq!((counts[0], counts[9], batch.moved_additive), (1, 1, 1));
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0]);
|
||||
assert_eq!(batch.combined[4].layer, OIT_ENTITY | 3 << 16);
|
||||
assert_eq!(batch.combined[4].normal, [0; 4]);
|
||||
assert_eq!(batch.combined[8].layer, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_material_order_tails_and_reuse() {
|
||||
let mut batch = EntityBatch::default();
|
||||
@@ -227,7 +342,7 @@ mod tests {
|
||||
equivalent(&mut batch, &entities, &additive, &hand);
|
||||
for _ in 0..50 {
|
||||
black_box(baseline(black_box(&entities), &additive, &hand));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
|
||||
black_box(&batch.combined);
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
@@ -240,7 +355,7 @@ mod tests {
|
||||
before.push(start.elapsed().as_nanos() / 100);
|
||||
let start = Instant::now();
|
||||
for _ in 0..100 {
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
|
||||
black_box(&batch.combined);
|
||||
}
|
||||
after.push(start.elapsed().as_nanos() / 100);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
//! Improved transparency: vanilla 26.3's order-independent transparency
|
||||
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
|
||||
//! against the opaque world's depth, without writing it, into targets the
|
||||
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
|
||||
//! composites the sum. With the option off none of this exists and the world
|
||||
//! draws as before.
|
||||
|
||||
use super::oit_features::Features;
|
||||
use glam::Mat4;
|
||||
|
||||
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
|
||||
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
|
||||
r: -(f32::MAX as f64),
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 0.0,
|
||||
};
|
||||
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
|
||||
const GROUP: u32 = 2;
|
||||
|
||||
/// `OitStage`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
DepthBounds,
|
||||
Transmittance,
|
||||
Accumulate,
|
||||
}
|
||||
|
||||
/// What the device must support: max blending on 32-bit float targets.
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
|
||||
}
|
||||
|
||||
pub fn supported(device: &wgpu::Device) -> bool {
|
||||
device
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_BLENDABLE)
|
||||
}
|
||||
|
||||
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
|
||||
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
|
||||
let blend = |operation| wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation,
|
||||
};
|
||||
let target = |format, operation| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(operation),
|
||||
alpha: blend(operation),
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
};
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
match stage {
|
||||
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
|
||||
Stage::Transmittance => vec![
|
||||
target(COEFFICIENT_FORMAT, add),
|
||||
target(COEFFICIENT_FORMAT, add),
|
||||
],
|
||||
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
|
||||
/// blocks along the view axis (`oit_linear_depth`).
|
||||
fn projection_terms(projection: &Mat4) -> [f32; 2] {
|
||||
[projection.z_axis.z, projection.w_axis.z]
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
bounds: wgpu::Texture,
|
||||
culled: wgpu::Texture,
|
||||
views: Views,
|
||||
/// The uniform alone, with the culled bounds, with those and the
|
||||
/// coefficients, for the cull with the unculled bounds, and the composite.
|
||||
alone: wgpu::BindGroup,
|
||||
sampled: wgpu::BindGroup,
|
||||
accumulate: wgpu::BindGroup,
|
||||
cull: wgpu::BindGroup,
|
||||
composite: wgpu::BindGroup,
|
||||
/// The depth the terrain's stages test against while a water mask is
|
||||
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
|
||||
terrain_depth: Option<Depth>,
|
||||
cloud_depth: Option<Depth>,
|
||||
}
|
||||
|
||||
struct Depth {
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl Depth {
|
||||
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent(size),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
|
||||
wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the translucent layer's other drawers add to a frame: each draws with
|
||||
/// a pipeline per stage where it has one.
|
||||
#[derive(Default)]
|
||||
pub struct Extras<'a> {
|
||||
/// `executeOitWaterMask`: the boats' water patches, depth only.
|
||||
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
|
||||
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
|
||||
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
|
||||
/// "Features, World Border, Weather": draws what the entity, weather and
|
||||
/// border shaders put after the opaque world, with `Oit::features`'s
|
||||
/// pipelines of the stage.
|
||||
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
|
||||
}
|
||||
|
||||
struct Views {
|
||||
bounds: wgpu::TextureView,
|
||||
culled: wgpu::TextureView,
|
||||
coefficients: [wgpu::TextureView; 2],
|
||||
accumulated: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Oit {
|
||||
uniform: wgpu::Buffer,
|
||||
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
|
||||
/// coefficients; and with the accumulation too.
|
||||
layouts: [wgpu::BindGroupLayout; 4],
|
||||
/// One per `Stage`.
|
||||
terrain: [wgpu::RenderPipeline; 3],
|
||||
/// The terrain's flat section draws (`Flat`), where the device has them.
|
||||
flat: Option<[wgpu::RenderPipeline; 3]>,
|
||||
features: Features,
|
||||
clouds: [wgpu::RenderPipeline; 3],
|
||||
cull: wgpu::RenderPipeline,
|
||||
composite: wgpu::RenderPipeline,
|
||||
targets: Option<Targets>,
|
||||
}
|
||||
|
||||
impl Oit {
|
||||
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
|
||||
/// `flat` the flat draws' second group, `features` the entity, weather
|
||||
/// and world border layouts,
|
||||
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
|
||||
/// constants, `format` the world target's.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
terrain: &wgpu::BindGroupLayout,
|
||||
flat: Option<&wgpu::BindGroupLayout>,
|
||||
features: [&wgpu::BindGroupLayout; 3],
|
||||
clouds: [&wgpu::BindGroupLayout; 2],
|
||||
sprites: [f64; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance,
|
||||
}),
|
||||
];
|
||||
let buffers = &buffers[..];
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../terrain.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let texture = |binding| {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
};
|
||||
let params = entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
);
|
||||
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries,
|
||||
})
|
||||
};
|
||||
let layouts = [
|
||||
layout("Improved transparency", &[params]),
|
||||
layout("Improved transparency bounds", &[params, texture(1)]),
|
||||
layout(
|
||||
"Improved transparency coefficients",
|
||||
&[params, texture(1), texture(2), texture(3)],
|
||||
),
|
||||
layout(
|
||||
"Improved transparency composite",
|
||||
&[params, texture(1), texture(2), texture(3), texture(4)],
|
||||
),
|
||||
];
|
||||
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
bind_group_layouts: &[first, None, Some(&layouts[group])],
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
let depth = |write| {
|
||||
Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
})
|
||||
};
|
||||
let pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
targets: &[Option<wgpu::ColorTargetState>],
|
||||
depth_stencil| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let terrain_pipeline = |label, fragment, stage| {
|
||||
pipeline(
|
||||
label,
|
||||
&pipeline_layout(Some(terrain), stage as usize),
|
||||
"vs_terrain",
|
||||
fragment,
|
||||
buffers,
|
||||
&stage_targets(stage),
|
||||
depth(false),
|
||||
)
|
||||
};
|
||||
let terrain_pipelines = [
|
||||
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
|
||||
terrain_pipeline(
|
||||
"OIT transmittance",
|
||||
"fs_oit_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
|
||||
];
|
||||
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
|
||||
// the draw table at group 1.
|
||||
let flat_pipelines = flat.map(|draw| {
|
||||
let flat_buffers = &buffers[..1];
|
||||
[
|
||||
(
|
||||
"OIT depth bounds (flat)",
|
||||
"fs_oit_bounds",
|
||||
Stage::DepthBounds,
|
||||
),
|
||||
(
|
||||
"OIT transmittance (flat)",
|
||||
"fs_oit_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
(
|
||||
"OIT accumulate (flat)",
|
||||
"fs_oit_accumulate",
|
||||
Stage::Accumulate,
|
||||
),
|
||||
]
|
||||
.map(|(label, fragment, stage)| {
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency flat terrain"),
|
||||
bind_group_layouts: &[
|
||||
Some(terrain),
|
||||
Some(draw),
|
||||
Some(&layouts[stage as usize]),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
pipeline(
|
||||
label,
|
||||
&layout,
|
||||
"vs_terrain_flat",
|
||||
fragment,
|
||||
flat_buffers,
|
||||
&stage_targets(stage),
|
||||
depth(false),
|
||||
)
|
||||
})
|
||||
});
|
||||
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
|
||||
pipeline(
|
||||
label,
|
||||
&pipeline_layout(None, group),
|
||||
"vs_sky",
|
||||
fragment,
|
||||
&[],
|
||||
targets,
|
||||
depth_stencil,
|
||||
)
|
||||
};
|
||||
let replace = |format| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
};
|
||||
let cull = screen(
|
||||
"OIT cull depth bounds",
|
||||
"fs_oit_cull",
|
||||
1,
|
||||
&[replace(BOUNDS_FORMAT)],
|
||||
depth(true),
|
||||
);
|
||||
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
|
||||
// the nearest see-through depth written.
|
||||
let premultiplied = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: add,
|
||||
};
|
||||
let composite = screen(
|
||||
"OIT composite",
|
||||
"fs_oit_composite",
|
||||
3,
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: premultiplied,
|
||||
alpha: premultiplied,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
);
|
||||
// The clouds: entity.wgsl's vertex shader and vertex format, with the
|
||||
// stages' fragments.
|
||||
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency clouds"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &cloud_attributes,
|
||||
})];
|
||||
let cloud_pipeline = |label, fragment, stage: Stage| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency clouds"),
|
||||
bind_group_layouts: &[
|
||||
Some(clouds[0]),
|
||||
Some(clouds[1]),
|
||||
Some(&layouts[stage as usize]),
|
||||
],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some("vs_cloud"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &cloud_buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
|
||||
// so the later stages keep the nearest face of each cloud.
|
||||
depth_stencil: depth(stage == Stage::DepthBounds),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: Default::default(),
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let cloud_pipelines = [
|
||||
cloud_pipeline(
|
||||
"OIT cloud depth bounds",
|
||||
"fs_oit_cloud_bounds",
|
||||
Stage::DepthBounds,
|
||||
),
|
||||
cloud_pipeline(
|
||||
"OIT cloud transmittance",
|
||||
"fs_oit_cloud_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
cloud_pipeline(
|
||||
"OIT cloud accumulate",
|
||||
"fs_oit_cloud_accumulate",
|
||||
Stage::Accumulate,
|
||||
),
|
||||
];
|
||||
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
|
||||
Self {
|
||||
uniform,
|
||||
layouts,
|
||||
terrain: terrain_pipelines,
|
||||
flat: flat_pipelines,
|
||||
features,
|
||||
clouds: cloud_pipelines,
|
||||
cull,
|
||||
composite,
|
||||
targets: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipeline the terrain's translucent layer draws with in `stage`.
|
||||
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
|
||||
&self.terrain[stage as usize]
|
||||
}
|
||||
|
||||
/// The pipeline the terrain's translucent layer draws with in `stage`
|
||||
/// as flat section draws, where they run.
|
||||
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
|
||||
self.flat.as_ref().map(|f| &f[stage as usize])
|
||||
}
|
||||
|
||||
pub fn features(&self) -> &Features {
|
||||
&self.features
|
||||
}
|
||||
|
||||
/// Sizes the targets to the world's and writes this frame's projection.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
projection: &Mat4,
|
||||
wants: [bool; 2],
|
||||
) {
|
||||
queue.write_buffer(
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
|
||||
);
|
||||
let [mask, clouds] = wants;
|
||||
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
|
||||
if mask && t.terrain_depth.is_none() {
|
||||
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
|
||||
}
|
||||
if clouds && t.cloud_depth.is_none() {
|
||||
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let make = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent(size),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let attachment =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let bounds = make(
|
||||
"OIT depth bounds",
|
||||
BOUNDS_FORMAT,
|
||||
attachment | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let culled = make(
|
||||
"OIT culled depth bounds",
|
||||
BOUNDS_FORMAT,
|
||||
attachment | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let coefficients =
|
||||
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
|
||||
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
|
||||
let views = Views {
|
||||
bounds: bounds.create_view(&Default::default()),
|
||||
culled: culled.create_view(&Default::default()),
|
||||
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
|
||||
accumulated: accumulated.create_view(&Default::default()),
|
||||
};
|
||||
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
}];
|
||||
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
}));
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
layout,
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
let [c0, c1] = &views.coefficients;
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
alone: group(&self.layouts[0], &[]),
|
||||
sampled: group(&self.layouts[1], &[&views.culled]),
|
||||
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
|
||||
cull: group(&self.layouts[1], &[&views.bounds]),
|
||||
composite: group(
|
||||
&self.layouts[3],
|
||||
&[&views.bounds, c0, c1, &views.accumulated],
|
||||
),
|
||||
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
|
||||
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
|
||||
bounds,
|
||||
culled,
|
||||
views,
|
||||
});
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
|
||||
/// layer into each stage's pass with that stage's pipeline, `extras` the
|
||||
/// water masks and clouds, then the composite goes over `color`. `depth`
|
||||
/// is the opaque world's: the stages only read it; the cull and the
|
||||
/// composite write it.
|
||||
pub fn run(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
|
||||
extras: &Extras<'_>,
|
||||
) {
|
||||
let Some(t) = &self.targets else { return };
|
||||
let attachment = |view, load| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
let load = wgpu::LoadOp::Load;
|
||||
let read_only = |view| {
|
||||
Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
})
|
||||
};
|
||||
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
|
||||
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
|
||||
if let Some((mask, _)) = water_mask {
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
|
||||
let [c0, c1] = &t.views.coefficients;
|
||||
let culled = &t.views.culled;
|
||||
for (stage, label, group, first, then) in [
|
||||
(
|
||||
Stage::DepthBounds,
|
||||
"OIT depth bounds",
|
||||
&t.alone,
|
||||
vec![attachment(
|
||||
&t.views.bounds,
|
||||
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
|
||||
)],
|
||||
// The clouds' bounds join the culled ones.
|
||||
vec![attachment(culled, load)],
|
||||
),
|
||||
(
|
||||
Stage::Transmittance,
|
||||
"OIT transmittance",
|
||||
&t.sampled,
|
||||
vec![attachment(c0, clear), attachment(c1, clear)],
|
||||
vec![attachment(c0, load), attachment(c1, load)],
|
||||
),
|
||||
(
|
||||
Stage::Accumulate,
|
||||
"OIT accumulate",
|
||||
&t.accumulate,
|
||||
vec![attachment(&t.views.accumulated, clear)],
|
||||
vec![attachment(&t.views.accumulated, load)],
|
||||
),
|
||||
] {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, stage);
|
||||
}
|
||||
if let Some(features) = extras.features {
|
||||
// Over the world's own depth, even where a water mask patched
|
||||
// the terrain's: the masks only hide water.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[attachment(
|
||||
match stage {
|
||||
Stage::DepthBounds => &t.views.bounds,
|
||||
Stage::Transmittance => c0,
|
||||
Stage::Accumulate => &t.views.accumulated,
|
||||
},
|
||||
load,
|
||||
)]
|
||||
.into_iter()
|
||||
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
|
||||
.collect::<Vec<_>>(),
|
||||
depth_stencil_attachment: read_only(depth),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
features(&mut pass, stage);
|
||||
}
|
||||
if stage == Stage::DepthBounds {
|
||||
self.execute_depth_bounds_cull(encoder, t, depth);
|
||||
}
|
||||
if let Some((clouds, cloud_depth)) = cloud_depth {
|
||||
self.render_clouds(
|
||||
encoder,
|
||||
t,
|
||||
stage,
|
||||
depth,
|
||||
(label, group, &then),
|
||||
(clouds, cloud_depth),
|
||||
);
|
||||
}
|
||||
if stage == Stage::DepthBounds
|
||||
&& let Some((mask, _)) = water_mask
|
||||
{
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT composite"),
|
||||
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.composite);
|
||||
pass.set_bind_group(GROUP, &t.composite, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
|
||||
/// the world's depth (after the cull) and draws into it, so the later
|
||||
/// stages, which only read it, keep each pixel's nearest cloud face.
|
||||
fn render_clouds(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
stage: Stage,
|
||||
depth: &wgpu::TextureView,
|
||||
(label, group, targets): (
|
||||
&str,
|
||||
&wgpu::BindGroup,
|
||||
&[Option<wgpu::RenderPassColorAttachment<'_>>],
|
||||
),
|
||||
(draw, cloud_depth): (
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
|
||||
&Depth,
|
||||
),
|
||||
) {
|
||||
if stage == Stage::DepthBounds {
|
||||
encoder.copy_texture_to_texture(
|
||||
depth.texture().as_image_copy(),
|
||||
cloud_depth.texture.as_image_copy(),
|
||||
extent(t.size),
|
||||
);
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: targets,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &cloud_depth.view,
|
||||
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, &self.clouds[stage as usize]);
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
|
||||
/// again, with the boats' water patches on top.
|
||||
fn execute_water_mask(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
|
||||
) {
|
||||
let Some(terrain) = &t.terrain_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
depth.texture().as_image_copy(),
|
||||
terrain.texture.as_image_copy(),
|
||||
extent(t.size),
|
||||
);
|
||||
// The mask pipeline writes depth only but has a colour target.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT water mask"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &terrain.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
mask(&mut pass);
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
|
||||
/// the nearest fully opaque see-through fragment is in front of the world,
|
||||
/// the far bound stops there and its depth replaces the world's.
|
||||
fn execute_depth_bounds_cull(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.bounds.as_image_copy(),
|
||||
t.culled.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: t.size[0].max(1),
|
||||
height: t.size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT cull depth bounds"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.views.culled,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.cull);
|
||||
pass.set_bind_group(GROUP, &t.cull, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn validate(source: &str, entries: &[&str]) {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
for entry in entries {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|e| e.name == *entry),
|
||||
"{entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terrain_shader_validates() {
|
||||
validate(
|
||||
&(include_str!("../terrain.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl")),
|
||||
&[
|
||||
"fs_oit_bounds",
|
||||
"fs_oit_transmittance",
|
||||
"fs_oit_accumulate",
|
||||
"fs_oit_cull",
|
||||
"fs_oit_composite",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cloud_shader_validates() {
|
||||
validate(
|
||||
&(super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl")),
|
||||
&[
|
||||
"fs_oit_cloud_bounds",
|
||||
"fs_oit_cloud_transmittance",
|
||||
"fs_oit_cloud_accumulate",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feature_shaders_validate() {
|
||||
let stages = |name: &str| {
|
||||
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
|
||||
};
|
||||
let entity = ["entity", "shadow", "weather", "swirl"]
|
||||
.map(stages)
|
||||
.concat();
|
||||
validate(
|
||||
&(super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl")),
|
||||
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let border = stages("border");
|
||||
validate(
|
||||
&(include_str!("../border.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_border.wgsl")),
|
||||
&border.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_depth_gives_the_distance_along_the_view_axis() {
|
||||
// The world's reversed-Z projection: near plane at depth 1.
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let [x, y] = projection_terms(&projection);
|
||||
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
|
||||
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
|
||||
let device = clip.z / clip.w;
|
||||
let linear = y / (device + x);
|
||||
assert!(
|
||||
(linear - distance).abs() < distance * 2e-3,
|
||||
"{distance}: {linear}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Improved transparency for what is drawn after the opaque world besides the
|
||||
//! terrain and the clouds: `LevelRenderer.executeOit`'s "Features, World
|
||||
//! Border, Weather" pass. Each kind keeps its classic vertex shader, vertex
|
||||
//! format and depth state (minus the depth writes, as every `OIT_*` pipeline),
|
||||
//! with the three stages' fragments (`oit_features.wgsl`, `oit_border.wgsl`).
|
||||
|
||||
use super::oit::{Stage, stage_targets};
|
||||
|
||||
/// One pipeline per stage.
|
||||
type Set = [wgpu::RenderPipeline; 3];
|
||||
|
||||
pub struct Features {
|
||||
/// `OIT_ENTITY`/`OIT_ITEM`: translucent items and particles.
|
||||
pub entity: Set,
|
||||
/// `OIT_TEXT_POLYGON_OFFSET`.
|
||||
pub text: Set,
|
||||
/// `OIT_CRUMBLING`.
|
||||
pub crumbling: Set,
|
||||
/// `OIT_ENTITY_SHADOW`.
|
||||
pub shadow: Set,
|
||||
/// `OIT_ENERGY_SWIRL`.
|
||||
pub swirl: Set,
|
||||
/// `OIT_LIGHTNING`: the retained bolts.
|
||||
pub lightning: Set,
|
||||
/// `OIT_DRAGON_RAYS`: the additive geometry after the bolts.
|
||||
pub additive: Set,
|
||||
/// `OIT_WEATHER`.
|
||||
pub weather: Set,
|
||||
/// `OIT_WORLD_BORDER`.
|
||||
pub border: Set,
|
||||
}
|
||||
|
||||
/// What a kind differs in.
|
||||
struct Kind<'a> {
|
||||
label: &'a str,
|
||||
/// The entry points' common prefix: `fs_oit_<name>_<stage>`.
|
||||
name: &'a str,
|
||||
vertex: &'a str,
|
||||
cull: Option<wgpu::Face>,
|
||||
bias: wgpu::DepthBiasState,
|
||||
}
|
||||
|
||||
const PLAIN: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
|
||||
impl Features {
|
||||
/// `layouts`: `Oit`'s stage groups (at group 2); `entity`, `weather` and
|
||||
/// `border` the bind group layouts of the classic pipelines.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
layouts: &[wgpu::BindGroupLayout; 4],
|
||||
entity: &wgpu::BindGroupLayout,
|
||||
weather: &wgpu::BindGroupLayout,
|
||||
border: &wgpu::BindGroupLayout,
|
||||
) -> Self {
|
||||
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency features"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency world border"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../border.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_border.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let weather_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
|
||||
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
|
||||
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
|
||||
let entity_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
})];
|
||||
let weather_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::weather::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &weather_attributes,
|
||||
})];
|
||||
let border_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::border_render::BorderVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &border_attributes,
|
||||
})];
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_buffers = [
|
||||
entity_buffers[0].clone(),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &lightning_attributes,
|
||||
}),
|
||||
];
|
||||
let set = |module: &wgpu::ShaderModule,
|
||||
groups: [Option<&wgpu::BindGroupLayout>; 2],
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
kind: Kind<'_>| {
|
||||
[Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate].map(|stage| {
|
||||
let suffix = ["bounds", "transmittance", "accumulate"][stage as usize];
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency features"),
|
||||
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&format!("OIT {} {suffix}", kind.label)),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some(kind.vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: kind.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: kind.bias,
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
|
||||
compilation_options: Default::default(),
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
};
|
||||
let entities = [Some(entity), None];
|
||||
let kind = |label, name, cull, bias| Kind {
|
||||
label,
|
||||
name,
|
||||
vertex: "vs_entity",
|
||||
cull,
|
||||
bias,
|
||||
};
|
||||
// The classic pipelines' biases: text and cracks (10, 1), shadows
|
||||
// (128 ulps and a slope, over the block tops they lie on).
|
||||
let offset = wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
let shadow_bias = wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
};
|
||||
Self {
|
||||
entity: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("entities", "entity", None, PLAIN),
|
||||
),
|
||||
text: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", None, offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("breaking cracks", "entity", Some(wgpu::Face::Back), offset),
|
||||
),
|
||||
shadow: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("entity shadows", "shadow", None, shadow_bias),
|
||||
),
|
||||
swirl: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("energy swirls", "swirl", None, PLAIN),
|
||||
),
|
||||
lightning: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&lightning_buffers,
|
||||
Kind {
|
||||
label: "lightning",
|
||||
name: "lightning",
|
||||
vertex: "vs_lightning",
|
||||
cull: None,
|
||||
bias: PLAIN,
|
||||
},
|
||||
),
|
||||
additive: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
[Some(entity), Some(weather)],
|
||||
&weather_buffers,
|
||||
Kind {
|
||||
label: "weather",
|
||||
name: "weather",
|
||||
vertex: "vs_weather",
|
||||
cull: None,
|
||||
bias: PLAIN,
|
||||
},
|
||||
),
|
||||
// `OIT_WORLD_BORDER`: a polygon offset of 3 towards the camera.
|
||||
border: set(
|
||||
&border_module,
|
||||
[Some(border), None],
|
||||
&border_buffers,
|
||||
Kind {
|
||||
label: "world border",
|
||||
name: "border",
|
||||
vertex: "vs_border",
|
||||
cull: None,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 3,
|
||||
slope_scale: 3.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
@@ -349,7 +349,10 @@ impl PackEntities {
|
||||
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
|
||||
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
|
||||
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
self.uniforms.extend_from_slice(&bytes);
|
||||
@@ -408,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
|
||||
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
|
||||
/// shader draws in its own way; entity.wgsl).
|
||||
fn plain(run: &[Vertex]) -> bool {
|
||||
run.iter()
|
||||
.step_by(4)
|
||||
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
|
||||
run.iter().step_by(4).all(|v| {
|
||||
!matches!(
|
||||
v.layer & !super::entity_batch::OIT_ENTITY,
|
||||
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The programs of entity runs, in the order they draw.
|
||||
|
||||
@@ -203,9 +203,9 @@ fn body(angle: f32, tilt: f32) -> Vec3 {
|
||||
}
|
||||
|
||||
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
|
||||
/// positive angle moves the noon sun toward +Z (south).
|
||||
/// positive angle moves the noon sun toward -Z (north), as Iris does.
|
||||
pub fn tilt_matrix(tilt: f32) -> Mat4 {
|
||||
Mat4::from_rotation_x(tilt.to_radians())
|
||||
Mat4::from_rotation_x(-tilt.to_radians())
|
||||
}
|
||||
|
||||
/// The world direction of the shadow light (7.1): the sun by day, the moon by night.
|
||||
@@ -232,38 +232,39 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
depth * gl * view
|
||||
}
|
||||
|
||||
/// Distance from the snapped point to the shadow camera, toward the light: the middle of the
|
||||
/// default near and far planes (OQ-6).
|
||||
const SHADOW_EYE: f64 = 128.0;
|
||||
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
|
||||
/// not of `shadowModelView` (Iris's matrices, measured).
|
||||
const SHADOW_EYE: f32 = 100.0;
|
||||
|
||||
/// The shadow camera (7.1; OQ-6, impl-plan.md): its view (player space to shadow view space)
|
||||
/// and GL projection. The camera looks from the light, world +Z up (+Y when the light is near
|
||||
/// Z); the point it centres moves in steps of `shadowIntervalSize` along the shadow view's
|
||||
/// own axes, and it sits `SHADOW_EYE` blocks toward the light from it. Orthographic over
|
||||
/// `shadowDistance` each side, or perspective with `shadowMapFov`.
|
||||
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
|
||||
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
|
||||
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
|
||||
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
|
||||
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
|
||||
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
|
||||
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
|
||||
/// with `shadowMapFov`.
|
||||
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
|
||||
let light = light.as_dvec3();
|
||||
let up = if light.z.abs() > 0.99 {
|
||||
DVec3::Y
|
||||
} else {
|
||||
DVec3::Z
|
||||
};
|
||||
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
|
||||
let at = rotation.transform_vector3(camera);
|
||||
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
|
||||
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
|
||||
let zenith = untilted.x.atan2(untilted.y);
|
||||
let rotation = DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)
|
||||
* DMat4::from_rotation_z(zenith)
|
||||
* DMat4::from_rotation_x(tilt);
|
||||
let step = f64::from(shadow.interval);
|
||||
let snapped = if step > 0.0 {
|
||||
(at / step).floor() * step
|
||||
let origin = if step > 0.0 {
|
||||
DVec3::splat(step / 2.0) - camera % step
|
||||
} else {
|
||||
at
|
||||
DVec3::ZERO
|
||||
};
|
||||
let offset = at - snapped - DVec3::new(0.0, 0.0, SHADOW_EYE);
|
||||
let view = (DMat4::from_translation(offset) * rotation).as_mat4();
|
||||
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
|
||||
let (near, far) = (shadow.near, shadow.far);
|
||||
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
|
||||
let projection = match shadow.fov {
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far),
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
|
||||
None => {
|
||||
let d = shadow.distance;
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, near, far)
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
|
||||
}
|
||||
};
|
||||
(view, projection)
|
||||
@@ -360,7 +361,8 @@ impl FrameUniforms {
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
"aspectRatio" => Value::f(&[aspect]),
|
||||
"near" => Value::f(&[0.05]),
|
||||
"far" => Value::f(&[frame.distance]),
|
||||
// The chunk render distance, not the distant terrain's reach.
|
||||
"far" => Value::f(&[frame.detail_distance]),
|
||||
"rainStrength" => Value::f(&[inputs.rain]),
|
||||
"wetness" => Value::f(&[self.wetness]),
|
||||
"thunderStrength" => Value::f(&[inputs.thunder]),
|
||||
@@ -486,8 +488,8 @@ mod tests {
|
||||
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
|
||||
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(body(90.0, 0.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
// sunPathRotation tilts noon toward +Z and leaves sunset on the horizon.
|
||||
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, 50.0), 1e-3));
|
||||
// sunPathRotation tilts noon toward -Z and leaves sunset on the horizon.
|
||||
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, -50.0), 1e-3));
|
||||
assert!(body(90.0, 30.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
}
|
||||
|
||||
@@ -500,10 +502,13 @@ mod tests {
|
||||
let at = |p: Vec3| view.transform_point3(p);
|
||||
// Straight down: a block below the player is farther from the light.
|
||||
assert!(at(Vec3::new(0.0, -1.0, 0.0)).z < at(Vec3::ZERO).z);
|
||||
// The player is less than a step from the centre, 128 blocks from the light.
|
||||
let centre = at(Vec3::ZERO);
|
||||
assert!(centre.x.abs() <= 2.0 && centre.y.abs() <= 2.0, "{centre}");
|
||||
assert!((centre.z + 128.0).abs() < 1e-3, "{centre}");
|
||||
// The view's origin is interval / 2 - camera % interval from the player on each axis,
|
||||
// the remainder with the camera's sign: up to a step and a half on negative axes.
|
||||
let origin = view.inverse().w_axis.truncate();
|
||||
assert!(
|
||||
origin.abs_diff_eq(Vec3::new(0.7, 1.0, 1.7), 1e-3),
|
||||
"{origin}"
|
||||
);
|
||||
// Moving within a step keeps the world where it was in the map.
|
||||
camera.x = 1000.6;
|
||||
let (moved, _) = shadow_camera(Vec3::Y, camera, &shadow);
|
||||
@@ -515,7 +520,38 @@ mod tests {
|
||||
.abs_diff_eq(at(world), 1e-3)
|
||||
);
|
||||
// shadowDistance blocks reach the map's edge.
|
||||
let edge = projection.project_point3(Vec3::new(160.0, 0.0, -128.0));
|
||||
let edge = projection.project_point3(Vec3::new(160.0, 0.0, 0.0));
|
||||
assert!((edge.x.abs() - 1.0).abs() < 1e-4, "{edge}");
|
||||
}
|
||||
|
||||
/// Iris's matrices at 3000 ticks with `sunPathRotation = 30` and the camera at
|
||||
/// (0.5, -58.38, 0.5), read from its uniforms by a probe pack.
|
||||
#[test]
|
||||
fn the_shadow_camera_matches_iris() {
|
||||
let shadow = Shadow {
|
||||
sun_path_rotation: 30.0,
|
||||
..Shadow::default()
|
||||
};
|
||||
let light = Vec3::new(0.56782, 0.71287, -0.41158).normalize();
|
||||
let (view, projection) = shadow_camera(light, DVec3::new(0.5, -58.38, 0.5), &shadow);
|
||||
let iris_view = Mat4::from_cols_array_2d(&[
|
||||
[0.82315, 0.0, 0.56782, 0.0],
|
||||
[-0.49175, -0.5, 0.71287, 0.0],
|
||||
[0.28391, -0.86603, -0.41158, 0.0],
|
||||
[0.12508, 1.12301, -1.06188, 1.0],
|
||||
]);
|
||||
assert!(view.abs_diff_eq(iris_view, 2e-4), "{view}");
|
||||
assert!(
|
||||
(projection.z_axis.z + 0.0078110).abs() < 1e-6,
|
||||
"{projection}"
|
||||
);
|
||||
assert!((projection.w_axis.z + 0.21851).abs() < 1e-4, "{projection}");
|
||||
// Elsewhere the origin keeps to the steps: Iris put it at (-0.5, 1.38, 2.5) here.
|
||||
let (view, _) = shadow_camera(light, DVec3::new(3.5, -58.38, -1.5), &shadow);
|
||||
let origin = view.inverse().w_axis.truncate();
|
||||
assert!(
|
||||
origin.abs_diff_eq(Vec3::new(-0.5, 1.38, 2.5), 1e-3),
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
bounds: Vec<Option<[Vec3; 2]>>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
column_index: FxHashMap<(i32, i32), u32>,
|
||||
@@ -40,6 +41,7 @@ impl<T> Default for Resident<T> {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
bounds: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
column_index: FxHashMap::default(),
|
||||
@@ -57,6 +59,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
|
||||
@@ -70,18 +80,26 @@ impl<T> Resident<T> {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.bounds.clear();
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
}
|
||||
/// Adds or replaces `key`'s value.
|
||||
/// Adds or replaces `key`'s value with default 16x16x16 bounds.
|
||||
#[cfg(test)]
|
||||
pub fn insert(&mut self, key: SectionKey, value: T) {
|
||||
self.insert_with_bounds(key, value, None);
|
||||
}
|
||||
/// Adds or replaces `key`'s value with optional tight geometry bounds.
|
||||
pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) {
|
||||
if let Some(&slot) = self.slots.get(&key) {
|
||||
self.items[slot as usize].1 = value;
|
||||
self.bounds[slot as usize] = bounds;
|
||||
return;
|
||||
}
|
||||
let slot = self.items.len() as u32;
|
||||
self.items.push((key, value));
|
||||
self.bounds.push(bounds);
|
||||
self.slots.insert(key, slot);
|
||||
let column = match self.column_index.get(&(key.0, key.2)) {
|
||||
Some(&c) => &mut self.columns[c as usize],
|
||||
@@ -121,6 +139,7 @@ impl<T> Resident<T> {
|
||||
} else {
|
||||
column.bounds();
|
||||
}
|
||||
let _ = self.bounds.swap_remove(slot as usize);
|
||||
let (_, value) = self.items.swap_remove(slot as usize);
|
||||
// The last item now sits in `slot`: point its key and column at it.
|
||||
if let Some(&(moved, _)) = self.items.get(slot as usize) {
|
||||
@@ -167,31 +186,57 @@ fn camera_column(camera: DVec3) -> (i32, i32) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The world AABB (min, max) of a section from its origin and optional tight bounds.
|
||||
#[inline]
|
||||
fn section_box(min: Vec3, bounds: Option<[Vec3; 2]>) -> (Vec3, Vec3) {
|
||||
match bounds {
|
||||
Some([bmin, bmax]) => (min + bmin, min + bmax),
|
||||
None => (min, min + Vec3::splat(16.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the section at `key` is drawn: its column within the render
|
||||
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
|
||||
/// partly inside the frustum. `None` when it is not, else its squared centre
|
||||
/// distance and origin. `Resident::visible` walks columns instead.
|
||||
#[cfg(test)]
|
||||
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
in_view_with_bounds(key, camera, radius, frustum, None)
|
||||
}
|
||||
|
||||
/// `in_view` with optional tight bounds.
|
||||
#[cfg(test)]
|
||||
fn in_view_with_bounds(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
radius: i32,
|
||||
frustum: &Frustum,
|
||||
bounds: Option<[Vec3; 2]>,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false, bounds)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
#[cfg(test)]
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
bounds: Option<[Vec3; 2]>,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
if !inside {
|
||||
let (bmin, bmax) = section_box(min, bounds);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
}
|
||||
@@ -231,17 +276,50 @@ impl<T> Resident<T> {
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
let ox = f64::from(column.x * 16) - camera.x;
|
||||
let oz = f64::from(column.z * 16) - camera.z;
|
||||
let min_x = ox as f32;
|
||||
let min_z = oz as f32;
|
||||
let dx8 = ox + 8.0;
|
||||
let dz8 = oz + 8.0;
|
||||
let xz_sq = dx8 * dx8 + dz8 * dz8;
|
||||
|
||||
if inside {
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let dy8 = oy + 8.0;
|
||||
let distance = xz_sq + dy8 * dy8;
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
distance: distance.to_bits(),
|
||||
key,
|
||||
slot,
|
||||
origin: o.to_array(),
|
||||
origin: min.to_array(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
continue;
|
||||
}
|
||||
let dy8 = oy + 8.0;
|
||||
let distance = xz_sq + dy8 * dy8;
|
||||
out.push(Visible {
|
||||
distance: distance.to_bits(),
|
||||
key,
|
||||
slot,
|
||||
origin: min.to_array(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -263,7 +341,15 @@ mod tests {
|
||||
for y in 0..24 {
|
||||
// Leave holes so columns have uneven bounds.
|
||||
if (x * 7 + y * 3 + z * 5).rem_euclid(4) != 0 {
|
||||
r.insert((x, y, z), ());
|
||||
// Mix of sections with fallback None and randomized tight bounds.
|
||||
let bounds = if (x + y + z).rem_euclid(2) == 0 {
|
||||
let y_min = ((x + z).rem_euclid(4)) as f32;
|
||||
let y_max = y_min + 2.0 + ((y.rem_euclid(5)) as f32);
|
||||
Some([Vec3::new(0.0, y_min, 0.0), Vec3::new(16.0, y_max, 16.0)])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
r.insert_with_bounds((x, y, z), (), bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,12 +375,21 @@ mod tests {
|
||||
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(k, _)| {
|
||||
.enumerate()
|
||||
.filter_map(|(slot, &(k, _))| {
|
||||
let near = in_view_distance(camera_column(camera), radius, k.0, k.2);
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
in_view_with_bounds(k, camera, radius, &frustum, r.bounds[slot])
|
||||
.filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
in_frustum(
|
||||
k,
|
||||
crate::chunk::MIN_Y,
|
||||
camera,
|
||||
&frustum,
|
||||
false,
|
||||
r.bounds[slot],
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -463,4 +558,152 @@ mod tests {
|
||||
list.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_mesh_bounds_culls_section_outside_frustum() {
|
||||
let mut r = Resident::default();
|
||||
let key = (0, 0, 0);
|
||||
// Section geometry only occupies local Y in [0.0, 2.0]
|
||||
let tight = Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, 2.0, 16.0)]);
|
||||
r.insert_with_bounds(key, (), tight);
|
||||
|
||||
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 15), -2.0);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let view = crate::camera::view_matrix(0.0, -60.0);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
|
||||
// Tight bounds culls the section whose geometry is entirely outside the frustum
|
||||
let mut out = Vec::new();
|
||||
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"tight bounds should cull section whose geometry is below frustum"
|
||||
);
|
||||
|
||||
// With fallback 16x16x16 bounds, the section intersects the frustum and is visible
|
||||
r.insert_with_bounds(key, (), None);
|
||||
out.clear();
|
||||
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"full 16x16x16 bounds intersect frustum and are accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn tight_bounds_benchmark() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut r_fallback = Resident::default();
|
||||
let mut r_tight = Resident::default();
|
||||
|
||||
let radius = 16;
|
||||
for x in -radius..=radius {
|
||||
for z in -radius..=radius {
|
||||
// Section 0..4: underground rock
|
||||
for y in 0..4 {
|
||||
r_fallback.insert((x, y, z), ());
|
||||
r_tight.insert_with_bounds((x, y, z), (), None);
|
||||
}
|
||||
// Section 4: surface terrain with grass / hills (e.g. Y in [0, 4] or [0, 6])
|
||||
let height = 2.0 + (((x * 3 + z * 5).rem_euclid(5)) as f32) * 1.5;
|
||||
let surface_bounds =
|
||||
Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, height, 16.0)]);
|
||||
r_fallback.insert((x, 4, z), ());
|
||||
r_tight.insert_with_bounds((x, 4, z), (), surface_bounds);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n=== Tight Bounds Culling Benchmark ===");
|
||||
println!("Total Resident Sections: {}", r_tight.len());
|
||||
|
||||
let test_views = [
|
||||
("Looking level across terrain (pitch 0°)", 0.0, 0.0),
|
||||
("Looking slightly up (pitch -15°)", 45.0, -15.0),
|
||||
("Looking up towards sky (pitch -35°)", 90.0, -35.0),
|
||||
("Looking down at ground (pitch 25°)", 180.0, 25.0),
|
||||
("Diagonal view (yaw 135°, pitch -10°)", 135.0, -10.0),
|
||||
];
|
||||
|
||||
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 4 * 16 + 10), 8.0);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
|
||||
for (desc, yaw, pitch) in test_views {
|
||||
let view = crate::camera::view_matrix(yaw, pitch);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
|
||||
let mut out_fallback = Vec::new();
|
||||
let mut out_tight = Vec::new();
|
||||
|
||||
r_fallback.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_fallback,
|
||||
);
|
||||
r_tight.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_tight,
|
||||
);
|
||||
|
||||
let culled_extra = out_fallback.len().saturating_sub(out_tight.len());
|
||||
let pct_saved = if !out_fallback.is_empty() {
|
||||
(culled_extra as f64 / out_fallback.len() as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Timing over 1,000 passes
|
||||
let iters = 1_000;
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..iters {
|
||||
out_fallback.clear();
|
||||
r_fallback.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_fallback,
|
||||
);
|
||||
}
|
||||
let dur_fallback = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
for _ in 0..iters {
|
||||
out_tight.clear();
|
||||
r_tight.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_tight,
|
||||
);
|
||||
}
|
||||
let dur_tight = t1.elapsed();
|
||||
|
||||
println!(
|
||||
"{desc}\n Fallback 16x16: {} sections | {:?}\n Tight Bounds: {} sections | {:?}\n Reduction: -{} sections ({:.1}% culled)\n",
|
||||
out_fallback.len(),
|
||||
dur_fallback / iters,
|
||||
out_tight.len(),
|
||||
dur_tight / iters,
|
||||
culled_extra,
|
||||
pct_saved
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,9 @@ mod tests {
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let mut best = 0.0f32;
|
||||
let mut k0 = d.dot(n(TRIANGLES));
|
||||
for i in 0..TRIANGLES {
|
||||
for (i, triangle) in normals.iter().take(TRIANGLES).enumerate() {
|
||||
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
|
||||
let w = normals[i][3];
|
||||
let w = triangle[3];
|
||||
if k0 * w <= 0.0 && k1 * w >= 0.0 {
|
||||
let apex = d.dot(n(i));
|
||||
let s = apex + k1 - k0;
|
||||
@@ -75,6 +75,115 @@ mod tests {
|
||||
best
|
||||
}
|
||||
|
||||
// Projecting the fan onto the YZ plane leaves uniformly spaced rim
|
||||
// angles after undoing its Y squash. Test both ray directions because
|
||||
// the projection plane goes through the camera.
|
||||
fn sector_weight(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let normals = normals(alpha, sun);
|
||||
if d.y * d.y + d.z * d.z < 1e-10 {
|
||||
return weight(&normals, d);
|
||||
}
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let apex = d.dot(n(0));
|
||||
if apex * normals[0][3] < -1e-4 {
|
||||
return 0.0;
|
||||
}
|
||||
let y = d.dot(n(16));
|
||||
let x = -d.dot(n(20));
|
||||
let ax = x.abs();
|
||||
let ay = y.abs();
|
||||
let t = std::f32::consts::SQRT_2 - 1.0;
|
||||
let q = if ay > ax {
|
||||
if ax < ay * t { 3 } else { 2 }
|
||||
} else if ay > ax * t {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let sector = if x >= 0.0 {
|
||||
if y >= 0.0 { q } else { 15 - q }
|
||||
} else if y >= 0.0 {
|
||||
7 - q
|
||||
} else {
|
||||
8 + q
|
||||
};
|
||||
let mut best = 0.0f32;
|
||||
let k0 = d.dot(n(16 + sector as usize));
|
||||
let k1 = d.dot(n(16 + ((sector + 1) % 16) as usize));
|
||||
let offsets: &[i32] = if k0.abs().min(k1.abs()) < ax.max(ay) * 0.001 {
|
||||
&[0, 8, -1, 1, 7, 9]
|
||||
} else {
|
||||
&[0, 8]
|
||||
};
|
||||
for &offset in offsets {
|
||||
let i = (sector + offset).rem_euclid(16) as usize;
|
||||
let k0 = d.dot(n(16 + i));
|
||||
let k1 = d.dot(n(16 + (i + 1) % 16));
|
||||
let w = normals[i][3];
|
||||
let apex = d.dot(n(i));
|
||||
let sum = apex + k1 - k0;
|
||||
if sum.abs() >= 1e-6
|
||||
&& sum * w > 0.0
|
||||
&& apex * sum >= 0.0
|
||||
&& k1 * sum >= 0.0
|
||||
&& k0 * sum <= 0.0
|
||||
{
|
||||
best = best.max(apex / sum);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
#[test]
|
||||
fn angular_sector_matches_random_and_exact_rim_directions() {
|
||||
let mut state = 19u32;
|
||||
let mut random = || {
|
||||
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(state >> 8) as f32 / 16777216.0
|
||||
};
|
||||
for _ in 0..1000 {
|
||||
let alpha = 0.0011 + random() * 0.9989;
|
||||
let z = if random() > 0.5 { 0.3 } else { -0.2 };
|
||||
let sun = Vec3::new(0.0, (1.0f32 - z * z).sqrt(), z);
|
||||
let normals = normals(alpha, sun);
|
||||
let (_, rim) = vertices(alpha, sun);
|
||||
let random_ray = Vec3::new(
|
||||
random() * 2.0 - 1.0,
|
||||
random() * 2.0 - 1.0,
|
||||
random() * 2.0 - 1.0,
|
||||
)
|
||||
.normalize();
|
||||
for ray in rim.into_iter().chain([random_ray]) {
|
||||
let d = ray.normalize();
|
||||
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{alpha} {sun} {d}: {got} != {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_sector_matches_all_triangle_search() {
|
||||
for alpha in [0.0011f32, 0.05, 0.6, 1.0] {
|
||||
for z in [-0.2f32, 0.3] {
|
||||
let sun = Vec3::new(0.0, (1.0 - z * z).sqrt(), z);
|
||||
let normals = normals(alpha, sun);
|
||||
for yaw in 0..360 {
|
||||
for pitch in -89..90 {
|
||||
let (y, p) = ((yaw as f32).to_radians(), (pitch as f32).to_radians());
|
||||
let d = Vec3::new(y.cos() * p.cos(), p.sin(), y.sin() * p.cos());
|
||||
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"alpha={alpha} z={z} d={d} {got} != {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
|
||||
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
|
||||
@@ -124,6 +124,26 @@ impl Weather {
|
||||
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
|
||||
}
|
||||
/// The classic pipeline, for its layouts.
|
||||
pub fn pipeline(&self) -> &wgpu::RenderPipeline {
|
||||
&self.pipeline
|
||||
}
|
||||
/// `draw` with another pipeline of the same layouts and vertex format.
|
||||
pub fn draw_with(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
entities: &wgpu::BindGroup,
|
||||
) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, entities, &[]);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
|
||||
}
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
|
||||
@@ -344,7 +344,7 @@ impl Post {
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
sampled,
|
||||
sampled | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
|
||||
@@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
@@ -391,6 +393,8 @@ pub struct Graphics {
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -429,6 +433,7 @@ impl Default for Graphics {
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -507,6 +512,7 @@ impl Graphics {
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -524,7 +530,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -556,11 +562,20 @@ impl Graphics {
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
)
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
HDR_KEY => self.hdr_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
@@ -691,6 +706,7 @@ mod tests {
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! lay out rows and handle input; a menu theme replaces this module.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -131,8 +132,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(
|
||||
@@ -144,10 +157,11 @@ impl Style {
|
||||
cost: Cost,
|
||||
text: &str,
|
||||
) {
|
||||
let tag = Text::literal(cost.label());
|
||||
let tag = Text::literal(lang::get(cost.label()));
|
||||
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
|
||||
let indent = atlas.font.width_text(&tag) + 4.0;
|
||||
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
|
||||
let text = lang::get(text);
|
||||
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
|
||||
for (i, line) in lines.iter().take(2).enumerate() {
|
||||
let lx = if i == 0 { x + indent } else { x };
|
||||
batch.text_styled(
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings::{Opt, Settings},
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -43,6 +44,8 @@ pub enum H {
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
@@ -153,6 +156,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrKey,
|
||||
None,
|
||||
"A key that opens this screen from the game. Not bound at first.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
@@ -354,11 +362,14 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "On" } else { "Off" }
|
||||
fn on_off(on: bool) -> String {
|
||||
lang::get(if on { "On" } else { "Off" })
|
||||
}
|
||||
/// A control's caption and its value, both through the language table: Horizon's captions are
|
||||
/// English sentences, and some of the values ("Off", "12 chunks") are words too.
|
||||
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
|
||||
Text::literal(format!("{caption}: {value}"))
|
||||
let value = value.to_string();
|
||||
Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value)))
|
||||
}
|
||||
|
||||
impl H {
|
||||
@@ -396,19 +407,21 @@ impl H {
|
||||
H::Preset => labelled("Preset", g.preset.label()),
|
||||
H::LodDistance => match g.lod_distance {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", format!("{n} chunks")),
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", format!("{n} per frame")),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
},
|
||||
H::UploadBudget => match g.upload_budget_kib {
|
||||
0 => labelled("Upload Budget", "Unlimited"),
|
||||
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
|
||||
k => labelled("Upload Budget", format!("{k} KiB/frame")),
|
||||
k if k >= 1024 => {
|
||||
labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)]))
|
||||
}
|
||||
k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])),
|
||||
},
|
||||
H::Sky => labelled("Sky", on_off(g.sky)),
|
||||
H::Fog => labelled("Fog", on_off(g.fog)),
|
||||
@@ -423,7 +436,10 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Workers => match g.workers {
|
||||
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
|
||||
0 => labelled(
|
||||
"Worker Threads",
|
||||
lang::tr("Auto (%s)", &[&g.worker_threads()]),
|
||||
),
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
@@ -437,23 +453,32 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
|
||||
),
|
||||
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
lang::tr(
|
||||
"%s nits",
|
||||
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
|
||||
),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -618,7 +643,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
|
||||
"Full Detail Distance",
|
||||
format!("{} chunks", s.render_distance),
|
||||
lang::tr("%s chunks", &[&s.render_distance]),
|
||||
),
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s),
|
||||
@@ -686,6 +711,8 @@ pub enum Action {
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
/// Wait for a key or mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -708,6 +735,8 @@ pub struct GraphicsUi {
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
/// The key binding the options screen is waiting on (`Action::BindKey`).
|
||||
pub binding: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
@@ -818,7 +847,7 @@ impl GraphicsUi {
|
||||
let x = STYLE.row_x(w);
|
||||
for (y, name) in headers {
|
||||
if shown(y, STYLE.header_height) {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
|
||||
@@ -840,11 +869,17 @@ impl GraphicsUi {
|
||||
if !shown(wd.y, 20.0) {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let mut label = item.label(s);
|
||||
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
|
||||
// KeyBindsScreen: "> key <" while it waits.
|
||||
label = Text::literal("Shortcut: > ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <");
|
||||
}
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -855,6 +890,7 @@ impl GraphicsUi {
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
self.dragging == Some(item),
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -884,7 +920,7 @@ impl GraphicsUi {
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&Text::literal(label),
|
||||
&Text::literal(lang::get(label)),
|
||||
hover,
|
||||
true,
|
||||
);
|
||||
@@ -906,7 +942,7 @@ impl GraphicsUi {
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
STYLE.title(batch, atlas, &Text::literal(lang::get(title)));
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
@@ -964,6 +1000,12 @@ impl GraphicsUi {
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrKey)) => {
|
||||
vec![
|
||||
Action::ButtonClick,
|
||||
Action::BindKey(crate::graphics::HDR_KEY),
|
||||
]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
/// A `MobEffectInstance` as `extractEffects` sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectIcon {
|
||||
/// The effect id without namespace (`speed`): the sprite is `mob_effect/<id>`.
|
||||
/// The effect id: vanilla's without namespace (`speed`), a mod's `<ns>:<path>`.
|
||||
pub id: String,
|
||||
/// `MobEffect.isBeneficial`.
|
||||
pub beneficial: bool,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn panels(
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (effect, icon))| {
|
||||
let mut name = translate(&format!("effect.minecraft.{}", effect.id))
|
||||
let mut name = translate(&horizon_client_core::potions::description_id(effect.id))
|
||||
.unwrap_or_else(|| effect.id.to_owned());
|
||||
if (1..=9).contains(&effect.amplifier) {
|
||||
let level = effect.amplifier + 1;
|
||||
|
||||
@@ -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 {
|
||||
@@ -66,10 +321,25 @@ pub fn smooth(smoothed: f32, progress: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// `ARGB.opaque` as the batch's RGBA.
|
||||
fn opaque(rgb: u32) -> u32 {
|
||||
pub const fn opaque(rgb: u32) -> u32 {
|
||||
0xFF00_0000 | (rgb & 0xFF) << 16 | (rgb & 0xFF00) | (rgb >> 16 & 0xFF)
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR` (RGB: 239, 50, 61).
|
||||
pub const LOGO_BACKGROUND_COLOR: u32 = opaque(0xEF323D);
|
||||
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR_DARK` (black).
|
||||
pub const LOGO_BACKGROUND_COLOR_DARK: u32 = opaque(0x000000);
|
||||
|
||||
/// `LoadingOverlay.BRAND_BACKGROUND`: black when `darkMojangStudiosBackground` is set,
|
||||
/// Mojang red otherwise.
|
||||
pub fn brand_background(dark: bool) -> u32 {
|
||||
if dark {
|
||||
LOGO_BACKGROUND_COLOR_DARK
|
||||
} else {
|
||||
LOGO_BACKGROUND_COLOR
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadingScreen.extractRenderState` over its background: the text,
|
||||
/// the progress bar and the chunk map, `status(x, z)` for x and z in
|
||||
/// `0..=2 * STATUS_RADIUS` from the map's north-west corner.
|
||||
@@ -115,6 +385,27 @@ pub fn draw(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
|
||||
/// progress line and bar.
|
||||
pub fn draw_loading_overlay(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
line: &str,
|
||||
progress: f32,
|
||||
dark: bool,
|
||||
) {
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
brand_background(dark),
|
||||
);
|
||||
draw_progress(batch, atlas, line, progress);
|
||||
}
|
||||
|
||||
/// The startup loading screen: the stage's line and a progress bar, centred
|
||||
/// like `LevelLoadingScreen`'s text and bar.
|
||||
pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progress: f32) {
|
||||
@@ -165,6 +456,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 +582,20 @@ mod tests {
|
||||
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brand_background_matches_dark_mojang_studios_setting() {
|
||||
assert_eq!(brand_background(true), LOGO_BACKGROUND_COLOR_DARK);
|
||||
assert_eq!(brand_background(false), LOGO_BACKGROUND_COLOR);
|
||||
assert_eq!(LOGO_BACKGROUND_COLOR_DARK, 0xFF00_0000);
|
||||
assert_eq!(LOGO_BACKGROUND_COLOR, crate::hud::rgba(239, 50, 61, 255));
|
||||
}
|
||||
|
||||
#[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 +607,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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
pub mod auth;
|
||||
pub mod ban_notice;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_entities;
|
||||
@@ -39,6 +40,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 +52,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,7 +62,10 @@ pub mod packs;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod post_chain;
|
||||
pub mod post_glsl;
|
||||
pub mod potent_sulfur;
|
||||
pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
@@ -77,8 +83,11 @@ 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;
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -240,7 +241,8 @@ impl LightTables {
|
||||
/// `edge_occluded` for two packed cells.
|
||||
#[inline]
|
||||
fn packed_edge_occluded(&self, top: u16, bottom: u16) -> bool {
|
||||
bottom & 0xF != 0 || self.packed_blocked(top, bottom, Direction::Down as usize)
|
||||
bottom & 0xF != 0
|
||||
|| ((top | bottom) != 0 && self.packed_blocked(top, bottom, Direction::Down as usize))
|
||||
}
|
||||
pub fn emission(&self, state: u32) -> u8 {
|
||||
self.cells[state as usize].emission
|
||||
@@ -266,12 +268,64 @@ const OFFSETS: [(i32, i32, i32); 6] = [
|
||||
(1, 0, 0),
|
||||
];
|
||||
|
||||
struct LightScratch {
|
||||
states: Vec<u16>,
|
||||
sky: Vec<u8>,
|
||||
block: Vec<u8>,
|
||||
queue: Vec<u32>,
|
||||
sky_queue: Vec<u32>,
|
||||
present: Vec<bool>,
|
||||
lowest: Vec<i32>,
|
||||
ids: Box<[u32; 4096]>,
|
||||
}
|
||||
|
||||
impl LightScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
states: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
sky_queue: Vec::new(),
|
||||
present: vec![false; W * W],
|
||||
lowest: vec![0; W * W],
|
||||
ids: Box::new([0u32; 4096]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear queues and shrink them if capacity exceeds the retention cap.
|
||||
fn clear_queues(&mut self) {
|
||||
const MAX_QUEUE_CAPACITY: usize = 65_536;
|
||||
self.queue.clear();
|
||||
if self.queue.capacity() > MAX_QUEUE_CAPACITY {
|
||||
self.queue.shrink_to(MAX_QUEUE_CAPACITY);
|
||||
}
|
||||
self.sky_queue.clear();
|
||||
if self.sky_queue.capacity() > MAX_QUEUE_CAPACITY {
|
||||
self.sky_queue.shrink_to(MAX_QUEUE_CAPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
|
||||
}
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
SCRATCH.with_borrow_mut(|scratch| light_chunk_with_scratch(region, tables, sky_light, scratch))
|
||||
}
|
||||
|
||||
fn light_chunk_with_scratch(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
scratch: &mut LightScratch,
|
||||
) -> Vec<Light> {
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
@@ -287,142 +341,194 @@ pub fn light_chunk(
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
let total = W * W * h;
|
||||
|
||||
scratch.states.resize(total, 0);
|
||||
scratch.states.fill(0);
|
||||
scratch.sky.resize(total, 0);
|
||||
scratch.sky.fill(0);
|
||||
scratch.block.resize(total, 0);
|
||||
scratch.block.fill(0);
|
||||
scratch.clear_queues();
|
||||
|
||||
let states = &mut scratch.states[..total];
|
||||
let sky = &mut scratch.sky[..total];
|
||||
let block = &mut scratch.block[..total];
|
||||
let queue = &mut scratch.queue;
|
||||
let sky_queue = &mut scratch.sky_queue;
|
||||
let ids = &mut scratch.ids;
|
||||
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
let z_base = dz * 16;
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
let x_base = dx * 16;
|
||||
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push_back(p as u32);
|
||||
section.unpack(ids);
|
||||
let y_base = si * 16;
|
||||
for ly in 0..16 {
|
||||
let y = y_base + ly;
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let z = z_base + lz;
|
||||
let p_row = y_offset + z * W + x_base;
|
||||
let i_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let id = ids[i_row + lx];
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let p = p_row + lx;
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Columns of missing neighbour chunks neither hold nor pass light.
|
||||
let mut present = vec![false; W * W];
|
||||
scratch.present.resize(W * W, false);
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
scratch.present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
let present = &scratch.present[..W * W];
|
||||
|
||||
scratch.lowest.resize(W * W, h as i32);
|
||||
let lowest = &mut scratch.lowest[..W * W];
|
||||
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
let col = z * W + x;
|
||||
if !present[col] || !sky_light {
|
||||
lowest[col] = h as i32;
|
||||
continue;
|
||||
}
|
||||
// The top layer (above the occupied range) is always open sky.
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0
|
||||
&& !tables.packed_edge_occluded(
|
||||
states[at(x, y as usize, z)],
|
||||
states[at(x, y as usize - 1, z)],
|
||||
)
|
||||
{
|
||||
let mut p = (h - 1) * (W * W) + col;
|
||||
sky[p] = 15;
|
||||
while y > 0 {
|
||||
let next_p = p - (W * W);
|
||||
if tables.packed_edge_occluded(states[p], states[next_p]) {
|
||||
break;
|
||||
}
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
p = next_p;
|
||||
sky[p] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y;
|
||||
lowest[col] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let own = lowest[z * W + x];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
|
||||
if sky_light {
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
let col = z * W + x;
|
||||
if !present[col] {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Seed propagation from the source cells that border darker cells.
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h - 1);
|
||||
for y in from..=to {
|
||||
if sky[at(x, y, z)] == 15 {
|
||||
sky_queue.push_back(at(x, y, z) as u32);
|
||||
let own = lowest[col];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
}
|
||||
}
|
||||
// Seed propagation from the source cells that border darker cells.
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h.saturating_sub(1));
|
||||
if from <= to {
|
||||
let mut p = from * (W * W) + col;
|
||||
for _ in from..=to {
|
||||
if sky[p] == 15 {
|
||||
sky_queue.push(p as u32);
|
||||
}
|
||||
p += W * W;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
|
||||
while let Some(p) = queue.pop_front() {
|
||||
let p = p as usize;
|
||||
|
||||
let block_masks = &tables.block_masks;
|
||||
let types = tables.types;
|
||||
|
||||
let spread = |light: &mut [u8], queue: &mut Vec<u32>| {
|
||||
let mut head = 0;
|
||||
while head < queue.len() {
|
||||
let p = queue[head] as usize;
|
||||
head += 1;
|
||||
let level = light[p];
|
||||
if level <= 1 {
|
||||
continue;
|
||||
}
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let min_threshold = level - 1;
|
||||
let y = p / (W * W);
|
||||
let col = p % (W * W);
|
||||
let z = col / W;
|
||||
let x = col % W;
|
||||
let from = states[p];
|
||||
let col = z * W + x;
|
||||
let from_row = (from >> 4) as usize * types;
|
||||
|
||||
// Neighbours by linear offset; `present` only depends on the
|
||||
// column, and the axis bounds rule out wrapping.
|
||||
macro_rules! neighbour {
|
||||
($d:expr, $q:expr, $ncol:expr) => {{
|
||||
if present[$ncol] {
|
||||
let q = $q;
|
||||
macro_rules! step_neighbour {
|
||||
($d:expr, $q:expr) => {{
|
||||
let q = $q;
|
||||
if light[q] < min_threshold {
|
||||
let to = states[q];
|
||||
let next = level.saturating_sub((to & 0xF).max(1) as u8);
|
||||
if next > light[q] && !tables.packed_blocked(from, to, $d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
let damp = (to & 0xF).max(1) as u8;
|
||||
if level > light[q] + damp {
|
||||
if block_masks[from_row + (to >> 4) as usize] & (1 << $d) == 0 {
|
||||
light[q] = level - damp;
|
||||
queue.push(q as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
if y > 0 {
|
||||
neighbour!(0, p - W * W, col);
|
||||
step_neighbour!(0, p - W * W);
|
||||
}
|
||||
if y + 1 < h {
|
||||
neighbour!(1, p + W * W, col);
|
||||
step_neighbour!(1, p + W * W);
|
||||
}
|
||||
if z > 0 {
|
||||
neighbour!(2, p - W, col - W);
|
||||
if z > 0 && present[col - W] {
|
||||
step_neighbour!(2, p - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
neighbour!(3, p + W, col + W);
|
||||
if z + 1 < W && present[col + W] {
|
||||
step_neighbour!(3, p + W);
|
||||
}
|
||||
if x > 0 {
|
||||
neighbour!(4, p - 1, col - 1);
|
||||
if x > 0 && present[col - 1] {
|
||||
step_neighbour!(4, p - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
neighbour!(5, p + 1, col + 1);
|
||||
if x + 1 < W && present[col + 1] {
|
||||
step_neighbour!(5, p + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
spread(sky, sky_queue);
|
||||
spread(block, queue);
|
||||
let result = pack_lighting(sections, h, sky, block, sky_light);
|
||||
scratch.clear_queues();
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
@@ -434,7 +540,6 @@ fn pack_lighting(
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
@@ -447,10 +552,13 @@ fn pack_lighting(
|
||||
if y >= h {
|
||||
continue;
|
||||
}
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let p_row = y_offset + (16 + lz) * W + 16;
|
||||
let out_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let p = at(16 + lx, y, 16 + lz);
|
||||
data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p];
|
||||
let p = p_row + lx;
|
||||
data[out_row + lx] = sky[p] << 4 | block[p];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,8 +575,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 +650,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 +774,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 +948,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;
|
||||
};
|
||||
@@ -1352,6 +1483,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() {
|
||||
@@ -1626,9 +1765,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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1800,4 +1940,336 @@ mod tests {
|
||||
changed.sections().count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn benchmark_light_chunk() {
|
||||
use std::time::Instant;
|
||||
let t = tables();
|
||||
let layout = ColumnLayout::VANILLA;
|
||||
let mut chunks = Vec::new();
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
let mut c = Chunk::with_layout(cx, cz, layout);
|
||||
for y in layout.min_y()..64 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
for x in 2..14 {
|
||||
for z in 2..14 {
|
||||
c.set(x, 64, z, SLAB);
|
||||
}
|
||||
}
|
||||
c.set(8, 65, 8, TORCH);
|
||||
c.set(4, 30, 4, TORCH);
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
let region = [
|
||||
[Some(&chunks[0]), Some(&chunks[1]), Some(&chunks[2])],
|
||||
[Some(&chunks[3]), Some(&chunks[4]), Some(&chunks[5])],
|
||||
[Some(&chunks[6]), Some(&chunks[7]), Some(&chunks[8])],
|
||||
];
|
||||
|
||||
for _ in 0..5 {
|
||||
let _ = light_chunk(®ion, &t, true);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let iters = 200;
|
||||
for _ in 0..iters {
|
||||
let _ = std::hint::black_box(light_chunk(std::hint::black_box(®ion), &t, true));
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
println!(
|
||||
"benchmark_light_chunk: {:?} total, {:.2} µs/chunk",
|
||||
elapsed,
|
||||
elapsed.as_micros() as f64 / iters as f64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_queues_shrink_above_capacity_cap() {
|
||||
let mut scratch = LightScratch::new();
|
||||
scratch.queue.resize(100_000, 0);
|
||||
scratch.sky_queue.resize(100_000, 0);
|
||||
assert!(scratch.queue.capacity() >= 100_000);
|
||||
assert!(scratch.sky_queue.capacity() >= 100_000);
|
||||
scratch.clear_queues();
|
||||
assert_eq!(scratch.queue.len(), 0);
|
||||
assert_eq!(scratch.sky_queue.len(), 0);
|
||||
assert!(scratch.queue.capacity() < 100_000);
|
||||
assert!(scratch.sky_queue.capacity() < 100_000);
|
||||
}
|
||||
|
||||
/// The baseline flood-fill lighting algorithm before the scratch and wavefront optimizations.
|
||||
fn old_light_chunk(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
for (i, s) in chunk.sections.iter().enumerate().rev() {
|
||||
if !s.is_uniform(0) {
|
||||
top_section = top_section.max(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push_back(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut present = vec![false; W * W];
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
let old_packed_edge_occluded = |top: u16, bottom: u16| -> bool {
|
||||
bottom & 0xF != 0 || tables.packed_blocked(top, bottom, Direction::Down as usize)
|
||||
};
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0
|
||||
&& !old_packed_edge_occluded(
|
||||
states[at(x, y as usize, z)],
|
||||
states[at(x, y as usize - 1, z)],
|
||||
)
|
||||
{
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let own = lowest[z * W + x];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
}
|
||||
}
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h.saturating_sub(1));
|
||||
for y in from..=to {
|
||||
if sky[at(x, y, z)] == 15 {
|
||||
sky_queue.push_back(at(x, y, z) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
|
||||
while let Some(p) = queue.pop_front() {
|
||||
let p = p as usize;
|
||||
let level = light[p];
|
||||
if level <= 1 {
|
||||
continue;
|
||||
}
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let y = p / (W * W);
|
||||
let from = states[p];
|
||||
let col = z * W + x;
|
||||
macro_rules! neighbour {
|
||||
($d:expr, $q:expr, $ncol:expr) => {{
|
||||
if present[$ncol] {
|
||||
let q = $q;
|
||||
let to = states[q];
|
||||
let next = level.saturating_sub((to & 0xF).max(1) as u8);
|
||||
if next > light[q] && !tables.packed_blocked(from, to, $d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
if y > 0 {
|
||||
neighbour!(0, p - W * W, col);
|
||||
}
|
||||
if y + 1 < h {
|
||||
neighbour!(1, p + W * W, col);
|
||||
}
|
||||
if z > 0 {
|
||||
neighbour!(2, p - W, col - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
neighbour!(3, p + W, col + W);
|
||||
}
|
||||
if x > 0 {
|
||||
neighbour!(4, p - 1, col - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
neighbour!(5, p + 1, col + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_chunk_matches_old_algorithm() {
|
||||
let t = tables();
|
||||
let layout = ColumnLayout::VANILLA;
|
||||
|
||||
let mut rng = 0x1234_5678_u64;
|
||||
let mut next = |max: usize| -> usize {
|
||||
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
((rng >> 32) as usize) % max
|
||||
};
|
||||
|
||||
let cases = [
|
||||
// (sky_light, missing_pattern)
|
||||
// 0 = full 3x3
|
||||
// 1 = missing corners
|
||||
// 2 = missing edges
|
||||
// 3 = random missing neighbours
|
||||
// 4 = only centre and single neighbour
|
||||
(true, 0),
|
||||
(false, 0),
|
||||
(true, 1),
|
||||
(false, 1),
|
||||
(true, 2),
|
||||
(false, 2),
|
||||
(true, 3),
|
||||
(false, 3),
|
||||
(true, 4),
|
||||
(false, 4),
|
||||
];
|
||||
|
||||
for (round, &(sky_light, pattern)) in cases.iter().enumerate() {
|
||||
let mut chunks = Vec::new();
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
let mut c = Chunk::with_layout(cx, cz, layout);
|
||||
let base_y = -64 + next(64) as i32;
|
||||
for y in layout.min_y()..base_y {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
let features = 10 + next(20);
|
||||
for _ in 0..features {
|
||||
let fx = next(16) as i32;
|
||||
let fz = next(16) as i32;
|
||||
let fy = base_y + next(32) as i32;
|
||||
let block_type = match next(4) {
|
||||
0 => AIR,
|
||||
1 => STONE,
|
||||
2 => TORCH,
|
||||
_ => SLAB,
|
||||
};
|
||||
c.set(fx, fy, fz, block_type);
|
||||
}
|
||||
let roof_y = base_y + 10 + next(10) as i32;
|
||||
for rx in 4..12 {
|
||||
for rz in 4..12 {
|
||||
if next(3) != 0 {
|
||||
c.set(rx, roof_y, rz, SLAB);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.set(8, roof_y - 1, 8, TORCH);
|
||||
c.set(next(16) as i32, roof_y + 2, next(16) as i32, TORCH);
|
||||
c.set(0, base_y + 1, 0, TORCH);
|
||||
c.set(15, base_y + 1, 15, TORCH);
|
||||
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
let mut region: [[Option<&Chunk>; 3]; 3] = [[None; 3]; 3];
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let is_centre = dz == 1 && dx == 1;
|
||||
let chunk_ref = &chunks[dz * 3 + dx];
|
||||
let include = if is_centre {
|
||||
true
|
||||
} else {
|
||||
match pattern {
|
||||
0 => true,
|
||||
1 => (dz + dx) % 2 == 1,
|
||||
2 => (dz + dx) % 2 == 0,
|
||||
3 => next(10) < 6,
|
||||
4 => dz == 1 && dx == 2,
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
if include {
|
||||
region[dz][dx] = Some(chunk_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old = old_light_chunk(®ion, &t, sky_light);
|
||||
let new = light_chunk(®ion, &t, sky_light);
|
||||
|
||||
assert_eq!(
|
||||
old.len(),
|
||||
new.len(),
|
||||
"round {round}: section count mismatch"
|
||||
);
|
||||
for si in 0..old.len() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
new[si].get(i),
|
||||
old[si].get(i),
|
||||
"mismatch at round {round} (sky_light={sky_light}, pattern={pattern}), section {si}, index {i} (lx={}, ly={}, lz={}): new={:#04x}, old={:#04x}",
|
||||
i & 15,
|
||||
i >> 8,
|
||||
(i >> 4) & 15,
|
||||
new[si].get(i),
|
||||
old[si].get(i)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -358,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),
|
||||
@@ -372,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])
|
||||
@@ -380,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 {
|
||||
@@ -452,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);
|
||||
@@ -645,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,
|
||||
@@ -750,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
|
||||
@@ -810,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,
|
||||
@@ -840,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();
|
||||
@@ -861,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(),
|
||||
@@ -1119,7 +1142,7 @@ 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])
|
||||
@@ -1156,7 +1179,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
if chunks.len() >= 32 {
|
||||
if chunks.len() >= DETAIL_REQUEST {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1166,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
|
||||
@@ -1183,7 +1206,6 @@ impl Lod {
|
||||
pub fn update(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
yaw: f32,
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
covered: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -1192,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)],
|
||||
@@ -1232,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)>) {
|
||||
@@ -1389,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(
|
||||
@@ -1417,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);
|
||||
@@ -1633,7 +1594,10 @@ 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();
|
||||
@@ -1739,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}"
|
||||
);
|
||||
}
|
||||
@@ -1875,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;
|
||||
}
|
||||
@@ -2303,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;
|
||||
}
|
||||
@@ -2321,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"
|
||||
@@ -2351,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;
|
||||
}
|
||||
@@ -2389,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 {
|
||||
@@ -2448,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();
|
||||
@@ -2471,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"
|
||||
);
|
||||
|
||||
@@ -2488,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))
|
||||
@@ -122,6 +130,12 @@ impl Mesher {
|
||||
let tint = match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"spruce_leaves" => Tint::Constant(0x61_9961),
|
||||
"birch_leaves" => Tint::Constant(0x80_A755),
|
||||
// Cherry (and other pre-coloured) leaves: the texture is
|
||||
// already pink; biome foliage tint turns a grove yellow (#4688).
|
||||
"cherry_leaves"
|
||||
| "azalea_leaves"
|
||||
| "flowering_azalea_leaves"
|
||||
| "pale_oak_leaves" => Tint::None,
|
||||
"leaf_litter" => Tint::Dry,
|
||||
_ if leaves => Tint::Foliage,
|
||||
_ => Tint::Grass,
|
||||
@@ -179,7 +193,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 +274,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 +285,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 +311,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 +343,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 +457,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 +502,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 +633,7 @@ mod refinement_tests {
|
||||
None,
|
||||
&std::array::from_fn(|_| Vec::new()),
|
||||
&[fine],
|
||||
|_, _| Material {
|
||||
|_, _, _| Material {
|
||||
faces: [
|
||||
Some(Paint {
|
||||
layer: 1,
|
||||
@@ -646,13 +682,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 +707,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 +728,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 +1022,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 +1220,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 {
|
||||
@@ -283,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();
|
||||
@@ -308,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.
|
||||
@@ -403,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() {
|
||||
@@ -478,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()
|
||||
}
|
||||
|
||||
@@ -139,11 +139,50 @@ mod vertex_tests {
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
|
||||
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
|
||||
assert_eq!(hdr_tier("sea_pickle", 15), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_mesh_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compute_bounds_empty_solid_and_translucent() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
assert_eq!(mesh.compute_bounds(), None);
|
||||
|
||||
let v1 = TerrainVertex::new([1.0, 2.0, 3.0], [0.0, 0.0], 0, 0, 0);
|
||||
let v2 = TerrainVertex::new([4.0, 5.0, 6.0], [0.0, 0.0], 0, 0, 0);
|
||||
mesh.solid.push(v1);
|
||||
mesh.solid.push(v2);
|
||||
|
||||
let pad = glam::Vec3::splat(SectionMesh::BOUNDS_PAD);
|
||||
assert_eq!(
|
||||
mesh.compute_bounds(),
|
||||
Some([
|
||||
glam::Vec3::new(1.0, 2.0, 3.0) - pad,
|
||||
glam::Vec3::new(4.0, 5.0, 6.0) + pad,
|
||||
])
|
||||
);
|
||||
|
||||
let v3 = TerrainVertex::new([0.5, 7.0, 8.0], [0.0, 0.0], 0, 0, 0);
|
||||
mesh.translucent.push(v3);
|
||||
assert_eq!(
|
||||
mesh.compute_bounds(),
|
||||
Some([
|
||||
glam::Vec3::new(0.5, 2.0, 3.0) - pad,
|
||||
glam::Vec3::new(4.0, 7.0, 8.0) + pad,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
@@ -158,6 +197,8 @@ pub struct SectionMesh {
|
||||
/// each frame by `block_entity_render` like vanilla's per-section
|
||||
/// `SectionCompiler.Results.blockEntities`.
|
||||
pub block_entities: Vec<([i32; 3], u32)>,
|
||||
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
|
||||
pub bounds: Option<[glam::Vec3; 2]>,
|
||||
}
|
||||
impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
@@ -167,19 +208,66 @@ impl Default for SectionMesh {
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
|
||||
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
self.sides = Sides::default();
|
||||
self.visibility = ALL_VISIBLE;
|
||||
self.bounds = None;
|
||||
}
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
}
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
pub fn sort_solid(&mut self) {
|
||||
self.sides = sides::sort(&mut self.solid);
|
||||
self.bounds = self.compute_bounds();
|
||||
}
|
||||
|
||||
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
|
||||
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
|
||||
if self.solid.is_empty() && self.translucent.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut min = glam::Vec3::splat(f32::INFINITY);
|
||||
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
|
||||
for v in &self.solid {
|
||||
let p = glam::Vec3::from(v.pos);
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
for v in &self.translucent {
|
||||
let p = glam::Vec3::from(v.pos);
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
let pad = glam::Vec3::splat(Self::BOUNDS_PAD);
|
||||
Some([min - pad, max + pad])
|
||||
}
|
||||
}
|
||||
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
@@ -201,7 +289,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
return 0;
|
||||
}
|
||||
let mut result = 0u64;
|
||||
let mut stack = Vec::new();
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
@@ -458,7 +545,12 @@ impl MeshTables {
|
||||
});
|
||||
let mut face = [0u8; 6];
|
||||
let mut cover = [0f32; 6];
|
||||
for d in Direction::ALL {
|
||||
// A server's visibility knows no state (`FaceVisibility::empty`):
|
||||
// it meshes no fluid, so these stay unset.
|
||||
for d in Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|_| !visibility.is_empty())
|
||||
{
|
||||
let (face_id, shape) = visibility
|
||||
.occlusion_face(BlockStateId(id as u32), d)
|
||||
.ok_or("missing occlusion face")?;
|
||||
@@ -631,21 +723,30 @@ fn pi(x: i32, y: i32, z: i32) -> usize {
|
||||
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
|
||||
}
|
||||
/// Blocks and light of one section plus a `B`-block border.
|
||||
#[derive(Default)]
|
||||
struct Padded {
|
||||
blocks: Vec<u32>,
|
||||
light: Vec<u8>,
|
||||
}
|
||||
impl Padded {
|
||||
#[cfg(test)]
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let mut pad = Self::default();
|
||||
pad.fill(region, sy, &mut Box::new([0; 4096]));
|
||||
pad
|
||||
}
|
||||
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
|
||||
self.blocks.resize(P * P * P, 0);
|
||||
self.blocks.fill(0);
|
||||
self.light.resize(P * P * P, 0);
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(&mut own);
|
||||
section.unpack(own);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
@@ -662,15 +763,33 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { blocks, light }
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 88 KiB per worker.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -693,14 +812,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
|
||||
| "redstone_lamp" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Glowing at every charge, not only the brighter ones.
|
||||
"respawn_anchor" => INCANDESCENT,
|
||||
"redstone_torch"
|
||||
| "redstone_wall_torch"
|
||||
| "magma_block"
|
||||
| "ochre_froglight"
|
||||
| "verdant_froglight"
|
||||
| "pearlescent_froglight"
|
||||
| "shroomlight" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// One tier at every oxidation stage, waxed or not.
|
||||
_ if name.ends_with("copper_bulb") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
@@ -840,14 +970,30 @@ pub struct Mesher<'a> {
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = SectionMesh::default();
|
||||
self.mesh_reusing(
|
||||
region,
|
||||
sy,
|
||||
SectionMesh::default(),
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mut out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
out.clear();
|
||||
let center = region[1][1].expect("centre chunk");
|
||||
let section = ¢er.sections[sy as usize];
|
||||
if section.is_uniform(0) {
|
||||
return out;
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
@@ -882,7 +1028,7 @@ impl Mesher<'_> {
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let nether = self.nether_shading;
|
||||
fluid::tesselate(self.tables, nether, &pad, water, &mut out, [x, y, z]);
|
||||
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
|
||||
}
|
||||
if !state.model {
|
||||
continue;
|
||||
@@ -932,7 +1078,7 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
self.face(
|
||||
&pad,
|
||||
pad,
|
||||
&mut tints,
|
||||
&mut out,
|
||||
[x, y, z],
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
let mut pad = Padded::default();
|
||||
let mut own = Box::new([u32::MAX; 4096]);
|
||||
for min_y in [MIN_Y, -256, 0, 256] {
|
||||
let layout = crate::chunk::ColumnLayout::new(min_y, 32).unwrap();
|
||||
let base_y = min_y + 16;
|
||||
let mut center = Chunk::with_layout(0, 0, layout);
|
||||
center.set(0, base_y, 0, 7);
|
||||
center.set(15, base_y + 15, 15, 9);
|
||||
let mut neighbor = Chunk::with_layout(1, 0, layout);
|
||||
neighbor.set(0, base_y + 2, 3, 11);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&neighbor);
|
||||
pad.fill(®ion, 1, &mut own);
|
||||
let buffers = (pad.blocks.as_ptr(), pad.light.as_ptr());
|
||||
for current in [region, [[None; 3]; 3], region] {
|
||||
pad.fill(¤t, 1, &mut own);
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let chunk = current[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
// A missing neighbour continues the centre's edge column.
|
||||
let (block, light) = match (chunk, current[1][1]) {
|
||||
(Some(c), _) => (
|
||||
c.get(x & 15, base_y + y, z & 15),
|
||||
c.light(x & 15, base_y + y, z & 15),
|
||||
),
|
||||
(None, Some(c)) => {
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
(c.get(ex, base_y + y, ez), c.light(ex, base_y + y, ez))
|
||||
}
|
||||
(None, None) => (0, 0xF0),
|
||||
};
|
||||
assert_eq!(pad.blocks[pi(x, y, z)], block);
|
||||
assert_eq!(pad.light[pi(x, y, z)], light);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(buffers, (pad.blocks.as_ptr(), pad.light.as_ptr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let names = [
|
||||
"minecraft:stone",
|
||||
"minecraft:glass",
|
||||
"minecraft:grass_block",
|
||||
"minecraft:oak_leaves",
|
||||
"minecraft:water",
|
||||
"minecraft:chest",
|
||||
];
|
||||
let mut dense = Chunk::empty(0, 0);
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
dense.set(
|
||||
x,
|
||||
MIN_Y + 4,
|
||||
z,
|
||||
state(names[((x + z) as usize) % names.len()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut sparse = Chunk::empty(-3, 5);
|
||||
sparse.set(0, MIN_Y + 15, 0, state("minecraft:short_grass"));
|
||||
sparse.set(15, MIN_Y + 1, 15, state("minecraft:lava"));
|
||||
let empty = Chunk::empty(0, 0);
|
||||
let mut neighbor = Chunk::empty(1, 0);
|
||||
neighbor.set(0, MIN_Y + 4, 0, state("minecraft:stone"));
|
||||
let mut custom = Chunk::with_layout(2, -4, crate::chunk::ColumnLayout::new(-256, 64).unwrap());
|
||||
custom.set(1, -252, 2, state("minecraft:chest"));
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
// The same scratch and output buffers survive a resource reload. They must
|
||||
// contain no references into the previous RenderAssets or MeshTables.
|
||||
for reload in 0..2 {
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility,
|
||||
zoom: BiomeZoom::new(42),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
(&empty, None),
|
||||
(&dense, Some(&neighbor)),
|
||||
(&sparse, None),
|
||||
(&empty, None),
|
||||
(&dense, None),
|
||||
(&custom, None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(center);
|
||||
region[1][2] = east;
|
||||
let mut fresh = mesher.mesh(®ion, 0);
|
||||
recycled = mesher.mesh_reusing(®ion, 0, recycled, &mut scratch);
|
||||
if reload == 0 && case == 0 {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.solid),
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.solid)
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.translucent),
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.translucent)
|
||||
);
|
||||
assert_eq!(fresh.block_entities, recycled.block_entities);
|
||||
assert_eq!(fresh.visibility, recycled.visibility);
|
||||
assert_eq!(fresh.sides, recycled.sides);
|
||||
assert_eq!(fresh.bounds, recycled.bounds);
|
||||
if std::ptr::eq(center, &dense) {
|
||||
assert!(!fresh.solid.is_empty());
|
||||
assert!(!fresh.translucent.is_empty());
|
||||
assert!(!fresh.block_entities.is_empty());
|
||||
}
|
||||
if std::ptr::eq(center, &custom) {
|
||||
assert_eq!(
|
||||
fresh.block_entities,
|
||||
[([33, -252, -62], state("minecraft:chest"))]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An isolated scratch-stage comparison, not a whole-mesher or FPS benchmark.
|
||||
/// Output each sample so the caller can retain the observations and spread.
|
||||
#[test]
|
||||
#[ignore = "opt-in scratch microbenchmark; run with --nocapture --test-threads=1"]
|
||||
fn benchmark_padded_reuse() {
|
||||
const ITERATIONS: usize = 4096;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, MIN_Y + y, z, 1 + ((x + y + z) % 7) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let mut pad = Padded::new(®ion, 0);
|
||||
let mut own = Box::new([0; 4096]);
|
||||
let pointers = (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr());
|
||||
let bytes = pad.blocks.capacity() * 4 + pad.light.capacity() + 4096 * 4;
|
||||
// Warm both code paths and the process allocator before recording samples.
|
||||
for _ in 0..1024 {
|
||||
std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0));
|
||||
pad.fill(std::hint::black_box(®ion), 0, &mut own);
|
||||
std::hint::black_box(&pad);
|
||||
}
|
||||
for sample in 0..9 {
|
||||
// Alternate execution order to expose, rather than hide, order effects.
|
||||
for reused in [sample % 2 == 0, sample % 2 != 0] {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..ITERATIONS {
|
||||
if reused {
|
||||
pad.fill(std::hint::black_box(®ion), 0, &mut own);
|
||||
std::hint::black_box(&pad);
|
||||
} else {
|
||||
std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{{\"sample\":{sample},\"reused\":{reused},\"iterations\":{ITERATIONS},\"elapsed_ms\":{:.6},\"scratch_bytes\":{bytes}}}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,21 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -12,6 +12,11 @@ use std::{
|
||||
pub const FILE: &str = "horizon-mods.txt";
|
||||
/// The folder holding `.hmod` files, beside `options.txt`.
|
||||
pub const DIR: &str = "horizon-mods";
|
||||
/// Where the launcher installs native mods (`<id>.hnmod`), beside [`DIR`]. They run only in a
|
||||
/// game the launcher built with them.
|
||||
pub const NATIVE_DIR: &str = "horizon-native-mods";
|
||||
/// Why a native mod installed in [`NATIVE_DIR`] doesn't run in this game.
|
||||
pub const NOT_BUILT: &str = "Installed, not built into this game: build the game with it in the launcher (Settings > Native mods), then play the instance that build is in.";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModList {
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
//! `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.focused {
|
||||
crate::preedit::report_caret(cursor_x, cursor_y, 10.0);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Improved transparency (the options screen's order-independent transparency),
|
||||
// appended to terrain.wgsl. Vanilla 26.3 draws its translucent layer three
|
||||
// times: once to find each pixel's nearest and furthest see-through depth (the
|
||||
// depth bounds), once to add each fragment's absorbance into a small wavelet
|
||||
// transform over that range (the transmittance), and once to weight every
|
||||
// fragment's colour by what the transform says lies in front of it (the
|
||||
// accumulation). A last full-screen pass puts the sum over the opaque world.
|
||||
//
|
||||
// Eight coefficients in two RGBA16F targets: [0] is the mean absorbance, [1]
|
||||
// the widest wavelet, [2..4] the next level, [4..8] the finest, one per depth
|
||||
// bin pair.
|
||||
struct OitParams {
|
||||
// x, y: the projection's [2][2] and [3][2], which turn a device depth into
|
||||
// a distance along the view axis: y / (depth + x).
|
||||
projection: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> oit: OitParams;
|
||||
@group(2) @binding(1) var oit_bounds: texture_2d<f32>;
|
||||
@group(2) @binding(2) var oit_coeff0: texture_2d<f32>;
|
||||
@group(2) @binding(3) var oit_coeff1: texture_2d<f32>;
|
||||
@group(2) @binding(4) var oit_accumulated: texture_2d<f32>;
|
||||
|
||||
const OIT_FULLY_OPAQUE_ALPHA: f32 = 0.99;
|
||||
const OIT_FULLY_OPAQUE_TRANSMITTANCE: f32 = 0.02;
|
||||
const OIT_BINS: i32 = 8;
|
||||
const OIT_LOW_PRECISION_BINS: f32 = 2.0;
|
||||
const OIT_HIGH_PRECISION_RANGE: f32 = 10.0;
|
||||
const OIT_ALPHA_CUTOUT: f32 = 0.1;
|
||||
|
||||
fn oit_linear_depth(device_depth: f32) -> f32 {
|
||||
return oit.projection.y / (device_depth + oit.projection.x);
|
||||
}
|
||||
fn oit_absorbance(transmittance: f32) -> f32 {
|
||||
return clamp(-log(max(transmittance, 0.0001)), 0.0, 4.0);
|
||||
}
|
||||
fn oit_transmittance(absorbance: f32) -> f32 {
|
||||
return clamp(exp(-absorbance), 0.0001, 1.0);
|
||||
}
|
||||
|
||||
// Where a fragment sits in its pixel's depth range, 0 to 1. The last
|
||||
// OIT_LOW_PRECISION_BINS bins stretch over whatever is past the first ten
|
||||
// blocks, so a thin pane near the eye keeps its resolution in a deep range.
|
||||
fn oit_normalize_depth(device_depth: f32, pixel: vec2<f32>) -> f32 {
|
||||
let bounds = textureLoad(oit_bounds, vec2<i32>(pixel), 0);
|
||||
let nearest = -bounds.r;
|
||||
let furthest = bounds.g;
|
||||
let linear = oit_linear_depth(device_depth);
|
||||
let range = max(furthest - nearest, 0.0001);
|
||||
let within = clamp(linear - nearest, 0.0, range);
|
||||
let fine_share = (f32(OIT_BINS) - OIT_LOW_PRECISION_BINS) / f32(OIT_BINS);
|
||||
let fine_range = fine_share * range;
|
||||
if fine_range <= OIT_HIGH_PRECISION_RANGE {
|
||||
return within / range;
|
||||
}
|
||||
if within <= OIT_HIGH_PRECISION_RANGE {
|
||||
return within / OIT_HIGH_PRECISION_RANGE * fine_share;
|
||||
}
|
||||
return fine_share + (within - OIT_HIGH_PRECISION_RANGE)
|
||||
/ (range - OIT_HIGH_PRECISION_RANGE) * (1.0 - fine_share);
|
||||
}
|
||||
|
||||
struct OitCoefficients {
|
||||
@location(0) first: vec4<f32>,
|
||||
@location(1) second: vec4<f32>,
|
||||
}
|
||||
// One fragment's contribution to the transform (summed by additive blending).
|
||||
fn oit_add_transmittance(transmittance: f32, frag_depth: f32, pixel: vec2<f32>) -> OitCoefficients {
|
||||
var c: array<f32, 8>;
|
||||
let absorbance = oit_absorbance(transmittance);
|
||||
let original = oit_normalize_depth(frag_depth, pixel);
|
||||
let depth = original * f32(OIT_BINS - 1) / f32(OIT_BINS);
|
||||
c[0] = absorbance * (1.0 - depth);
|
||||
// The wavelet tree's leaves start at index 8.
|
||||
var tree = i32(floor(original * f32(OIT_BINS - 1))) + OIT_BINS;
|
||||
for (var level = 0; level <= 2; level++) {
|
||||
let power = 2 - level;
|
||||
let parent = tree >> 1u;
|
||||
let index_at_parent = f32(parent & ((1 << u32(power)) - 1));
|
||||
let right = tree & 1;
|
||||
let side = 1 - (right << 1u);
|
||||
let width = exp2(-f32(power));
|
||||
let height = exp2(f32(power) * 0.5);
|
||||
let offset = depth - width * index_at_parent;
|
||||
c[parent] = (f32(right) * width + f32(side) * offset) * height * absorbance;
|
||||
tree = parent;
|
||||
}
|
||||
return OitCoefficients(vec4(c[0], c[1], c[2], c[3]), vec4(c[4], c[5], c[6], c[7]));
|
||||
}
|
||||
|
||||
// The absorbance in front of `original` (a normalized depth), without the
|
||||
// fragment's own contribution `current` that the transform already holds.
|
||||
fn oit_sample_absorbance(coefficients: array<f32, 8>, original: f32, current: f32) -> f32 {
|
||||
var c = coefficients;
|
||||
var average = c[0];
|
||||
if average == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let in_bins = original * f32(OIT_BINS - 1);
|
||||
let depth = in_bins / f32(OIT_BINS);
|
||||
average -= current * (1.0 - depth);
|
||||
// B: the bin the fragment is in; A: the one before it.
|
||||
var tree_b = clamp(i32(floor(in_bins)), 0, OIT_BINS - 1);
|
||||
let sample_a_too = tree_b >= 1;
|
||||
var tree_a = select(tree_b, tree_b - 1, sample_a_too);
|
||||
tree_b += OIT_BINS;
|
||||
tree_a += OIT_BINS;
|
||||
var sample_b = average;
|
||||
var sample_a = select(0.0, average, sample_a_too);
|
||||
for (var level = 0; level <= 2; level++) {
|
||||
let power = 2 - level;
|
||||
let width = exp2(-f32(power));
|
||||
let height = exp2(f32(power) * 0.5);
|
||||
let parent_b = tree_b >> 1u;
|
||||
let right_b = tree_b & 1;
|
||||
let side_b = 1 - (right_b << 1u);
|
||||
let index_at_parent = f32(parent_b & ((1 << u32(power)) - 1));
|
||||
let offset = depth - width * index_at_parent;
|
||||
let own = (f32(right_b) * width + f32(side_b) * offset) * height * current;
|
||||
let difference_b = c[parent_b] - own;
|
||||
sample_b -= height * difference_b * f32(side_b);
|
||||
tree_b = parent_b;
|
||||
if sample_a_too {
|
||||
let parent_a = tree_a >> 1u;
|
||||
let difference_a = select(c[parent_a], difference_b, parent_a == parent_b);
|
||||
let side_a = 1 - ((tree_a & 1) << 1u);
|
||||
sample_a -= height * difference_a * f32(side_a);
|
||||
tree_a = parent_a;
|
||||
}
|
||||
}
|
||||
let blend = select(fract(in_bins), 1.0, in_bins >= f32(OIT_BINS));
|
||||
return mix(sample_a, sample_b, blend);
|
||||
}
|
||||
fn oit_sample_transmittance(pixel: vec2<i32>, depth: f32, current: f32) -> f32 {
|
||||
let a = textureLoad(oit_coeff0, pixel, 0);
|
||||
let b = textureLoad(oit_coeff1, pixel, 0);
|
||||
let c = array<f32, 8>(a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w);
|
||||
return oit_transmittance(oit_sample_absorbance(c, depth, oit_absorbance(current)));
|
||||
}
|
||||
|
||||
// The three stages' ends for a pipeline whose fragment colour is computed as
|
||||
// it is without improved transparency (vanilla's `executeAlphaOnlyPhase` and
|
||||
// `sampleColorForAccumulation`). `color` is the classic result, fogged:
|
||||
// weighting a fogged colour equals fogging the weighted one, since the fog
|
||||
// is a mix and vanilla premultiplies the fog colour by the same alpha.
|
||||
fn oit_bounds_fragment(alpha: f32, clip: vec4<f32>) -> vec4<f32> {
|
||||
if alpha < 0.01 { discard; }
|
||||
let linear = oit_linear_depth(clip.z);
|
||||
let opaque = select(0.0, clip.z, alpha > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, clip.z, opaque);
|
||||
}
|
||||
fn oit_transmittance_fragment(alpha: f32, clip: vec4<f32>) -> OitCoefficients {
|
||||
return oit_add_transmittance(1.0 - alpha, clip.z, clip.xy);
|
||||
}
|
||||
fn oit_accumulate_fragment(color: vec4<f32>, clip: vec4<f32>) -> vec4<f32> {
|
||||
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
|
||||
oit_normalize_depth(clip.z, clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
}
|
||||
// `OIT_ADDITIVE`: no coverage and nothing in the transmittance. The colour
|
||||
// is not fogged yet: the fog colour is premultiplied by the alpha, which is
|
||||
// zero, so the fog fades it out instead of tinting it.
|
||||
fn oit_accumulate_additive(color: vec4<f32>, fog: f32, clip: vec4<f32>) -> vec4<f32> {
|
||||
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
|
||||
oit_normalize_depth(clip.z, clip.xy), 1.0);
|
||||
return vec4(color.rgb * color.a * (1.0 - fog), 0.0) * seen;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Improved transparency's stages for the world border (`OIT_WORLD_BORDER`:
|
||||
// `world_border.fsh` with `OIT_ADDITIVE`), appended to border.wgsl after
|
||||
// oit.wgsl.
|
||||
@fragment fn fs_oit_border_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(border_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_border_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_border_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_additive(border_color(in), 0.0, in.clip);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Improved transparency's cloud stages (`clouds.fsh` under OIT_*), appended to
|
||||
// entity.wgsl after oit.wgsl. The clouds' vertex shader and draw are the
|
||||
// classic ones; only the fragment colour goes through the three stages.
|
||||
|
||||
// The cloud's colour; it fades out towards the cloud fog end everywhere but in
|
||||
// the depth bounds stage, which sees the unfaded alpha.
|
||||
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
|
||||
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
|
||||
return vec4(color.rgb, color.a * select(1.0, faded, fade));
|
||||
}
|
||||
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, false);
|
||||
if color.a < 0.01 { discard; }
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Improved transparency's stages for what the entity shader draws after the
|
||||
// opaque world (`LevelRenderer.executeOit`'s "Features, World Border,
|
||||
// Weather" pass), appended to entity.wgsl after oit.wgsl. The colour of each
|
||||
// is the classic fragment's, so the shaders cannot drift apart.
|
||||
|
||||
// Items, particles, text, breaking cracks (`RenderPipelines.OIT_ENTITY` and
|
||||
// the others on entity.fsh).
|
||||
@fragment fn fs_oit_entity_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_color(in, front), in.clip);
|
||||
}
|
||||
// `OIT_ENTITY_SHADOW`.
|
||||
@fragment fn fs_oit_shadow_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(shadow_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_shadow_transmittance(in: Out) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(shadow_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_shadow_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(shadow_color(in), in.clip);
|
||||
}
|
||||
// `OIT_WEATHER` (the particle shader).
|
||||
@fragment fn fs_oit_weather_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(weather_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_weather_transmittance(in: Out) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(weather_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_weather_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(weather_color(in), in.clip);
|
||||
}
|
||||
// `OIT_ENERGY_SWIRL`: entity.fsh with `OIT_ADDITIVE`, which caps the alpha.
|
||||
@fragment fn fs_oit_swirl_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(min(0.99, energy_swirl_base(in).a), in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_swirl_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_swirl_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), energy_swirl_fog(in), in.clip);
|
||||
}
|
||||
// `OIT_LIGHTNING` and `OIT_DRAGON_RAYS`: additive (`OIT_ADDITIVE`), the
|
||||
// classic fragment's colour (fs_celestial) with the alpha capped.
|
||||
@fragment fn fs_oit_lightning_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(min(0.99, celestial_color(in).a), in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_lightning_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Improved transparency's terrain stages and its screen passes (oit.wgsl
|
||||
// has the shared maths), appended to terrain.wgsl after oit.wgsl.
|
||||
// The terrain's translucent layer, once per stage.
|
||||
fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
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 < OIT_ALPHA_CUTOUT { discard; }
|
||||
return color;
|
||||
}
|
||||
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
|
||||
// depth), combined by max blending.
|
||||
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = oit_terrain_color(in);
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
// Stage 2.
|
||||
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
|
||||
let color = oit_terrain_color(in);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
// Stage 3: premultiplied colour, weighted by the transmittance in front.
|
||||
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = oit_terrain_color(in);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
let weighted = vec4(color.rgb * color.a, color.a) * seen;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
// The fog colour is premultiplied by the same weight.
|
||||
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
struct OitFragment {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
// After the bounds stage: a fully opaque fragment hides what is behind it, so
|
||||
// the far bound stops there and its depth goes into the depth buffer.
|
||||
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
|
||||
let opaque = oit_linear_depth(b.a);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
|
||||
}
|
||||
// The sum over the world, renormalized to the coverage the transform gives.
|
||||
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let p = vec2<i32>(pos.xy);
|
||||
let accumulated = textureLoad(oit_accumulated, p, 0);
|
||||
let seen = oit_sample_transmittance(p, 100000.0, 1.0);
|
||||
let coverage = 1.0 - select(seen, 0.0, seen < OIT_FULLY_OPAQUE_TRANSMITTANCE);
|
||||
// Additive surfaces add light but no coverage.
|
||||
if coverage < 0.00001 && dot(accumulated.rgb, vec3(1.0)) < 0.00001 { discard; }
|
||||
let scale = select(1.0, coverage / accumulated.a, accumulated.a > 0.00001);
|
||||
return OitFragment(vec4(accumulated.rgb * scale, coverage), textureLoad(oit_bounds, p, 0).b);
|
||||
}
|
||||
@@ -128,7 +128,6 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
@@ -141,8 +140,7 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the quit keys and the mouse don't read them yet.
|
||||
| Opt::QuitShortcuts
|
||||
// Saved, but the mouse doesn't read it yet.
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
@@ -202,6 +200,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",
|
||||
@@ -333,6 +336,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),
|
||||
@@ -366,6 +378,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")
|
||||
@@ -470,6 +495,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"))
|
||||
@@ -580,6 +625,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",
|
||||
@@ -731,6 +787,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,
|
||||
@@ -770,6 +840,8 @@ impl Opt {
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::MenuBlur
|
||||
| Opt::ChunkBuilder
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
)
|
||||
}
|
||||
/// Advance a cycle button (CycleButton.cycleValue).
|
||||
@@ -800,6 +872,7 @@ impl Opt {
|
||||
| Opt::HideLightningFlashes
|
||||
| Opt::HideSplashTexts
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
@@ -819,6 +892,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"];
|
||||
@@ -968,8 +1080,7 @@ fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
|
||||
events.push(Event::Volume(source, after));
|
||||
}
|
||||
}
|
||||
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
|
||||
/// filtering and anisotropy have no option here yet).
|
||||
/// 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() {
|
||||
@@ -989,6 +1100,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;
|
||||
@@ -1006,10 +1118,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";
|
||||
@@ -1207,6 +1349,9 @@ pub struct OptionsUi {
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
pack_focus: Instant,
|
||||
/// The selection carried over from a pack screen made again over a changed pack list
|
||||
/// (`update_pack_infos`) differs from the settings'.
|
||||
pack_reselected: bool,
|
||||
pub in_world: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
@@ -1242,6 +1387,7 @@ impl Default for OptionsUi {
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
pack_reselected: false,
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
@@ -1273,6 +1419,10 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
if !opt.available() {
|
||||
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
|
||||
}
|
||||
// VideoSettingsScreen.tick: Max Anisotropy only works with Anisotropic filtering.
|
||||
if opt == Opt::MaxAnisotropy && s.file.int("textureFiltering") != 2 {
|
||||
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
|
||||
}
|
||||
match opt.kind(s) {
|
||||
Kind::Slider(v) => Widget {
|
||||
x,
|
||||
@@ -1327,6 +1477,23 @@ impl OptionsUi {
|
||||
self.settings_search.focused
|
||||
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
|
||||
}
|
||||
/// Whether the resource pack screen is open (not Create World's data packs on the same
|
||||
/// page).
|
||||
pub fn resource_packs_open(&self) -> bool {
|
||||
self.page() == Page::Packs && self.data_pack_dir.is_none()
|
||||
}
|
||||
/// The packs in `resourcepacks/` changed while the pack screen is open
|
||||
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
|
||||
/// `infos`, keeping the selection made on it so far.
|
||||
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
self.pack_selection = p.commit();
|
||||
self.pack_reselected = true;
|
||||
}
|
||||
self.pack_infos = infos;
|
||||
}
|
||||
pub fn page(&self) -> Page {
|
||||
*self.stack.last().unwrap_or(&Page::Main)
|
||||
}
|
||||
@@ -1648,13 +1815,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));
|
||||
@@ -1673,6 +1843,8 @@ impl OptionsUi {
|
||||
Opt::CloudRange,
|
||||
Opt::CutoutLeaves,
|
||||
Opt::ImprovedTransparency,
|
||||
Opt::TextureFiltering,
|
||||
Opt::MaxAnisotropy,
|
||||
Opt::WeatherRadius,
|
||||
]));
|
||||
rows.push(Row::Header("options.video.preferences.header"));
|
||||
@@ -1816,7 +1988,9 @@ impl OptionsUi {
|
||||
.map(|(i, installed)| {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
if installed.problem.as_deref() == Some(crate::mod_list::NOT_BUILT) {
|
||||
label = label.append(tr("horizon.mods.not_built"));
|
||||
} else if installed.problem.is_some() {
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
@@ -2001,6 +2175,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 = "";
|
||||
@@ -2423,6 +2612,7 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.binding = self.selected_key;
|
||||
self.graphics
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
@@ -2490,6 +2680,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),
|
||||
..
|
||||
@@ -2551,6 +2748,7 @@ impl OptionsUi {
|
||||
shown,
|
||||
hover || dragging,
|
||||
true,
|
||||
dragging,
|
||||
);
|
||||
}
|
||||
WKind::Text {
|
||||
@@ -2674,6 +2872,15 @@ impl OptionsUi {
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
use crate::graphics_ui::Action;
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
winit::event::MouseButton::Right
|
||||
} else {
|
||||
winit::event::MouseButton::Left
|
||||
};
|
||||
return self.bind_mouse(s, button);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.graphics
|
||||
@@ -2685,6 +2892,7 @@ impl OptionsUi {
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::BindKey(name) => self.selected_key = Some(name),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -3053,7 +3261,7 @@ impl OptionsUi {
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
let mut screen = crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
@@ -3061,7 +3269,9 @@ impl OptionsUi {
|
||||
self.in_world,
|
||||
self.pack_infos.clone(),
|
||||
&self.pack_selection,
|
||||
));
|
||||
);
|
||||
screen.changed = std::mem::take(&mut self.pack_reselected);
|
||||
self.pack_screen = Some(screen);
|
||||
}
|
||||
}
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
@@ -3175,7 +3385,7 @@ impl OptionsUi {
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
vec![Event::Changed]
|
||||
@@ -3292,10 +3502,12 @@ mod tests {
|
||||
assert_eq!(loaded.file.double(opt.file_key().unwrap()), expected);
|
||||
}
|
||||
}
|
||||
assert!(Opt::MonochromeLogo.available());
|
||||
for opt in [
|
||||
Opt::TextBackground,
|
||||
Opt::HideLightningFlashes,
|
||||
Opt::HideSplashTexts,
|
||||
Opt::MonochromeLogo,
|
||||
] {
|
||||
let key = opt.file_key().unwrap();
|
||||
let before = s.file.bool(key);
|
||||
@@ -3432,6 +3644,17 @@ 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).
|
||||
@@ -3672,6 +3895,246 @@ 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}");
|
||||
}
|
||||
assert!(Opt::TextureFiltering.available());
|
||||
assert!(Opt::MaxAnisotropy.available());
|
||||
// The anisotropy slider only works with Anisotropic filtering (VideoSettingsScreen.tick).
|
||||
let grey = |s: &Settings| {
|
||||
matches!(
|
||||
option_widget(s, Opt::MaxAnisotropy, 0.0, 0.0, 150.0).kind,
|
||||
WKind::Button { active: false, .. }
|
||||
)
|
||||
};
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(1));
|
||||
assert!(grey(&s));
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(2));
|
||||
assert!(!grey(&s));
|
||||
}
|
||||
|
||||
#[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();
|
||||
@@ -4516,7 +4979,7 @@ mod tests {
|
||||
MouseButton::Back,
|
||||
MouseButton::Forward,
|
||||
] {
|
||||
for name in [SORT_KEY, "key.jump"] {
|
||||
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut s = Settings::default();
|
||||
ui.selected_key = Some(name);
|
||||
@@ -4525,6 +4988,7 @@ mod tests {
|
||||
let value = keybinds::mouse_name(button);
|
||||
match name {
|
||||
SORT_KEY => assert_eq!(s.extras.sort_key, value),
|
||||
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
|
||||
_ => assert_eq!(s.keys.get(name), value),
|
||||
}
|
||||
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
|
||||
|
||||
@@ -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
|
||||
@@ -251,6 +414,13 @@ fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
|
||||
.flatten()
|
||||
.filter(|e| overlay_applies(e))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
stays_inside(d)
|
||||
&& !d.contains('/')
|
||||
&& d.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
|
||||
@@ -278,7 +448,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)
|
||||
@@ -375,7 +546,7 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
|
||||
let zip = Zip::open(z)?;
|
||||
for root in &roots {
|
||||
let prefix = format!("{root}assets/");
|
||||
for name in zip.names() {
|
||||
for name in zip.names().filter(|name| stays_inside(name)) {
|
||||
if let Some(rest) = name.strip_prefix(&prefix) {
|
||||
out.insert(format!("assets/{rest}"), (None, name.to_owned()));
|
||||
}
|
||||
@@ -385,6 +556,15 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
/// Whether a zip entry's name, joined to the overlay folder, stays in it: `/`-separated parts
|
||||
/// (zip.rs turns `\` into `/`), none empty, `.` or `..`, and no `:` (a drive or a stream on
|
||||
/// Windows). A pack, a server's too, may name entries anything.
|
||||
fn stays_inside(name: &str) -> bool {
|
||||
!name.contains([':', '\0'])
|
||||
&& name
|
||||
.split('/')
|
||||
.all(|part| !part.is_empty() && part != "." && part != "..")
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
// Relative sources are relative to the process, not the overlay directory.
|
||||
@@ -402,31 +582,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
|
||||
}
|
||||
}
|
||||
/// A directory junction, which Windows creates without privileges (a directory symlink needs
|
||||
/// developer mode). The mirror falls back to copying the tree when it fails.
|
||||
/// A directory junction (`horizon_threads::junction`); the mirror falls back to copying the
|
||||
/// tree when it fails.
|
||||
#[cfg(windows)]
|
||||
fn junction(target: &Path, at: &Path) -> io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
|
||||
// unquoted path would split or change the command. A Windows path cannot hold `"`.
|
||||
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
|
||||
let mut quoted = std::ffi::OsString::from("\"");
|
||||
quoted.push(std::path::absolute(path)?);
|
||||
quoted.push("\"");
|
||||
Ok(quoted)
|
||||
};
|
||||
let status = std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.raw_arg(quoted(at)?)
|
||||
.raw_arg(quoted(target)?)
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| io::Error::other("mklink /J failed"))
|
||||
horizon_threads::junction(target, at)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn junction(_: &Path, _: &Path) -> io::Result<()> {
|
||||
@@ -738,6 +898,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 +1104,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 +1262,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(),
|
||||
@@ -1106,6 +1418,56 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/block/stone.png"),
|
||||
b"vanilla",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(base.join("packs")).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(base.join("packs/evil.zip")).unwrap());
|
||||
for (name, data) in [
|
||||
(
|
||||
"pack.mcmeta",
|
||||
&br#"{"pack":{"description":"d","pack_format":97}}"#[..],
|
||||
),
|
||||
("assets/minecraft/textures/block/stone.png", b"pack"),
|
||||
("assets/../../escaped.txt", b"out"),
|
||||
("assets/minecraft/../../../escaped_lang.json", b"{}"),
|
||||
("assets\\..\\..\\escaped_backslash.txt", b"out"),
|
||||
("assets/./minecraft/dot.txt", b"in"),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
assert_eq!(packs.len(), 1);
|
||||
let out = base.join("work/overlay");
|
||||
build_overlay(&vanilla, &packs, &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join("assets/minecraft/textures/block/stone.png")).unwrap(),
|
||||
b"pack"
|
||||
);
|
||||
for escaped in [
|
||||
"escaped.txt",
|
||||
"escaped_lang.json",
|
||||
"escaped_backslash.txt",
|
||||
"work/escaped.txt",
|
||||
"work/escaped_lang.json",
|
||||
"work/escaped_backslash.txt",
|
||||
] {
|
||||
assert!(!base.join(escaped).exists(), "{escaped} was written");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn built_in_and_folder_packs_give_their_pack_png() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
|
||||
@@ -1153,6 +1515,15 @@ mod tests {
|
||||
{"min_format":97,"max_format":[97,5],"directory":"also"},
|
||||
{"formats":50,"directory":"no"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["new", "also"]);
|
||||
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
{"formats":97,"directory":".."},
|
||||
{"formats":97,"directory":"../outside"},
|
||||
{"formats":97,"directory":"/etc"},
|
||||
{"formats":97,"directory":"a/b"},
|
||||
{"formats":97,"directory":"C:"},
|
||||
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
|
||||
@@ -9,7 +9,9 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Bump when overlay/merge semantics change. Markers are disposable cache data.
|
||||
// Version 4 mirrored every untouched directory file by file on Windows.
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
const VERSION: u32 = 5;
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
const VERSION: u32 = 6;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
@@ -18,13 +20,16 @@ pub(super) struct Dependencies {
|
||||
failed: bool,
|
||||
#[serde(skip)]
|
||||
tracking: bool,
|
||||
/// The vanilla root (absolute): its files are stamped without reading them.
|
||||
#[serde(skip)]
|
||||
vanilla: PathBuf,
|
||||
}
|
||||
impl Dependencies {
|
||||
fn record(&mut self, path: &Path, directory: bool) {
|
||||
if !self.tracking {
|
||||
return;
|
||||
}
|
||||
match dependency_signature(path, directory) {
|
||||
match dependency_signature(path, directory, &self.vanilla) {
|
||||
Ok(signature) => self.entries.push((path.into(), directory, signature)),
|
||||
Err(_) => self.failed = true,
|
||||
}
|
||||
@@ -38,7 +43,7 @@ impl Dependencies {
|
||||
fn valid(&self) -> bool {
|
||||
!self.failed
|
||||
&& self.entries.iter().all(|(path, directory, signature)| {
|
||||
dependency_signature(path, *directory).is_ok_and(|s| s == *signature)
|
||||
dependency_signature(path, *directory, &self.vanilla).is_ok_and(|s| s == *signature)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,13 +77,16 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
|
||||
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
|
||||
let meta = fs::metadata(path)?;
|
||||
hash.update(meta.len().to_le_bytes());
|
||||
field(hash, format!("{:?}", meta.modified()?).as_bytes());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// The change time stands in for the bytes here.
|
||||
let _ = read;
|
||||
hash.update(meta.dev().to_le_bytes());
|
||||
hash.update(meta.ino().to_le_bytes());
|
||||
hash.update(meta.ctime().to_le_bytes());
|
||||
@@ -87,8 +95,11 @@ fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Portable metadata lacks a change timestamp. Preserve content-based
|
||||
// validation for materialized files on those platforms.
|
||||
if meta.is_file() {
|
||||
// validation for the packs' materialized files on those platforms;
|
||||
// vanilla's (the launcher's, tens of thousands) keep to the size and
|
||||
// the write and creation times: reading each costs minutes on Windows,
|
||||
// where every open is scanned by the antivirus.
|
||||
if read && meta.is_file() {
|
||||
return content(hash, path);
|
||||
}
|
||||
if let Ok(created) = meta.created() {
|
||||
@@ -104,17 +115,18 @@ fn entries(path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
fn dependency_signature(path: &Path, directory: bool) -> io::Result<String> {
|
||||
fn dependency_signature(path: &Path, directory: bool, vanilla: &Path) -> io::Result<String> {
|
||||
let mut hash = Sha256::new();
|
||||
let read = !(path.starts_with(vanilla) || absolute(path).is_ok_and(|p| p.starts_with(vanilla)));
|
||||
match fs::metadata(path) {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => field(&mut hash, b"missing"),
|
||||
Err(e) => return Err(e),
|
||||
Ok(meta) if directory && meta.is_dir() => {
|
||||
field(&mut hash, b"directory");
|
||||
stamp(&mut hash, path)?;
|
||||
stamp(&mut hash, path, read)?;
|
||||
}
|
||||
Ok(_) if directory => field(&mut hash, b"not-directory"),
|
||||
Ok(_) => stamp(&mut hash, path)?,
|
||||
Ok(_) => stamp(&mut hash, path, read)?,
|
||||
}
|
||||
Ok(finish(hash))
|
||||
}
|
||||
@@ -190,7 +202,7 @@ fn tree_inner(
|
||||
if pack.is_none() {
|
||||
return content(hash, path);
|
||||
}
|
||||
stamp(hash, path)?;
|
||||
stamp(hash, path, true)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -283,11 +295,14 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
|
||||
let signature = pack_signature(packs);
|
||||
if let Ok(signature) = &signature
|
||||
&& let Ok(bytes) = fs::read(&marker_path)
|
||||
&& let Ok(marker) = serde_json::from_slice::<Marker>(&bytes)
|
||||
&& let Ok(mut marker) = serde_json::from_slice::<Marker>(&bytes)
|
||||
&& marker.version == VERSION
|
||||
&& marker.vanilla == vanilla_key
|
||||
&& marker.packs == *signature
|
||||
&& marker.dependencies.valid()
|
||||
&& {
|
||||
marker.dependencies.vanilla = vanilla_key.clone();
|
||||
marker.dependencies.valid()
|
||||
}
|
||||
&& out.is_dir()
|
||||
{
|
||||
return Ok(());
|
||||
@@ -300,6 +315,7 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
|
||||
}
|
||||
let mut dependencies = Dependencies {
|
||||
tracking: signature.is_ok(),
|
||||
vanilla: vanilla_key.clone(),
|
||||
..Dependencies::default()
|
||||
};
|
||||
build_overlay_uncached(vanilla, packs, out, &mut dependencies)?;
|
||||
|
||||
@@ -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
|
||||
@@ -1062,8 +1064,9 @@ pub struct ParticleEngine {
|
||||
pub particle_level: u8,
|
||||
/// FireworkParticles.Starter instances.
|
||||
starters: Vec<Starter>,
|
||||
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
|
||||
/// Local sounds requested by particles (dripstone drips, firework bursts):
|
||||
/// (event, position, volume, pitch, distanceDelay).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
|
||||
/// How many of each type were made, for `horizon-ctl status`.
|
||||
made: std::collections::BTreeMap<&'static str, u64>,
|
||||
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
|
||||
@@ -1080,6 +1083,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 +1441,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 +2650,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;
|
||||
@@ -3040,7 +3060,7 @@ impl ParticleEngine {
|
||||
(false, false) => "minecraft:entity.firework_rocket.blast",
|
||||
};
|
||||
let pitch = 0.95 + self.random.next_float() * 0.1;
|
||||
self.sounds.push((sound, s.at, 20.0, pitch));
|
||||
self.sounds.push((sound, s.at, 20.0, pitch, true));
|
||||
}
|
||||
if s.life % 2 == 0 && ((s.life / 2) as usize) < s.explosions.len() {
|
||||
let e = s.explosions[(s.life / 2) as usize].clone();
|
||||
@@ -3091,7 +3111,7 @@ impl ParticleEngine {
|
||||
"minecraft:entity.firework_rocket.twinkle"
|
||||
};
|
||||
let pitch = 0.9 + self.random.next_float() * 0.15;
|
||||
self.sounds.push((sound, s.at, 20.0, pitch));
|
||||
self.sounds.push((sound, s.at, 20.0, pitch, true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -3286,7 +3306,7 @@ impl ParticleEngine {
|
||||
}
|
||||
if let Some(sound) = sound {
|
||||
let volume = p.random.next_float() * 0.7 + 0.3;
|
||||
self.sounds.push((sound, p.position, volume, 1.0));
|
||||
self.sounds.push((sound, p.position, volume, 1.0, false));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -3743,7 +3763,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,134 @@
|
||||
// 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`).
|
||||
// Targets keep GL's rows (row 0 is the bottom edge): the passes draw with clip y
|
||||
// negated (`vs_quad_gl`, `vs_rotscale`) and read their textures as they are;
|
||||
// `vs_quad` with `fs_blit` is the flip between the frame and a chain's `main`.
|
||||
|
||||
// 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, uv, 0.0);
|
||||
}
|
||||
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(blur_tex, blur_smp, uv, 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_quad_gl(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.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.x * 2.0 - 1.0, 1.0 - p.y * 2.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);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Vanilla's post-processing GLSL (`#version 330` with `layout(std140)` uniform blocks
|
||||
//! and `uniform sampler2D`s, Vulkan-style `gl_VertexIndex`) turned into naga modules at
|
||||
//! run time, from whichever resource root is active, the way Horizon's shader packs turn
|
||||
//! their GLSL into naga (`horizon-shaders`' translator, which handles the compatibility
|
||||
//! dialect; the post shaders need only the block and sampler binding rewrite below).
|
||||
//!
|
||||
//! `ShaderManager`/`GlslPreprocessor` read `assets/<ns>/shaders/<path>.{vsh,fsh}` and
|
||||
//! resolve `#include <ns:path>` to `assets/<ns>/shaders/include/<path>`.
|
||||
//!
|
||||
//! The rewrite gives every uniform block a binding of bind group 0, shared by both
|
||||
//! stages, and splits each `uniform sampler2D NameSampler;` into a texture and a sampler
|
||||
//! object at the next two bindings, `NameSampler` becoming the combined
|
||||
//! `sampler2D(texture, sampler)` again where it is used. The vertex stage's `main` is
|
||||
//! wrapped so that clip y is negated: targets keep GL's rows (row 0 is the bottom edge),
|
||||
//! so `texCoord` reads the texel it would in vanilla.
|
||||
use naga::ShaderStage;
|
||||
use naga::front::glsl::{Frontend, Options};
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
use std::path::Path;
|
||||
|
||||
/// One program, ready for wgpu.
|
||||
pub struct Program {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// Every uniform block either stage declares, with its binding.
|
||||
pub blocks: Vec<(String, u32)>,
|
||||
/// Every sampler (`NameSampler`): the texture's binding; the sampler object is the next.
|
||||
pub samplers: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
fn is_ident(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_'
|
||||
}
|
||||
|
||||
/// The source with its comments blanked (same length, so offsets stay valid).
|
||||
fn blank_comments(src: &str) -> String {
|
||||
let b = src.as_bytes();
|
||||
let mut out = b.to_vec();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
out[i] = b' ';
|
||||
i += 1;
|
||||
}
|
||||
} else if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
|
||||
while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
|
||||
if b[i] != b'\n' {
|
||||
out[i] = b' ';
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
for slot in out.iter_mut().take((i + 2).min(b.len())).skip(i) {
|
||||
*slot = b' ';
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `GlslPreprocessor`'s `#include <ns:path>`.
|
||||
fn read_source(root: &Path, id: &str, ext: &str) -> Result<String, String> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = root
|
||||
.join("assets")
|
||||
.join(ns)
|
||||
.join("shaders")
|
||||
.join(format!("{path}.{ext}"));
|
||||
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
fn include(root: &Path, src: &str, depth: u32) -> Result<String, String> {
|
||||
if depth > 8 {
|
||||
return Err("#include nested too deep".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if let Some(rest) = t.strip_prefix("#include") {
|
||||
let name = rest
|
||||
.trim()
|
||||
.trim_start_matches(['<', '"'])
|
||||
.trim_end_matches(['>', '"'])
|
||||
.trim();
|
||||
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
let file = root
|
||||
.join("assets")
|
||||
.join(ns)
|
||||
.join("shaders")
|
||||
.join("include")
|
||||
.join(path);
|
||||
let text = std::fs::read_to_string(&file)
|
||||
.map_err(|e| format!("#include <{name}>: {}: {e}", file.display()))?;
|
||||
out.push_str(&include(root, &text, depth + 1)?);
|
||||
} else if t.starts_with("#version") || t.starts_with("#extension") {
|
||||
// Re-added below, for the translator.
|
||||
out.push('\n');
|
||||
continue;
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every `uniform` declaration of a source: (start of the declaration including a
|
||||
/// `layout(...)` before it, end of the type or of the whole `;`, type, name, is a sampler).
|
||||
fn declarations(src: &str) -> Vec<(usize, usize, String, String, bool)> {
|
||||
let plain = blank_comments(src);
|
||||
let b = plain.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while let Some(found) = plain[at..].find("uniform") {
|
||||
let start = at + found;
|
||||
at = start + 7;
|
||||
if (start > 0 && is_ident(b[start - 1])) || b.get(start + 7).is_some_and(|c| is_ident(*c)) {
|
||||
continue;
|
||||
}
|
||||
let word = |from: usize| -> Option<(usize, usize)> {
|
||||
let mut s = from;
|
||||
while s < b.len() && b[s].is_ascii_whitespace() {
|
||||
s += 1;
|
||||
}
|
||||
let mut e = s;
|
||||
while e < b.len() && is_ident(b[e]) {
|
||||
e += 1;
|
||||
}
|
||||
(e > s).then_some((s, e))
|
||||
};
|
||||
let Some((ts, te)) = word(start + 7) else {
|
||||
continue;
|
||||
};
|
||||
let ty = plain[ts..te].to_owned();
|
||||
// The `layout(...)` right before `uniform`, if any.
|
||||
let before = plain[..start].trim_end();
|
||||
let decl_start = before
|
||||
.ends_with(')')
|
||||
.then(|| before.rfind("layout"))
|
||||
.flatten()
|
||||
.filter(|l| {
|
||||
before[*l + 6..].trim_start().starts_with('(') && !before[*l..].contains(';')
|
||||
})
|
||||
.unwrap_or(start);
|
||||
if ty.starts_with("sampler") {
|
||||
let Some((ns, ne)) = word(te) else { continue };
|
||||
let end = plain[ne..].find(';').map_or(ne, |p| ne + p + 1);
|
||||
out.push((decl_start, end, ty, plain[ns..ne].to_owned(), true));
|
||||
} else {
|
||||
let mut s = te;
|
||||
while s < b.len() && b[s].is_ascii_whitespace() {
|
||||
s += 1;
|
||||
}
|
||||
if b.get(s) == Some(&b'{') {
|
||||
out.push((decl_start, te, String::new(), ty, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The source with each declaration given its binding from the shared tables.
|
||||
fn rewrite(src: &str, blocks: &[(String, u32)], samplers: &[(String, u32)]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut pos = 0;
|
||||
for (start, end, ty, name, sampler) in declarations(src) {
|
||||
out.push_str(&src[pos..start]);
|
||||
pos = end;
|
||||
if sampler {
|
||||
let tex = samplers.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
|
||||
let kind = if ty == "sampler2D" { "texture2D" } else { &ty };
|
||||
out.push_str(&format!(
|
||||
"layout(set = 0, binding = {tex}) uniform {kind} {name}_hz_tex; \
|
||||
layout(set = 0, binding = {}) uniform sampler {name}_hz_smp;\n\
|
||||
#define {name} sampler2D({name}_hz_tex, {name}_hz_smp)\n",
|
||||
tex + 1
|
||||
));
|
||||
} else {
|
||||
let binding = blocks.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
|
||||
out.push_str(&format!(
|
||||
"layout(std140, set = 0, binding = {binding}) uniform {name}"
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str(&src[pos..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Replaces the word `main` of `void main(` with `hz_main`.
|
||||
fn wrap_main(src: &str) -> Result<String, String> {
|
||||
let plain = blank_comments(src);
|
||||
let mut at = 0;
|
||||
while let Some(found) = plain[at..].find("main") {
|
||||
let p = at + found;
|
||||
at = p + 4;
|
||||
let b = plain.as_bytes();
|
||||
if (p > 0 && is_ident(b[p - 1])) || b.get(p + 4).is_some_and(|c| is_ident(*c)) {
|
||||
continue;
|
||||
}
|
||||
if plain[..p].trim_end().ends_with("void") && plain[p + 4..].trim_start().starts_with('(') {
|
||||
let mut out = String::with_capacity(src.len() + 80);
|
||||
out.push_str(&src[..p]);
|
||||
out.push_str("hz_main");
|
||||
out.push_str(&src[p + 4..]);
|
||||
out.push_str("\nvoid main() { hz_main(); gl_Position.y = -gl_Position.y; }\n");
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
Err("no main".into())
|
||||
}
|
||||
|
||||
fn parse(stage: ShaderStage, text: &str) -> Result<naga::Module, String> {
|
||||
let module = Frontend::default()
|
||||
.parse(&Options::from(stage), text)
|
||||
.map_err(|e| {
|
||||
e.errors
|
||||
.iter()
|
||||
.map(|e| e.kind.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})?;
|
||||
Validator::new(ValidationFlags::all(), Capabilities::empty())
|
||||
.validate(&module)
|
||||
.map_err(|e| format!("{:?}", e.into_inner()))?;
|
||||
Ok(module)
|
||||
}
|
||||
|
||||
/// Translates the vertex and fragment shader a pass names (`minecraft:post/invert`).
|
||||
pub fn translate(root: &Path, vertex: &str, fragment: &str) -> Result<Program, String> {
|
||||
let vs = include(root, &read_source(root, vertex, "vsh")?, 0)?;
|
||||
let fs = include(root, &read_source(root, fragment, "fsh")?, 0)?;
|
||||
// Blocks and samplers are shared by name across the stages, so both agree on a binding.
|
||||
let mut blocks: Vec<(String, u32)> = Vec::new();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for src in [&vs, &fs] {
|
||||
for (_, _, _, name, sampler) in declarations(src) {
|
||||
if sampler {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
} else if !blocks.iter().any(|(n, _)| *n == name) {
|
||||
blocks.push((name, blocks.len() as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = blocks.len() as u32;
|
||||
let samplers: Vec<(String, u32)> = names.into_iter().zip((first..).step_by(2)).collect();
|
||||
let vs = wrap_main(&rewrite(&vs, &blocks, &samplers))?;
|
||||
let fs = rewrite(&fs, &blocks, &samplers);
|
||||
let vertex_module = parse(ShaderStage::Vertex, &format!("#version 450\n{vs}"))
|
||||
.map_err(|e| format!("{vertex}: {e}"))?;
|
||||
let fragment_module = parse(ShaderStage::Fragment, &format!("#version 450\n{fs}"))
|
||||
.map_err(|e| format!("{fragment}: {e}"))?;
|
||||
Ok(Program {
|
||||
vertex: vertex_module,
|
||||
fragment: fragment_module,
|
||||
blocks,
|
||||
samplers,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn root() -> Option<std::path::PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)?
|
||||
.join(".local/horizon-client/26.3");
|
||||
dir.join("assets/minecraft/shaders/post/blit.fsh")
|
||||
.exists()
|
||||
.then_some(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_post_shaders_translate() {
|
||||
let Some(root) = root() else { return };
|
||||
let vs = ["minecraft:core/screenquad", "minecraft:post/rotscale"];
|
||||
let fs = [
|
||||
"bits",
|
||||
"blit",
|
||||
"box_blur",
|
||||
"color_convolve",
|
||||
"entity_outline_box_blur",
|
||||
"entity_sobel",
|
||||
"invert",
|
||||
"spiderclip",
|
||||
];
|
||||
for f in fs {
|
||||
for v in vs {
|
||||
let name = format!("minecraft:post/{f}");
|
||||
let p = translate(&root, v, &name);
|
||||
// rotscale's outputs only match spiderclip, but each stage translates alone.
|
||||
if let Err(e) = &p {
|
||||
panic!("{v} + {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_and_block_bindings() {
|
||||
let src = "layout(std140) uniform A {\n float x;\n};\nuniform sampler2D InSampler; // c\nvoid main(){}\n";
|
||||
let d = declarations(src);
|
||||
assert_eq!(d.len(), 2);
|
||||
let out = rewrite(src, &[("A".into(), 0)], &[("InSampler".into(), 1)]);
|
||||
assert!(out.contains("layout(std140, set = 0, binding = 0) uniform A {"));
|
||||
assert!(out.contains("binding = 1) uniform texture2D InSampler_hz_tex;"));
|
||||
assert!(out.contains("binding = 2) uniform sampler InSampler_hz_smp;"));
|
||||
assert!(wrap_main("void main() {}").unwrap().contains("hz_main"));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 701 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user