Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159b5c4be6 | ||
|
|
a392d4b3ba | ||
|
|
491710cd2f | ||
|
|
0932cefc79 | ||
|
|
77af961663 | ||
|
|
2f60199b75 | ||
|
|
033b4a9e60 | ||
|
|
6a0b9e435b | ||
|
|
485226a6fd | ||
|
|
e4ce68e271 | ||
|
|
151aa6c82b | ||
|
|
b5406e647a | ||
|
|
2d72b0b260 | ||
|
|
d79a36101f | ||
|
|
35f3dad207 | ||
|
|
5de5b6fd91 | ||
|
|
ca17c0c5ba | ||
|
|
23c3faedc1 | ||
|
|
ba3c3feebf | ||
|
|
ec298576f6 | ||
|
|
97c9d1a14f | ||
|
|
9099175f8a | ||
|
|
e1e39361db | ||
|
|
ef19179033 | ||
|
|
b189c72fcc | ||
|
|
3a229da6b3 | ||
|
|
e22ccc17d2 | ||
|
|
493dd75acf | ||
|
|
f4481233b0 | ||
|
|
63cbec701e | ||
|
|
a597387a66 | ||
|
|
2d306dc52c | ||
|
|
4f6be12c2e | ||
|
|
8c93f81143 | ||
|
|
a20e9ce2d0 | ||
|
|
aba1a90e6f | ||
|
|
73a3c0348d | ||
|
|
c95f24bc32 | ||
|
|
ca0b945857 | ||
|
|
33be2a611a | ||
|
|
1199c117f2 | ||
|
|
2e749948f4 | ||
|
|
f5f96b1be4 | ||
|
|
522634ca7f | ||
|
|
f7543ccffa | ||
|
|
1fd2f02081 | ||
|
|
8d8c250f48 | ||
|
|
d21bc19b84 | ||
|
|
3afc51e740 | ||
|
|
17778b9722 | ||
|
|
abaa2a9f68 | ||
|
|
d1c1684fb5 | ||
|
|
fdc87aecdb | ||
|
|
52343380b3 | ||
|
|
e2680a5450 | ||
|
|
5e7d248148 | ||
|
|
67f34e0fdd | ||
|
|
17a68d7b97 | ||
|
|
90b30ee17b | ||
|
|
5578753b58 | ||
|
|
6faf3ef3f3 | ||
|
|
ab44611814 | ||
|
|
8c612bc687 | ||
|
|
9debdf26d4 | ||
|
|
27a2eb565e | ||
|
|
d7ae75b441 | ||
|
|
bc017eec9b | ||
|
|
693178dc36 | ||
|
|
b19634003c | ||
|
|
8711aabd75 | ||
|
|
76a5bd8a22 | ||
|
|
453d8b89ce | ||
|
|
845132a56b | ||
|
|
051e3dd546 | ||
|
|
ab5dfae985 | ||
|
|
19a6e9b81b | ||
|
|
de118feb4e | ||
|
|
e508e7919f | ||
|
|
f222616f7e | ||
|
|
0f89fd7e00 | ||
|
|
6889d9366b | ||
|
|
0804e2219a | ||
|
|
1c6a22db1a | ||
|
|
8a9a6d5e3f | ||
|
|
d17e8fa343 | ||
|
|
6f374959b7 | ||
|
|
15a8b2a77c | ||
|
|
1206818cde | ||
|
|
12157a2d67 | ||
|
|
b029b9d04f | ||
|
|
9b89ff00ee | ||
|
|
2e13076a93 | ||
|
|
9974322832 | ||
|
|
b21f0c92f2 | ||
|
|
31545199d4 | ||
|
|
9a8781468e | ||
|
|
1117b98d15 | ||
|
|
8fe0e40d68 | ||
|
|
f68c335eb2 | ||
|
|
12dad67f8c | ||
|
|
922f00f52e | ||
|
|
1fe21fb861 | ||
|
|
68b03b5184 | ||
|
|
200615a30a | ||
|
|
1ba0db3c3c | ||
|
|
0ce0e67282 | ||
|
|
00d1e1a46e | ||
|
|
c794b87841 | ||
|
|
1ee069b6ba | ||
|
|
c30dfd55e5 | ||
|
|
0f8cf58043 | ||
|
|
2ebf00a028 | ||
|
|
821426be75 | ||
|
|
7325d8811b | ||
|
|
5bbfe0f311 | ||
|
|
f5c936af5e | ||
|
|
0a898f21b0 | ||
|
|
6cc93d13ce | ||
|
|
9a292609eb | ||
|
|
fd4d709dc2 | ||
|
|
c34114751a | ||
|
|
dd93b0164a | ||
|
|
ffef7b9c7e | ||
|
|
7622c02706 | ||
|
|
744fcd7e01 | ||
|
|
e760415d7b | ||
|
|
95f1756a8d | ||
|
|
e8c56045c9 | ||
|
|
1d63e0a5a3 | ||
|
|
8cd24bc4e5 | ||
|
|
c78187dfff | ||
|
|
fc2868b2b2 | ||
|
|
e414a7a14b | ||
|
|
2b8d23af24 | ||
|
|
6560641ba5 | ||
|
|
295a73028d | ||
|
|
7cf67fa99d | ||
|
|
ad89bb3123 | ||
|
|
8c3f88a194 | ||
|
|
ac02477732 | ||
|
|
b893b6ae31 | ||
|
|
37cee5b4fd | ||
|
|
6416c415a7 | ||
|
|
29804afe17 | ||
|
|
0628aa5870 | ||
|
|
fb03dbf6e3 | ||
|
|
915f58a8c5 | ||
|
|
2557cb185b | ||
|
|
db2bc09336 | ||
|
|
23af2bfc62 | ||
|
|
f6980a83e6 | ||
|
|
dd64878f38 | ||
|
|
f0e0d96b61 | ||
|
|
45cc5a91e0 | ||
|
|
132aa6b682 | ||
|
|
fcc57182ae | ||
|
|
fcc6416b34 | ||
|
|
c85011b04d | ||
|
|
d93bf565f4 | ||
|
|
9c5a3734ea | ||
|
|
2ff274b96c | ||
|
|
13d03ec87f | ||
|
|
4ea806a2db | ||
|
|
303255f2d8 | ||
|
|
a6339e67b5 | ||
|
|
1cd80a0bcb | ||
|
|
4ebee0d01e | ||
|
|
0ebe0d40de | ||
|
|
30ee97bbc7 | ||
|
|
2a57a30ae2 | ||
|
|
b3f9eddfd3 | ||
|
|
f5a6eb9835 | ||
|
|
dd3f458ba1 | ||
|
|
d4006f0770 | ||
|
|
bed953b207 | ||
|
|
0e674b827c | ||
|
|
2f0b066f38 | ||
|
|
7d8bb9e69b | ||
|
|
dd3c1d17a2 | ||
|
|
d5f43e0967 | ||
|
|
d372562a73 | ||
|
|
99ddbc629e | ||
|
|
37f69234e7 | ||
|
|
8fd21e02d4 | ||
|
|
32191d5b6a | ||
|
|
8331bedf2b | ||
|
|
37656cc775 | ||
|
|
ae0ccc199c | ||
|
|
4c1d1990e1 | ||
|
|
bc0ddfbff0 | ||
|
|
330d3db811 | ||
|
|
a7a2cf9305 | ||
|
|
1087b5a6ff | ||
|
|
fb3e73d3e2 | ||
|
|
951fc0f0cb | ||
|
|
2f88719668 | ||
|
|
25a1add0a2 | ||
|
|
4e1c0d68be | ||
|
|
7753a08ca9 | ||
|
|
34a9bfaa88 | ||
|
|
5fdf9fb0ac | ||
|
|
6e19f95786 | ||
|
|
cf2ab15de3 | ||
|
|
68ec463175 | ||
|
|
35d60b45b5 | ||
|
|
a4df22049c | ||
|
|
4bf0560d40 | ||
|
|
18840db914 | ||
|
|
4c4728ead5 | ||
|
|
ea55e8156c | ||
|
|
320d55a88c | ||
|
|
3b7dde0cd5 | ||
|
|
4df3bd4db0 | ||
|
|
f16cc3d6d0 | ||
|
|
0000cd3e7d | ||
|
|
2303a445fe | ||
|
|
6c53f16dbf | ||
|
|
9495790d0b | ||
|
|
ee8a79e80e | ||
|
|
f98c9b1f74 | ||
|
|
7524ec7664 | ||
|
|
1bb63d1e2a | ||
|
|
b85e0f4f67 | ||
|
|
35b963c3b8 | ||
|
|
e43c075351 | ||
|
|
da21244ed0 | ||
|
|
2617902218 | ||
|
|
bd5dc3ac4a | ||
|
|
471cdcf2db | ||
|
|
6ffbbfc44c | ||
|
|
c0e74b2b53 | ||
|
|
d7cc28f952 | ||
|
|
16b511ed65 | ||
|
|
301765dc69 | ||
|
|
0748416e9e | ||
|
|
f64b1b9d55 | ||
|
|
59dff021a1 | ||
|
|
1748b489eb | ||
|
|
98b8827123 | ||
|
|
ba61774567 | ||
|
|
5eeb4d5678 | ||
|
|
a5129be064 | ||
|
|
7b03ce49c7 | ||
|
|
4f2f4078d1 | ||
|
|
6ac83b92d7 | ||
|
|
7fcc1c900a | ||
|
|
fd06d1c265 | ||
|
|
484a9d0ae8 | ||
|
|
d3319194cc | ||
|
|
6cbece0baa | ||
|
|
99901a6359 | ||
|
|
7cb47be9fe | ||
|
|
d14e14539b | ||
|
|
b6ab7d72b5 | ||
|
|
725f9bb17d | ||
|
|
b5d3de5ea4 | ||
|
|
db673e9b76 | ||
|
|
d8ae358b69 | ||
|
|
5564c36339 | ||
|
|
0eba976485 | ||
|
|
28cb958bb2 | ||
|
|
2535dd9715 | ||
|
|
9fa7986397 | ||
|
|
e9bd8be99b | ||
|
|
4972ede23a | ||
|
|
e86cb3276b | ||
|
|
3e80662fbb | ||
|
|
a9e99c3385 | ||
|
|
31908392d0 | ||
|
|
35e72b8da9 | ||
|
|
3787a6b53c | ||
|
|
ee026a522e | ||
|
|
3933ed160b | ||
|
|
8a83859133 | ||
|
|
36d9983718 | ||
|
|
d306a4c171 | ||
|
|
b06f5f36b9 | ||
|
|
9319ad4bf1 | ||
|
|
411d1ca42c | ||
|
|
552f2c1b4b | ||
|
|
765f388607 | ||
|
|
0007d5c2a8 | ||
|
|
59aa0e53df | ||
|
|
6920ab1e45 | ||
|
|
9bd77676c8 | ||
|
|
c3135f0196 | ||
|
|
44abc057eb | ||
|
|
111ba4fd45 | ||
|
|
8a50b63daa | ||
|
|
073e47caed | ||
|
|
e36bbcfc63 | ||
|
|
afdeb72891 | ||
|
|
f58b34cd26 | ||
|
|
6cc37bdcef | ||
|
|
e7bc612275 | ||
|
|
7ee4860304 | ||
|
|
536b3316bd | ||
|
|
5c7efbc427 | ||
|
|
67902962fd | ||
|
|
8aa99b4f64 | ||
|
|
a93c0e1f11 | ||
|
|
7615a429d3 | ||
|
|
e533502260 | ||
|
|
b8206893b2 | ||
|
|
81c1cd8b33 | ||
|
|
f4858e8a25 | ||
|
|
261a343e75 | ||
|
|
5798723345 | ||
|
|
37f3271472 | ||
|
|
d887b1331d | ||
|
|
2fcdb9f7af | ||
|
|
99809b5a55 | ||
|
|
bc472639ed | ||
|
|
7ebad255b0 | ||
|
|
1faa3281c2 | ||
|
|
9d1c979e91 | ||
|
|
b00d753c62 | ||
|
|
7ab92f9eb7 | ||
|
|
3aaef65c5d | ||
|
|
278ffc3b96 | ||
|
|
7cfee48c36 | ||
|
|
5c18aa614e | ||
|
|
f7ba58871b | ||
|
|
017aee7bcf | ||
|
|
09ec3f2452 | ||
|
|
8667fab8c9 | ||
|
|
d89ca171f3 | ||
|
|
59f6b76699 | ||
|
|
5fe63f13e8 | ||
|
|
bbe57b6b19 | ||
|
|
a26743d278 | ||
|
|
0876de55cc | ||
|
|
6f01443e55 | ||
|
|
2e41b259c2 | ||
|
|
e255c1b2fa | ||
|
|
e8d2cf4ee2 | ||
|
|
199ef4ad95 | ||
|
|
f2658dff92 | ||
|
|
07b7935bae | ||
|
|
b9bc67509a | ||
|
|
f834560860 | ||
|
|
70f9c6e829 | ||
|
|
1e35443f6e | ||
|
|
5075231ffa | ||
|
|
004a39be07 | ||
|
|
d4dc032645 | ||
|
|
81261cc98d | ||
|
|
36fc4fa187 | ||
|
|
9ab09b8ed4 | ||
|
|
92b2dbfbc7 | ||
|
|
b3eee8adef | ||
|
|
542f14f3bf | ||
|
|
bdb927a7c4 | ||
|
|
3788883346 | ||
|
|
62a480890c | ||
|
|
c4a240cb63 | ||
|
|
7ea951a07e | ||
|
|
2f548e8b23 | ||
|
|
64d97f7db8 | ||
|
|
39e13b9f70 | ||
|
|
90bdc031fe | ||
|
|
b66a4683f1 | ||
|
|
cdd4332ae4 | ||
|
|
4835c74731 | ||
|
|
de976257da | ||
|
|
33f6584496 | ||
|
|
7386e3968f | ||
|
|
9478067990 | ||
|
|
37d8dc0e56 | ||
|
|
d12e6637f0 | ||
|
|
1f205c93df | ||
|
|
66b8b9e69c | ||
|
|
17995c544f | ||
|
|
31338299d6 | ||
|
|
e2d24c60a4 | ||
|
|
648f935466 | ||
|
|
23d08068aa | ||
|
|
4f0045d2d0 | ||
|
|
6ab56ba9f1 | ||
|
|
d193cb15ca | ||
|
|
cc7f3b4c22 | ||
|
|
b1eaf67925 | ||
|
|
185187e7e2 | ||
|
|
383f733f9e | ||
|
|
b984398cbc | ||
|
|
3baa4f56ae | ||
|
|
a03c91e630 | ||
|
|
59bafaf8b6 | ||
|
|
c76534d9f1 | ||
|
|
2b9cb6ba26 | ||
|
|
a4951f7377 | ||
|
|
bddd5ebf76 | ||
|
|
07ed5dac67 | ||
|
|
bda6cb38ae | ||
|
|
3ec6d2426f | ||
|
|
34c4cd3322 | ||
|
|
2c142a1d49 | ||
|
|
b83cbac5c4 | ||
|
|
49471d6449 | ||
|
|
2e57c30ab2 | ||
|
|
745dc168bc | ||
|
|
698b836e2e | ||
|
|
c8388a50f6 | ||
|
|
c8d6221624 | ||
|
|
44a7fa4dda | ||
|
|
53304d1bb6 | ||
|
|
4dfa1dc31f | ||
|
|
06e83b2f80 | ||
|
|
17c146a7be | ||
|
|
438b47c86b | ||
|
|
bd9883050e | ||
|
|
0faacc6a85 | ||
|
|
6a9f30fd40 | ||
|
|
097dd3a63f | ||
|
|
0787a9e7ff | ||
|
|
22e1582558 | ||
|
|
9b524815b9 | ||
|
|
4b1561050d | ||
|
|
ef3856a408 | ||
|
|
8d50119388 | ||
|
|
6f4a6a3865 | ||
|
|
ffa3a4d413 | ||
|
|
aaccb9b51e | ||
|
|
06ca1059f8 | ||
|
|
4a951a3199 | ||
|
|
b09ef7084f | ||
|
|
b4c2f89660 | ||
|
|
b0ae048383 | ||
|
|
4b753f5956 | ||
|
|
880f8c6b56 | ||
|
|
d2b41ed400 | ||
|
|
7c5466e763 | ||
|
|
f670d71c41 | ||
|
|
1edfd2f7ea | ||
|
|
6fbbfdde1c | ||
|
|
1301820f0d | ||
|
|
b888cebdf6 | ||
|
|
337c5bbd4d | ||
|
|
aef2dc6528 | ||
|
|
65c57efffe | ||
|
|
446e36c1a6 | ||
|
|
4efc3f6bb1 | ||
|
|
aae27797d9 | ||
|
|
c59cb034a2 | ||
|
|
fa733b3efa | ||
|
|
ccb85ce454 | ||
|
|
3cb56882d9 | ||
|
|
596a11fb08 | ||
|
|
eba31f178c | ||
|
|
e539cd328f | ||
|
|
f71a05a062 | ||
|
|
48886daeb6 | ||
|
|
f423aeea61 | ||
|
|
745b95ace9 | ||
|
|
b9e891bf3e | ||
|
|
e494a9d1f5 | ||
|
|
898d7a0b4a | ||
|
|
3fc2ed796f | ||
|
|
fa9d06a48f | ||
|
|
0da0d2b260 | ||
|
|
be8d3d59d9 | ||
|
|
a2727a069e | ||
|
|
ac3436923a | ||
|
|
a3c17924ca | ||
|
|
fab81be9ac | ||
|
|
9ffa81a748 | ||
|
|
7ce65988b2 | ||
|
|
71d809e40c | ||
|
|
818c598db9 | ||
|
|
213cfce453 | ||
|
|
8b7f726609 | ||
|
|
d59814a196 | ||
|
|
18309e4967 | ||
|
|
955c1ba290 | ||
|
|
d8676fff04 | ||
|
|
34d078e57b | ||
|
|
1ddc0b8a4d | ||
|
|
868f88a2ce | ||
|
|
1d63e80fc1 | ||
|
|
69ceb53897 | ||
|
|
5f2f0e689a | ||
|
|
1c42629d64 | ||
|
|
a892d2d1eb | ||
|
|
74643df024 | ||
|
|
07c0d4e8fb | ||
|
|
ecd3764d93 | ||
|
|
8bfcf86e6d | ||
|
|
747b958bd9 | ||
|
|
7f1eef46b2 | ||
|
|
001f3d008d | ||
|
|
c80eac9506 | ||
|
|
edeee4d656 | ||
|
|
a0d291e2bb | ||
|
|
4ed6650b88 | ||
|
|
a124aba31a | ||
|
|
7397241fb6 | ||
|
|
733d3abd7e | ||
|
|
f8a7a59085 | ||
|
|
330c59d954 | ||
|
|
02e7620924 | ||
|
|
71b527b7c2 | ||
|
|
9d4cecee14 | ||
|
|
a456a1476c | ||
|
|
85a97ed37c | ||
|
|
d15d01fbc2 | ||
|
|
c14c063507 | ||
|
|
539c847e65 | ||
|
|
fe28bf9738 | ||
|
|
99a593a6bf | ||
|
|
c752fee870 | ||
|
|
3e484d6adf | ||
|
|
3ba815a17a | ||
|
|
d2d4e99101 | ||
|
|
a92071933a | ||
|
|
786bc6d8ca | ||
|
|
eab98fdd88 | ||
|
|
3cdf31772f | ||
|
|
254d2c8e10 | ||
|
|
5a6a6e4e77 | ||
|
|
07b8984a5b | ||
|
|
476bb33916 | ||
|
|
cf69ad16ef | ||
|
|
c7d27a84a1 | ||
|
|
73737f9e6c | ||
|
|
7351b11d1a | ||
|
|
8c13c07e40 | ||
|
|
a661a2262d | ||
|
|
00a77c1e1f | ||
|
|
37b174ada3 | ||
|
|
129e00b6b1 | ||
|
|
39f7f1bf42 | ||
|
|
63ac8a4adf | ||
|
|
75a078b0ce | ||
|
|
3b64b39c5b | ||
|
|
58859564a8 | ||
|
|
113cd40efb | ||
|
|
b37e409c9c | ||
|
|
3874099e52 | ||
|
|
dfbfc81aa9 | ||
|
|
df010b6204 | ||
|
|
8e033a0480 | ||
|
|
89bfc9ab79 | ||
|
|
83d3c8afa5 | ||
|
|
a3d9d8f2c3 | ||
|
|
332f312b38 | ||
|
|
4ee0e8dd33 | ||
|
|
9634732468 | ||
|
|
bb24c87d5d | ||
|
|
197eb01adf | ||
|
|
f17d86aa79 | ||
|
|
59ef682c9c | ||
|
|
176caeb5fd | ||
|
|
1e7af3a2c5 | ||
|
|
9dac406ddc | ||
|
|
40ffd36b11 | ||
|
|
0fe1d0e15d | ||
|
|
3f38b2d0bd | ||
|
|
1088c588e4 | ||
|
|
fe0a3ac905 | ||
|
|
439c074655 | ||
|
|
03340f679c | ||
|
|
005530f4de | ||
|
|
295d815ec2 | ||
|
|
1b1f240ab1 | ||
|
|
c6735e2d8e | ||
|
|
98afd549d4 | ||
|
|
44753cefe0 | ||
|
|
b9526c300c | ||
|
|
6e09e5035b | ||
|
|
b376a835f6 | ||
|
|
effe6ea423 | ||
|
|
1f31d3d23a | ||
|
|
399171f0e0 | ||
|
|
1a43ca3439 | ||
|
|
1e95d86f56 | ||
|
|
070a8019e0 | ||
|
|
c50b3ef699 | ||
|
|
daefbd84e7 | ||
|
|
33ec06ab12 | ||
|
|
147c6bceef | ||
|
|
94a66c81d4 | ||
|
|
8650e51ddb | ||
|
|
0be2751890 | ||
|
|
bb8be7d516 | ||
|
|
4d025d5e93 | ||
|
|
8744511d74 | ||
|
|
e35010eb7d | ||
|
|
014f25363a | ||
|
|
b0286fb598 | ||
|
|
cf73e1d25f | ||
|
|
4ac75c281a | ||
|
|
401693ba65 | ||
|
|
c40c92e0bc | ||
|
|
62298d7c36 | ||
|
|
a3b1e9dd08 | ||
|
|
d705f96e06 | ||
|
|
ea750ddf7b | ||
|
|
5bea1f4226 | ||
|
|
266040ff8e | ||
|
|
a123c03b16 | ||
|
|
90b9df2899 | ||
|
|
3ba55cc9fa | ||
|
|
0b0a58af3e | ||
|
|
afd49ac9c2 | ||
|
|
c8cdfd117a | ||
|
|
218449af0c | ||
|
|
87386f72d9 | ||
|
|
e1604cc9c0 | ||
|
|
c81dc55a1c | ||
|
|
335326296c | ||
|
|
6e0b26dbc2 | ||
|
|
7ba6892f93 | ||
|
|
0443141598 | ||
|
|
b02930a2f2 | ||
|
|
8dad7f0ac0 | ||
|
|
35e7942094 | ||
|
|
e9b071e19e | ||
|
|
84efc873cf | ||
|
|
2abb7c86a1 | ||
|
|
d4a985e599 | ||
|
|
00879f19d7 | ||
|
|
a3abc60adf | ||
|
|
a4ffae3402 | ||
|
|
701af0a693 | ||
|
|
a8dc253fe9 | ||
|
|
ac7f3dbcf9 | ||
|
|
4ae9769ab7 | ||
|
|
e41c28a92b | ||
|
|
39c12c2542 | ||
|
|
bafc55bbe3 | ||
|
|
a8c6fb9d35 | ||
|
|
bf60148c4f | ||
|
|
ec0c273cf2 | ||
|
|
3571849861 | ||
|
|
cf358cdd4e | ||
|
|
4745f91af6 | ||
|
|
edc6751798 | ||
|
|
f214ffcc8a | ||
|
|
a82ad5eed6 | ||
|
|
3122ae98c0 | ||
|
|
1a6fba23c7 | ||
|
|
fac11b3d6a | ||
|
|
81bcb3950f | ||
|
|
58f31cdf25 | ||
|
|
2e779eed59 | ||
|
|
82ac14c4ad | ||
|
|
8578927d70 | ||
|
|
d9b7730a6f | ||
|
|
eb649daff5 | ||
|
|
1716888f1c | ||
|
|
9267013546 | ||
|
|
e95b6a26c5 | ||
|
|
cfab7464ed | ||
|
|
597adf33bd | ||
|
|
9d141f2e48 | ||
|
|
790d68e500 | ||
|
|
c72e75d8cf | ||
|
|
3d47396e9b | ||
|
|
e1a9161f7e | ||
|
|
155bca35fd | ||
|
|
8e05ff9b80 | ||
|
|
c05ef4bc91 | ||
|
|
a04eee71ba | ||
|
|
c29349aaf0 | ||
|
|
8e35638625 | ||
|
|
3dd2820398 | ||
|
|
1cfaf0bc96 | ||
|
|
23dcce8ab0 | ||
|
|
e58593bb50 | ||
|
|
426786c862 | ||
|
|
3e90d34739 | ||
|
|
a7954d4bdc | ||
|
|
42849387d1 | ||
|
|
b67b146635 | ||
|
|
04f7aa963b | ||
|
|
b2423d6d62 | ||
|
|
6fa16423ac | ||
|
|
712305de32 | ||
|
|
94b5bf1aa0 | ||
|
|
fc8db41b0d | ||
|
|
b07b8f865b | ||
|
|
8db0ec744e | ||
|
|
0c0d33cb29 | ||
|
|
edc49a0118 | ||
|
|
27d81ba1b9 | ||
|
|
e5e49e6242 | ||
|
|
e08c7a3ee0 | ||
|
|
eb649762c3 | ||
|
|
62d0687fea | ||
|
|
e2b9c29ff0 | ||
|
|
ff0a8b31f8 | ||
|
|
9365b979ac | ||
|
|
a99b380175 | ||
|
|
6d7496f62a | ||
|
|
e28eb5363a | ||
|
|
24267c893b | ||
|
|
3c7bba9bdb | ||
|
|
86c0211c35 | ||
|
|
dd35df0f18 | ||
|
|
921a31e8a1 | ||
|
|
038365e19b | ||
|
|
3b29a9fbc5 | ||
|
|
c3d63d1e98 | ||
|
|
cc3ee16e46 | ||
|
|
42372aa760 | ||
|
|
d29c8eb63f | ||
|
|
8852c0e0b8 | ||
|
|
d3e25901ff | ||
|
|
54bef9b2fb | ||
|
|
4e294c46f1 | ||
|
|
67268cb5b4 | ||
|
|
6bd3217727 | ||
|
|
21f7031e72 | ||
|
|
7dde5f518c | ||
|
|
b65d5f5dcd | ||
|
|
f88c50772f | ||
|
|
d064372d9d | ||
|
|
ca0a90eee3 | ||
|
|
c6780b8daf | ||
|
|
aca15c91d4 | ||
|
|
01166b25ae | ||
|
|
311cf0d31f | ||
|
|
9e9078f17f | ||
|
|
d00c6bcd25 | ||
|
|
a96e04043e | ||
|
|
424f045246 | ||
|
|
dfd4b135ef | ||
|
|
baceff1b55 | ||
|
|
de808d2d30 | ||
|
|
44338647fa | ||
|
|
3f41fda7a1 | ||
|
|
57bc596fe1 | ||
|
|
4090de57e0 | ||
|
|
1d78698703 | ||
|
|
9a344e0c31 | ||
|
|
d48b349d72 | ||
|
|
3568cc4cc7 | ||
|
|
d68ada1e13 | ||
|
|
c367cb95a2 | ||
|
|
25d9cc84c2 |
No files matched your search
@@ -51,7 +51,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
|
||||
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
|
||||
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
|
||||
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
|
||||
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings), modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
|
||||
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
|
||||
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
|
||||
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
|
||||
@@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `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)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
@@ -92,8 +93,8 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
|
||||
```sh
|
||||
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
|
||||
python3 tools/pr_ready.py --quick # same without harnesses
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
|
||||
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
|
||||
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
@@ -105,8 +106,8 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
|
||||
harnesses for CI.
|
||||
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
|
||||
- A harness = `tools/test_<name>_parity.py` + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
|
||||
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (mostly horizon-world and horizon-client),
|
||||
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
|
||||
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
|
||||
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
|
||||
@@ -120,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
Source paths remain in `crates/horizon-client/`. Measure actual harness selection on refreshed
|
||||
records with `tools/parity.py --changed-files paths.txt --list`; historical counts are not a guarantee.
|
||||
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
|
||||
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio, controllers, `discord`, `friends` and `heartbeat` (the server build).
|
||||
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
|
||||
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
|
||||
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
|
||||
@@ -151,23 +152,23 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| 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`, `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` |
|
||||
| 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), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); 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`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
@@ -176,7 +177,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
|
||||
- `struct Game` (game.rs) holds everything. Its methods are spread over many files: game.rs keeps
|
||||
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
|
||||
`impl Game<'_>` block for one feature, with its own state types. Fields stay private to the gameplay module and its descendants. Gameplay tests run in the gameplay library, separate from reusable-library tests.
|
||||
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
|
||||
@@ -190,7 +191,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
(`pack`), both in game.rs, their methods in `game/resource_tables.rs`.
|
||||
|
||||
## Single player runs on a server thread
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ queue (the hub, or `/suggest` on Discord) rather than to issues, which are for b
|
||||
Wherever vanilla has a feature, vanilla is the specification, down to the order of random calls. "Parity" here means
|
||||
Horizon gives the same results as the pinned 26.3 client and server for the same inputs, checked by a **harness**: a
|
||||
`tools/test_*_parity.py` script that runs a small Java oracle against the real game and a Rust probe against Horizon,
|
||||
and compares their outputs. There are about 290 of them.
|
||||
and compares their outputs.
|
||||
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
@@ -61,6 +61,10 @@ another project's source may also be read and used, **only when that project's l
|
||||
"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.
|
||||
- **Java's own library is GPL too.** OpenJDK's source (`java.lang.Math`, `StrictMath`, `FdLibm`, `HashMap`, the
|
||||
sorts, `Double.toString`, HotSpot's intrinsics...) is GPL-2.0, so it is off limits like the rest. Reproduce Java's
|
||||
behaviour from its API documentation (the Javadoc) and from what a JVM returns, or from a permissive original such
|
||||
as Sun's fdlibm C sources (see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)); see #6741.
|
||||
- **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.
|
||||
|
||||
@@ -131,9 +131,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"log",
|
||||
@@ -142,11 +142,20 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
@@ -243,6 +252,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
@@ -1411,6 +1426,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"dunce",
|
||||
"flate2",
|
||||
"fs4",
|
||||
@@ -1493,6 +1509,7 @@ dependencies = [
|
||||
"ab_glyph",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"getrandom 0.3.4",
|
||||
@@ -1552,7 +1569,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"cpal",
|
||||
@@ -1621,7 +1638,7 @@ dependencies = [
|
||||
name = "horizon-cosmetics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"glam",
|
||||
"png",
|
||||
"serde",
|
||||
@@ -1649,7 +1666,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.5",
|
||||
"spake2",
|
||||
]
|
||||
|
||||
@@ -1754,6 +1771,14 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
@@ -1793,6 +1818,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1808,7 +1834,7 @@ dependencies = [
|
||||
name = "horizon-release-sign"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"ring",
|
||||
]
|
||||
|
||||
@@ -1817,7 +1843,7 @@ name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-anticheat",
|
||||
@@ -1848,7 +1874,7 @@ dependencies = [
|
||||
name = "horizon-server-files"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
@@ -2052,12 +2078,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "if-addrs"
|
||||
version = "0.13.4"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24"
|
||||
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2381,9 +2407,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
@@ -2482,10 +2508,11 @@ checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "mysql"
|
||||
version = "27.0.0"
|
||||
version = "28.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "090068bc0a24762663338da78414148333d1ee246a7293c10a517ddf1548b291"
|
||||
checksum = "ca3de8a73b7c70f76dbcdd4b6abc9b53bbb6c4f7bd6c79d8e8a7ea2157c02487"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bufstream",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
@@ -2509,11 +2536,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.36.2"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20e7f6a15265801679ddf1ddb38fd74b4555a677a78378d4561fbe5f0b1d4d72"
|
||||
checksum = "0f27695f286b461da077b8c2f72f47feaa04ce3c3f9c0976257410e90e21208a"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.13.2",
|
||||
"btoi",
|
||||
"byteorder",
|
||||
@@ -3129,7 +3156,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"cms",
|
||||
"der",
|
||||
@@ -3181,11 +3208,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.23.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
@@ -4367,9 +4394,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
version = "1.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
@@ -4548,7 +4575,7 @@ version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -5237,15 +5264,6 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -5279,30 +5297,13 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
@@ -5324,12 +5325,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5342,12 +5337,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5360,24 +5349,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5390,12 +5367,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5408,12 +5379,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -5426,12 +5391,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5444,12 +5403,6 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
|
||||
@@ -37,6 +37,7 @@ members = [
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
@@ -47,7 +48,7 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.85"
|
||||
rust-version = "1.95"
|
||||
|
||||
# Ports mirror vanilla's Java signatures, index loops and nested generic types
|
||||
# one to one so they can be checked against the decompiled sources.
|
||||
@@ -81,6 +82,21 @@ libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
|
||||
# The native mod API is generated by a build script that parses every file of game/ with syn on
|
||||
# each gameplay edit; unoptimized that took ~6 s of every rebuild, optimized ~1.5 s.
|
||||
[profile.dev.package.horizon-mod-api-gen]
|
||||
opt-level = 3
|
||||
[profile.dev.package.syn]
|
||||
opt-level = 3
|
||||
[profile.dev.package.proc-macro2]
|
||||
opt-level = 3
|
||||
[profile.release.package.horizon-mod-api-gen]
|
||||
opt-level = 3
|
||||
[profile.release.package.syn]
|
||||
opt-level = 3
|
||||
[profile.release.package.proc-macro2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -49,6 +49,18 @@ Everything below is in the current release.
|
||||
|
||||
<sub>Nightly 2026-11-05 on a Mac mini (Apple M4 Pro, Metal): vanilla's textures, no mods or shader packs, render distance 32 with distant terrain on.</sub>
|
||||
|
||||
## Performance
|
||||
|
||||
**Horizon draws more frames per second than Java Edition, and loads faster, on the same machine, world and path.** `tools/perf_bench.py` runs each game in turn, from a fresh world (seed 1234), waiting for the terrain and then flying it over a village, a forest, an ocean and a mountain. Measured on 2026-10-29/30 on an idle Windows 11 machine (Ryzen 7 9800X3D, RTX 5080), Horizon v0.2.97 as the launcher installs it, 1280x720 in a window, VSync off, three games per figure. Frames per second, mean of the four scenes, with the slowest 1% of frames in brackets:
|
||||
|
||||
| Render distance | Horizon v0.2.97 | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 chunks | 3,289 (1,327) | 2,437 (640) | 2,375 (709) |
|
||||
| 16 chunks | 2,074 (598) | 1,661 (375) | 1,586 (432) |
|
||||
| 32 chunks | 1,207 (333) | 1,031 (176) | 879 (264) |
|
||||
|
||||
"Vanilla + mods" is Sodium, Lithium, FerriteCore and ImmediatelyFast. Horizon reaches the title screen in 2.9–3.2 s against vanilla's 7.5–7.7 s (the modded client's 10.8–11.1 s), loads a scene's chunks 2.9–3.8 times faster, and playing holds a steady working set of 1.1–1.7 GB against 1.9–2.9 GB. Every scene with its confidence interval and the run's load log: [the 2026-10-29 reports](benchmarks/perf/windows-9800x3d-5080-2026-10-29.md). Method, and how to measure your own machine: [docs/benchmark.md](docs/benchmark.md). [playhorizon.dev/benchmarks](https://playhorizon.dev/benchmarks) puts these runs beside a 2016 laptop, a Mac mini and a gaming desktop.
|
||||
|
||||
## Download
|
||||
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev), or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true):
|
||||
|
||||
@@ -410,16 +410,6 @@ The panel's later features (bans, worlds, server.properties import, logs and cra
|
||||
|
||||
</details>
|
||||
|
||||
#### Ely.by and other sign-in providers
|
||||
|
||||
Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Join servers on older versions
|
||||
|
||||
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Third-party notices
|
||||
|
||||
## Code removed for its license
|
||||
|
||||
Horizon once shipped three pieces of code under MIT that came from projects whose licenses don't allow that. All
|
||||
three are gone from main. They are listed here for anyone who holds an older commit, tag or release: in those
|
||||
versions, that code is not covered by Horizon's MIT license. It stays under its authors' copyright and its original
|
||||
license.
|
||||
|
||||
- **Shader packs written from Iris's source.** `crates/horizon-shaderpack` and the render stages that used it were
|
||||
written from the source of [Iris](https://github.com/IrisShaders/Iris) (LGPL-3.0, copyright the Iris contributors).
|
||||
They were added from 15 October 2026 (`b22583d057`), shipped in the game releases `v2026.10.16.02` to `v0.2.46`,
|
||||
and were removed on 24 October 2026 ([#1561](https://github.com/horizon-rust/horizon/pull/1561)) after a developer
|
||||
of Iris showed us. Shader packs came back in `crates/horizon-shaders`, first written from a description of the
|
||||
pack format.
|
||||
- **Math functions translated from OpenJDK.** In `crates/horizon-core/src/jmath.rs`, `fdlibm::{log, log10, cbrt,
|
||||
hypot}` were translated from OpenJDK's `java/lang/FdLibm.java` and `round_f32` from its `java/lang/Math.java`
|
||||
(GPL-2.0 with the Classpath Exception), and `aarch64::{sin, cos}` from HotSpot's `macroAssembler_aarch64_trig.cpp`
|
||||
(GPL-2.0); copyright Oracle and/or its affiliates, and Cavium for that file. The `fdlibm` and `aarch64` functions
|
||||
were added on 13 October 2026 (`558d140fca`, first released in `v2026.10.13`). `round_f32` was added on 14 October
|
||||
2026 as `java_round` in `crates/horizon-client/src/mipmap.rs` (`95db0fce9d`) and moved into `jmath.rs` on 3
|
||||
November ([#7770](https://github.com/horizon-rust/horizon/pull/7770)). All of them were replaced in
|
||||
[#11944](https://github.com/horizon-rust/horizon/pull/11944) by code written without OpenJDK's sources, from Sun's
|
||||
fdlibm C sources, the Java SE API documentation and a description of the behaviour, and checked against a JVM
|
||||
([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
- **Java's `HashMap` translated from OpenJDK.** `crates/horizon-world/src/java_hash_map.rs`, which reproduces the
|
||||
iteration order of `java.util.HashMap`, was translated from OpenJDK's `java/util/HashMap.java` (GPL-2.0 with the
|
||||
Classpath Exception; copyright Oracle and/or its affiliates). It was added on 7 October 2026 (`118e93ea51` and
|
||||
`194db0dbb4`, first released in `v2026.10.12`). It was replaced in
|
||||
[#12043](https://github.com/horizon-rust/horizon/pull/12043) by code written without OpenJDK's sources, from a
|
||||
description of the behaviour, the Java SE API documentation and the textbook red-black tree, and checked against a
|
||||
JVM ([#6741](https://github.com/horizon-rust/horizon/issues/6741)).
|
||||
|
||||
## Catnip / Ponder
|
||||
|
||||
`crates/horizon-client-render/src/fluid_render.rs` translates the still-fluid face tiling and
|
||||
UV rules of Catnip's `FluidRenderHelper` from Ponder 1.0.82 for Minecraft 1.21.1
|
||||
(`net.createmod.ponder:ponder-neoforge:1.0.82+mc1.21.1`). Copyright (c) 2022 The Create Team,
|
||||
licensed under MIT (`LICENSE_Ponder` in that artifact); the full license heads the Rust file.
|
||||
The native renderer uses Horizon's own block-sprite arrays, pose and light packing. The
|
||||
development-only oracle calls the unchanged helper from the player's local artifact. No
|
||||
original classes, game shaders, textures or prepared resources are distributed here.
|
||||
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
@@ -212,3 +253,56 @@ 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.
|
||||
```
|
||||
|
||||
## Just Enough Items
|
||||
|
||||
The Recipes GUI sprites and settings icon under `crates/horizon-client-render/src/jei_recipes` are from
|
||||
[Just Enough Items 31.17.0.108](https://github.com/mezz/JustEnoughItems/tree/0371fa6fafa14635a356019e4604f5580b26a34f)
|
||||
by mezz and contributors, under the MIT license in that directory (`LICENSE.txt`).
|
||||
|
||||
## Flywheel native model directional shading
|
||||
|
||||
The two-light diffuse expression and transformed-normal policy in
|
||||
`terrain.wgsl`'s native model entries follow Flywheel 1.0.6
|
||||
(`internal/diffuse.glsl`, `instance/transformed.vert`, `internal/common.vert`).
|
||||
No Flywheel classes, compiled shaders or original model/texture assets are bundled.
|
||||
|
||||
Copyright (c) 2021-2024 Jozufozu
|
||||
|
||||
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.
|
||||
|
||||
## fdlibm
|
||||
|
||||
`crates/horizon-core/src/jmath/fdlibm.rs` and the `fdlibm_asin`, `fdlibm_atan` and
|
||||
`fdlibm_atan2` functions in `crates/horizon-core/src/geometry/trig.rs` are Rust ports of
|
||||
[fdlibm](https://www.netlib.org/fdlibm/) 5.3, Sun's freely distributable math library, whose
|
||||
results the Java SE API specifies for `StrictMath`. `fdlibm.rs` ports `e_log.c`, `e_log10.c`,
|
||||
`s_cbrt.c` and `e_hypot.c`, and, with fused multiply-adds, `s_sin.c`, `s_cos.c`, `k_sin.c`,
|
||||
`k_cos.c` and `e_rem_pio2.c`; `trig.rs` ports `e_asin.c`, `s_atan.c` and `e_atan2.c`. This
|
||||
notice also heads both files:
|
||||
|
||||
```
|
||||
Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
|
||||
|
||||
Developed at SunSoft, a Sun Microsystems, Inc. business.
|
||||
Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
@@ -3,7 +3,7 @@ name = "cargo-horizon-mod"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.86"
|
||||
rust-version.workspace = true
|
||||
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
|
||||
|
||||
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
rust-version.workspace = true
|
||||
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -34,7 +34,10 @@ pub fn element(v: &Element) -> Value {
|
||||
.collect();
|
||||
json!({"from":bits(v.from),"to":bits(v.to),"faces":faces,"rotation":v.rotation.as_ref().map(rotation),"shade":v.shade_direction_override.map(Direction::name),"light":v.light_emission})
|
||||
}
|
||||
#[allow(dead_code)] // Shared by fixtures which normalize different model stages.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "shared by fixtures which normalize different model stages"
|
||||
)]
|
||||
pub fn model(v: &CuboidModel) -> Value {
|
||||
let textures: BTreeMap<_, _> = v
|
||||
.textures
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct ModelQuad {
|
||||
/// NeoForge's `render_type` names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RenderType {
|
||||
/// A declared render type unknown to this baker; distinct from an absent default.
|
||||
Unsupported,
|
||||
Solid,
|
||||
Cutout,
|
||||
CutoutMipped,
|
||||
@@ -234,7 +236,10 @@ fn bake_into(
|
||||
skip_flat: bool,
|
||||
output: &mut Vec<ModelQuad>,
|
||||
) -> Result<()> {
|
||||
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
|
||||
let render_type = model
|
||||
.render_type
|
||||
.as_deref()
|
||||
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
|
||||
for element in model.elements() {
|
||||
let element = element
|
||||
.as_ref()
|
||||
@@ -292,7 +297,10 @@ fn bake_obj(
|
||||
transform: BlockModelTransform,
|
||||
output: &mut Vec<ModelQuad>,
|
||||
) -> Result<()> {
|
||||
let render_type = model.render_type.as_deref().and_then(RenderType::by_name);
|
||||
let render_type = model
|
||||
.render_type
|
||||
.as_deref()
|
||||
.map(|name| RenderType::by_name(name).unwrap_or(RenderType::Unsupported));
|
||||
for face in obj.faces.iter().filter(|f| !hidden.contains(&f.object)) {
|
||||
let slot = match obj.materials.get(&face.material) {
|
||||
Some(MtlTexture::Sprite(sprite)) => {
|
||||
|
||||
@@ -179,19 +179,19 @@ impl Condition {
|
||||
let fields = object(raw)?;
|
||||
if fields.len() == 1 {
|
||||
for name in ["OR", "AND"] {
|
||||
if let Some(raw) = fields.get(name) {
|
||||
if let Ok(values) = array(raw).and_then(|values| {
|
||||
if let Some(raw) = fields.get(name)
|
||||
&& let Ok(values) = array(raw).and_then(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(Self::decode)
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}) {
|
||||
return Ok(if name == "OR" {
|
||||
Self::Or(values)
|
||||
} else {
|
||||
Self::And(values)
|
||||
});
|
||||
}
|
||||
})
|
||||
{
|
||||
return Ok(if name == "OR" {
|
||||
Self::Or(values)
|
||||
} else {
|
||||
Self::And(values)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +917,46 @@ impl NumericProperty {
|
||||
/// (`None` removes a prototype component).
|
||||
pub type Components = BTreeMap<String, Value>;
|
||||
|
||||
/// A stack's components read in place: its patch first, then its item's defaults. Reading one copies
|
||||
/// nothing, where [`ItemModels::components`] clones the defaults for a stack with a patch (an enchanted
|
||||
/// sword's are a few dozen JSON values, on every draw of it).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ComponentsView<'a> {
|
||||
prototype: &'a Components,
|
||||
patch: &'a BTreeMap<String, Option<Value>>,
|
||||
}
|
||||
|
||||
impl<'a> ComponentsView<'a> {
|
||||
pub fn get(&self, key: &str) -> Option<&'a Value> {
|
||||
match self.patch.get(key) {
|
||||
Some(patched) => patched.as_ref(),
|
||||
None => self.prototype.get(key),
|
||||
}
|
||||
}
|
||||
pub fn contains_key(&self, key: &str) -> bool {
|
||||
self.get(key).is_some()
|
||||
}
|
||||
/// Components with no patch: `components` as they are.
|
||||
pub fn of(components: &'a Components) -> Self {
|
||||
static NO_PATCH: BTreeMap<String, Option<Value>> = BTreeMap::new();
|
||||
Self {
|
||||
prototype: components,
|
||||
patch: &NO_PATCH,
|
||||
}
|
||||
}
|
||||
/// The merged map, for what needs one whole.
|
||||
pub fn to_map(&self) -> Components {
|
||||
let mut out = self.prototype.clone();
|
||||
for (key, value) in self.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// An item stack as the resolver reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackView {
|
||||
@@ -1087,14 +1127,15 @@ impl ItemModels {
|
||||
if stack.patch.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(prototype);
|
||||
}
|
||||
let mut out = prototype.clone();
|
||||
for (key, value) in &stack.patch {
|
||||
match value {
|
||||
Some(v) => out.insert(key.clone(), v.clone()),
|
||||
None => out.remove(key),
|
||||
};
|
||||
std::borrow::Cow::Owned(self.components_view(stack).to_map())
|
||||
}
|
||||
/// The stack's components, read in place (see [`ComponentsView`]).
|
||||
pub fn components_view<'a>(&'a self, stack: &'a StackView) -> ComponentsView<'a> {
|
||||
static NONE: Components = BTreeMap::new();
|
||||
ComponentsView {
|
||||
prototype: self.prototypes.get(&stack.item).unwrap_or(&NONE),
|
||||
patch: &stack.patch,
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
/// `ItemModelResolver.updateForTopItem`.
|
||||
pub fn resolve(&self, stack: &StackView, ctx: &Context, env: &dyn ItemEnv) -> ResolvedItem {
|
||||
@@ -1112,7 +1153,7 @@ impl ItemModels {
|
||||
ctx: &Context,
|
||||
env: &dyn ItemEnv,
|
||||
) {
|
||||
let components = self.components(stack);
|
||||
let components = self.components_view(stack);
|
||||
let Some(Value::String(model_id)) = components.get("minecraft:item_model") else {
|
||||
return;
|
||||
};
|
||||
@@ -1122,7 +1163,7 @@ impl ItemModels {
|
||||
let mut state = Resolve {
|
||||
models: self,
|
||||
stack,
|
||||
components: &components,
|
||||
components,
|
||||
ctx,
|
||||
env,
|
||||
};
|
||||
@@ -1140,7 +1181,7 @@ impl ItemModels {
|
||||
self.properties(stack).2
|
||||
}
|
||||
fn properties(&self, stack: &StackView) -> (bool, bool, f32) {
|
||||
match self.components(stack).get("minecraft:item_model") {
|
||||
match self.components_view(stack).get("minecraft:item_model") {
|
||||
Some(Value::String(id)) => identifier(id)
|
||||
.ok()
|
||||
.and_then(|id| self.definitions.get(&id))
|
||||
@@ -1159,7 +1200,7 @@ impl ItemModels {
|
||||
struct Resolve<'a> {
|
||||
models: &'a ItemModels,
|
||||
stack: &'a StackView,
|
||||
components: &'a Components,
|
||||
components: ComponentsView<'a>,
|
||||
ctx: &'a Context<'a>,
|
||||
env: &'a dyn ItemEnv,
|
||||
}
|
||||
@@ -1455,9 +1496,9 @@ impl Resolve<'_> {
|
||||
fn numeric(&self, property: &NumericProperty) -> f32 {
|
||||
let owner = self.ctx.owner;
|
||||
match property {
|
||||
NumericProperty::BundleFullness => {
|
||||
self.env.bundle_fullness(self.stack, self.components)
|
||||
}
|
||||
NumericProperty::BundleFullness => self
|
||||
.env
|
||||
.bundle_fullness(self.stack, &self.components.to_map()),
|
||||
NumericProperty::Compass {
|
||||
wobble,
|
||||
target,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
components: ComponentsView<'_>,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
@@ -143,13 +143,17 @@ mod tests {
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{}]}),
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
ComponentsView::of(&components),
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
@@ -166,12 +170,21 @@ mod tests {
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &NoEnv),
|
||||
Some(false)
|
||||
);
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
assert_eq!(
|
||||
matches(&predicate, ComponentsView::of(&components), &Tags),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn identifier_adds_default_namespace() {
|
||||
@@ -111,3 +112,49 @@ fn custom_condition_validates_its_name_and_both_branches() {
|
||||
assert!(matches!(node("example:charged", empty).unwrap(),
|
||||
ItemModelNode::Condition { property: ConditionProperty::Custom(name), .. } if name == "example:charged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
let mut models = ItemModels::default();
|
||||
models.prototypes.insert(
|
||||
"minecraft:diamond_sword".into(),
|
||||
Components::from_iter([
|
||||
(
|
||||
"minecraft:item_model".into(),
|
||||
json!("minecraft:diamond_sword"),
|
||||
),
|
||||
("minecraft:max_damage".into(), json!(1561)),
|
||||
("minecraft:rarity".into(), json!("common")),
|
||||
]),
|
||||
);
|
||||
let mut stack = StackView::new("minecraft:diamond_sword", 1);
|
||||
stack.patch.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
Some(json!({"minecraft:sharpness": 5})),
|
||||
);
|
||||
stack
|
||||
.patch
|
||||
.insert("minecraft:rarity".into(), Some(json!("rare")));
|
||||
stack.patch.insert("minecraft:max_damage".into(), None);
|
||||
let view = models.components_view(&stack);
|
||||
assert_eq!(view.get("minecraft:rarity"), Some(&json!("rare")));
|
||||
assert_eq!(view.get("minecraft:max_damage"), None, "a removed default");
|
||||
assert_eq!(
|
||||
view.get("minecraft:item_model"),
|
||||
Some(&json!("minecraft:diamond_sword"))
|
||||
);
|
||||
assert!(view.contains_key("minecraft:enchantments"));
|
||||
assert!(!view.contains_key("minecraft:custom_name"));
|
||||
// Every key reads as the merged map has it.
|
||||
let merged = models.components(&stack);
|
||||
for key in [
|
||||
"minecraft:rarity",
|
||||
"minecraft:max_damage",
|
||||
"minecraft:item_model",
|
||||
"minecraft:enchantments",
|
||||
"minecraft:custom_name",
|
||||
] {
|
||||
assert_eq!(view.get(key), merged.get(key), "{key}");
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
@@ -777,6 +777,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_unknown_declared_render_type_remains_distinct_from_absent() {
|
||||
let json = LIGHTS.replace("minecraft:cutout", "fixture:custom_material");
|
||||
let library = library(&[("block/custom", &json)]);
|
||||
let model = library.resolve("block/custom").unwrap();
|
||||
let quads = crate::bake::bake_block_model(
|
||||
&model,
|
||||
crate::bake::BlockModelTransform::new([0; 3], false),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!quads.is_empty());
|
||||
assert!(
|
||||
quads
|
||||
.iter()
|
||||
.all(|q| q.render_type == Some(crate::bake::RenderType::Unsupported))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn render_type_comes_from_the_leaf_most_model_and_loses_its_prefix() {
|
||||
let library = library(&[
|
||||
("block/base", r#"{"render_type":"translucent"}"#),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version.workspace = true
|
||||
license = "MIT"
|
||||
description = "The benchmark report format (docs/benchmark-report.md), shared by the game, the launcher and the upload"
|
||||
|
||||
|
||||
@@ -694,7 +694,7 @@ fn costly(
|
||||
Behaviour::FloodUse => {
|
||||
// A block within reach: place stone there, or break what is there.
|
||||
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
|
||||
if i % 2 == 0 {
|
||||
if i.is_multiple_of(2) {
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
@@ -723,7 +723,7 @@ fn costly(
|
||||
carried: None,
|
||||
}),
|
||||
Behaviour::FloodSuggest => {
|
||||
let text = if i % 2 == 0 {
|
||||
let text = if i.is_multiple_of(2) {
|
||||
"/"
|
||||
} else {
|
||||
LONG_COMMAND.as_str()
|
||||
@@ -1555,10 +1555,10 @@ fn main() {
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
println!("{summary:#}");
|
||||
} else if let Some(path) = &o.report {
|
||||
if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) {
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
} else if let Some(path) = &o.report
|
||||
&& let Err(e) = std::fs::write(path, format!("{summary:#}\n"))
|
||||
{
|
||||
eprintln!("horizon-bots: {path}: {e}");
|
||||
}
|
||||
let errors: Vec<String> = bots
|
||||
.iter()
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.88"
|
||||
rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-client-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -275,6 +275,25 @@ impl Registries {
|
||||
.clone_from(&e.advancement_criteria);
|
||||
}
|
||||
}
|
||||
/// Add the damage catalog's types ([`Self::damage_types`]: content mods'
|
||||
/// and the world's packs' with vanilla's) to the commands'
|
||||
/// `minecraft:damage_type` ids, which the export lists for vanilla alone.
|
||||
/// `ResourceArgument` looks a type up in the server's registries, so
|
||||
/// `/damage` takes every type the world has. The export's tags stay. The
|
||||
/// ids are replaced, not extended: `by_registry` outlives a world, and a
|
||||
/// pack's type must not stay accepted in the next world opened.
|
||||
pub fn sync_damage_type_ids(&mut self) {
|
||||
if self.damage_types.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut ids = self.damage_types.clone();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
self.by_registry
|
||||
.entry("minecraft:damage_type".into())
|
||||
.or_default()
|
||||
.ids = ids;
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
@@ -312,3 +331,49 @@ impl Registries {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Registries;
|
||||
|
||||
/// A content mod's damage type joins vanilla's in the commands' ids, the
|
||||
/// export's tags stay, and a second world open lists each type once.
|
||||
#[test]
|
||||
fn damage_type_ids_take_the_catalogs_types() {
|
||||
let export = Registries::from_export(&serde_json::json!({
|
||||
"by_registry": {"minecraft:damage_type": {
|
||||
"ids": ["minecraft:fall", "minecraft:magic"],
|
||||
"tags": ["minecraft:is_fall"],
|
||||
}},
|
||||
}));
|
||||
let mut reg = Registries {
|
||||
damage_types: vec![
|
||||
"minecraft:magic".into(),
|
||||
"example:test".into(),
|
||||
"minecraft:fall".into(),
|
||||
],
|
||||
..Registries::default()
|
||||
};
|
||||
reg.fill_from_exported(&export);
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:damage_type"),
|
||||
["minecraft:fall", "minecraft:magic"],
|
||||
"the export alone lists vanilla's types"
|
||||
);
|
||||
for _ in 0..2 {
|
||||
reg.sync_damage_type_ids();
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:damage_type"),
|
||||
["example:test", "minecraft:fall", "minecraft:magic"]
|
||||
);
|
||||
assert_eq!(reg.tags("minecraft:damage_type"), ["minecraft:is_fall"]);
|
||||
}
|
||||
// The next world, without the pack: its type is no longer accepted.
|
||||
reg.damage_types = vec!["minecraft:fall".into(), "minecraft:magic".into()];
|
||||
reg.sync_damage_type_ids();
|
||||
assert_eq!(
|
||||
reg.ids("minecraft:damage_type"),
|
||||
["minecraft:fall", "minecraft:magic"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name = "horizon-client-input"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -490,6 +490,37 @@ impl GlyphStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much the gameplay guide says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GuideDetail {
|
||||
#[default]
|
||||
Full,
|
||||
/// Only what can be done to what's in front of the player or under the crosshair.
|
||||
Basic,
|
||||
Off,
|
||||
}
|
||||
impl GuideDetail {
|
||||
pub const ALL: [GuideDetail; 3] = [GuideDetail::Full, GuideDetail::Basic, GuideDetail::Off];
|
||||
|
||||
/// The name in `horizon-controller.txt`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GuideDetail::Full => "full",
|
||||
GuideDetail::Basic => "basic",
|
||||
GuideDetail::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a name, or the `true` and `false` this option first saved. Anything else is full.
|
||||
pub fn parse(value: &str) -> Self {
|
||||
match value {
|
||||
"basic" => GuideDetail::Basic,
|
||||
"off" | "false" => GuideDetail::Off,
|
||||
_ => GuideDetail::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard A opens on a text field in focus mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum TextKeyboard {
|
||||
@@ -537,6 +568,8 @@ pub fn steam_keyboard(choice: TextKeyboard) -> bool {
|
||||
pub struct PadSettings {
|
||||
pub enabled: bool,
|
||||
pub vibration: bool,
|
||||
/// The pad keeps driving the game while its window has no focus.
|
||||
pub play_in_background: bool,
|
||||
/// A DualSense's triggers push back (Horizon's; docs/controllers.md).
|
||||
pub adaptive_triggers: bool,
|
||||
pub lightbar: Lightbar,
|
||||
@@ -556,6 +589,8 @@ pub struct PadSettings {
|
||||
/// by default on the Switch only.
|
||||
pub nintendo_letters: bool,
|
||||
pub keyboard: TextKeyboard,
|
||||
/// The gameplay guide: what the buttons do now, in the HUD's corners while the pad plays.
|
||||
pub guide_detail: GuideDetail,
|
||||
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
|
||||
pub pointer_speed: f32,
|
||||
pub scroll_speed: f32,
|
||||
@@ -569,6 +604,7 @@ impl Default for PadSettings {
|
||||
Self {
|
||||
enabled: true,
|
||||
vibration: true,
|
||||
play_in_background: false,
|
||||
adaptive_triggers: true,
|
||||
lightbar: Lightbar::Health,
|
||||
look_sensitivity: 0.5,
|
||||
@@ -581,6 +617,7 @@ impl Default for PadSettings {
|
||||
glyphs: GlyphStyle::Auto,
|
||||
nintendo_letters: cfg!(target_os = "horizon"),
|
||||
keyboard: TextKeyboard::Auto,
|
||||
guide_detail: GuideDetail::Full,
|
||||
pointer_speed: 1.0,
|
||||
scroll_speed: 12.0,
|
||||
repeat_delay: 0.35,
|
||||
@@ -635,6 +672,7 @@ impl PadSettings {
|
||||
match key.trim() {
|
||||
"enabled" => s.enabled = flag(s.enabled),
|
||||
"vibration" => s.vibration = flag(s.vibration),
|
||||
"playInBackground" => s.play_in_background = flag(s.play_in_background),
|
||||
"adaptiveTriggers" => s.adaptive_triggers = flag(s.adaptive_triggers),
|
||||
"lightbar" => s.lightbar = Lightbar::parse(value),
|
||||
"lookSensitivity" => s.look_sensitivity = unit(s.look_sensitivity),
|
||||
@@ -649,6 +687,7 @@ impl PadSettings {
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
"gameplayGuide" => s.guide_detail = GuideDetail::parse(value),
|
||||
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
@@ -706,7 +745,8 @@ impl PadSettings {
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
self.guide_detail.name(),
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
for (action, button) in &self.bindings {
|
||||
@@ -715,6 +755,10 @@ impl PadSettings {
|
||||
button.map_or("none", Button::name)
|
||||
));
|
||||
}
|
||||
text.push_str(&format!(
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str("bindLayout:1\n");
|
||||
text
|
||||
}
|
||||
@@ -786,7 +830,8 @@ pub fn stick_axes(
|
||||
{
|
||||
// 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]])
|
||||
// gilrs turns over only the axes it calls Y, and this Y is on one it doesn't.
|
||||
(left, [left_z, -right[0]])
|
||||
} else {
|
||||
(left, right)
|
||||
}
|
||||
@@ -1752,6 +1797,15 @@ bind_key.hotbar.2:north
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playing_in_the_background_is_off_until_saved_on() {
|
||||
assert!(!PadSettings::parse("vibration:false\n").play_in_background);
|
||||
let mut on = PadSettings::default();
|
||||
assert!(!on.play_in_background);
|
||||
on.play_in_background = true;
|
||||
assert!(PadSettings::parse(&on.serialize()).play_in_background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_these_defaults_keeps_its_buttons() {
|
||||
// Saved settings that never bound D-pad down: it stays on the advancements.
|
||||
@@ -1793,6 +1847,7 @@ bind_key.hotbar.2:north
|
||||
s.look_sensitivity = 0.75;
|
||||
s.glyphs = GlyphStyle::PlayStation;
|
||||
s.nintendo_letters = !s.nintendo_letters;
|
||||
s.guide_detail = GuideDetail::Basic;
|
||||
let text = s.serialize();
|
||||
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
|
||||
assert!(text.contains("bind_key.jump:east\n"));
|
||||
@@ -1820,7 +1875,11 @@ bind_key.hotbar.2:north
|
||||
false,
|
||||
);
|
||||
assert_eq!(left, [0.2, 0.3]);
|
||||
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
|
||||
assert_eq!(
|
||||
right,
|
||||
[-0.5, -0.7],
|
||||
"X from LeftZ, Y from RightStickX, turned over"
|
||||
);
|
||||
let kept = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
|
||||
@@ -0,0 +1,962 @@
|
||||
//! The gameplay guide: what the buttons do right now, in the corners of the HUD while a pad plays
|
||||
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
|
||||
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
|
||||
//! places the rows around what was drawn.
|
||||
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
|
||||
|
||||
/// What a use does, by what takes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Verb {
|
||||
Use,
|
||||
Open,
|
||||
Ride,
|
||||
Trade,
|
||||
Feed,
|
||||
Place,
|
||||
Eat,
|
||||
Drink,
|
||||
Shoot,
|
||||
Load,
|
||||
Throw,
|
||||
Raise,
|
||||
Equip,
|
||||
Fish,
|
||||
Zoom,
|
||||
Boost,
|
||||
}
|
||||
impl Verb {
|
||||
fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Verb::Use => "horizon.controller.guide.use",
|
||||
Verb::Open => "horizon.controller.guide.open",
|
||||
Verb::Ride => "horizon.controller.guide.ride",
|
||||
Verb::Trade => "horizon.controller.guide.trade",
|
||||
Verb::Feed => "horizon.controller.guide.feed",
|
||||
Verb::Place => "horizon.controller.guide.place",
|
||||
Verb::Eat => "horizon.controller.guide.eat",
|
||||
Verb::Drink => "horizon.controller.guide.drink",
|
||||
Verb::Shoot => "horizon.controller.guide.shoot",
|
||||
Verb::Load => "horizon.controller.guide.load",
|
||||
Verb::Throw => "horizon.controller.guide.throw",
|
||||
Verb::Raise => "horizon.controller.guide.raise",
|
||||
Verb::Equip => "horizon.controller.guide.equip",
|
||||
Verb::Fish => "horizon.controller.guide.fish",
|
||||
Verb::Zoom => "horizon.controller.guide.zoom",
|
||||
Verb::Boost => "horizon.controller.guide.boost",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.hitResult`, with what each button would do to it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
#[default]
|
||||
None,
|
||||
Block {
|
||||
breakable: bool,
|
||||
/// The block takes the use before the held item gets its turn.
|
||||
takes: Option<Verb>,
|
||||
},
|
||||
Entity {
|
||||
attackable: bool,
|
||||
takes: Option<Verb>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An item in use, by what letting go of it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Active {
|
||||
Shoot,
|
||||
Throw,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// What the guide knows of the player this moment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
pub spectator: bool,
|
||||
pub target: Target,
|
||||
/// What `Item.use` does on the target or in the air: the main hand's, else the off hand's.
|
||||
pub item_use: Option<Verb>,
|
||||
/// Pick Block finds something it can select or, in creative, add.
|
||||
pub can_pick: bool,
|
||||
pub using: Option<Active>,
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
pub hands_busy: bool,
|
||||
pub riding: bool,
|
||||
/// The vehicle jumps when told (a saddled horse).
|
||||
pub vehicle_jumps: bool,
|
||||
pub on_ground: bool,
|
||||
pub in_water: bool,
|
||||
pub flying: bool,
|
||||
pub may_fly: bool,
|
||||
pub gliding: bool,
|
||||
/// In the air with a glider on: a jump would start gliding.
|
||||
pub can_glide: bool,
|
||||
/// Sneaking that the button latched, so the next press stands up.
|
||||
pub sneak_latched: bool,
|
||||
pub can_sprint: bool,
|
||||
pub main_hand_item: bool,
|
||||
pub off_hand_item: bool,
|
||||
}
|
||||
|
||||
/// One button's line: the button and the translation key of what it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<Button>,
|
||||
pub button: Button,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
impl Row {
|
||||
fn new(button: Button, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: None,
|
||||
button,
|
||||
caption,
|
||||
}
|
||||
}
|
||||
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: Some(with),
|
||||
button,
|
||||
caption,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two columns, most useful row first.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Guide {
|
||||
/// Top left: movement and the player's own actions.
|
||||
pub left: Vec<Row>,
|
||||
/// Top right: break, use and pick block.
|
||||
pub right: Vec<Row>,
|
||||
}
|
||||
|
||||
impl Guide {
|
||||
/// The rows this detail keeps. Basic is the right column: what the buttons do to what's in
|
||||
/// front of the player, or the plain face buttons in an inventory.
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
GuideDetail::Basic => Guide {
|
||||
left: Vec::new(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
left: Vec::new(),
|
||||
right: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
|
||||
/// on Start, which is always pause.
|
||||
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
let row = |action: &str, caption: Option<&'static str>| {
|
||||
let button = settings
|
||||
.binding(action)
|
||||
.filter(|button| *button != Button::Start)?;
|
||||
Some(Row::new(button, caption?))
|
||||
};
|
||||
let player = !facts.spectator;
|
||||
let any_item = facts.main_hand_item || facts.off_hand_item;
|
||||
Guide {
|
||||
left: [
|
||||
row("key.jump", jump_caption(facts)),
|
||||
row("key.sneak", sneak_caption(facts)),
|
||||
row("key.sprint", facts.can_sprint.then_some("key.sprint")),
|
||||
row("key.inventory", player.then_some("key.inventory")),
|
||||
row(
|
||||
"key.drop",
|
||||
(player && facts.main_hand_item).then_some("horizon.controller.guide.drop"),
|
||||
),
|
||||
row(
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
right: [
|
||||
row("key.attack", attack_caption(facts)),
|
||||
row("key.use", use_caption(facts)),
|
||||
row("key.pickItem", pick_caption(facts)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.riding {
|
||||
f.vehicle_jumps.then_some("key.jump")
|
||||
} else if f.flying {
|
||||
Some("horizon.controller.guide.fly_up")
|
||||
} else if f.gliding {
|
||||
None
|
||||
} else if f.in_water {
|
||||
Some("horizon.controller.guide.swim_up")
|
||||
} else if f.on_ground {
|
||||
Some("key.jump")
|
||||
} else if f.can_glide {
|
||||
Some("horizon.controller.guide.glide")
|
||||
} else {
|
||||
f.may_fly.then_some("horizon.controller.guide.fly")
|
||||
}
|
||||
}
|
||||
|
||||
fn sneak_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.riding {
|
||||
Some("horizon.controller.guide.dismount")
|
||||
} else if f.flying {
|
||||
Some("horizon.controller.guide.fly_down")
|
||||
} else if f.gliding {
|
||||
None
|
||||
} else if f.in_water && !f.on_ground {
|
||||
Some("horizon.controller.guide.swim_down")
|
||||
} else if f.sneak_latched {
|
||||
Some("horizon.controller.guide.stand")
|
||||
} else {
|
||||
Some("key.sneak")
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.handleKeybinds` spends attack clicks while an item is in use, and
|
||||
/// `startAttack` does nothing with busy hands.
|
||||
fn attack_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.using.is_some() || f.hands_busy {
|
||||
return None;
|
||||
}
|
||||
match f.target {
|
||||
Target::Entity {
|
||||
attackable: true, ..
|
||||
} if f.spectator => Some("horizon.controller.guide.spectate"),
|
||||
Target::Entity {
|
||||
attackable: true, ..
|
||||
} => Some("horizon.controller.guide.attack"),
|
||||
Target::Block {
|
||||
breakable: true, ..
|
||||
} => Some("horizon.controller.guide.break"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Minecraft.startUseItem`: what is aimed at first, then the item.
|
||||
fn use_caption(f: &Facts) -> Option<&'static str> {
|
||||
match f.using {
|
||||
Some(Active::Shoot) => return Some("horizon.controller.guide.release_shoot"),
|
||||
Some(Active::Throw) => return Some("horizon.controller.guide.release_throw"),
|
||||
Some(Active::Stop) => return Some("horizon.controller.guide.release_stop"),
|
||||
None => {}
|
||||
}
|
||||
if f.hands_busy {
|
||||
return None;
|
||||
}
|
||||
let takes = match f.target {
|
||||
Target::Block { takes, .. } | Target::Entity { takes, .. } => takes,
|
||||
Target::None => None,
|
||||
};
|
||||
takes.or(f.item_use).map(Verb::caption)
|
||||
}
|
||||
|
||||
fn pick_caption(f: &Facts) -> Option<&'static str> {
|
||||
if f.spectator {
|
||||
// key.spectatorHotbar is this button: it opens the teleport menu.
|
||||
return Some("horizon.controller.guide.spectator_menu");
|
||||
}
|
||||
(f.can_pick && f.using.is_none()).then_some("key.pickItem")
|
||||
}
|
||||
|
||||
/// What the crosshair is on in an inventory.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Under {
|
||||
#[default]
|
||||
Nothing,
|
||||
/// A slot. `same` is the carried stack being its kind; `result` a crafting result, which is
|
||||
/// only ever taken whole.
|
||||
Slot {
|
||||
item: bool,
|
||||
same: bool,
|
||||
result: bool,
|
||||
/// The carried stack can go here.
|
||||
accepts: bool,
|
||||
},
|
||||
Recipe {
|
||||
direct_craft: bool,
|
||||
alternatives: bool,
|
||||
},
|
||||
/// One of the recipe book's other controls.
|
||||
Control,
|
||||
}
|
||||
|
||||
/// What an inventory's rows are resolved from.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MenuFacts {
|
||||
pub under: Under,
|
||||
pub carrying: bool,
|
||||
/// The recipe book is showing: the bumpers turn its pages, the triggers its categories.
|
||||
pub book: bool,
|
||||
/// The bumpers switch tabs.
|
||||
pub tabs: bool,
|
||||
}
|
||||
|
||||
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
|
||||
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
|
||||
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
|
||||
pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
|
||||
use Button::{Select, South, West};
|
||||
const TAKE: &str = "horizon.controller.guide.take";
|
||||
const MOVE: &str = "horizon.controller.guide.quick_move";
|
||||
let under = match f.under {
|
||||
// A result takes only into empty hands or the same stack.
|
||||
Under::Slot {
|
||||
item: true,
|
||||
result: true,
|
||||
same,
|
||||
..
|
||||
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
|
||||
Under::Slot { result: true, .. } => Vec::new(),
|
||||
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
|
||||
Under::Slot { item, same, .. } if f.carrying => vec![
|
||||
Row::new(
|
||||
South,
|
||||
if item && !same {
|
||||
"horizon.controller.guide.swap_stack"
|
||||
} else {
|
||||
"horizon.controller.guide.put"
|
||||
},
|
||||
),
|
||||
Row::new(West, "horizon.controller.guide.put_one"),
|
||||
],
|
||||
Under::Slot { item: true, .. } => vec![
|
||||
Row::new(South, TAKE),
|
||||
Row::new(West, "horizon.controller.guide.take_half"),
|
||||
Row::new(North, MOVE),
|
||||
Row::new(Select, "horizon.controller.guide.drop"),
|
||||
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
|
||||
],
|
||||
Under::Nothing | Under::Slot { .. } => Vec::new(),
|
||||
Under::Recipe {
|
||||
direct_craft,
|
||||
alternatives,
|
||||
} => [
|
||||
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
|
||||
direct_craft.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
South,
|
||||
"horizon.controller.direct_craft",
|
||||
)),
|
||||
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
|
||||
};
|
||||
let screen = [
|
||||
f.book.then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
"horizon.controller.guide.pages",
|
||||
)),
|
||||
f.book.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
"horizon.controller.guide.categories",
|
||||
)),
|
||||
(f.tabs && !f.book).then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
"horizon.controller.guide.tabs",
|
||||
)),
|
||||
];
|
||||
let close = Row::new(East, "horizon.controller.guide.close");
|
||||
let rows = std::iter::once(close)
|
||||
.chain(under)
|
||||
.chain(screen.into_iter().flatten());
|
||||
let (right, left) =
|
||||
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
|
||||
Guide { left, right }
|
||||
}
|
||||
|
||||
/// A row's height and the view's margin, in GUI pixels.
|
||||
pub const ROW_HEIGHT: f32 = 11.0;
|
||||
const MARGIN: f32 = 2.0;
|
||||
|
||||
fn overlap(a: &Rect, b: &Rect) -> bool {
|
||||
a[0] < b[0] + b[2] && b[0] < a[0] + a[2] && a[1] < b[1] + b[3] && b[1] < a[1] + a[3]
|
||||
}
|
||||
|
||||
/// Where a column's rows go, down from a top corner of the `[width, height]` view: under whatever
|
||||
/// a row would overlap in `taken`, or nowhere when that leaves the view's upper three quarters (the
|
||||
/// row is dropped: the rows come in the order they matter). The placed rows join `taken`.
|
||||
pub fn place_column(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
place(view, left, false, widths, taken)
|
||||
}
|
||||
|
||||
/// `place_column`'s rows up from a bottom corner, over what they would overlap, within the view's
|
||||
/// lower quarter: the first row is the lowest.
|
||||
pub fn place_column_up(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
place(view, left, true, widths, taken)
|
||||
}
|
||||
|
||||
fn place(
|
||||
view: [f32; 2],
|
||||
left: bool,
|
||||
up: bool,
|
||||
widths: &[f32],
|
||||
taken: &mut Vec<Rect>,
|
||||
) -> Vec<Option<Rect>> {
|
||||
let mut y = if up {
|
||||
view[1] - MARGIN - ROW_HEIGHT
|
||||
} else {
|
||||
MARGIN
|
||||
};
|
||||
widths
|
||||
.iter()
|
||||
.map(|&width| {
|
||||
if width > view[0] - 2.0 * MARGIN {
|
||||
return None;
|
||||
}
|
||||
let x = if left {
|
||||
MARGIN
|
||||
} else {
|
||||
view[0] - MARGIN - width
|
||||
};
|
||||
let mut at = y;
|
||||
loop {
|
||||
let outside = if up {
|
||||
at < view[1] * 0.75
|
||||
} else {
|
||||
at + ROW_HEIGHT > view[1] * 0.75
|
||||
};
|
||||
if outside {
|
||||
return None;
|
||||
}
|
||||
let rect = [x, at, width, ROW_HEIGHT];
|
||||
let hit = taken.iter().filter(|t| overlap(&rect, t));
|
||||
let past = if up {
|
||||
hit.map(|t| t[1]).fold(f32::INFINITY, f32::min)
|
||||
} else {
|
||||
hit.map(|t| t[1] + t[3]).fold(f32::NEG_INFINITY, f32::max)
|
||||
};
|
||||
if past.is_infinite() {
|
||||
taken.push(rect);
|
||||
y = if up {
|
||||
at - ROW_HEIGHT - 1.0
|
||||
} else {
|
||||
at + ROW_HEIGHT + 1.0
|
||||
};
|
||||
return Some(rect);
|
||||
}
|
||||
at = if up {
|
||||
past.floor() - ROW_HEIGHT - 1.0
|
||||
} else {
|
||||
past.ceil() + 1.0
|
||||
};
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn walking() -> Facts {
|
||||
Facts {
|
||||
on_ground: true,
|
||||
can_sprint: true,
|
||||
main_hand_item: true,
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
fn on_block(f: Facts, breakable: bool, takes: Option<Verb>) -> Facts {
|
||||
Facts {
|
||||
target: Target::Block { breakable, takes },
|
||||
..f
|
||||
}
|
||||
}
|
||||
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
|
||||
rows.iter().map(|r| (r.button, r.caption)).collect()
|
||||
}
|
||||
fn captions(rows: &[Row]) -> Vec<&'static str> {
|
||||
rows.iter().map(|r| r.caption).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survival_on_a_block_shows_break_use_and_pick_on_the_default_buttons() {
|
||||
let facts = Facts {
|
||||
can_pick: true,
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
[
|
||||
(Button::RightTrigger, "horizon.controller.guide.break"),
|
||||
(Button::LeftTrigger, "horizon.controller.guide.place"),
|
||||
(Button::DPadLeft, "key.pickItem"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&guide.left),
|
||||
[
|
||||
(Button::South, "key.jump"),
|
||||
(Button::RightStick, "key.sneak"),
|
||||
(Button::LeftStick, "key.sprint"),
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebound_action_moves_its_row_and_an_unbound_one_loses_it() {
|
||||
let mut settings = PadSettings::default();
|
||||
settings.bind("key.attack", Some(Button::North));
|
||||
settings.bind("key.pickItem", None);
|
||||
settings.bind("key.jump", Some(Button::Start));
|
||||
let facts = Facts {
|
||||
can_pick: true,
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
[(Button::North, "horizon.controller.guide.break")]
|
||||
);
|
||||
// Start only ever pauses.
|
||||
assert!(!captions(&guide.left).contains(&"key.jump"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_block_or_entity_takes_comes_before_the_held_item() {
|
||||
let settings = PadSettings::default();
|
||||
let chest = Facts {
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, Some(Verb::Open))
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&chest, &settings).right),
|
||||
[
|
||||
"horizon.controller.guide.break",
|
||||
"horizon.controller.guide.open"
|
||||
]
|
||||
);
|
||||
let villager = Facts {
|
||||
item_use: Some(Verb::Eat),
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: Some(Verb::Trade),
|
||||
},
|
||||
..walking()
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&villager, &settings).right),
|
||||
[
|
||||
"horizon.controller.guide.attack",
|
||||
"horizon.controller.guide.trade"
|
||||
]
|
||||
);
|
||||
// A mob the item means nothing to: the item is used on.
|
||||
let cow = Facts {
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: None,
|
||||
},
|
||||
..villager
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&cow, &settings).right)[1],
|
||||
"horizon.controller.guide.eat"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_do_leaves_the_right_column_empty() {
|
||||
let settings = PadSettings::default();
|
||||
assert!(resolve(&walking(), &settings).right.is_empty());
|
||||
// An unbreakable block too (bedrock, a creative sword, adventure without can_break).
|
||||
assert!(
|
||||
resolve(&on_block(walking(), false, None), &settings)
|
||||
.right
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_in_use_leaves_only_letting_go() {
|
||||
let facts = Facts {
|
||||
using: Some(Active::Shoot),
|
||||
can_pick: true,
|
||||
..on_block(walking(), true, Some(Verb::Open))
|
||||
};
|
||||
assert_eq!(
|
||||
captions(&resolve(&facts, &PadSettings::default()).right),
|
||||
["horizon.controller.guide.release_shoot"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_hands_neither_attack_nor_use() {
|
||||
let facts = Facts {
|
||||
hands_busy: true,
|
||||
riding: true,
|
||||
item_use: Some(Verb::Eat),
|
||||
..on_block(Facts::default(), true, None)
|
||||
};
|
||||
assert!(resolve(&facts, &PadSettings::default()).right.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_follows_what_the_player_is_doing() {
|
||||
let settings = PadSettings::default();
|
||||
let first_two = |f: Facts| {
|
||||
let left = resolve(&f, &settings).left;
|
||||
captions(&left).into_iter().take(2).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
flying: true,
|
||||
may_fly: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
in_water: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
[
|
||||
"horizon.controller.guide.swim_up",
|
||||
"horizon.controller.guide.swim_down"
|
||||
]
|
||||
);
|
||||
// In the air, only a glider or creative flight makes a jump do anything.
|
||||
assert_eq!(first_two(Facts::default()), ["key.sneak", "key.inventory"]);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
can_glide: true,
|
||||
..Facts::default()
|
||||
})[0],
|
||||
"horizon.controller.guide.glide"
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
sneak_latched: true,
|
||||
on_ground: true,
|
||||
..Facts::default()
|
||||
})[1],
|
||||
"horizon.controller.guide.stand"
|
||||
);
|
||||
// A boat can't jump; a saddled horse can; both dismount.
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
riding: true,
|
||||
on_ground: true,
|
||||
..Facts::default()
|
||||
})[0],
|
||||
"horizon.controller.guide.dismount"
|
||||
);
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
riding: true,
|
||||
vehicle_jumps: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.jump", "horizon.controller.guide.dismount"]
|
||||
);
|
||||
// Gliding: neither jump nor sneak does anything.
|
||||
assert_eq!(
|
||||
first_two(Facts {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
may_fly: true,
|
||||
main_hand_item: true,
|
||||
target: Target::Entity {
|
||||
attackable: true,
|
||||
takes: None,
|
||||
},
|
||||
..Facts::default()
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
assert_eq!(
|
||||
captions(&guide.right),
|
||||
[
|
||||
"horizon.controller.guide.spectate",
|
||||
"horizon.controller.guide.spectator_menu"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_stack_from_their_corner() {
|
||||
let mut taken = Vec::new();
|
||||
let left = place_column([200.0, 200.0], true, &[50.0, 30.0], &mut taken);
|
||||
assert_eq!(left[0], Some([2.0, 2.0, 50.0, 11.0]));
|
||||
assert_eq!(left[1], Some([2.0, 14.0, 30.0, 11.0]));
|
||||
let right = place_column([200.0, 200.0], false, &[40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 2.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_goes_under_what_it_would_overlap() {
|
||||
// The effect icons in the top right corner.
|
||||
let mut taken = vec![[150.0, 1.0, 50.0, 24.0]];
|
||||
let right = place_column([200.0, 200.0], false, &[40.0, 40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 26.0, 40.0, 11.0]));
|
||||
assert_eq!(right[1], Some([158.0, 38.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_columns_of_a_narrow_view_never_overlap_and_late_rows_drop() {
|
||||
// One quarter of a small window, a boss bar across its top: the rows have 31..67 left.
|
||||
let view = [160.0, 90.0];
|
||||
let bar = [0.0, 12.0, 160.0, 19.0];
|
||||
let mut taken = vec![bar];
|
||||
let right = place_column(view, false, &[90.0, 90.0, 90.0], &mut taken);
|
||||
let left = place_column(view, true, &[90.0; 6], &mut taken);
|
||||
let placed: Vec<Rect> = right.iter().chain(&left).flatten().copied().collect();
|
||||
for (i, a) in placed.iter().enumerate() {
|
||||
assert!(a[1] + a[3] <= 67.5 && a[0] >= 0.0 && a[0] + a[2] <= 160.0);
|
||||
assert!(!overlap(a, &bar));
|
||||
for b in &placed[i + 1..] {
|
||||
assert!(!overlap(a, b), "{a:?} overlaps {b:?}");
|
||||
}
|
||||
}
|
||||
// Three rows' worth of room, and the right column came first.
|
||||
assert_eq!(right.iter().flatten().count(), 3);
|
||||
assert_eq!(left.iter().flatten().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_stack_up_from_a_bottom_corner_and_drop_above_its_quarter() {
|
||||
let view = [200.0, 100.0];
|
||||
let mut taken = vec![[0.0, 80.0, 60.0, 5.0]];
|
||||
let left = place_column_up(view, true, &[50.0, 50.0], &mut taken);
|
||||
assert_eq!(left[0], Some([2.0, 87.0, 50.0, 11.0]));
|
||||
// Over the panel at 80 would be 68, under the lower quarter's 75.
|
||||
assert_eq!(left[1], None);
|
||||
let right = place_column_up(view, false, &[40.0], &mut taken);
|
||||
assert_eq!(right[0], Some([158.0, 87.0, 40.0, 11.0]));
|
||||
}
|
||||
|
||||
fn short(rows: &[Row]) -> Vec<&str> {
|
||||
rows.iter()
|
||||
.map(|r| r.caption.rsplit('.').next().unwrap_or(""))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventorys_face_buttons_go_right_and_the_rest_left() {
|
||||
let on = |under, carrying| {
|
||||
resolve_menu(&MenuFacts {
|
||||
under,
|
||||
carrying,
|
||||
..MenuFacts::default()
|
||||
})
|
||||
};
|
||||
let slot = |item, same, result| Under::Slot {
|
||||
item,
|
||||
same,
|
||||
result,
|
||||
accepts: true,
|
||||
};
|
||||
let close_only = ["close"];
|
||||
let empty: [&str; 0] = [];
|
||||
for quiet in [Under::Nothing, slot(false, false, false)] {
|
||||
let guide = on(quiet, false);
|
||||
assert_eq!(short(&guide.right), close_only);
|
||||
assert_eq!(short(&guide.left), empty);
|
||||
}
|
||||
let item = on(slot(true, false, false), false);
|
||||
assert_eq!(
|
||||
short(&item.right),
|
||||
["close", "take", "take_half", "quick_move"]
|
||||
);
|
||||
assert_eq!(short(&item.left), ["drop", "drop_stack"]);
|
||||
assert_eq!(
|
||||
short(&on(slot(false, false, false), true).right),
|
||||
["close", "put", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, false, false), true).right),
|
||||
["close", "swap_stack", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, true, false), true).right),
|
||||
["close", "put", "put_one"]
|
||||
);
|
||||
assert_eq!(
|
||||
short(&on(slot(true, false, true), false).right),
|
||||
["close", "take", "quick_move"]
|
||||
);
|
||||
let recipe = on(
|
||||
Under::Recipe {
|
||||
direct_craft: true,
|
||||
alternatives: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
short(&recipe.right),
|
||||
["close", "place_recipe", "place_recipe_all", "alternatives"]
|
||||
);
|
||||
assert_eq!(recipe.left.len(), 1);
|
||||
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
|
||||
assert_eq!(recipe.left[0].button, Button::South);
|
||||
let plain = on(
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
alternatives: false,
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
short(&plain.right),
|
||||
["close", "place_recipe", "place_recipe_all"]
|
||||
);
|
||||
assert!(plain.left.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inventorys_screen_rows_follow_its_book_and_tabs() {
|
||||
let left = |book, tabs| {
|
||||
resolve_menu(&MenuFacts {
|
||||
book,
|
||||
tabs,
|
||||
..MenuFacts::default()
|
||||
})
|
||||
.left
|
||||
};
|
||||
assert!(left(false, false).is_empty());
|
||||
assert_eq!(short(&left(true, false)), ["pages", "categories"]);
|
||||
assert_eq!(short(&left(false, true)), ["tabs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_wider_than_the_view_is_dropped() {
|
||||
let mut taken = Vec::new();
|
||||
assert_eq!(
|
||||
place_column([50.0, 200.0], true, &[60.0, 20.0], &mut taken),
|
||||
[None, Some([2.0, 2.0, 20.0, 11.0])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod detail_tests {
|
||||
use super::*;
|
||||
|
||||
fn rows() -> Guide {
|
||||
Guide {
|
||||
left: vec![Row::new(Button::South, "left")],
|
||||
right: vec![Row::new(Button::East, "right")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_keeps_both_columns() {
|
||||
let guide = rows().at_detail(GuideDetail::Full);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_keeps_the_right_column() {
|
||||
let guide = rows().at_detail(GuideDetail::Basic);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_keeps_nothing() {
|
||||
let guide = rows().at_detail(GuideDetail::Off);
|
||||
assert_eq!((guide.left.len(), guide.right.len()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_saved_with_the_old_switch_keeps_its_meaning() {
|
||||
assert_eq!(GuideDetail::parse("true"), GuideDetail::Full);
|
||||
assert_eq!(GuideDetail::parse("false"), GuideDetail::Off);
|
||||
assert_eq!(GuideDetail::parse("basic"), GuideDetail::Basic);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod slot_tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows for the slot: South or West, whichever column they're in.
|
||||
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
|
||||
let guide = resolve_menu(&MenuFacts {
|
||||
under: Under::Slot {
|
||||
item,
|
||||
same,
|
||||
result,
|
||||
accepts,
|
||||
},
|
||||
carrying,
|
||||
..MenuFacts::default()
|
||||
});
|
||||
[guide.left, guide.right]
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|row| matches!(row.button, Button::South | Button::West))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_that_refuses_the_carried_stack_offers_nothing() {
|
||||
assert_eq!(rows(false, false, false, false, true), 0);
|
||||
assert_eq!(rows(true, true, false, false, true), 0);
|
||||
assert!(rows(false, false, false, true, true) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_is_taken_only_into_empty_hands_or_the_same_stack() {
|
||||
assert!(rows(true, false, true, true, false) > 0);
|
||||
assert!(rows(true, true, true, true, true) > 0);
|
||||
assert_eq!(rows(true, false, true, true, true), 0);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod actions;
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
|
||||
@@ -393,38 +393,17 @@ impl FullscreenState {
|
||||
let reapply = self.set_exclusive(exclusive);
|
||||
self.set_preferred_mode(preferred);
|
||||
let mode_change = !in_video_screen && self.take_mode_change();
|
||||
if wanted && window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => enter_fullscreen(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
} else {
|
||||
match request {
|
||||
Some(false) => window.set_fullscreen(None),
|
||||
Some(true) => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None if reapply || mode_change => {
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen_request(
|
||||
window,
|
||||
exclusive,
|
||||
preferred.as_ref(),
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None if reapply || mode_change => enter_fullscreen(
|
||||
window,
|
||||
fullscreen_request(window, exclusive, preferred.as_ref()),
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +581,34 @@ fn monitor_current_mode(
|
||||
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
|
||||
}
|
||||
|
||||
/// `window.set_fullscreen(Some(fullscreen))`. On Windows a maximized window leaves its maximized
|
||||
/// state first: winit keeps a maximized window's client area inside the work area, so a
|
||||
/// borderless fullscreen kept the taskbar over its bottom and pushed its top off screen (#9984).
|
||||
pub fn enter_fullscreen(window: &winit::window::Window, fullscreen: winit::window::Fullscreen) {
|
||||
#[cfg(target_os = "windows")]
|
||||
if window.is_maximized() {
|
||||
window.set_maximized(false);
|
||||
}
|
||||
window.set_fullscreen(Some(fullscreen));
|
||||
}
|
||||
|
||||
/// Borderless fullscreen on the window's own monitor (the primary one when it has none).
|
||||
pub fn borderless_fullscreen(window: &winit::window::Window) -> winit::window::Fullscreen {
|
||||
winit::window::Fullscreen::Borderless(
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor()),
|
||||
)
|
||||
}
|
||||
|
||||
/// A window created fullscreen that the system also marked maximized enters it again
|
||||
/// unmaximized ([`enter_fullscreen`]); only Windows does that.
|
||||
pub fn settle_created_fullscreen(window: &winit::window::Window, fullscreen: bool) {
|
||||
if cfg!(target_os = "windows") && fullscreen && window.is_maximized() {
|
||||
enter_fullscreen(window, borderless_fullscreen(window));
|
||||
}
|
||||
}
|
||||
|
||||
/// `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.
|
||||
|
||||
@@ -6,7 +6,7 @@ autotests = false
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "Reusable client systems, control tool and parity probes"
|
||||
|
||||
[lib]
|
||||
@@ -42,7 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -73,9 +74,10 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
@@ -112,12 +114,18 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers"]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
|
||||
heartbeat = ["horizon-telemetry/live"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "horizon-client-render"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.90"
|
||||
rust-version.workspace = true
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
@@ -26,7 +26,7 @@ horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Headless production brewing-fuel component fixture.
|
||||
use horizon_client_render::{
|
||||
brewing::{Brewing, BrewingStand, brewing_fuel},
|
||||
providers::Providers,
|
||||
};
|
||||
use horizon_items::{
|
||||
item_data::decode_components,
|
||||
items::{ItemStack, Items},
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{
|
||||
io::{BufRead, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let root = Path::new(&args[1]);
|
||||
let items = Items::catalog(root, &PackOverlay::NONE)?;
|
||||
let providers = Providers::load(root);
|
||||
let brewing = Brewing {
|
||||
recipes: vec![],
|
||||
reagents: Default::default(),
|
||||
inputs: Default::default(),
|
||||
};
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3])?);
|
||||
for line in std::io::BufReader::new(std::fs::File::open(&args[2])?).lines() {
|
||||
let input: serde_json::Value = serde_json::from_str(&line?)?;
|
||||
let mut stack = ItemStack::new(
|
||||
input["item"].as_str().unwrap(),
|
||||
input["count"].as_u64().unwrap() as u32,
|
||||
);
|
||||
let components = horizon_client_core::scoreboard::json_to_tag(&input["components"]);
|
||||
let (_, extra) = decode_components(Some(&components));
|
||||
stack.extra = extra;
|
||||
let accepted = brewing_fuel(&items, &stack).is_some();
|
||||
let mut stand = BrewingStand::new();
|
||||
// A newly constructed vanilla block entity has no saved total yet.
|
||||
stand.total_fuel = 0;
|
||||
stand.items[4] = Some(stack);
|
||||
for _ in 0..2 {
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
writeln!(
|
||||
out,
|
||||
"{} {} {} {} {}",
|
||||
accepted,
|
||||
stand.fuel,
|
||||
stand.total_fuel,
|
||||
stand.speed.to_bits(),
|
||||
stand.items[4].as_ref().map_or(0, |s| s.count)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -67,6 +67,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
|
||||
@@ -225,6 +225,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
|
||||
@@ -127,6 +127,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
assert!(
|
||||
@@ -349,6 +350,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -261,6 +261,40 @@ pub struct Audio {
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
/// The look at the output devices running on its own thread, until a tick
|
||||
/// takes its answer: listing them can take 100 ms (#11490).
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: Option<PendingProbe>,
|
||||
}
|
||||
/// What one look at the output devices found: the system default, the
|
||||
/// fingerprint, and the device list when those say it may have changed.
|
||||
#[cfg(feature = "audio")]
|
||||
struct DeviceProbe {
|
||||
default: Option<String>,
|
||||
cards: Option<String>,
|
||||
devices: Option<Vec<String>>,
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
type PendingProbe = Arc<Mutex<Option<DeviceProbe>>>;
|
||||
/// Looks at the output devices as `should_change_device` did on the render
|
||||
/// thread: the device list only when the default or the fingerprint moved.
|
||||
#[cfg(feature = "audio")]
|
||||
fn probe_devices(default_seen: Option<String>, cards_seen: Option<String>) -> DeviceProbe {
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them; on Windows
|
||||
// every endpoint, with no fingerprint to skip it); only the fingerprint's
|
||||
// devices can change the list, and it is cheap.
|
||||
let devices = (default != default_seen
|
||||
|| cards_changed(cards_seen.as_deref(), cards.as_deref()))
|
||||
.then(output_devices);
|
||||
DeviceProbe {
|
||||
default,
|
||||
cards,
|
||||
devices,
|
||||
}
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32, u16)> {
|
||||
use symphonia::core::{
|
||||
@@ -1051,6 +1085,8 @@ impl Audio {
|
||||
devices_seen: devices,
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -1211,6 +1247,8 @@ impl Audio {
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
@@ -1232,7 +1270,8 @@ impl Audio {
|
||||
/// `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.
|
||||
/// at once a second, on a thread of their own: a later tick reads what it
|
||||
/// found.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
// A splitscreen share plays through the first player's output: it leaves the device,
|
||||
@@ -1246,29 +1285,54 @@ impl Audio {
|
||||
};
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.device_probe = None;
|
||||
self.default_seen = default_output_name();
|
||||
self.cards_seen = card_fingerprint();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
if let Some(pending) = &self.device_probe {
|
||||
let Some(probe) = pending.lock().ok().and_then(|mut found| found.take()) else {
|
||||
return false;
|
||||
};
|
||||
self.device_probe = None;
|
||||
return self.devices_changed(probe, current);
|
||||
}
|
||||
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 cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them); only the
|
||||
// fingerprint's devices can change the list, and it is cheap.
|
||||
if default == self.default_seen
|
||||
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
|
||||
{
|
||||
return false;
|
||||
let pending = PendingProbe::default();
|
||||
let out = Arc::clone(&pending);
|
||||
let seen = (self.default_seen.clone(), self.cards_seen.clone());
|
||||
let started = std::thread::Builder::new()
|
||||
.name("audio-devices".into())
|
||||
.spawn(move || {
|
||||
let probe = probe_devices(seen.0, seen.1);
|
||||
if let Ok(mut found) = out.lock() {
|
||||
*found = Some(probe);
|
||||
}
|
||||
});
|
||||
match started {
|
||||
Ok(_) => self.device_probe = Some(pending),
|
||||
Err(e) => log::warn!("Horizon: could not look at the audio devices: {e}"),
|
||||
}
|
||||
false
|
||||
}
|
||||
/// The rest of `shouldChangeDevice` with what a look at the devices found,
|
||||
/// `current` being the device playing now.
|
||||
#[cfg(feature = "audio")]
|
||||
fn devices_changed(&mut self, probe: DeviceProbe, current: String) -> bool {
|
||||
let DeviceProbe {
|
||||
default,
|
||||
cards,
|
||||
devices,
|
||||
} = probe;
|
||||
let Some(devices) = devices else {
|
||||
return false;
|
||||
};
|
||||
self.cards_seen = cards;
|
||||
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 {
|
||||
|
||||
@@ -114,12 +114,8 @@ mod tests {
|
||||
ambient_occlusion: true,
|
||||
particle: None,
|
||||
cube: None,
|
||||
faces: vec![BakedFace {
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
quad: ModelQuad {
|
||||
faces: vec![BakedFace::new(
|
||||
ModelQuad {
|
||||
positions: [
|
||||
[0.0, 0.5, 0.0],
|
||||
[0.0, 0.5, 1.0],
|
||||
@@ -142,7 +138,11 @@ mod tests {
|
||||
render_type: None,
|
||||
shade: true,
|
||||
},
|
||||
}],
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
)],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -387,8 +387,21 @@ impl BrewingStand {
|
||||
out.brewed = true;
|
||||
}
|
||||
}
|
||||
/// The `minecraft:brewing_fuel` providers of an item: (uses, speed_multiplier).
|
||||
/// `ItemStack.get(BREWING_FUEL)`: the stack's providers (uses, speed_multiplier),
|
||||
/// respecting component patches before falling back to the item's defaults.
|
||||
pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)> {
|
||||
if let Some(data) = stack.data() {
|
||||
if data.other("!minecraft:brewing_fuel").is_some() {
|
||||
return None;
|
||||
}
|
||||
if let Some(own) = data.other("minecraft:brewing_fuel") {
|
||||
let component = horizon_client_core::scoreboard::tag_to_json(own);
|
||||
return Some((
|
||||
component["uses"].clone(),
|
||||
component["speed_multiplier"].clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
items.get(&stack.item).and_then(|i| i.brewing_fuel.clone())
|
||||
}
|
||||
|
||||
@@ -396,6 +409,50 @@ pub fn brewing_fuel(items: &Items, stack: &ItemStack) -> Option<(Value, Value)>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stack_fuel_controls_brew_duration_and_consumption() {
|
||||
let items = Items::for_tests(Default::default());
|
||||
let brewing = Brewing {
|
||||
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
inputs: HashSet::new(),
|
||||
recipes: vec![BrewingRecipe {
|
||||
id: "awkward".into(),
|
||||
input: PotionIngredient {
|
||||
items: ["minecraft:potion".to_owned()].into_iter().collect(),
|
||||
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
|
||||
},
|
||||
reagent: PotionIngredient {
|
||||
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
potions: None,
|
||||
},
|
||||
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
|
||||
}],
|
||||
};
|
||||
let mut fuel = ItemStack::new("minecraft:stone", 2);
|
||||
fuel.data_mut().set_loot_component(
|
||||
"minecraft:brewing_fuel",
|
||||
&serde_json::json!({"uses": 7, "speed_multiplier": 2.5}),
|
||||
);
|
||||
let mut stand = BrewingStand::new();
|
||||
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
|
||||
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
|
||||
stand.items[4] = Some(fuel);
|
||||
let providers = Providers::empty();
|
||||
stand.tick(&brewing, &items, &providers);
|
||||
assert_eq!((stand.brew_time, stand.fuel, stand.total_fuel), (160, 6, 7));
|
||||
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
|
||||
for _ in 1..160 {
|
||||
assert!(!stand.tick(&brewing, &items, &providers).brewed);
|
||||
}
|
||||
assert!(stand.tick(&brewing, &items, &providers).brewed);
|
||||
assert_eq!(
|
||||
stand.items[0].as_ref().unwrap().potion(),
|
||||
Some("minecraft:awkward")
|
||||
);
|
||||
assert_eq!(stand.fuel, 6);
|
||||
assert_eq!(stand.items[4].as_ref().unwrap().count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn potion_colours_and_names() {
|
||||
// Swiftness: speed colour 3402751 = 0x33EBFF.
|
||||
|
||||
@@ -1188,6 +1188,18 @@ impl SuggestLayout {
|
||||
};
|
||||
}
|
||||
|
||||
/// A suggestion accepted by Tab or a click (`SuggestionsList.useSuggestion`).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct SuggestionCompletion {
|
||||
/// The suggestion list's original input, also when cycling with Tab.
|
||||
pub original: String,
|
||||
/// The replaced range in `original`, as Java UTF-16 start/end offsets.
|
||||
pub range: (usize, usize),
|
||||
pub text: String,
|
||||
/// The resulting input, after the text field's length limit.
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -1211,6 +1223,7 @@ pub struct ChatScreen {
|
||||
/// the command up to the cursor.
|
||||
request: Option<(i32, String)>,
|
||||
suggestions: Option<SuggestionList>,
|
||||
completion: Option<SuggestionCompletion>,
|
||||
usage: Vec<Text>,
|
||||
usage_position: i32,
|
||||
usage_width: i32,
|
||||
@@ -1333,6 +1346,7 @@ impl ChatScreen {
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
completion: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
@@ -1370,6 +1384,7 @@ impl ChatScreen {
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
completion: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
@@ -1385,6 +1400,11 @@ impl ChatScreen {
|
||||
pub fn value(&self) -> &str {
|
||||
&self.input.value
|
||||
}
|
||||
/// Take the most recently accepted suggestion, at most once. Call after
|
||||
/// each key or click to observe every acceptance, including Tab cycling.
|
||||
pub fn take_suggestion_completion(&mut self) -> Option<SuggestionCompletion> {
|
||||
self.completion.take()
|
||||
}
|
||||
/// Replace the input for a suggest-command link and refresh completions.
|
||||
pub fn suggest_command(&mut self, m: &Measure, c: &Commands, command: &str) {
|
||||
self.input.set_value(m, command);
|
||||
@@ -1434,6 +1454,24 @@ impl ChatScreen {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Edit the existing input and refresh its command responder when it changes.
|
||||
pub fn edit_input(&mut self, m: &Measure, c: &Commands, f: impl FnOnce(&mut TextField)) {
|
||||
let before = (
|
||||
self.input.value.clone(),
|
||||
self.input.cursor,
|
||||
self.input.highlight,
|
||||
);
|
||||
f(&mut self.input);
|
||||
if before
|
||||
!= (
|
||||
self.input.value.clone(),
|
||||
self.input.cursor,
|
||||
self.input.highlight,
|
||||
)
|
||||
{
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
/// Typed text (`charTyped` per character).
|
||||
pub fn type_text(&mut self, m: &Measure, c: &Commands, text: &str) {
|
||||
for ch in text.chars() {
|
||||
@@ -1733,6 +1771,17 @@ impl ChatScreen {
|
||||
self.select(l, l.current as isize);
|
||||
self.keep_suggestions = false;
|
||||
l.tab_cycles = true;
|
||||
let units = |byte: usize| {
|
||||
l.original[..byte.min(l.original.len())]
|
||||
.encode_utf16()
|
||||
.count()
|
||||
};
|
||||
self.completion = Some(SuggestionCompletion {
|
||||
original: l.original.clone(),
|
||||
range: (units(s.range.0), units(s.range.1)),
|
||||
text: s.text,
|
||||
input: self.input.value.clone(),
|
||||
});
|
||||
}
|
||||
/// `ChatScreen.keyPressed`.
|
||||
pub fn key(
|
||||
@@ -2022,6 +2071,11 @@ impl ChatScreen {
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
// A row for the pad: nothing else can pick a suggestion without Tab.
|
||||
crate::widgets::target_as(
|
||||
[rx as f32, y as f32, rw as f32, 12.0],
|
||||
crate::widgets::Kind::Row,
|
||||
);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -7,6 +7,85 @@ fn full() -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::Full)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_suggestions_report_utf16_ranges_and_tab_cycles() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries {
|
||||
players: vec!["Steve".into(), "Steven".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.type_text(&m, &c, "😀 hi st");
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
screen.key(&m, &c, &[], K::Tab, false, false);
|
||||
assert!(
|
||||
screen.take_suggestion_completion().is_none(),
|
||||
"opening is not accepting"
|
||||
);
|
||||
for (shift, text) in [(false, "Steve"), (false, "Steven"), (true, "Steve")] {
|
||||
screen.key(&m, &c, &[], K::Tab, shift, false);
|
||||
assert_eq!(screen.value(), format!("😀 hi {text}"));
|
||||
assert_eq!(
|
||||
screen.take_suggestion_completion(),
|
||||
Some(SuggestionCompletion {
|
||||
original: "😀 hi st".into(),
|
||||
range: (6, 8),
|
||||
text: text.into(),
|
||||
input: format!("😀 hi {text}"),
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
screen.take_suggestion_completion().is_none(),
|
||||
"reported once"
|
||||
);
|
||||
}
|
||||
screen.key(&m, &c, &[], K::ArrowDown, false, false);
|
||||
assert!(
|
||||
screen.take_suggestion_completion().is_none(),
|
||||
"selection is not accepting"
|
||||
);
|
||||
screen.key(&m, &c, &[], K::Escape, false, false);
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_suggestion_reports_the_actual_length_limited_input() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let font = font();
|
||||
let m = Measure(&font);
|
||||
let registries = Registries {
|
||||
players: vec!["Steve".into()],
|
||||
..Registries::default()
|
||||
};
|
||||
let c = Commands {
|
||||
dispatcher: crate::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
|
||||
screen.input.max_length = 6;
|
||||
screen.type_text(&m, &c, "hi st");
|
||||
screen.key(&m, &c, &[], K::Tab, false, false);
|
||||
let [x, y, _, _] = screen.suggestions.as_ref().unwrap().rect;
|
||||
assert!(screen.click(&m, &c, f64::from(x + 1), f64::from(y + 1)));
|
||||
assert_eq!(screen.value(), "hi Ste");
|
||||
assert_eq!(
|
||||
screen.take_suggestion_completion(),
|
||||
Some(SuggestionCompletion {
|
||||
original: "hi st".into(),
|
||||
range: (3, 5),
|
||||
text: "Steve".into(),
|
||||
input: "hi Ste".into(),
|
||||
})
|
||||
);
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
}
|
||||
|
||||
fn font() -> Font {
|
||||
// The missing-glyph metrics let these tests run without game assets.
|
||||
Font::load(
|
||||
@@ -483,8 +562,18 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt");
|
||||
screen.server_suggestions(&m, &c, request, (13, 2), reply("pt6:check"));
|
||||
assert!(screen.take_suggestion_completion().is_none());
|
||||
tab(&mut screen);
|
||||
assert_eq!(screen.value(), "/bossbar get pt6:check");
|
||||
assert_eq!(
|
||||
screen.take_suggestion_completion(),
|
||||
Some(SuggestionCompletion {
|
||||
original: "/bossbar get pt".into(),
|
||||
range: (13, 15),
|
||||
text: "pt6:check".into(),
|
||||
input: "/bossbar get pt6:check".into(),
|
||||
})
|
||||
);
|
||||
// Without the server's provider the local list is done at once.
|
||||
let local = crate::command::vanilla::game_dispatcher();
|
||||
let c = Commands {
|
||||
|
||||
@@ -11,7 +11,7 @@ use horizon_world::{
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, Mutex, OnceLock, PoisonError,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
@@ -108,6 +108,9 @@ pub enum BlockEntity {
|
||||
pub struct ModBlockEntity {
|
||||
fields: ModBlockEntityFields,
|
||||
revision: ModRevision,
|
||||
// A client update can consume transient effects without changing saved
|
||||
// fields. Most entities never use this; do not allocate their queue.
|
||||
client_update_fields: OnceLock<Mutex<Compound>>,
|
||||
}
|
||||
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
@@ -138,11 +141,55 @@ impl ModBlockEntity {
|
||||
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
|
||||
changes: 0,
|
||||
},
|
||||
client_update_fields: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
pub fn revision(&self) -> ModRevision {
|
||||
self.revision
|
||||
}
|
||||
/// Replace the owner's pending, one-shot client update fields. These
|
||||
/// fields are not saved, cloned, compared or retained for drawing, and
|
||||
/// changing them does not change this entity's revision. The owner must
|
||||
/// explicitly take and merge them into its update tag. Calling this does
|
||||
/// not schedule a block update.
|
||||
///
|
||||
/// An empty batch clears a pending batch without allocating an idle queue.
|
||||
pub fn set_client_update_fields(&mut self, fields: Compound) {
|
||||
if fields.is_empty() {
|
||||
self.clear_client_update_fields();
|
||||
return;
|
||||
}
|
||||
*self
|
||||
.client_update_fields
|
||||
.get_or_init(|| Mutex::new(Compound::new()))
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = fields;
|
||||
}
|
||||
/// Discard the owner's pending client update fields, without changing
|
||||
/// saved data, revision or a queue that has never been used.
|
||||
pub fn clear_client_update_fields(&mut self) {
|
||||
if let Some(fields) = self.client_update_fields.get() {
|
||||
fields
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clear();
|
||||
}
|
||||
}
|
||||
/// Atomically take the owner's pending fields at update-tag construction.
|
||||
/// A loaded chunk and a block change use this same boundary. Construct a
|
||||
/// tag once, then send that tag to every recipient; another construction
|
||||
/// sees an empty batch. A save or a clone does not consume this batch.
|
||||
///
|
||||
/// This is safe for an update-tag hook receiving only a shared reference,
|
||||
/// including a saved chunk encoded by a worker thread. If a previous lock
|
||||
/// holder panicked, preserve its pending data instead of dropping it.
|
||||
pub fn take_client_update_fields(&self) -> Compound {
|
||||
self.client_update_fields
|
||||
.get()
|
||||
.map_or_else(Compound::new, |fields| {
|
||||
std::mem::take(&mut *fields.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
})
|
||||
}
|
||||
/// A client's copy loads the tag the server sent (`loadWithComponents`
|
||||
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
|
||||
/// replace its own of the same names. One it already has as sent is
|
||||
@@ -706,6 +753,85 @@ pub struct CommandBlock {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transient_client_update_fields_are_lazy_replaceable_and_drained_once() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "test:machine".into(),
|
||||
nbt: Compound::from([("progress".into(), Tag::Int(7))]),
|
||||
..Default::default()
|
||||
});
|
||||
let revision = machine.revision();
|
||||
let saved = machine.fields.clone();
|
||||
let debug = format!("{machine:?}");
|
||||
machine.clear_client_update_fields();
|
||||
machine.set_client_update_fields(Compound::new());
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
assert!(machine.client_update_fields.get().is_none());
|
||||
|
||||
machine.set_client_update_fields(Compound::from([("discarded".into(), Tag::Int(1))]));
|
||||
let expected = Compound::from([("effects".into(), Tag::List(vec![Tag::Int(2)]))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
let copy = machine.clone();
|
||||
assert_eq!(machine, copy, "transient fields are not retained contents");
|
||||
assert_ne!(machine.revision(), copy.revision());
|
||||
assert!(copy.client_update_fields.get().is_none());
|
||||
assert!(copy.take_client_update_fields().is_empty());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
machine.set_client_update_fields(expected);
|
||||
machine.clear_client_update_fields();
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
assert_eq!(machine.fields, saved);
|
||||
assert_eq!(machine.revision(), revision);
|
||||
assert_eq!(format!("{machine:?}"), debug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_client_update_constructions_consume_one_batch() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
let received = std::thread::scope(|scope| {
|
||||
let handles = (0..16)
|
||||
.map(|_| scope.spawn(|| machine.take_client_update_fields()))
|
||||
.collect::<Vec<_>>();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| h.join().unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let nonempty = received
|
||||
.into_iter()
|
||||
.filter(|c| !c.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(nonempty, [expected]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poisoned_client_update_lock_preserves_its_pending_batch() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let expected = Compound::from([("effects".into(), Tag::Int(23))]);
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
std::thread::scope(|scope| {
|
||||
assert!(
|
||||
scope
|
||||
.spawn(|| {
|
||||
let _held = machine.client_update_fields.get().unwrap().lock().unwrap();
|
||||
panic!("interrupt before consuming the queued batch");
|
||||
})
|
||||
.join()
|
||||
.is_err()
|
||||
);
|
||||
});
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
assert!(machine.take_client_update_fields().is_empty());
|
||||
machine.set_client_update_fields(expected.clone());
|
||||
assert_eq!(machine.take_client_update_fields(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
|
||||
@@ -65,6 +65,44 @@ pub struct Setting {
|
||||
pub item: Item,
|
||||
pub text: &'static str,
|
||||
}
|
||||
/// What the chosen value does, for a setting whose values each do something different.
|
||||
fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
Some(match opt {
|
||||
Opt::PadGameplayGuide => match pad.guide_detail {
|
||||
gamepad::GuideDetail::Full => {
|
||||
"Shows what your buttons do right now, in play and in inventories."
|
||||
}
|
||||
gamepad::GuideDetail::Basic => {
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons."
|
||||
}
|
||||
gamepad::GuideDetail::Off => "Doesn't show what your buttons do.",
|
||||
},
|
||||
Opt::PadGlyphs => match pad.glyphs {
|
||||
GlyphStyle::Auto => "Shows the buttons of the controller you're using.",
|
||||
GlyphStyle::Xbox => "Always shows Xbox buttons.",
|
||||
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
|
||||
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
|
||||
},
|
||||
Opt::PadKeyboard => match pad.keyboard {
|
||||
gamepad::TextKeyboard::Auto => {
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
|
||||
}
|
||||
gamepad::TextKeyboard::Horizon => "Always opens Horizon's keyboard.",
|
||||
gamepad::TextKeyboard::Steam => "Always opens Steam's keyboard.",
|
||||
},
|
||||
Opt::PadLightbar => match pad.lightbar {
|
||||
gamepad::Lightbar::Health => {
|
||||
"A DualSense's light shows your health, from green through amber to red."
|
||||
}
|
||||
gamepad::Lightbar::Dimension => {
|
||||
"A DualSense's light changes with the dimension you're in."
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn option(opt: Opt, text: &'static str) -> Setting {
|
||||
Setting {
|
||||
item: Item::Option(opt),
|
||||
@@ -97,10 +135,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
vec![
|
||||
(
|
||||
"Controller",
|
||||
vec![option(
|
||||
Opt::PadEnabled,
|
||||
"Off, the game ignores every controller for this player.",
|
||||
)],
|
||||
vec![
|
||||
option(
|
||||
Opt::PadEnabled,
|
||||
"Off, the game ignores every controller for this player.",
|
||||
),
|
||||
option(
|
||||
Opt::PadPlayInBackground,
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Look",
|
||||
@@ -135,10 +179,16 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
(
|
||||
"Play",
|
||||
vec![option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
)],
|
||||
vec![
|
||||
option(
|
||||
Opt::PadGameplayGuide,
|
||||
"Show the buttons for what you can do, while you play with a controller.",
|
||||
),
|
||||
option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
@@ -279,6 +329,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Option(Opt::PadEnabled) => Some(c.enabled),
|
||||
Item::Option(Opt::PadVibration) => Some(c.vibration),
|
||||
Item::Option(Opt::PadPlayInBackground) => Some(c.play_in_background),
|
||||
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
|
||||
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
|
||||
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
|
||||
@@ -604,6 +655,7 @@ impl ControllerUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
}
|
||||
@@ -818,8 +870,12 @@ impl ControllerUi {
|
||||
/// What a row does. A binding names the controller's own buttons, and what else its button
|
||||
/// does when it shares it.
|
||||
fn description(&self, setting: Setting, s: &Settings) -> String {
|
||||
let Item::Bind(action) = setting.item else {
|
||||
return lang::get(setting.text);
|
||||
let action = match setting.item {
|
||||
Item::Bind(action) => action,
|
||||
Item::Option(opt) => {
|
||||
return lang::get(choice_description(opt, &s.controller).unwrap_or(setting.text));
|
||||
}
|
||||
_ => return lang::get(setting.text),
|
||||
};
|
||||
let pad = &s.controller;
|
||||
let style = self.context.style;
|
||||
@@ -1265,6 +1321,7 @@ mod tests {
|
||||
for opt in [
|
||||
Opt::PadEnabled,
|
||||
Opt::PadVibration,
|
||||
Opt::PadPlayInBackground,
|
||||
Opt::PadAdaptiveTriggers,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadLookSensitivity,
|
||||
@@ -1372,6 +1429,30 @@ mod tests {
|
||||
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// What the screen draws at the bottom of the page changes with the choice, in every one of
|
||||
/// these settings.
|
||||
#[test]
|
||||
fn each_choice_describes_itself() {
|
||||
let ui = ControllerUi::default();
|
||||
let mut s = Settings::default();
|
||||
for (opt, choices) in [
|
||||
(Opt::PadGameplayGuide, 3),
|
||||
(Opt::PadGlyphs, 4),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..choices {
|
||||
let text = ui.description(setting, &s);
|
||||
assert_ne!(text, "the fixed sentence", "{opt:?}");
|
||||
assert!(!seen.contains(&text), "{opt:?} repeats {text}");
|
||||
seen.push(text);
|
||||
opt.cycle(&mut s, false, FB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
@@ -1385,6 +1466,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
for opt in [
|
||||
Opt::PadGameplayGuide,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
opt.cycle(&mut settings, false, FB);
|
||||
}
|
||||
}
|
||||
for text in [
|
||||
"Controller",
|
||||
"Player profile",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Draw {
|
||||
pub image: u16,
|
||||
pub blend: Blend,
|
||||
pub brightness: f32,
|
||||
/// Camera-relative world from the skybox's space.
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct EffectInstance {
|
||||
pub amplifier: i32,
|
||||
pub ambient: bool,
|
||||
pub visible: bool,
|
||||
/// `MobEffectInstance.showIcon`: whether the HUD and inventory list it.
|
||||
pub show_icon: bool,
|
||||
pub hidden: Option<Box<EffectInstance>>,
|
||||
}
|
||||
impl EffectInstance {
|
||||
@@ -24,9 +26,18 @@ impl EffectInstance {
|
||||
amplifier: amplifier.clamp(0, 255),
|
||||
ambient: false,
|
||||
visible: true,
|
||||
show_icon: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
/// `new MobEffectInstance(effect, duration, amplifier, ambient, visible)`:
|
||||
/// the icon shows exactly when the particles do.
|
||||
pub fn with_flags(mut self, ambient: bool, visible: bool) -> Self {
|
||||
self.ambient = ambient;
|
||||
self.visible = visible;
|
||||
self.show_icon = visible;
|
||||
self
|
||||
}
|
||||
fn infinite(&self) -> bool {
|
||||
self.duration == -1
|
||||
}
|
||||
@@ -71,6 +82,10 @@ impl EffectInstance {
|
||||
self.visible = take.visible;
|
||||
changed = true;
|
||||
}
|
||||
if take.show_icon != self.show_icon {
|
||||
self.show_icon = take.show_icon;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `MobEffectInstance.tickClient`: a client counts its effects down
|
||||
@@ -98,6 +113,7 @@ impl EffectInstance {
|
||||
self.amplifier = h.amplifier;
|
||||
self.ambient = h.ambient;
|
||||
self.visible = h.visible;
|
||||
self.show_icon = h.show_icon;
|
||||
self.hidden = h.hidden;
|
||||
}
|
||||
}
|
||||
@@ -171,6 +187,57 @@ pub enum OmenTick {
|
||||
/// It added this effect, then returned false.
|
||||
Add(EffectInstance),
|
||||
}
|
||||
/// A saved player's effect: `MobEffectInstance.Details` under its `id`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SavedEffect {
|
||||
pub id: String,
|
||||
pub duration: i32,
|
||||
pub amplifier: i32,
|
||||
pub ambient: bool,
|
||||
/// `show_particles`.
|
||||
pub particles: bool,
|
||||
/// `show_icon`.
|
||||
pub icon: bool,
|
||||
/// `hidden_effect`: the weaker, longer instance of the same effect that
|
||||
/// comes back when this one ends.
|
||||
pub hidden: Option<Box<SavedEffect>>,
|
||||
}
|
||||
impl SavedEffect {
|
||||
/// Ambient off, particles and icon shown, nothing hidden: the codec's
|
||||
/// defaults.
|
||||
pub fn new(id: impl Into<String>, duration: i32, amplifier: i32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
duration,
|
||||
amplifier,
|
||||
ambient: false,
|
||||
particles: true,
|
||||
icon: true,
|
||||
hidden: None,
|
||||
}
|
||||
}
|
||||
fn of(e: &EffectInstance) -> Self {
|
||||
Self {
|
||||
id: potions::namespaced(e.id).to_owned(),
|
||||
duration: e.duration,
|
||||
amplifier: e.amplifier,
|
||||
ambient: e.ambient,
|
||||
particles: e.visible,
|
||||
icon: e.show_icon,
|
||||
hidden: e.hidden.as_deref().map(|h| Box::new(Self::of(h))),
|
||||
}
|
||||
}
|
||||
/// `new MobEffectInstance(effect, details)`: the hidden chain comes back
|
||||
/// with it.
|
||||
fn instance(&self, id: &'static str) -> EffectInstance {
|
||||
let mut e = EffectInstance::new(id, self.duration, self.amplifier);
|
||||
e.ambient = self.ambient;
|
||||
e.visible = self.particles;
|
||||
e.show_icon = self.icon;
|
||||
e.hidden = self.hidden.as_deref().map(|h| Box::new(h.instance(id)));
|
||||
e
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PlayerEffects {
|
||||
pub list: Vec<EffectInstance>,
|
||||
@@ -203,39 +270,25 @@ impl PlayerEffects {
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
/// The effects of a saved player (`Player.active_effects`: id,
|
||||
/// duration, amplifier, ambient, particles visible).
|
||||
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
|
||||
/// The effects of a saved player (`Player.active_effects`), each with
|
||||
/// the instances it hides.
|
||||
pub fn from_saved(saved: &[SavedEffect]) -> Self {
|
||||
let mut effects = Self::default();
|
||||
for (id, duration, amplifier, ambient, particles) in saved {
|
||||
if let Some(def) = potions::effect(id) {
|
||||
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
|
||||
e.ambient = *ambient;
|
||||
e.visible = *particles;
|
||||
effects.add(e);
|
||||
for s in saved {
|
||||
if let Some(def) = potions::effect(&s.id) {
|
||||
effects.add(s.instance(def.id));
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `Player.active_effects` for the save.
|
||||
pub fn saved(&self) -> Vec<(String, i32, i32, bool, bool)> {
|
||||
self.list
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
potions::namespaced(e.id).to_owned(),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
e.visible,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
pub fn saved(&self) -> Vec<SavedEffect> {
|
||||
self.list.iter().map(SavedEffect::of).collect()
|
||||
}
|
||||
/// ClientboundUpdateMobEffectPacket's flags without blending: ambient,
|
||||
/// particles visible and the icon shown (always, for these effects).
|
||||
/// particles visible and the icon shown.
|
||||
pub fn flags(e: &EffectInstance) -> i8 {
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
|
||||
i8::from(e.ambient) | i8::from(e.visible) << 1 | i8::from(e.show_icon) << 2
|
||||
}
|
||||
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
|
||||
/// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
|
||||
@@ -536,6 +589,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Warden darkness (`new MobEffectInstance(DARKNESS, 260, 0, false,
|
||||
/// false)`) shows neither particles nor its icon; a renewal that shows the
|
||||
/// icon turns it back on (`MobEffectInstance.update`).
|
||||
#[test]
|
||||
fn an_effect_without_particles_hides_its_icon() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("darkness", 260, 0).with_flags(false, false));
|
||||
let darkness = fx.get("darkness").unwrap();
|
||||
// ClientboundUpdateMobEffectPacket: no ambient, particles or icon bit.
|
||||
assert_eq!(PlayerEffects::flags(darkness), 0);
|
||||
assert!(!crate::hud_overlays::EffectIcon::from(darkness).show_icon);
|
||||
assert!(fx.add(EffectInstance::new("darkness", 100, 0)));
|
||||
assert_eq!(PlayerEffects::flags(fx.get("darkness").unwrap()), 2 | 4);
|
||||
|
||||
// Details keeps show_icon apart from show_particles.
|
||||
let mut speed = EffectInstance::new("speed", 100, 0);
|
||||
speed.show_icon = false;
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(speed);
|
||||
let saved = fx.saved();
|
||||
assert_eq!(
|
||||
saved,
|
||||
[SavedEffect {
|
||||
icon: false,
|
||||
..SavedEffect::new("minecraft:speed", 100, 0)
|
||||
}]
|
||||
);
|
||||
let loaded = PlayerEffects::from_saved(&saved);
|
||||
assert_eq!(PlayerEffects::flags(loaded.get("speed").unwrap()), 2);
|
||||
}
|
||||
#[test]
|
||||
fn adding_counts_a_change_only_when_the_effect_changed() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
@@ -572,6 +655,21 @@ mod tests {
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
#[test]
|
||||
fn a_reloaded_effect_falls_back_to_the_one_it_hid() {
|
||||
// /effect give poison 6 s level II over poison 30 s level I, saved
|
||||
// and loaded: when level II runs out, level I resumes with what is
|
||||
// left of its 30 s.
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new("poison", 600, 0));
|
||||
fx.add(EffectInstance::new("poison", 120, 1));
|
||||
let mut loaded = PlayerEffects::from_saved(&fx.saved());
|
||||
for _ in 0..120 {
|
||||
loaded.tick();
|
||||
}
|
||||
let poison = loaded.get("poison").expect("level I resumed");
|
||||
assert_eq!((poison.amplifier, poison.duration), (0, 480));
|
||||
}
|
||||
/// The only test here that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mod_effect_saves_under_its_namespace() {
|
||||
@@ -587,11 +685,11 @@ mod tests {
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(saved, [SavedEffect::new("ruby:glow", 600, 1)]);
|
||||
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)])
|
||||
PlayerEffects::from_saved(&[SavedEffect::new("gone:glow", 600, 1)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
/// The group of the mod that replaced an extra.
|
||||
const MODS: &str = "horizon.pages.mods";
|
||||
const ZOOM: &str = "horizon.extras.zoom";
|
||||
const ZOOM_INSTALL: &str = "horizon.extras.zoom_mod_install";
|
||||
const MOD_SETTINGS: &str = "horizon.mods.settings";
|
||||
/// Its descriptions run long, as tooltips they were: most need more than two lines.
|
||||
const NOTE_LINES: usize = 5;
|
||||
/// The official native mod that took over from the Zoom extra (#8713).
|
||||
const ZOOM_ID: &str = "zoom";
|
||||
|
||||
/// A row of the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ExtraRow {
|
||||
Item(Item),
|
||||
/// The Zoom mod, at this place among the installed ones, on or off.
|
||||
Zoom(usize),
|
||||
/// Its own settings page.
|
||||
ZoomSettings(usize),
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
let e = &s.extras;
|
||||
let value = match (item.key_name(), item.switch(e)) {
|
||||
(Some(key), _) => Value::Key(key, item.value(e)),
|
||||
(None, Some(on)) => Value::Switch(on),
|
||||
(None, None) => Value::Choice(item.value(e)),
|
||||
};
|
||||
// A setting of a switch that is off does nothing, so it is greyed out.
|
||||
let live = item
|
||||
.parent()
|
||||
.is_none_or(|parent| parent.switch(e) != Some(false));
|
||||
Row::new(ExtraRow::Item(item), item.caption_text(), value)
|
||||
.about(item.description_text())
|
||||
.live(live)
|
||||
}
|
||||
|
||||
/// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now; Extras keeps a way to
|
||||
/// the one players look for here.
|
||||
fn zoom_rows(s: &Settings) -> Vec<Row<ExtraRow>> {
|
||||
let installed = &s.mods.installed;
|
||||
let found = installed
|
||||
.iter()
|
||||
.position(|m| m.id == ZOOM_ID && m.native.is_some());
|
||||
let Some(i) = found else {
|
||||
let note = Row::new(ExtraRow::ZoomInstall, lang::get(ZOOM_INSTALL), Value::Note);
|
||||
return vec![note.live(false)];
|
||||
};
|
||||
let about = installed[i].problem.as_ref();
|
||||
let about = about.unwrap_or(&installed[i].description).clone();
|
||||
let on = Value::Switch(s.mods.is_on(i));
|
||||
let mut rows = vec![Row::new(ExtraRow::Zoom(i), lang::get(ZOOM), on).about(about)];
|
||||
let page = installed[i].native.as_ref();
|
||||
if page.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty()) {
|
||||
let name = lang::get(MOD_SETTINGS);
|
||||
rows.push(Row::new(ExtraRow::ZoomSettings(i), name, Value::Press));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// The page itself holds nothing: everything it shows is in the settings.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ExtrasScreen;
|
||||
|
||||
impl Screen for ExtrasScreen {
|
||||
type Page = ();
|
||||
type Row = ExtraRow;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
name: MODS,
|
||||
rows: zoom_rows(s),
|
||||
}];
|
||||
for row in extras::rows() {
|
||||
match row {
|
||||
Err(name) => groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
}),
|
||||
Ok(item) => {
|
||||
if let Some(group) = groups.last_mut() {
|
||||
group.rows.push(item_row(item, s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![Sheet {
|
||||
id: (),
|
||||
name: lang::get(TITLE),
|
||||
about: lang::get(ABOUT),
|
||||
groups,
|
||||
}]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => match item.key_name() {
|
||||
Some(key) => vec![Output::BindKey(key)],
|
||||
None => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
},
|
||||
ExtraRow::Zoom(i) => {
|
||||
s.mods.toggle(i);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
ExtraRow::ZoomSettings(i) => vec![
|
||||
Output::Event(Event::ButtonClick),
|
||||
Output::Push(Page::NativeMod(i)),
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::settings_shell::{Hosted, Shell};
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
fn rows_of(s: &Settings) -> Vec<Row<ExtraRow>> {
|
||||
let sheets = ExtrasScreen.sheets(s);
|
||||
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
|
||||
groups.flat_map(|group| group.rows).collect()
|
||||
}
|
||||
|
||||
/// Greyed exactly while its own switch is off, so it takes no press then.
|
||||
#[test]
|
||||
fn a_setting_of_a_switch_that_is_off_is_greyed_and_takes_no_press() {
|
||||
let mut s = Settings::default();
|
||||
let live = |s: &Settings| {
|
||||
let wheel = ExtraRow::Item(Item::MouseTweaksWheel);
|
||||
rows_of(s).iter().find(|r| r.id == wheel).unwrap().live
|
||||
};
|
||||
s.extras.mouse_tweaks = false;
|
||||
assert!(!live(&s));
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let row = ExtraRow::Item(Item::MouseTweaksWheel);
|
||||
let at = shell.point_at(&s, SIZE, row);
|
||||
let before = s.extras.mouse_tweaks_wheel;
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(s.extras.mouse_tweaks_wheel, before);
|
||||
s.extras.mouse_tweaks = true;
|
||||
assert!(live(&s));
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert_ne!(s.extras.mouse_tweaks_wheel, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_three_way_cosmetics_row_turns_both_ways() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::CosmeticsOthers);
|
||||
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
screen.press(&mut s, row, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
screen.press(&mut s, row, true);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
}
|
||||
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::SortKey);
|
||||
let key = s.extras.sort_key.clone();
|
||||
assert_eq!(
|
||||
screen.press(&mut s, row, false),
|
||||
vec![Output::BindKey(extras::SORT_KEY)]
|
||||
);
|
||||
screen.wheel_step(&mut s, row, true);
|
||||
assert_eq!(s.extras.sort_key, key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_flips_a_switch_and_leaves_the_mod_to_scroll() {
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
let row = ExtraRow::Item(Item::Sort);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, row, true),
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
);
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Still-fluid tiling follows Catnip/Ponder 1.0.82 FluidRenderHelper.renderTiledFace.
|
||||
// Copyright (c) 2022 The Create Team
|
||||
//
|
||||
// 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.
|
||||
|
||||
use horizon_core::geometry::Direction;
|
||||
|
||||
/// Controls a still-fluid box. The tint is stack ARGB, independent of biome tint.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeFluidRenderOptions {
|
||||
pub argb: Option<u32>,
|
||||
pub render_bottom: bool,
|
||||
/// TextureAtlasSprite.uvShrinkRatio: 4 / the largest stitched atlas dimension.
|
||||
/// Explicit because Horizon's block sprites occupy arrays rather than a stitched atlas.
|
||||
pub uv_shrink_ratio: f32,
|
||||
}
|
||||
|
||||
/// A corner before the caller's pose, with UVs within the still sprite, and its outward normal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidVertex {
|
||||
pub pos: [f32; 3],
|
||||
pub uv: [f32; 2],
|
||||
pub normal: [i8; 3],
|
||||
}
|
||||
|
||||
/// Preserve sky light and increase block light to the fluid's luminosity (native light packing).
|
||||
pub fn fluid_light(light: u32, luminosity: u8) -> u32 {
|
||||
(light & 0xf0) | (((light >> 8 & 0xf0).max(u32::from(luminosity.min(15)) * 16)) << 8)
|
||||
}
|
||||
|
||||
/// Emit whole tiled quads, or refuse invalid/unreasonably large geometry before emitting anything.
|
||||
/// Negative coordinates and exact integer boundaries retain the source's local UV fractions.
|
||||
pub fn fluid_box(
|
||||
bounds: [f32; 6],
|
||||
options: NativeFluidRenderOptions,
|
||||
mut emit: impl FnMut(FluidVertex),
|
||||
) -> bool {
|
||||
if !options.uv_shrink_ratio.is_finite()
|
||||
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|
||||
|| bounds
|
||||
.iter()
|
||||
.any(|x| !x.is_finite() || x.abs() > 1_048_576.0)
|
||||
|| (0..3).any(|i| bounds[i] > bounds[i + 3])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let tiles =
|
||||
std::array::from_fn::<_, 3, _>(|i| (bounds[i + 3].ceil() - bounds[i].floor()) as u64);
|
||||
let quads = tiles[0] * tiles[1] * 2
|
||||
+ tiles[1] * tiles[2] * 2
|
||||
+ tiles[0] * tiles[2] * if options.render_bottom { 2 } else { 1 };
|
||||
if quads > 65_536 {
|
||||
return false;
|
||||
}
|
||||
let [x0, y0, z0, x1, y1, z1] = bounds;
|
||||
for (side, face) in [
|
||||
(Direction::Down, [x0, z0, x1, z1, y0]),
|
||||
(Direction::Up, [x0, z0, x1, z1, y1]),
|
||||
(Direction::North, [x0, y0, x1, y1, z0]),
|
||||
(Direction::South, [x0, y0, x1, y1, z1]),
|
||||
(Direction::West, [z0, y0, z1, y1, x0]),
|
||||
(Direction::East, [z0, y0, z1, y1, x1]),
|
||||
] {
|
||||
if side != Direction::Down || options.render_bottom {
|
||||
tiled_face(&mut emit, side, face, options.uv_shrink_ratio);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tiled_face(out: &mut impl FnMut(FluidVertex), side: Direction, face: [f32; 5], ratio: f32) {
|
||||
let [a0, b0, a1, b1, plane] = face;
|
||||
let positive = matches!(side, Direction::Up | Direction::South | Direction::East);
|
||||
let shrink = ratio * 0.25;
|
||||
let lerp = |v| v + shrink * (0.5 - v);
|
||||
let mut a = a0;
|
||||
while a < a1 {
|
||||
let end_a = (a.floor() + 1.0).min(a1);
|
||||
let (u0, u1) = if matches!(side, Direction::North | Direction::East) {
|
||||
(end_a.ceil() - end_a, end_a.ceil() - a)
|
||||
} else {
|
||||
(a - a.floor(), end_a - a.floor())
|
||||
};
|
||||
let (u0, u1) = (lerp(u0), lerp(u1));
|
||||
let mut b = b0;
|
||||
while b < b1 {
|
||||
let end_b = (b.floor() + 1.0).min(b1);
|
||||
let (v0, v1) = if side == Direction::Up {
|
||||
(b - b.floor(), end_b - b.floor())
|
||||
} else {
|
||||
(end_b.ceil() - end_b, end_b.ceil() - b)
|
||||
};
|
||||
let (v0, v1) = (lerp(v0), lerp(v1));
|
||||
let positions = match side {
|
||||
Direction::West | Direction::East => {
|
||||
let (near, far) = if positive { (end_a, a) } else { (a, end_a) };
|
||||
[
|
||||
[plane, end_b, near],
|
||||
[plane, b, near],
|
||||
[plane, b, far],
|
||||
[plane, end_b, far],
|
||||
]
|
||||
}
|
||||
Direction::North | Direction::South => {
|
||||
let (near, far) = if positive { (a, end_a) } else { (end_a, a) };
|
||||
[
|
||||
[near, end_b, plane],
|
||||
[near, b, plane],
|
||||
[far, b, plane],
|
||||
[far, end_b, plane],
|
||||
]
|
||||
}
|
||||
Direction::Down | Direction::Up => {
|
||||
let (near, far) = if positive { (b, end_b) } else { (end_b, b) };
|
||||
[
|
||||
[a, plane, near],
|
||||
[a, plane, far],
|
||||
[end_a, plane, far],
|
||||
[end_a, plane, near],
|
||||
]
|
||||
}
|
||||
};
|
||||
let normal = match side {
|
||||
Direction::Down => [0, -1, 0],
|
||||
Direction::Up => [0, 1, 0],
|
||||
Direction::North => [0, 0, -1],
|
||||
Direction::South => [0, 0, 1],
|
||||
Direction::West => [-1, 0, 0],
|
||||
Direction::East => [1, 0, 0],
|
||||
};
|
||||
for (pos, uv) in positions
|
||||
.into_iter()
|
||||
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
|
||||
{
|
||||
out(FluidVertex { pos, uv, normal });
|
||||
}
|
||||
b = end_b;
|
||||
}
|
||||
a = end_a;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn source_tiling_is_outward_and_does_not_inset_geometry() {
|
||||
let bounds = [-1.25, 0.125, -0.25, 1.5, 1.125, 0.75];
|
||||
let mut result = Vec::new();
|
||||
assert!(fluid_box(
|
||||
bounds,
|
||||
NativeFluidRenderOptions {
|
||||
argb: None,
|
||||
render_bottom: false,
|
||||
uv_shrink_ratio: 4.0 / 2048.0,
|
||||
},
|
||||
|v| result.push(v),
|
||||
));
|
||||
assert_eq!(result.len(), (4 * 2 * 2 + 2 * 2 * 2 + 4 * 2) * 4);
|
||||
for quad in result.as_chunks::<4>().0 {
|
||||
let [a, b, c, _] = quad.map(|v| glam::Vec3::from_array(v.pos));
|
||||
assert!(
|
||||
(b - a)
|
||||
.cross(c - a)
|
||||
.dot(glam::Vec3::from_array(quad[0].normal.map(f32::from)))
|
||||
> 0.0
|
||||
);
|
||||
assert!(
|
||||
quad.iter()
|
||||
.all(|v| (0..3).all(|i| v.pos[i] >= bounds[i] && v.pos[i] <= bounds[i + 3]))
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.iter()
|
||||
.any(|v| v.pos == [bounds[0], bounds[4], bounds[2]])
|
||||
);
|
||||
assert!(!fluid_box(
|
||||
[0.0, 0.0, 0.0, 1_000.0, 1_000.0, 1_000.0],
|
||||
NativeFluidRenderOptions {
|
||||
argb: None,
|
||||
render_bottom: true,
|
||||
uv_shrink_ratio: 0.0,
|
||||
},
|
||||
|_| panic!("invalid geometry must fail before emitting"),
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn luminosity_changes_only_block_light() {
|
||||
assert_eq!(fluid_light(0x20a0, 15), 0xf0a0);
|
||||
assert_eq!(fluid_light(0x25a5, 15), 0xf0a0);
|
||||
assert_eq!(fluid_light(0xe030, 3), 0xe030);
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,12 @@ pub fn clear_distance_fog(fog: &mut FogData, input: &FogInputs) {
|
||||
(fog.environmental_start, fog.environmental_end) = (1e9, 2e9);
|
||||
}
|
||||
}
|
||||
/// What `AtmosphericFogEnvironment.updateRainFogState` eases the rain fog toward: the rain, where
|
||||
/// the sky reaches the eye, halved in a biome without precipitation.
|
||||
pub fn rain_fog_target(rain: f32, sky_light: u8, biome_rains: bool) -> f32 {
|
||||
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
|
||||
rain * sky * if biome_rains { 1.0 } else { 0.5 }
|
||||
}
|
||||
/// Whether the frame draws no sky, only the clear to the fog colour: `LevelRenderer.addSkyPass`
|
||||
/// leaves the sky out in lava and powder snow and while blindness or darkness is on (`Camera`'s
|
||||
/// `doesMobEffectBlockSky`), and `GameRenderer.renderLevel` gives `LevelRenderer.render`
|
||||
@@ -428,10 +434,14 @@ pub fn update_rain_fog(
|
||||
biome_rains: bool,
|
||||
delta_ticks: f32,
|
||||
) {
|
||||
let sky = ((f32::from(sky_light) - 8.0) / 7.0).clamp(0.0, 1.0);
|
||||
let target = rain * sky * if biome_rains { 1.0 } else { 0.5 };
|
||||
let target = rain_fog_target(rain, sky_light, biome_rains);
|
||||
state.rain_fog_multiplier += (target - state.rain_fog_multiplier) * delta_ticks * 0.2;
|
||||
}
|
||||
/// The rain fog at its target at once: a frozen world under `HORIZON_STILL`, where the rain sits
|
||||
/// at its level and easing would wait for ticks that do not come.
|
||||
pub fn settle_rain_fog(state: &mut FogState, rain: f32, sky_light: u8, biome_rains: bool) {
|
||||
state.rain_fog_multiplier = rain_fog_target(rain, sky_light, biome_rains);
|
||||
}
|
||||
fn rgb(c: u32) -> [f32; 3] {
|
||||
[
|
||||
(c >> 16 & 255) as f32 / 255.0,
|
||||
@@ -726,6 +736,30 @@ mod tests {
|
||||
assert_eq!(clear.environmental_end, settled.environmental_end);
|
||||
assert_eq!(clear.color, settled.color);
|
||||
}
|
||||
/// Settling puts the rain fog at its target exactly, where a tick of easing moves it a fifth
|
||||
/// of the way, as before.
|
||||
#[test]
|
||||
fn settled_rain_fog_is_its_target_and_easing_is_unchanged() {
|
||||
assert_eq!(
|
||||
rain_fog_target(1.0, 15, false),
|
||||
0.5,
|
||||
"a dry biome halves it"
|
||||
);
|
||||
assert_eq!(rain_fog_target(1.0, 8, true), 0.0, "no sky, no rain fog");
|
||||
let start = FogState {
|
||||
rain_fog_multiplier: 0.1,
|
||||
..FogState::default()
|
||||
};
|
||||
let mut settled = start;
|
||||
settle_rain_fog(&mut settled, 0.5, 15, true);
|
||||
assert_eq!(settled.rain_fog_multiplier, 0.5);
|
||||
let mut eased = start;
|
||||
update_rain_fog(&mut eased, 0.5, 15, true, 1.0);
|
||||
assert_eq!(
|
||||
eased.rain_fog_multiplier.to_bits(),
|
||||
(0.1f32 + (0.5f32 - 0.1) * 1.0 * 0.2).to_bits()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rain_fog_pulls_fog_in() {
|
||||
let sky = Sky {
|
||||
|
||||
@@ -45,18 +45,21 @@ fn static_entity_shader() -> String {
|
||||
mod crumbling_tests;
|
||||
mod entity_batch;
|
||||
mod entity_outline;
|
||||
mod fluid;
|
||||
#[cfg(test)]
|
||||
mod inter_stage_tests;
|
||||
mod terrain_model;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
mod weather;
|
||||
mod world_text;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, FLUID_OVERLAY, OIT_ENTITY,
|
||||
SHADOW_OVERLAY, TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, TERRAIN_MODEL_CUTOUT_OVERLAY,
|
||||
TERRAIN_MODEL_SOLID_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
@@ -170,6 +173,7 @@ mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
@@ -227,6 +231,13 @@ fn sky_visible(frame: &Frame) -> bool {
|
||||
!frame.title && frame.skybox && sky_open(frame)
|
||||
}
|
||||
|
||||
/// The End during the dragon fight, whose world fog leaves out vanilla's sky: a shader pack's
|
||||
/// `gbuffers_skybasic` still draws a fan around the horizon (`pack_sky`), as our probe packs read
|
||||
/// it in Iris 1.11.7.
|
||||
fn end_fog_sky(frame: &Frame) -> bool {
|
||||
!frame.title && frame.end_sky && frame.boss_world_fog
|
||||
}
|
||||
|
||||
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
|
||||
/// depth loaded, and its `surface` target, cleared on the first of these passes.
|
||||
fn surface_pass<'e>(
|
||||
@@ -469,9 +480,9 @@ struct SectionGpu {
|
||||
/// while its instance carries a fade (`drawn_faded`), and GPU culling's
|
||||
/// pass leaves it out.
|
||||
gpu_fading: bool,
|
||||
/// When it began to fade in over the far terrain it replaces, which
|
||||
/// stays whole behind it until then: a section new where far tiles are
|
||||
/// drawn. Meshed again, it stays as it was.
|
||||
/// When it came in over the far terrain it replaces (drawn whole at once,
|
||||
/// `fade`): a section new where far tiles are drawn, which skips vanilla's
|
||||
/// fade-in. Meshed again, it stays as it was.
|
||||
fading: Option<std::time::Instant>,
|
||||
/// When it began to fade out, its column gone over far terrain that
|
||||
/// stays behind it (`Renderer::leave_section`); released once it has.
|
||||
@@ -493,17 +504,19 @@ pub(crate) fn collect_animated_layers(
|
||||
}
|
||||
|
||||
impl SectionGpu {
|
||||
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
|
||||
/// its column has left the render distance, far terrain is coming in
|
||||
/// under it (`lod::HELD`).
|
||||
fn fade(&self, now: std::time::Instant, retiring: bool) -> u32 {
|
||||
match self.leaving {
|
||||
Some(since) => lod::fade_out_code(since, 0, now),
|
||||
None if retiring => lod::HELD,
|
||||
None => self
|
||||
.fading
|
||||
.and_then(|since| lod::fade_code(Some(since), 0, now))
|
||||
.unwrap_or(0),
|
||||
/// Its fade code this frame: 0, drawn whole, or `lod::HELD` (whole, in
|
||||
/// front of far terrain) while it leaves (`leaving`) or its column has left
|
||||
/// the render distance and far terrain is coming in under it (`retiring`).
|
||||
/// A near section never dithers over far terrain: the holes of its pattern
|
||||
/// showed its own inside (cave walls, the stone under the grass), nearer
|
||||
/// than the tile behind, an x-ray of each column coming in or going out.
|
||||
/// It comes in whole over the tile it replaces, which stays behind it until
|
||||
/// `fading` is `lod::FADE` old, and leaves whole after as long.
|
||||
fn fade(&self, retiring: bool) -> u32 {
|
||||
if self.leaving.is_some() || retiring {
|
||||
lod::HELD
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
/// Its vertices in `arena`, and in `translucent_arena` when `split` (there
|
||||
@@ -569,23 +582,21 @@ impl SectionGpu {
|
||||
/// once false, this stays false until the section is meshed again, leaves,
|
||||
/// is held (`lod_retiring`), or the chunk fade-in time changes.
|
||||
fn drawn_faded(&self, key: SectionKey, f: FadeInputs<'_>) -> bool {
|
||||
let fade = self.fade(f.now, f.retiring.contains(&key));
|
||||
let fade = self.fade(f.retiring.contains(&key));
|
||||
let visibility = section_visibility(f.uploaded.get(&key), f.fade_ms, f.now);
|
||||
section_instance([0.0; 3], fade, visibility)[3].to_bits()
|
||||
!= section_instance([0.0; 3], 0, 1.0)[3].to_bits()
|
||||
}
|
||||
/// When its fade is over at the latest (`drawn_faded`): the later of the
|
||||
/// ends of its fade-in over far terrain (`fading` and `lod::FADE`, a few
|
||||
/// milliseconds after `fade` reaches 0) and of vanilla's (its first upload
|
||||
/// and `f.fade_ms`). None while it leaves or is held, which ends only when
|
||||
/// it goes or is meshed again, and without either fade-in.
|
||||
/// When its fade is over at the latest (`drawn_faded`): the end of
|
||||
/// vanilla's fade-in (its first upload and `f.fade_ms`; one coming in over
|
||||
/// far terrain is whole at once, `fade`). None while it leaves or is held,
|
||||
/// which ends only when it goes or is meshed again, and without a fade-in.
|
||||
fn fade_end(&self, key: SectionKey, f: FadeInputs<'_>) -> Option<Instant> {
|
||||
if self.leaving.is_some() || f.retiring.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
let vanilla = f.uploaded.get(&key).filter(|_| f.fade_ms > 0);
|
||||
let vanilla = vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms));
|
||||
vanilla.max(self.fading.map(|t| t + lod::FADE))
|
||||
vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms))
|
||||
}
|
||||
}
|
||||
/// Which of a mesh's ranges `move_off_page` moves: what it holds in the section
|
||||
@@ -883,6 +894,8 @@ pub struct Frame<'a> {
|
||||
pub end_sky: bool,
|
||||
/// End sky atlas bounds (u0, v0, u1, v1); absent when the sprite is missing.
|
||||
pub end_sky_sprite: Option<[f32; 4]>,
|
||||
/// A boss bar makes world fog (`BossHealthOverlay.shouldCreateWorldFog`: the Ender Dragon's).
|
||||
pub boss_world_fog: bool,
|
||||
/// Environmental fog start/end (Nether 10..96), if any.
|
||||
pub environment_fog: Option<(f32, f32)>,
|
||||
pub water_fog: [f32; 3],
|
||||
@@ -1204,10 +1217,15 @@ pub struct Renderer {
|
||||
world_primitives: world_primitives::WorldPrimitives,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
world_text_pipeline: wgpu::RenderPipeline,
|
||||
fluid_pipeline: wgpu::RenderPipeline,
|
||||
terrain_model_pipelines: [wgpu::RenderPipeline; 3],
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
custom_sky: custom_sky::CustomSky,
|
||||
custom_sky_draws: Vec<crate::custom_sky::Draw>,
|
||||
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
|
||||
entity_outline: entity_outline::EntityOutline,
|
||||
panorama: panorama::Panorama,
|
||||
@@ -1236,6 +1254,10 @@ pub struct Renderer {
|
||||
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
group: wgpu::BindGroup,
|
||||
fluid_group: wgpu::BindGroup,
|
||||
fluid_sampler: wgpu::Sampler,
|
||||
terrain_model_group: wgpu::BindGroup,
|
||||
terrain_model_sampler: wgpu::Sampler,
|
||||
globals: wgpu::Buffer,
|
||||
/// Resource-pack normal/specular maps, split like the colour sprite arrays.
|
||||
pbr_maps: Option<Vec<[wgpu::Texture; 2]>>,
|
||||
@@ -1471,6 +1493,8 @@ pub struct Renderer {
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
|
||||
pub screenshot_hdr: Option<std::path::PathBuf>,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
@@ -2022,24 +2046,26 @@ fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The frame of a renderer made with a shader API 0.2 pack picked under
|
||||
/// `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md): float, so values over 1
|
||||
/// survive from the world to the tonemap.
|
||||
/// The frame of a renderer made under `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md)
|
||||
/// with a shader API 0.2 pack picked, or with HDR on and no pack: float, so values over 1
|
||||
/// survive from the world to the tonemap and the HDR pass.
|
||||
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// What a renderer's frame is drawn in: `LINEAR_FRAME` for a 0.2 pack under the switch
|
||||
/// (`linear`: `(picked, the device draws it)`); else `WIDE_FRAME` when it is made with HDR on
|
||||
/// and Smooth Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
|
||||
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
|
||||
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
/// What a renderer's frame is drawn in: `LINEAR_FRAME` under the switch where the device draws
|
||||
/// it, for a 0.2 pack, or for HDR on without a pack where the surface offers HDR output
|
||||
/// (`linear`: `(a 0.2 pack picked, HDR on without a pack, the device draws it)`); else
|
||||
/// `WIDE_FRAME` when it is made with HDR on and Smooth Gradients (`wide_frame`,
|
||||
/// `Graphics::wide_frame`), the surface offering HDR output (`hdr_offered`) and the device
|
||||
/// drawing it (`draws_wide_frame`): an 8-bit frame's steps band in dark gradients, and HDR
|
||||
/// shows them. Else the swapchain's 8-bit format, as without HDR.
|
||||
fn frame_format(
|
||||
wide_frame: bool,
|
||||
hdr_offered: bool,
|
||||
device_draws_it: bool,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
linear: (bool, bool),
|
||||
linear: (bool, bool, bool),
|
||||
) -> wgpu::TextureFormat {
|
||||
if linear.0 && linear.1 {
|
||||
if linear.2 && (linear.0 || linear.1 && hdr_offered) {
|
||||
LINEAR_FRAME
|
||||
} else if wide_frame && hdr_offered && device_draws_it {
|
||||
WIDE_FRAME
|
||||
@@ -2516,6 +2542,7 @@ impl Renderer {
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
linear_pack: bool,
|
||||
hdr_float: bool,
|
||||
) -> Result<Self> {
|
||||
let Opened {
|
||||
instance,
|
||||
@@ -2651,9 +2678,10 @@ impl Renderer {
|
||||
crate::graphics::offer_smooth_gradients(wide_ok);
|
||||
let linear = (
|
||||
linear_pack,
|
||||
hdr_float,
|
||||
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
|
||||
);
|
||||
if linear == (true, false) {
|
||||
if linear.0 && !linear.2 {
|
||||
log::warn!(
|
||||
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
|
||||
);
|
||||
@@ -2763,6 +2791,20 @@ impl Renderer {
|
||||
bind_group_layouts: &[Some(&bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let fluid_pipeline = fluid::pipeline(
|
||||
&device,
|
||||
Some(&layout),
|
||||
&shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&sprite_constants,
|
||||
);
|
||||
let terrain_model_pipelines = terrain_model::pipelines(
|
||||
&device,
|
||||
Some(&layout),
|
||||
&shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
&sprite_constants,
|
||||
);
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let terrain_buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
@@ -3321,6 +3363,34 @@ impl Renderer {
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let fluid_sampler = device.create_sampler(&fluid::SAMPLER);
|
||||
let fluid_group = terrain_group(
|
||||
&device,
|
||||
"Still fluid resources",
|
||||
&bindings,
|
||||
&globals,
|
||||
&sprite_views,
|
||||
&fluid_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let terrain_model_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Native cutout model (unmipped)"),
|
||||
lod_max_clamp: 0.0,
|
||||
..fluid::SAMPLER
|
||||
});
|
||||
let terrain_model_group = terrain_group(
|
||||
&device,
|
||||
"Native cutout model resources",
|
||||
&bindings,
|
||||
&globals,
|
||||
&sprite_views,
|
||||
&terrain_model_sampler,
|
||||
&lightmap_view,
|
||||
&lightmap_sampler,
|
||||
&emission_stats.view,
|
||||
);
|
||||
let crosshair_group = terrain_group(
|
||||
&device,
|
||||
"Debug crosshair resources",
|
||||
@@ -3888,12 +3958,8 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let world_text_pipeline = world_text::pipeline(
|
||||
&device,
|
||||
Some(&entity_layout),
|
||||
&entity_shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
);
|
||||
let world_text_pipeline =
|
||||
world_text::pipeline(&device, Some(&entity_layout), &entity_shader, frame_format);
|
||||
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
|
||||
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
|
||||
// ground it is stopped by, #5950). Terrain and entities are separate draw
|
||||
@@ -4595,6 +4661,7 @@ impl Renderer {
|
||||
let uploads = upload::Uploads::new(&device);
|
||||
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
|
||||
let stars = stars::Stars::new(&device, frame_format);
|
||||
let custom_sky = custom_sky::CustomSky::new(&device, frame_format);
|
||||
let entity_outline = entity_outline::EntityOutline::new(
|
||||
&device,
|
||||
(&entity_shader, &entity_layout, &entity_vertex),
|
||||
@@ -4713,10 +4780,14 @@ impl Renderer {
|
||||
world_primitives,
|
||||
text_polygon_offset_pipeline,
|
||||
world_text_pipeline,
|
||||
fluid_pipeline,
|
||||
terrain_model_pipelines,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
stars,
|
||||
custom_sky,
|
||||
custom_sky_draws: Vec::new(),
|
||||
entity_outline,
|
||||
panorama,
|
||||
border_pipeline,
|
||||
@@ -4743,6 +4814,10 @@ impl Renderer {
|
||||
entity_batch: EntityBatch::default(),
|
||||
entity_skin,
|
||||
group,
|
||||
fluid_group,
|
||||
fluid_sampler,
|
||||
terrain_model_group,
|
||||
terrain_model_sampler,
|
||||
globals,
|
||||
pbr_maps,
|
||||
lightmap,
|
||||
@@ -4863,6 +4938,7 @@ impl Renderer {
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_hdr: None,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
@@ -4933,6 +5009,15 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
}
|
||||
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
|
||||
/// frame, empty when the world's sky has none.
|
||||
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
|
||||
&mut self.custom_sky_draws
|
||||
}
|
||||
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
|
||||
/// outline, or the built-in one.
|
||||
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
|
||||
@@ -6584,6 +6669,7 @@ impl Renderer {
|
||||
post,
|
||||
self.frame_format,
|
||||
world,
|
||||
&std::sync::atomic::AtomicBool::new(false),
|
||||
)
|
||||
});
|
||||
self.pack_loading = None;
|
||||
@@ -6615,6 +6701,50 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
|
||||
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
|
||||
pub fn reset_shader_pack_history(&mut self) {
|
||||
// A frame still on the submit thread uses the targets and readbacks replaced
|
||||
// here.
|
||||
self.land_frame();
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.reset_history(&self.device);
|
||||
}
|
||||
if let Some(native) = &mut self.native {
|
||||
native.frame.reset_history();
|
||||
if let Some(post) = &mut native.post {
|
||||
post.reset_history();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The running shader pack's kind: `iris` (a `shaders/` pack) or `native` (one of
|
||||
/// Horizon's own).
|
||||
pub fn shader_pack_kind(&self) -> Option<&'static str> {
|
||||
match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(_))), _) => Some("iris"),
|
||||
(_, Some(_)) => Some("native"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `screenshot-sequence`: waits (30 seconds at most) for the frame just drawn to finish on
|
||||
/// the GPU, and takes the centre depth a `shaders/` pack copied in it, which its next frame
|
||||
/// reads: that frame's, or an error, never an older one. The last frame's PNG has already
|
||||
/// waited for it, without a limit, as `screenshot` does.
|
||||
pub fn finish_sequence_frame(&mut self) -> Result<()> {
|
||||
self.device
|
||||
.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
})
|
||||
.map_err(|e| format!("the frame didn't finish on the GPU: {e}"))?;
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.take_center_depth(&self.device)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// As `set_shader_pack` with a pack, but its pipelines build on a thread of their own while
|
||||
/// the game keeps drawing without one: the driver compiles a big pack's programs for seconds.
|
||||
/// `poll_shader_pack` runs it once they are built. `label` and `started` come back with it.
|
||||
@@ -6656,6 +6786,8 @@ impl Renderer {
|
||||
.map(|f| f.draw_layout.clone());
|
||||
let terrain = self.pack_atlas.is_some();
|
||||
let clouds = self.cloud_bindings.clone();
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let cancelled = cancel.clone();
|
||||
let build = move || {
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertex = wgpu::VertexBufferLayout {
|
||||
@@ -6690,7 +6822,7 @@ impl Renderer {
|
||||
weather: true,
|
||||
hand: true,
|
||||
};
|
||||
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world)
|
||||
pack_post::PackPost::new(&device, &queue, &adapter, pack, format, world, &cancelled)
|
||||
};
|
||||
// A pack is not the game's code: a panic building it turns the pack off (#3525).
|
||||
let thread = std::thread::Builder::new()
|
||||
@@ -6705,9 +6837,15 @@ impl Renderer {
|
||||
thread,
|
||||
label,
|
||||
started,
|
||||
_cancel: CancelOnDrop(cancel),
|
||||
});
|
||||
}
|
||||
Err(e) => log::error!("shader pack {label}: no thread to build it on: {e}"),
|
||||
Err(e) => {
|
||||
log::error!("shader pack {label}: no thread to build it on: {e}");
|
||||
// Off for this dimension, as a pack that fails, not tried again every frame.
|
||||
self.pack_loading = None;
|
||||
self.pack_post = Some((dimension, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The pack `load_shader_pack` builds, once its thread is done: it runs from now on, as
|
||||
@@ -6786,7 +6924,7 @@ impl Renderer {
|
||||
&& !std::mem::replace(&mut self.frame_kept_logged, true)
|
||||
{
|
||||
log::info!(
|
||||
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader API 0.2 pack picked or dropped since runs on it",
|
||||
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader pack picked or dropped since runs on it",
|
||||
self.frame_format
|
||||
);
|
||||
}
|
||||
@@ -7035,6 +7173,28 @@ impl Renderer {
|
||||
&self.lightmap_sampler,
|
||||
&self.emission_stats.view,
|
||||
);
|
||||
self.fluid_group = terrain_group(
|
||||
&self.device,
|
||||
"Still fluid resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&self.sprite_views,
|
||||
&self.fluid_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
&self.emission_stats.view,
|
||||
);
|
||||
self.terrain_model_group = terrain_group(
|
||||
&self.device,
|
||||
"Native cutout model resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&self.sprite_views,
|
||||
&self.terrain_model_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
&self.emission_stats.view,
|
||||
);
|
||||
}
|
||||
/// Block sprite layers per array on this device: a sprite's array is its layer over it, its
|
||||
/// layer in that array the rest (`block_sprites::Lookup`).
|
||||
@@ -7081,6 +7241,24 @@ impl Renderer {
|
||||
planes: frustum(views.cull().view_proj(bob)),
|
||||
}
|
||||
}
|
||||
/// A conservative view for geometry built before TAA chooses this frame's
|
||||
/// jitter. Cover a whole rendered pixel on each side, at the configured
|
||||
/// resolution scale/upscale, rather than borrowing the previous frame's size.
|
||||
pub fn camera_frustum_with_jitter(
|
||||
&self,
|
||||
fov_degrees: f32,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
bob: Mat4,
|
||||
depth_far: f32,
|
||||
) -> Frustum {
|
||||
let views = ViewSet::single(self.screen_view(fov_degrees, yaw, pitch, depth_far));
|
||||
taa_frustum(
|
||||
views.cull().view_proj(bob),
|
||||
self.world_post
|
||||
.taa_render_size([self.config.width, self.config.height]),
|
||||
)
|
||||
}
|
||||
fn screen_view(&self, fov_degrees: f32, yaw: f32, pitch: f32, far: f32) -> View {
|
||||
let aspect = self.config.width as f32 / self.config.height.max(1) as f32;
|
||||
View::screen(yaw, pitch, fov_degrees, aspect, far)
|
||||
@@ -8055,7 +8233,7 @@ impl Renderer {
|
||||
let fading_over_lods = self.sections_fading_over_lods(now);
|
||||
let fade_code = move |section: &SectionGpu, key: &SectionKey| {
|
||||
if fading_over_lods {
|
||||
section.fade(now, retiring.contains(key))
|
||||
section.fade(retiring.contains(key))
|
||||
} else {
|
||||
0
|
||||
}
|
||||
@@ -8676,8 +8854,12 @@ impl Renderer {
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
oit_entity_quads,
|
||||
fluid_quads,
|
||||
world_text_quads,
|
||||
world_text_offset_quads,
|
||||
native_solid_quads,
|
||||
native_cutout_quads,
|
||||
native_cutout_mipped_quads,
|
||||
] = self.entity_batch.update(
|
||||
entities,
|
||||
frame.additive,
|
||||
@@ -8704,13 +8886,23 @@ impl Renderer {
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads
|
||||
+ fluid_quads
|
||||
+ world_text_quads
|
||||
+ world_text_offset_quads;
|
||||
+ world_text_offset_quads
|
||||
+ native_solid_quads
|
||||
+ native_cutout_quads
|
||||
+ native_cutout_mipped_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads =
|
||||
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
|
||||
let base_quads = world_quads
|
||||
- oit_entity_quads
|
||||
- fluid_quads
|
||||
- world_text_quads
|
||||
- world_text_offset_quads
|
||||
- native_solid_quads
|
||||
- native_cutout_quads
|
||||
- native_cutout_mipped_quads;
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
@@ -8954,6 +9146,12 @@ impl Renderer {
|
||||
brightness,
|
||||
);
|
||||
}
|
||||
self.custom_sky.prepare(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
jittered,
|
||||
&self.custom_sky_draws,
|
||||
);
|
||||
let celestial_vertices = frame.celestial.len().min(2048 * 4);
|
||||
if frame.end_sky && !frame.title {
|
||||
self.end_sky.prepare(&self.device, frame.end_sky_sprite);
|
||||
@@ -9697,6 +9895,12 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
|
||||
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
|
||||
}
|
||||
// OptiFine custom sky layers, over the disc and sunrise and under the
|
||||
// sun, moon and stars.
|
||||
if self.custom_sky.is_drawing() {
|
||||
self.custom_sky.draw(&mut pass);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9709,6 +9913,13 @@ impl Renderer {
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if frame.end_sky {
|
||||
self.end_sky.draw(&mut pass);
|
||||
// The End's custom sky layers go over its sky box.
|
||||
if self.custom_sky.is_drawing() && !pack_draws_sky && self.native.is_none()
|
||||
{
|
||||
self.custom_sky.draw(&mut pass);
|
||||
pass.set_pipeline(&self.celestial_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
}
|
||||
}
|
||||
if celestial_vertices > 0 && !pack_draws_celestial {
|
||||
// The Overworld's bodies start with the sun's quad
|
||||
@@ -10123,6 +10334,50 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// Native terrain models use the block sprite arrays with their own
|
||||
// alpha/mipmap policies and ordinary opaque depth. Storage follows
|
||||
// the existing materials without moving their indices or frame tails.
|
||||
let mut first =
|
||||
world_quads - native_solid_quads - native_cutout_quads - native_cutout_mipped_quads;
|
||||
for (i, count) in [
|
||||
native_solid_quads,
|
||||
native_cutout_quads,
|
||||
native_cutout_mipped_quads,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if count > 0 {
|
||||
pass.set_pipeline(&self.terrain_model_pipelines[i]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
if i == 1 {
|
||||
&self.terrain_model_group
|
||||
} else {
|
||||
&self.fluid_group
|
||||
},
|
||||
&[],
|
||||
);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
first += count;
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
// Still-fluid block entities draw as their own ordinary sorted
|
||||
// translucent material, before terrain transparency. This material
|
||||
// always writes depth and never joins the improved-transparency queues.
|
||||
if fluid_quads > 0 {
|
||||
pass.set_pipeline(&self.fluid_pipeline);
|
||||
pass.set_bind_group(0, &self.fluid_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..fluid_quads * 6,
|
||||
((base_quads + oit_entity_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -10509,7 +10764,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
let first = base_quads + oit_entity_quads;
|
||||
let first = base_quads + oit_entity_quads + fluid_quads;
|
||||
for (pipeline, count, offset) in [
|
||||
(&self.world_text_pipeline, world_text_quads, first),
|
||||
(
|
||||
@@ -10903,7 +11158,10 @@ impl Renderer {
|
||||
// submission needs this one: screenshots' readbacks, the error scopes (one per
|
||||
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
|
||||
// Frame generation presents its own frames (`frame_gen.present` below).
|
||||
// A `screenshot-sequence` frame is submitted here, so it can wait for it to
|
||||
// finish.
|
||||
let (encoder, surface_frame, after) = if readback.is_none()
|
||||
&& frame.pack.frame_timing.is_none()
|
||||
&& fg_frames.is_none()
|
||||
&& hdr_readback.is_none()
|
||||
&& checks.is_none()
|
||||
@@ -10988,10 +11246,14 @@ impl Renderer {
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
self.screenshot_hdr = None;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
self.screenshot_hdr = Some(hdr_path);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
@@ -12026,6 +12288,16 @@ fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
|
||||
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
|
||||
// Widen x/y before extracting the planes: this includes every such shifted
|
||||
// view, including a posed camera, without changing the near/far planes.
|
||||
let [x, y] = rendered_size.map(|size| 1.0 / (1.0 + 2.0 / size.max(1) as f32));
|
||||
Frustum {
|
||||
planes: frustum(Mat4::from_scale(Vec3::new(x, y, 1.0)) * view_proj),
|
||||
}
|
||||
}
|
||||
|
||||
fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
let r = |i| m.row(i);
|
||||
let planes = [
|
||||
@@ -12041,6 +12313,57 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn taa_culling_covers_jittered_view_edges_but_still_rejects_behind_the_camera() {
|
||||
use super::*;
|
||||
|
||||
let mut outside_unjittered = 0;
|
||||
for size in [[1, 1], [320, 180], [1280, 720], [3840, 2160]] {
|
||||
for (yaw, pitch) in [(0.0, 0.0), (73.0, -35.0), (-142.0, 79.0)] {
|
||||
let pose = Mat4::from_translation(Vec3::new(0.03, -0.02, 0.01))
|
||||
* Mat4::from_scale(Vec3::new(1.1, 0.9, 1.0))
|
||||
* crate::camera::view_matrix(yaw, pitch);
|
||||
let matrix = world_projection(70.0, 16.0 / 9.0, 512.0) * pose;
|
||||
let guarded = taa_frustum(matrix, size);
|
||||
let ordinary = Frustum {
|
||||
planes: frustum(matrix),
|
||||
};
|
||||
for x in [-1.0, 1.0] {
|
||||
for y in [-1.0, 1.0] {
|
||||
let jitter = Mat4::from_translation(Vec3::new(
|
||||
x / size[0] as f32,
|
||||
y / size[1] as f32,
|
||||
0.0,
|
||||
));
|
||||
let inverse = (jitter * matrix).inverse();
|
||||
for clip_x in [-1.0, 0.0, 1.0] {
|
||||
for clip_y in [-1.0, 0.0, 1.0] {
|
||||
for depth in [0.001, 0.5, 0.999] {
|
||||
// Independent oracle: a point on an actual jittered
|
||||
// viewport edge, transformed back into world space.
|
||||
let point = inverse * Vec4::new(clip_x, clip_y, depth, 1.0);
|
||||
let point = point.truncate() / point.w;
|
||||
assert!(guarded.intersects_sphere(point, 1.0e-4));
|
||||
let slack = Vec3::splat(1.0e-4);
|
||||
assert!(guarded.intersects(point - slack, point + slack));
|
||||
if !ordinary.intersects_sphere(point, 0.0) {
|
||||
outside_unjittered += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let behind = pose.inverse().transform_point3(Vec3::new(0.0, 0.0, 10.0));
|
||||
assert!(!guarded.intersects_sphere(behind, 0.3));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
outside_unjittered > 0,
|
||||
"the test must need the jitter guard"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_culling_keeps_jittered_edges_and_depth_crossing_models() {
|
||||
use super::*;
|
||||
@@ -12424,8 +12747,14 @@ mod tests {
|
||||
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
|
||||
use super::frame_format;
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
|
||||
// No 0.2 pack (or a device without the float frame) leaves it as before.
|
||||
for linear in [(false, false), (false, true), (true, false)] {
|
||||
// No float frame asked for (or a device without it) leaves it as before.
|
||||
for linear in [
|
||||
(false, false, false),
|
||||
(false, false, true),
|
||||
(true, false, false),
|
||||
(false, true, false),
|
||||
(true, true, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
frame_format(true, true, true, Bgra8Unorm, linear),
|
||||
Rgba16Unorm
|
||||
@@ -12455,11 +12784,41 @@ mod tests {
|
||||
use wgpu::TextureFormat::Bgra8UnormSrgb;
|
||||
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, (true, true)),
|
||||
frame_format(
|
||||
wide_frame,
|
||||
hdr_offered,
|
||||
draws,
|
||||
Bgra8UnormSrgb,
|
||||
(true, false, true)
|
||||
),
|
||||
LINEAR_FRAME
|
||||
);
|
||||
}
|
||||
}
|
||||
/// HDR on without a pack under `HORIZON_HDR_FRAME=float` gets the float frame where the
|
||||
/// surface offers HDR output, Smooth Gradients or not; where it doesn't, the frame is as
|
||||
/// without the switch.
|
||||
#[test]
|
||||
fn hdr_without_a_pack_gets_the_float_frame_where_hdr_output_is_offered() {
|
||||
use super::{LINEAR_FRAME, frame_format};
|
||||
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb};
|
||||
for (wide_frame, draws) in [(true, true), (false, false)] {
|
||||
assert_eq!(
|
||||
frame_format(wide_frame, true, draws, Bgra8UnormSrgb, (false, true, true)),
|
||||
LINEAR_FRAME
|
||||
);
|
||||
assert_eq!(
|
||||
frame_format(
|
||||
wide_frame,
|
||||
false,
|
||||
draws,
|
||||
Bgra8UnormSrgb,
|
||||
(false, true, true)
|
||||
),
|
||||
Bgra8Unorm
|
||||
);
|
||||
}
|
||||
}
|
||||
/// What `draws_wide_frame` needs of a device: both features, and the usages and flags
|
||||
/// every pass takes of the frame. Missing any one of them keeps the 8-bit frame.
|
||||
#[test]
|
||||
@@ -12533,7 +12892,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -13292,7 +13651,8 @@ mod tests {
|
||||
last = now;
|
||||
}
|
||||
assert!(set_by.iter().all(|&n| n > 20), "flags set {set_by:?}");
|
||||
assert!(handed_back > 2_000, "{handed_back} handed back");
|
||||
// Over a thousand: the half of the uploads over far terrain come in whole.
|
||||
assert!(handed_back > 1_000, "{handed_back} handed back");
|
||||
// Switched off with sections still flagged: none keeps a record or a
|
||||
// flag, and each is marked as while GPU culling is off.
|
||||
assert!(sim.records.fading() > 0, "none flagged at the end");
|
||||
@@ -14212,8 +14572,12 @@ mod shadow_caster_tests {
|
||||
|
||||
/// A `shaders/` pack building on its own thread (`Renderer::load_shader_pack`): the dimension it
|
||||
/// is for, the thread (a panic in it contained), and what the game named it with and when.
|
||||
/// Dropping it (another pack, none, or another dimension) cancels the build: the thread stops at
|
||||
/// the next program instead of compiling the rest for nothing.
|
||||
struct PackLoading {
|
||||
dimension: i32,
|
||||
/// Held to be dropped with it: that cancels the build.
|
||||
_cancel: CancelOnDrop,
|
||||
#[allow(clippy::type_complexity)]
|
||||
thread: std::thread::JoinHandle<
|
||||
std::result::Result<std::result::Result<pack_post::PackPost, pack_post::Refused>, String>,
|
||||
@@ -14221,3 +14585,12 @@ struct PackLoading {
|
||||
label: String,
|
||||
started: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Sets its flag when dropped: `PackLoading`'s cancel, which `PackPost::new` checks per program.
|
||||
struct CancelOnDrop(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,8 @@ pub struct FrameInputs {
|
||||
pub skybox: bool,
|
||||
pub end_sky: bool,
|
||||
pub end_sky_sprite: Option<[f32; 4]>,
|
||||
#[serde(default)]
|
||||
pub boss_world_fog: bool,
|
||||
pub environment_fog: Option<(f32, f32)>,
|
||||
pub water_fog: [f32; 3],
|
||||
pub fog: Option<FogData>,
|
||||
@@ -414,6 +416,7 @@ impl Renderer {
|
||||
skybox: frame.skybox,
|
||||
end_sky: frame.end_sky,
|
||||
end_sky_sprite: frame.end_sky_sprite,
|
||||
boss_world_fog: frame.boss_world_fog,
|
||||
environment_fog: frame.environment_fog,
|
||||
water_fog: frame.water_fog,
|
||||
fog: frame.fog,
|
||||
@@ -510,6 +513,7 @@ impl Scene {
|
||||
skybox: f.skybox,
|
||||
end_sky: f.end_sky,
|
||||
end_sky_sprite: f.end_sky_sprite,
|
||||
boss_world_fog: f.boss_world_fog,
|
||||
environment_fog: f.environment_fog,
|
||||
water_fog: f.water_fog,
|
||||
fog: f.fog,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
Blend::Add,
|
||||
Blend::Subtract,
|
||||
Blend::Multiply,
|
||||
Blend::Dodge,
|
||||
Blend::Burn,
|
||||
Blend::Screen,
|
||||
Blend::Replace,
|
||||
Blend::Overlay,
|
||||
Blend::Alpha,
|
||||
];
|
||||
|
||||
/// Layers drawn in a frame at most; a pack's later ones wait for a frame with fewer.
|
||||
const MAX_DRAWS: usize = 32;
|
||||
/// Dynamic uniform offsets are 256-byte aligned on every backend.
|
||||
const STRIDE: u64 = 256;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
brightness: f32,
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Vertex {
|
||||
position: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
cell: [f32; 4],
|
||||
}
|
||||
|
||||
pub(super) struct CustomSky {
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
/// One bind group per layer texture of the pack.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
/// This frame's layers: bind group and pipeline indices.
|
||||
frame: Vec<(u16, u8)>,
|
||||
}
|
||||
|
||||
/// The fragment's output for each mode is in custom_sky.wgsl; these factors finish it.
|
||||
fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
use wgpu::BlendFactor as F;
|
||||
let (src_factor, dst_factor, operation) = match mode {
|
||||
Blend::Add => (F::One, F::One, wgpu::BlendOperation::Add),
|
||||
Blend::Subtract => (F::One, F::One, wgpu::BlendOperation::ReverseSubtract),
|
||||
Blend::Multiply => (F::Zero, F::Src, wgpu::BlendOperation::Add),
|
||||
// dst + dst * src: lightens.
|
||||
Blend::Dodge => (F::Dst, F::One, wgpu::BlendOperation::Add),
|
||||
// dst * (1 - src): darkens.
|
||||
Blend::Burn => (F::Zero, F::OneMinusSrc, wgpu::BlendOperation::Add),
|
||||
// src + dst * (1 - src) = 1 - (1 - src)(1 - dst).
|
||||
Blend::Screen => (F::One, F::OneMinusSrc, wgpu::BlendOperation::Add),
|
||||
Blend::Replace => (F::One, F::Zero, wgpu::BlendOperation::Add),
|
||||
// dst * src + src * dst.
|
||||
Blend::Overlay => (F::Dst, F::Src, wgpu::BlendOperation::Add),
|
||||
Blend::Alpha => (F::SrcAlpha, F::OneMinusSrcAlpha, wgpu::BlendOperation::Add),
|
||||
};
|
||||
wgpu::BlendComponent {
|
||||
src_factor,
|
||||
dst_factor,
|
||||
operation,
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
for face in Face::ALL {
|
||||
let cell = face.cell();
|
||||
let [u0, v0, u1, v1] = cell;
|
||||
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
|
||||
let corners = face.corners();
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
out.push(Vertex {
|
||||
position: corners[i],
|
||||
uv: uv[i],
|
||||
cell,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl CustomSky {
|
||||
pub(super) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Custom sky bindings"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: true,
|
||||
min_binding_size: wgpu::BufferSize::new(
|
||||
std::mem::size_of::<Uniform>() as u64
|
||||
),
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Custom sky shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Custom sky layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
|
||||
let pipelines = MODES
|
||||
.iter()
|
||||
.map(|&mode| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
|
||||
// under the layers keeps its own.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(mode),
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Custom sky"),
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Custom sky layers"),
|
||||
size: STRIDE * MAX_DRAWS as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Custom sky box"),
|
||||
contents: bytemuck::cast_slice(&mesh()),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Self {
|
||||
pipelines,
|
||||
layout,
|
||||
sampler,
|
||||
uniform,
|
||||
vertices,
|
||||
groups: Vec::new(),
|
||||
frame: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
let (width, height, rgba) = if image.width <= limit && image.height <= limit {
|
||||
(image.width, image.height, &image.rgba[..])
|
||||
} else {
|
||||
log::warn!(
|
||||
"custom sky texture of {}x{} too large",
|
||||
image.width,
|
||||
image.height
|
||||
);
|
||||
(1, 1, &[0u8; 4][..])
|
||||
};
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
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: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
rgba,
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.uniform,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(std::mem::size_of::<Uniform>() as u64),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Upload this frame's layers; `clip` is clip space from camera-relative world.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
clip: Mat4,
|
||||
draws: &[Draw],
|
||||
) {
|
||||
self.frame.clear();
|
||||
for draw in draws.iter().take(MAX_DRAWS) {
|
||||
if usize::from(draw.image) >= self.groups.len() {
|
||||
continue;
|
||||
}
|
||||
let mode = MODES.iter().position(|m| *m == draw.blend).unwrap_or(0);
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: (clip * draw.rotation).to_cols_array_2d(),
|
||||
brightness: draw.brightness.clamp(0.0, 1.0),
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
|
||||
self.frame.push((draw.image, mode as u8));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_drawing(&self) -> bool {
|
||||
!self.frame.is_empty()
|
||||
}
|
||||
|
||||
/// The layers [`CustomSky::prepare`] uploaded. Leaves the pass's pipeline and bind group 0
|
||||
/// changed.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
for (i, &(image, mode)) in self.frame.iter().enumerate() {
|
||||
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
&self.groups[usize::from(image)],
|
||||
&[(STRIDE * i as u64) as u32],
|
||||
);
|
||||
pass.draw(0..36, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
let k = a * b;
|
||||
let src = match mode {
|
||||
Blend::Multiply => 1.0 + (t - 1.0) * k,
|
||||
Blend::Overlay => 0.5 + (t - 0.5) * k,
|
||||
Blend::Replace | Blend::Alpha => t,
|
||||
_ => t * k,
|
||||
};
|
||||
let c = blend(mode);
|
||||
let f = |factor: wgpu::BlendFactor| match factor {
|
||||
wgpu::BlendFactor::One => 1.0,
|
||||
wgpu::BlendFactor::Zero => 0.0,
|
||||
wgpu::BlendFactor::Src => src,
|
||||
wgpu::BlendFactor::OneMinusSrc => 1.0 - src,
|
||||
wgpu::BlendFactor::Dst => dst,
|
||||
wgpu::BlendFactor::SrcAlpha => k,
|
||||
wgpu::BlendFactor::OneMinusSrcAlpha => 1.0 - k,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let (s, d) = (src * f(c.src_factor), dst * f(c.dst_factor));
|
||||
match c.operation {
|
||||
wgpu::BlendOperation::Add => s + d,
|
||||
wgpu::BlendOperation::ReverseSubtract => d - s,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mode_gives_its_documented_result() {
|
||||
let close = |a: f32, b: f32| assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
// Full brightness and alpha, a sky texel of 0.4 under a layer texel of 0.8.
|
||||
let (d, t) = (0.4, 0.8);
|
||||
close(mix(Blend::Add, d, t, 1.0, 1.0), 1.2);
|
||||
close(mix(Blend::Subtract, d, t, 1.0, 1.0), -0.4);
|
||||
close(mix(Blend::Multiply, d, t, 1.0, 1.0), 0.32);
|
||||
close(mix(Blend::Screen, d, t, 1.0, 1.0), 1.0 - 0.6 * 0.2);
|
||||
close(mix(Blend::Replace, d, t, 1.0, 1.0), 0.8);
|
||||
close(mix(Blend::Overlay, d, t, 1.0, 1.0), 0.64);
|
||||
close(mix(Blend::Overlay, d, 0.2, 1.0, 1.0), 0.16);
|
||||
close(mix(Blend::Alpha, d, t, 0.25, 1.0), 0.4 * 0.75 + 0.8 * 0.25);
|
||||
assert!(mix(Blend::Dodge, d, t, 1.0, 1.0) > d);
|
||||
assert!(mix(Blend::Burn, d, t, 1.0, 1.0) < d);
|
||||
// Faded out, every mode but replace (drawn only when bright) leaves the sky alone.
|
||||
for mode in MODES {
|
||||
if mode != Blend::Replace {
|
||||
close(mix(mode, d, t, 1.0, 0.0), d);
|
||||
}
|
||||
}
|
||||
// Half faded: add adds half.
|
||||
close(mix(Blend::Add, d, t, 1.0, 0.5), 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_covers_every_face_with_its_cell() {
|
||||
let mesh = mesh();
|
||||
assert_eq!(mesh.len(), 36);
|
||||
for v in &mesh {
|
||||
let [u0, v0, u1, v1] = v.cell;
|
||||
assert!((u0..=u1).contains(&v.uv[0]) && (v0..=v1).contains(&v.uv[1]));
|
||||
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// OptiFine custom sky layers (gfx/custom_sky.rs): one skybox per draw, its six faces cut from a
|
||||
// 3x2 texture, mixed with the sky under it by the pipeline's blend. The fragment's output is
|
||||
// chosen so that blend gives the layer's documented mode at its brightness, and a layer at
|
||||
// brightness 0 leaves the sky as it was.
|
||||
struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
|
||||
@group(0) @binding(2) var sky_sampler: sampler;
|
||||
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
// The face's cell (u0, v0, u1, v1): sampling stays half a texel inside it.
|
||||
@location(1) @interpolate(flat) cell: vec4<f32>,
|
||||
}
|
||||
|
||||
@vertex fn vs_custom_sky(
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(1) uv: vec2<f32>,
|
||||
@location(2) cell: vec4<f32>,
|
||||
) -> Out {
|
||||
var out: Out;
|
||||
out.clip = layer.clip_from_sky * vec4(position * 100.0, 1.0);
|
||||
out.uv = uv;
|
||||
out.cell = cell;
|
||||
return out;
|
||||
}
|
||||
|
||||
const ADD: u32 = 0u;
|
||||
const SUBTRACT: u32 = 1u;
|
||||
const MULTIPLY: u32 = 2u;
|
||||
const DODGE: u32 = 3u;
|
||||
const BURN: u32 = 4u;
|
||||
const SCREEN: u32 = 5u;
|
||||
const REPLACE: u32 = 6u;
|
||||
const OVERLAY: u32 = 7u;
|
||||
const ALPHA: u32 = 8u;
|
||||
|
||||
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
|
||||
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
|
||||
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
|
||||
let t = textureSample(sky_texture, sky_sampler, uv);
|
||||
let k = t.a * layer.brightness;
|
||||
switch layer.mode {
|
||||
// dst * src: white leaves the sky alone.
|
||||
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
|
||||
// 2 * dst * src: grey leaves it alone.
|
||||
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
|
||||
case REPLACE: { return vec4(t.rgb, 1.0); }
|
||||
// mix(dst, src, alpha).
|
||||
case ALPHA: { return vec4(t.rgb, k); }
|
||||
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
|
||||
default: { return vec4(t.rgb * k, 1.0); }
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,24 @@ use super::Vertex;
|
||||
/// 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.
|
||||
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
|
||||
const MATERIALS: usize = 12;
|
||||
/// Sorted still-fluid geometry follows, then the two native world-canvas text materials,
|
||||
/// NORMAL and POLYGON_OFFSET. Material storage order is independent of their draw stages.
|
||||
const MATERIALS: usize = 16;
|
||||
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
|
||||
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
|
||||
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
|
||||
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
|
||||
pub fn is_native_terrain(layer: u32) -> bool {
|
||||
matches!(
|
||||
layer & 0xffff,
|
||||
FLUID_OVERLAY
|
||||
| TERRAIN_MODEL_SOLID_OVERLAY
|
||||
| TERRAIN_MODEL_CUTOUT_OVERLAY
|
||||
| TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY
|
||||
)
|
||||
}
|
||||
/// Sorted depth-writing still fluid, sampling terrain sprite arrays rather than the shared atlas.
|
||||
pub const FLUID_OVERLAY: u32 = 41;
|
||||
/// 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`,
|
||||
@@ -31,7 +47,9 @@ pub const DISSOLVE_OVERLAY: u32 = 38;
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
for vertex in quads {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
if !is_native_terrain(vertex.layer) {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
@@ -60,12 +78,21 @@ pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
|
||||
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
|
||||
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
match layer & 0xffff {
|
||||
TERRAIN_MODEL_SOLID_OVERLAY => return 13,
|
||||
TERRAIN_MODEL_CUTOUT_OVERLAY => return 14,
|
||||
TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY => return 15,
|
||||
_ => {}
|
||||
}
|
||||
if layer & 0xffff == FLUID_OVERLAY {
|
||||
return 10;
|
||||
}
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
WORLD_TEXT_OVERLAY => 10,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
|
||||
WORLD_TEXT_OVERLAY => 11,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 12,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -148,10 +175,10 @@ impl EntityBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
// RenderType.text sorts alpha-blended quads from far to near. Keep
|
||||
// both world-canvas layers after existing materials (including eyes
|
||||
// moved out of additive); front text uses polygon offset.
|
||||
for runs in &self.runs[10..] {
|
||||
// Fluid and RenderType.text sort their own alpha-blended quads far to
|
||||
// near. Fluid precedes both world-canvas layers, after existing materials
|
||||
// (including eyes moved out of additive).
|
||||
for runs in &self.runs[10..13] {
|
||||
let start = self.combined.len();
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
@@ -169,6 +196,13 @@ impl EntityBatch {
|
||||
distance(b).total_cmp(&distance(a))
|
||||
});
|
||||
}
|
||||
// Native solid/cutout models are depth writing and keep their submission order.
|
||||
for runs in &self.runs[13..] {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
@@ -189,7 +223,57 @@ mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
#[test]
|
||||
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
|
||||
fn native_models_keep_opaque_order_and_do_not_move_existing_materials_or_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [x, 0.0, 0.0],
|
||||
..geometry(1, false)[0]
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [
|
||||
(FLUID_OVERLAY, 10.0),
|
||||
(WORLD_TEXT_OVERLAY, 11.0),
|
||||
(TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16, 3.0),
|
||||
(TERRAIN_MODEL_SOLID_OVERLAY, 1.0),
|
||||
(TERRAIN_MODEL_CUTOUT_OVERLAY, 4.0),
|
||||
(TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY, 5.0),
|
||||
(OIT_ENTITY, 6.0),
|
||||
(0, 7.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
let counts = batch.update(
|
||||
&entities,
|
||||
&quad(0, 8.0),
|
||||
&quad(0, 9.0),
|
||||
&quad(0, 12.0),
|
||||
true,
|
||||
);
|
||||
assert_eq!(counts, [1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 2, 1, 1]);
|
||||
let xs: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
xs,
|
||||
[7.0, 6.0, 10.0, 11.0, 3.0, 1.0, 4.0, 5.0, 8.0, 9.0, 12.0]
|
||||
);
|
||||
assert_eq!(
|
||||
batch.combined[16].layer,
|
||||
TERRAIN_MODEL_SOLID_OVERLAY | 279 << 16
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tagging_oit_does_not_corrupt_a_native_models_sprite_index() {
|
||||
let mut vertices = [geometry(1, false)[0]; 2];
|
||||
vertices[0].layer = TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16;
|
||||
vertices[1].layer = 3;
|
||||
mark_oit(&mut vertices);
|
||||
assert_eq!(vertices[0].layer, TERRAIN_MODEL_CUTOUT_OVERLAY | 17 << 16);
|
||||
assert_eq!(vertices[1].layer, 3 | OIT_ENTITY);
|
||||
}
|
||||
#[test]
|
||||
fn fluid_and_world_canvas_sort_after_oit_eyes_before_frame_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
@@ -204,6 +288,8 @@ mod tests {
|
||||
(WORLD_TEXT_OVERLAY, 4.0),
|
||||
(WORLD_TEXT_OVERLAY, 5.0),
|
||||
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
|
||||
(FLUID_OVERLAY, 11.0),
|
||||
(FLUID_OVERLAY | OIT_ENTITY, 12.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
@@ -215,12 +301,14 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
assert_eq!(
|
||||
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 1, 0, 0, 0]
|
||||
);
|
||||
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
|
||||
[
|
||||
1.0, 2.0, 3.0, 7.0, 12.0, 11.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,12 +408,12 @@ mod tests {
|
||||
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, 0, 0]);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 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, 0, 0]);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0]);
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Sorted still-fluid material: ordinary back culling, alpha blend and color/depth writes.
|
||||
pub(super) const SAMPLER: wgpu::SamplerDescriptor<'static> = wgpu::SamplerDescriptor {
|
||||
label: Some("Still fluid (nearest texels, linear mipmaps)"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
lod_min_clamp: 0.0,
|
||||
lod_max_clamp: 32.0,
|
||||
compare: None,
|
||||
anisotropy_clamp: 1,
|
||||
border_color: None,
|
||||
};
|
||||
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
constants: &[(&str, f64)],
|
||||
) -> wgpu::RenderPipeline {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Still fluid"),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_native_fluid"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_native_fluid"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
@@ -849,6 +849,15 @@ impl NativeFrame {
|
||||
inputs
|
||||
}
|
||||
|
||||
/// Forgets the frames before, as a freshly loaded pack: the post stage's next `history` is
|
||||
/// black, eased values start at their targets, wetness dry, the frame count at 0.
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.last = None;
|
||||
self.frames = 0;
|
||||
self.wetness = (0.0, None);
|
||||
self.start = std::time::Instant::now();
|
||||
}
|
||||
|
||||
/// Takes the post stage's pending history reset. It stays pending if post rendering is
|
||||
/// skipped for a frame, so the first frame it does render still sees black history.
|
||||
pub(super) fn take_history_reset(&mut self) -> bool {
|
||||
@@ -1119,6 +1128,13 @@ impl NativePost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Forgets the pack's own targets (`[[post]] output`): made again, blank, on the next frame,
|
||||
/// as for a pack just made. Its pipelines stay.
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.named = None;
|
||||
self.groups = None;
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -1737,6 +1753,152 @@ mod tests {
|
||||
assert!(!native.take_history_reset());
|
||||
}
|
||||
|
||||
/// After a reset, the same frames give the same inputs, to the bit, as a pack just made,
|
||||
/// however the frames before moved the eased values, wetness and the frame count.
|
||||
#[test]
|
||||
fn a_reset_frame_gives_a_fresh_pack_s_inputs() {
|
||||
use crate::gfx::pack_uniforms::FrameTiming;
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let hud = crate::hud::HudBatch::new([4, 4]);
|
||||
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
|
||||
let new = || {
|
||||
NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_1,
|
||||
)
|
||||
};
|
||||
let (mut used, mut fresh) = (new(), new());
|
||||
frame.pack.rain = 1.0;
|
||||
frame.pack.eye_light = [12, 3];
|
||||
frame.pack.eye_in = 1;
|
||||
for n in 1..=5 {
|
||||
frame.pack.frame_timing = Some(FrameTiming::nth(n, 700.0));
|
||||
used.next_inputs(&frame, [4, 4]);
|
||||
}
|
||||
used.reset_history();
|
||||
frame.pack.eye_light = [2, 15];
|
||||
frame.pack.eye_in = 0;
|
||||
for n in 1..=3 {
|
||||
frame.pack.frame_timing = Some(FrameTiming::nth(n, 50.0));
|
||||
let inputs = [&mut used, &mut fresh].map(|f| f.next_inputs(&frame, [4, 4]));
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(&inputs[0]),
|
||||
bytemuck::bytes_of(&inputs[1]),
|
||||
"frame {n}"
|
||||
);
|
||||
assert_eq!(used.take_history_reset(), fresh.take_history_reset());
|
||||
}
|
||||
}
|
||||
|
||||
/// After a reset a pass reads its pack's own targets blank, as in a pack just made: here
|
||||
/// `fb`, which a pass reads before the pass that writes it, so it holds last frame's.
|
||||
#[test]
|
||||
fn a_reset_post_reads_its_own_targets_blank() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let manifest = "schema = 1
|
||||
id = \"t\"
|
||||
name = \"T\"
|
||||
version = \"1\"
|
||||
api = \"0.1\"
|
||||
[[post]]
|
||||
file = \"a.wgsl\"
|
||||
[[post]]
|
||||
file = \"b.wgsl\"
|
||||
output = \"fb\"
|
||||
[[post]]
|
||||
file = \"c.wgsl\"
|
||||
";
|
||||
let a = "fn post(input: PostInput) -> vec4<f32> { return vec4<f32>(textureLoad(fb, vec2<i32>(input.pixel), 0).rgb + 0.25, 1.0); }
|
||||
";
|
||||
let pass = "fn post(input: PostInput) -> vec4<f32> { return input.color; }
|
||||
";
|
||||
let files = [
|
||||
(MANIFEST, manifest),
|
||||
("a.wgsl", a),
|
||||
("b.wgsl", pass),
|
||||
("c.wgsl", pass),
|
||||
]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_1,
|
||||
);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
.create_view(&Default::default());
|
||||
let screen = texture(
|
||||
&device,
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let view = screen.create_view(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let red = |post: &mut NativePost| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let world = (&color, &depth, &frame.surface_view);
|
||||
post.run(&device, &mut encoder, world, &view, [4, 4], false, None);
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
|
||||
rows_per_image: Some(4),
|
||||
},
|
||||
},
|
||||
screen.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let red = buffer.slice(..).get_mapped_range().unwrap()[0];
|
||||
buffer.unmap();
|
||||
red
|
||||
};
|
||||
let near = |got: u8, want: u8| got.abs_diff(want) <= 1;
|
||||
for want in [64, 128, 191] {
|
||||
let got = red(&mut post);
|
||||
assert!(near(got, want), "{got} before the reset, {want} expected");
|
||||
}
|
||||
post.reset_history();
|
||||
let got = red(&mut post);
|
||||
assert!(near(got, 64), "{got} after the reset: fb kept last frame's");
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -217,6 +217,41 @@ impl CenterDepth {
|
||||
})
|
||||
}
|
||||
|
||||
/// Once the GPU has finished the frame (`screenshot-sequence`), the depth it copied, as
|
||||
/// the next frame reads it: an error unless exactly that one sample was read back, never
|
||||
/// an older one.
|
||||
pub fn take_completed(&mut self, device: &wgpu::Device) -> Result<f32, String> {
|
||||
device
|
||||
.poll(wgpu::PollType::Poll)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let state = |s: &Slot| s.state.load(Ordering::Acquire);
|
||||
let mut mapped = self.slots.iter().filter(|s| state(s) == MAPPED);
|
||||
let (Some(slot), None) = (mapped.next(), mapped.next()) else {
|
||||
return Err("the frame's centre depth wasn't read back alone".into());
|
||||
};
|
||||
if self
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| !matches!(state(s), FREE | MAPPED))
|
||||
{
|
||||
return Err("the frame's centre depth wasn't read back alone".into());
|
||||
}
|
||||
let depth = slot
|
||||
.buffer
|
||||
.slice(..4)
|
||||
.get_mapped_range()
|
||||
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]))
|
||||
.map_err(|e| e.to_string());
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
let depth = depth?;
|
||||
if !depth.is_finite() {
|
||||
return Err("the centre depth isn't finite".into());
|
||||
}
|
||||
self.latest = Some(depth.clamp(0.0, 1.0));
|
||||
Ok(depth.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// The newest depth read back so far, `None` before the first arrives.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
@@ -265,6 +300,76 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// Inline and on the submit thread: each finished frame's depth, then none left to
|
||||
/// take; several outstanding samples are refused.
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU"]
|
||||
fn a_finished_frame_s_depth_is_taken_alone_never_an_older_one() {
|
||||
let (device, raw) = test_device().expect("host GPU required");
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 3,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = depth.create_view(&Default::default());
|
||||
for threaded in [false, true] {
|
||||
let queue = super::super::submit::Queue::new(raw.clone(), threaded);
|
||||
if threaded {
|
||||
assert!(queue.threaded(), "deferred submit unavailable");
|
||||
}
|
||||
let mut center = CenterDepth::new(&device);
|
||||
assert!(center.take_completed(&device).is_err());
|
||||
for value in [0.125, 0.75, 0.25] {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
clear(&mut encoder, &view, value);
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
let after = center.submitted().expect("one copied sample");
|
||||
if threaded {
|
||||
let job = super::super::submit::Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after: Some(Box::new(after)),
|
||||
};
|
||||
assert!(queue.submit_later(job).is_ok());
|
||||
queue.wait();
|
||||
} else {
|
||||
queue.submit([encoder.finish()]);
|
||||
after();
|
||||
}
|
||||
device
|
||||
.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: Some(std::time::Duration::from_secs(30)),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(center.take_completed(&device).unwrap(), value);
|
||||
assert_eq!(center.collect(&device), Some(value));
|
||||
assert!(
|
||||
center.take_completed(&device).is_err(),
|
||||
"no stale latest fallback"
|
||||
);
|
||||
}
|
||||
// Several outstanding copies are ambiguous, even after they all map.
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
for _ in 0..SLOTS {
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted().unwrap()();
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert!(center.take_completed(&device).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames that go to the submit thread start their readback there: the centre depth
|
||||
/// follows the frames instead of keeping the first ones' depth.
|
||||
#[test]
|
||||
|
||||
@@ -454,9 +454,31 @@ impl PackEntities {
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
|
||||
// Keep it in its own material even when a shader pack draws the
|
||||
// model in the same block-entity run. Do not corrupt either UV space.
|
||||
let whole: &[Vertex] = if whole
|
||||
.chunks(4)
|
||||
.any(|q| super::is_native_terrain(q[0].layer))
|
||||
{
|
||||
without_fluids.clear();
|
||||
for quad in whole.chunks(4) {
|
||||
if super::is_native_terrain(quad[0].layer) {
|
||||
if !span.hidden {
|
||||
rest.extend_from_slice(quad);
|
||||
}
|
||||
} else {
|
||||
without_fluids.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
&without_fluids
|
||||
} else {
|
||||
whole
|
||||
};
|
||||
// A block entity's run can hold End portal faces and beams beside plain parts (an End
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
@@ -1141,6 +1163,83 @@ fn skin_runs(skin: &super::entity_skin::SkinFrame) -> Vec<(EntitySpan, Range<u32
|
||||
mod gpu_span_tests {
|
||||
use super::*;
|
||||
use crate::gfx::entity_skin::{SkinFrame, SkinJob};
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn native_fluid_stays_native_while_model_uses_pack() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
for overlay in [
|
||||
super::super::FLUID_OVERLAY,
|
||||
super::super::TERRAIN_MODEL_SOLID_OVERLAY,
|
||||
super::super::TERRAIN_MODEL_CUTOUT_OVERLAY,
|
||||
super::super::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
|
||||
] {
|
||||
let mut mixed = vec![
|
||||
Vertex {
|
||||
layer: 0,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
8
|
||||
];
|
||||
for v in &mut mixed[4..] {
|
||||
v.layer = overlay | 279 << 16;
|
||||
v.uv = [0.25, 0.75];
|
||||
v.glint_uv = [17.0, 0.0];
|
||||
}
|
||||
let mut rest = Vec::new();
|
||||
for hidden in [false, true] {
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&mixed,
|
||||
&[EntitySpan {
|
||||
start: 0,
|
||||
id: 77,
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
hidden,
|
||||
..Default::default()
|
||||
}],
|
||||
[true; PROGRAMS],
|
||||
([0.; 4], [1; 2]),
|
||||
Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert_eq!(
|
||||
geometry.scratch.len(),
|
||||
4,
|
||||
"the model still uses the shader pack"
|
||||
);
|
||||
assert_eq!(geometry.draws.len(), 1);
|
||||
assert_eq!(
|
||||
(geometry.draws[0].pack, geometry.draws[0].shadow),
|
||||
(!hidden, true)
|
||||
);
|
||||
if hidden {
|
||||
assert!(
|
||||
rest.is_empty(),
|
||||
"hidden native fluid must not leak into the visible pass"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&rest),
|
||||
bytemuck::cast_slice::<_, u8>(&mixed[4..]),
|
||||
"fluid UV/layer payload cannot be sampled from the entity atlas"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_draws_preserve_subject_ids_overlays_and_hidden_shadow_casters() {
|
||||
let mut skin = SkinFrame {
|
||||
|
||||
@@ -23,6 +23,13 @@ pub(super) struct PackImage {
|
||||
}
|
||||
|
||||
impl PackImage {
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.view = None;
|
||||
self.copy = None;
|
||||
self.before = None;
|
||||
self.made_for = [0; 2];
|
||||
}
|
||||
|
||||
pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
|
||||
self.format
|
||||
.sample_type(None, Some(features))
|
||||
@@ -316,6 +323,11 @@ impl StandIn {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reset_stand_ins(device: &wgpu::Device, images: &mut Vec<StandIn>) {
|
||||
let wanted = images.iter().map(|i| (i.format, i.dimension)).collect();
|
||||
*images = stand_ins(device, wanted);
|
||||
}
|
||||
|
||||
/// One stand-in for each format and dimension of `wanted`.
|
||||
pub(super) fn stand_ins(
|
||||
device: &wgpu::Device,
|
||||
@@ -392,6 +404,12 @@ pub(super) struct Buffers {
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
self.group = None;
|
||||
self.buffers.clear();
|
||||
self.made_for = [0; 2];
|
||||
}
|
||||
|
||||
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
|
||||
/// `minimums` long.
|
||||
pub(super) fn new(
|
||||
|
||||
@@ -149,6 +149,7 @@ void main() {
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
|
||||
@@ -31,6 +31,9 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
mod packed;
|
||||
|
||||
#[cfg(test)]
|
||||
mod history_reset_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
@@ -73,6 +76,9 @@ mod custom_type_tests;
|
||||
#[cfg(test)]
|
||||
mod sky_depth_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod end_fan_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_length_tests;
|
||||
|
||||
@@ -375,12 +381,13 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass's pipeline: a draw, or a compute dispatch.
|
||||
/// A pass's pipeline: a draw, or a compute dispatch. Its slot is filled when the pack's
|
||||
/// pipelines are compiled (`compile`), all of them at once once the pack is laid out.
|
||||
#[derive(Clone)]
|
||||
enum Pipeline {
|
||||
Render(wgpu::RenderPipeline),
|
||||
Render(Slot<wgpu::RenderPipeline>),
|
||||
Compute {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
pipeline: Slot<wgpu::ComputePipeline>,
|
||||
/// `workGroups`, else `workGroupsRender` and the local size.
|
||||
groups: Option<[u32; 3]>,
|
||||
render: [f32; 2],
|
||||
@@ -395,10 +402,12 @@ struct Pass {
|
||||
pipeline: Pipeline,
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[wgpu::RenderPipeline; 2]>,
|
||||
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
/// Shader-declared defaults must survive a reset of the mutable upload block.
|
||||
initial_block: Vec<u8>,
|
||||
/// (index into `pack_uniforms::NAMES`, declared type, offset).
|
||||
uniforms: Vec<(Option<usize>, String, u32)>,
|
||||
samplers: Vec<Binding>,
|
||||
@@ -452,9 +461,19 @@ struct Pass {
|
||||
}
|
||||
|
||||
impl Pass {
|
||||
/// Back to the uniform values the pass was made with, and no bind groups.
|
||||
fn reset_history(&mut self) {
|
||||
self.block.clone_from(&self.initial_block);
|
||||
self.groups.clear();
|
||||
self.portal_groups.clear();
|
||||
for compute in &mut self.compute {
|
||||
compute.reset_history();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self) -> &wgpu::RenderPipeline {
|
||||
match &self.pipeline {
|
||||
Pipeline::Render(p) => p,
|
||||
Pipeline::Render(p) => p.get().expect("compiled with its pack"),
|
||||
Pipeline::Compute { .. } => unreachable!("a compute pass draws nothing"),
|
||||
}
|
||||
}
|
||||
@@ -497,6 +516,7 @@ struct Targets {
|
||||
}
|
||||
|
||||
/// What a pack's gbuffers programs read and draw from the game's world.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
@@ -1225,6 +1245,7 @@ fn block_ids(
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
/// (flat.rs), the layout of their bind group 1.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Geometry<'a> {
|
||||
pub buffers: &'a [Option<wgpu::VertexBufferLayout<'a>>],
|
||||
pub table: Option<&'a wgpu::BindGroupLayout>,
|
||||
@@ -1268,6 +1289,177 @@ struct Resources<'a> {
|
||||
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
|
||||
/// unset): the translator's helpers blend a gather of it (`hz_depth_linear`).
|
||||
shadow_linear: u8,
|
||||
/// While the pack is laid out for `compile`: the pipelines its programs need. `None`
|
||||
/// compiles each as its program is built.
|
||||
jobs: Option<std::cell::RefCell<Vec<Job>>>,
|
||||
}
|
||||
|
||||
impl Resources<'_> {
|
||||
/// The slot of the render pipeline `job` makes: filled now, or by `compile` when the pack
|
||||
/// is laid out.
|
||||
fn render_pipeline(&self, device: &wgpu::Device, job: RenderJob) -> Slot<wgpu::RenderPipeline> {
|
||||
let slot = Slot::default();
|
||||
let job = Job::Render(slot.clone(), job);
|
||||
match &self.jobs {
|
||||
Some(jobs) => jobs.borrow_mut().push(job),
|
||||
None => job.create(device),
|
||||
}
|
||||
slot
|
||||
}
|
||||
/// As `render_pipeline`, for a compute pipeline.
|
||||
fn compute_pipeline(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
job: ComputeJob,
|
||||
) -> Slot<wgpu::ComputePipeline> {
|
||||
let slot = Slot::default();
|
||||
let job = Job::Compute(slot.clone(), job);
|
||||
match &self.jobs {
|
||||
Some(jobs) => jobs.borrow_mut().push(job),
|
||||
None => job.create(device),
|
||||
}
|
||||
slot
|
||||
}
|
||||
}
|
||||
|
||||
/// A pipeline, made once: `Pipeline`'s, filled by its `Job`.
|
||||
type Slot<T> = std::sync::Arc<std::sync::OnceLock<T>>;
|
||||
|
||||
/// What a render pipeline of a pack's program is made of, kept to make it later.
|
||||
#[derive(Clone)]
|
||||
struct RenderJob {
|
||||
label: String,
|
||||
layout: wgpu::PipelineLayout,
|
||||
vs: wgpu::ShaderModule,
|
||||
fs: wgpu::ShaderModule,
|
||||
/// The vertex buffers: stride, step mode and attributes.
|
||||
buffers: Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>>,
|
||||
primitive: wgpu::PrimitiveState,
|
||||
depth_stencil: Option<wgpu::DepthStencilState>,
|
||||
targets: Vec<Option<wgpu::ColorTargetState>>,
|
||||
}
|
||||
|
||||
/// What a compute pipeline of a pack's program is made of.
|
||||
struct ComputeJob {
|
||||
label: String,
|
||||
layout: wgpu::PipelineLayout,
|
||||
module: wgpu::ShaderModule,
|
||||
}
|
||||
|
||||
/// A pipeline to make, and the slot it goes in.
|
||||
enum Job {
|
||||
Render(Slot<wgpu::RenderPipeline>, RenderJob),
|
||||
Compute(Slot<wgpu::ComputePipeline>, ComputeJob),
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// Makes the pipeline (the driver compiles its shaders here) into its slot.
|
||||
fn create(&self, device: &wgpu::Device) {
|
||||
match self {
|
||||
Job::Render(slot, j) => {
|
||||
let buffers: Vec<Option<wgpu::VertexBufferLayout>> = j
|
||||
.buffers
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.as_ref()
|
||||
.map(|(stride, step, attributes)| wgpu::VertexBufferLayout {
|
||||
array_stride: *stride,
|
||||
step_mode: *step,
|
||||
attributes,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&j.label),
|
||||
layout: Some(&j.layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &j.vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: j.primitive,
|
||||
depth_stencil: j.depth_stencil.clone(),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &j.fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &j.targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let _ = slot.set(pipeline);
|
||||
}
|
||||
Job::Compute(slot, j) => {
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&j.label),
|
||||
layout: Some(&j.layout),
|
||||
module: &j.module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let _ = slot.set(pipeline);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn label(&self) -> &str {
|
||||
match self {
|
||||
Job::Render(_, j) => &j.label,
|
||||
Job::Compute(_, j) => &j.label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the pipelines of `jobs` on as many threads as the machine has cores (up to 8): the
|
||||
/// driver compiles a big pack's programs for tens of seconds one at a time, on one core. The
|
||||
/// first one wgpu or the driver refuses, with why; `None` when all are made.
|
||||
///
|
||||
/// A refusal doesn't stop the others: every job is made, so that the serial rebuild `new` then
|
||||
/// runs finds the driver's shader cache warm instead of compiling the rest cold, one at a time.
|
||||
/// `cancel` set stops handing out jobs: the caller then drops the pack (`PackPost::new`).
|
||||
fn compile(
|
||||
device: &wgpu::Device,
|
||||
jobs: &[Job],
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Option<(String, String)> {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get())
|
||||
.clamp(1, 8)
|
||||
.min(jobs.len().max(1));
|
||||
let next = AtomicUsize::new(0);
|
||||
let refused = std::sync::Mutex::new(None);
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..threads {
|
||||
scope.spawn(|| {
|
||||
while !cancel.load(Ordering::Relaxed)
|
||||
&& let Some(job) = jobs.get(next.fetch_add(1, Ordering::Relaxed))
|
||||
{
|
||||
if let Err(e) = checked(device, || job.create(device)) {
|
||||
let mut refused = refused.lock().unwrap_or_else(|e| e.into_inner());
|
||||
refused.get_or_insert((job.label().to_owned(), e));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
refused.into_inner().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Vertex buffer layouts owned, for a `RenderJob`.
|
||||
fn owned_buffers(
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
) -> Vec<Option<(u64, wgpu::VertexStepMode, Vec<wgpu::VertexAttribute>)>> {
|
||||
buffers
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.as_ref()
|
||||
.map(|b| (b.array_stride, b.step_mode, b.attributes.to_vec()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1876,60 +2068,47 @@ fn build_pass(
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
packed::bitcast_outputs(&mut fragment, &int_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let descriptor = |targets: &[Option<wgpu::ColorTargetState>]| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or(&[], |g| g.buffers),
|
||||
let job = RenderJob {
|
||||
label: p.name.clone(),
|
||||
layout: pipeline_layout,
|
||||
vs,
|
||||
fs,
|
||||
// A geometry stage reads its vertices itself.
|
||||
buffers: geometry
|
||||
.filter(|_| p.geometry_triangles == 0)
|
||||
.map_or_else(Vec::new, |g| owned_buffers(g.buffers)),
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
// Rows flipped to GL's turn front faces clockwise.
|
||||
primitive: wgpu::PrimitiveState {
|
||||
front_face: wgpu::FrontFace::Cw,
|
||||
cull_mode: geometry
|
||||
.filter(|g| g.cull && p.geometry_triangles == 0)
|
||||
.map(|_| wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: geometry.map(|g| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}),
|
||||
targets: targets.to_vec(),
|
||||
};
|
||||
let pipeline = descriptor(&targets);
|
||||
// The program's own blend replaced by vanilla's for the part; a buffer's own
|
||||
// `blend.<program>.<buffer>` still applies (Iris 1.11.7 blended the sky disc into that
|
||||
// buffer only, with a probe pack of ours).
|
||||
// buffer only, with a probe pack of ours). Two more pipelines of the program, made
|
||||
// with the others (`compile`).
|
||||
let sky_parts = p.vanilla_sky_blends.then(|| {
|
||||
[None, Some(blend_state(horizon_shaders::STARS_BLEND))].map(|part| {
|
||||
let targets: Vec<_> = (targets.iter().enumerate())
|
||||
let targets: Vec<_> = (job.targets.iter().enumerate())
|
||||
.map(|(l, t)| {
|
||||
let (own, blendable) = own_blends.get(l).copied().unwrap_or((None, false));
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
@@ -1938,10 +2117,16 @@ fn build_pass(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
descriptor(&targets)
|
||||
pack.render_pipeline(
|
||||
device,
|
||||
RenderJob {
|
||||
targets,
|
||||
..job.clone()
|
||||
},
|
||||
)
|
||||
})
|
||||
});
|
||||
(layout, pipeline, sky_parts)
|
||||
(layout, pack.render_pipeline(device, job), sky_parts)
|
||||
});
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
Ok(created) => created,
|
||||
@@ -1973,6 +2158,7 @@ fn build_pass(
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: interface.initial_values.clone(),
|
||||
initial_block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -2080,15 +2266,12 @@ fn build_compute(
|
||||
label: Some(&name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(&name),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(layout, pipeline)
|
||||
let job = ComputeJob {
|
||||
label: name.clone(),
|
||||
layout: pipeline_layout,
|
||||
module,
|
||||
};
|
||||
(layout, pack.compute_pipeline(device, job))
|
||||
});
|
||||
let (layout, pipeline) = match created {
|
||||
Ok(created) => created,
|
||||
@@ -2108,6 +2291,7 @@ fn build_compute(
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: interface.initial_values.clone(),
|
||||
initial_block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -2156,17 +2340,73 @@ pub struct Refused {
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Refused {
|
||||
/// The reason of a build stopped by its `cancel` flag (`PackPost::new`).
|
||||
const CANCELLED: &str = "cancelled";
|
||||
fn cancelled(notes: Vec<String>) -> Self {
|
||||
Self {
|
||||
reason: Self::CANCELLED.into(),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
/// Whether the build was cancelled (another pack or dimension asked for): not the pack's
|
||||
/// fault, so nothing to report to the player.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.reason == Self::CANCELLED
|
||||
}
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
///
|
||||
/// The pack is laid out first and its pipelines are made after, all at once (`compile`).
|
||||
/// When wgpu or the driver refuses one, the pack is built again a program at a time, so that
|
||||
/// the program is skipped with its note as it always was.
|
||||
///
|
||||
/// `cancel` set (the pack isn't wanted any more) stops the build between programs: a refusal
|
||||
/// whose `is_cancelled` is true.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<Self, Refused> {
|
||||
use std::sync::atomic::Ordering;
|
||||
let spare = (post.clone(), world.clone());
|
||||
let (built, jobs) = Self::build(device, queue, adapter, post, screen, world, true, cancel)?;
|
||||
let refused = compile(device, &jobs, cancel);
|
||||
// Some pipelines may be left unmade: the pack can't draw.
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Err(Refused::cancelled(built.notes));
|
||||
}
|
||||
let Some((program, refused)) = refused else {
|
||||
return Ok(built);
|
||||
};
|
||||
log::warn!(
|
||||
"shader pack: {program} was refused ({refused}): building it again a program at a time"
|
||||
);
|
||||
drop((built, jobs));
|
||||
let (post, world) = spare;
|
||||
Self::build(device, queue, adapter, post, screen, world, false, cancel)
|
||||
.map(|(built, _)| built)
|
||||
}
|
||||
|
||||
/// `new`'s work: with `defer`, the pipelines are left to make (the jobs returned).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Result<Self, Refused> {
|
||||
defer: bool,
|
||||
cancel: &std::sync::atomic::AtomicBool,
|
||||
) -> Result<(Self, Vec<Job>), Refused> {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
@@ -2318,8 +2558,13 @@ impl PackPost {
|
||||
shadow_linear: (0..2)
|
||||
.filter(|&n| !shadow_config.nearest[n])
|
||||
.fold(0, |m, n| m | 1 << n),
|
||||
jobs: defer.then(Default::default),
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let in_shadow = p.group.shadow();
|
||||
let mut compute = std::mem::take(&mut p.compute);
|
||||
if !gpu.compute {
|
||||
@@ -2668,6 +2913,9 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(Refused::cancelled(notes));
|
||||
}
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
// Terrain in two parts draws both or neither: one alone would leave the other part's
|
||||
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
|
||||
@@ -3001,7 +3249,10 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Ok(Self {
|
||||
let jobs = (pack.jobs.as_ref())
|
||||
.map(std::cell::RefCell::take)
|
||||
.unwrap_or_default();
|
||||
let built = Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
@@ -3124,23 +3375,7 @@ impl PackPost {
|
||||
.create_view(&Default::default()),
|
||||
textures,
|
||||
shadow: (
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default()),
|
||||
super::pack_shadow::stand_in(device),
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack shadow"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
@@ -3186,7 +3421,76 @@ impl PackPost {
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
})
|
||||
};
|
||||
Ok((built, jobs))
|
||||
}
|
||||
|
||||
/// `Renderer::finish_sequence_frame`: the centre depth the finished frame copied.
|
||||
pub fn take_center_depth(&mut self, device: &wgpu::Device) -> Result<(), String> {
|
||||
self.center_depth
|
||||
.as_mut()
|
||||
.ok_or("the shader pack copied no centre depth")?
|
||||
.take_completed(device)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Forgets what the pack kept from earlier frames, so the next frame draws as a freshly
|
||||
/// loaded pack's first: buffers, images, storage buffers and shadow maps start over (made
|
||||
/// again at their sizes when next drawn), the frame uniforms' history, the custom uniforms
|
||||
/// and the centre depth too. Compiled programs and the pack's textures stay.
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device) {
|
||||
self.uniforms.reset_history();
|
||||
// A parsed pack's custom uniforms start at tick 0's seed, before any frame of the world.
|
||||
self.custom.restart_at(0);
|
||||
self.custom.forget_world();
|
||||
self.center_depth = None;
|
||||
self.targets = None;
|
||||
self.main = 0;
|
||||
self.mips_valid = 0;
|
||||
self.rgb_unset = true;
|
||||
self.final_target = None;
|
||||
self.blit_group = None;
|
||||
self.page_groups.clear();
|
||||
for image in &mut self.images {
|
||||
image.reset_history();
|
||||
}
|
||||
self.buffers.reset_history();
|
||||
pack_images::reset_stand_ins(device, &mut self.stand_ins);
|
||||
if let Some(shadow) = &mut self.shadow_targets {
|
||||
shadow.reset_history(device, &self.uniforms.shadow);
|
||||
}
|
||||
self.shadow.0 = super::pack_shadow::stand_in(device);
|
||||
self.shadow_cleared = false;
|
||||
self.shadow_cache = Default::default();
|
||||
self.setup_pending = true;
|
||||
self.portal_texture = None;
|
||||
self.portal_rect = [0.0; 4];
|
||||
let sky = self
|
||||
.sky
|
||||
.iter_mut()
|
||||
.flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
|
||||
let entities = self
|
||||
.entities
|
||||
.iter_mut()
|
||||
.flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
|
||||
for pass in self
|
||||
.passes
|
||||
.iter_mut()
|
||||
.chain(&mut self.terrain)
|
||||
.chain(&mut self.terrain_cutout)
|
||||
.chain(&mut self.water)
|
||||
.chain(sky)
|
||||
.chain(entities)
|
||||
.chain(&mut self.clouds)
|
||||
.chain(&mut self.lines)
|
||||
.chain(self.weather.iter_mut().flatten())
|
||||
.chain(self.hand.iter_mut().map(|h| &mut h.0))
|
||||
.chain(&mut self.shadow_terrain)
|
||||
.chain(&mut self.shadow_water)
|
||||
.chain(&mut self.shadow_comp)
|
||||
{
|
||||
pass.reset_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
|
||||
@@ -3446,6 +3750,7 @@ impl PackPost {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pipeline = pipeline.get().expect("compiled with its pack");
|
||||
let groups = groups.unwrap_or_else(|| {
|
||||
let n = |k: usize| (size[k] as f32 * render[k] / local[k] as f32).ceil() as u32;
|
||||
[n(0), n(1), 1]
|
||||
@@ -3660,8 +3965,8 @@ impl PackPost {
|
||||
pass.sky_parts
|
||||
.as_ref()
|
||||
.and_then(|[plain, stars]| match part.stage {
|
||||
"SKY" | "VOID" => Some(plain),
|
||||
"STARS" => Some(stars),
|
||||
"SKY" | "VOID" => plain.get(),
|
||||
"STARS" => stars.get(),
|
||||
_ => None,
|
||||
});
|
||||
rp.set_pipeline(part_pipeline.unwrap_or(pass.render()));
|
||||
@@ -3940,7 +4245,7 @@ impl PackPost {
|
||||
frame: &super::Frame,
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
if super::sky_visible(frame) {
|
||||
if super::sky_visible(frame) || super::end_fog_sky(frame) {
|
||||
self.draw_sky(device, queue, encoder, size, frame);
|
||||
}
|
||||
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
|
||||
@@ -5346,6 +5651,57 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// A pack's pipelines are made together on several threads: each lands in its pass's slot,
|
||||
/// and one wgpu refuses comes back by name (the pack is then built a program at a time).
|
||||
#[test]
|
||||
fn pipelines_compile_together_and_a_refused_one_is_named() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = device.create_pipeline_layout(&Default::default());
|
||||
let module = |source: &str| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let job = |label: &str, module: wgpu::ShaderModule| {
|
||||
let slot = Slot::default();
|
||||
let job = ComputeJob {
|
||||
label: label.into(),
|
||||
layout: layout.clone(),
|
||||
module,
|
||||
};
|
||||
(slot.clone(), Job::Compute(slot, job))
|
||||
};
|
||||
let plain = "@compute @workgroup_size(1) fn main() {}";
|
||||
let go = std::sync::atomic::AtomicBool::new(false);
|
||||
let (slots, jobs): (Vec<_>, Vec<_>) = (0..12)
|
||||
.map(|i| job(&format!("composite{i}"), module(plain)))
|
||||
.unzip();
|
||||
// Cancelled before it starts: nothing is made (`new` then drops the pack).
|
||||
assert_eq!(compile(&device, &jobs, &true.into()), None);
|
||||
assert!(slots.iter().all(|s| s.get().is_none()));
|
||||
assert_eq!(compile(&device, &jobs, &go), None);
|
||||
assert!(slots.iter().all(|s| s.get().is_some()));
|
||||
// A binding the layout doesn't have: wgpu refuses the pipeline.
|
||||
let bound = "@group(0) @binding(0) var<storage, read_write> b: array<u32>;\n\
|
||||
@compute @workgroup_size(1) fn main() { b[0] = 1u; }";
|
||||
let (_, bad) = job("deferred3", module(bound));
|
||||
let (slots, mut jobs): (Vec<_>, Vec<_>) =
|
||||
(0..4).map(|i| job(&format!("c{i}"), module(plain))).unzip();
|
||||
jobs.insert(2, bad);
|
||||
let (program, _why) = compile(&device, &jobs, &go).expect("the pipeline is refused");
|
||||
assert_eq!(program, "deferred3");
|
||||
// The refusal stops none of the others: they are all made.
|
||||
assert!(slots.iter().all(|s| s.get().is_some()));
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
@@ -5435,6 +5791,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let reads = |group: Group| {
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -5529,6 +5886,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
packed: [None; BUFFERS],
|
||||
};
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -5612,6 +5970,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
let (.., images) = bindings(
|
||||
@@ -5754,6 +6113,7 @@ mod tests {
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
jobs: None,
|
||||
};
|
||||
let draws = DrawUniforms::new(&device);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
@@ -99,6 +99,7 @@ fn the_atlas_blends_its_two_nearest_levels() {
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
for p in gpu.render(&mut post, [4, 4]) {
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
@@ -593,6 +594,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -244,6 +244,7 @@ impl Fixture {
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -431,6 +432,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! In the End, while the dragon fight's world fog leaves out vanilla's sky, a pack's
|
||||
//! `gbuffers_skybasic` draws a fan in the fog colour, as our probe packs read it in Iris 1.11.7.
|
||||
//! Original fixture, no pack files.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
/// `gbuffers_skybasic` writes its vertex colour into colortex0 and `gl_Vertex` into colortex1
|
||||
/// (32-bit floats). final shows, on the picture's top half, `gl_Vertex.y` of the view's lower
|
||||
/// half as a 24-bit integer ((y + 64) * 65536), and on its bottom half colortex0 of the view's
|
||||
/// upper half.
|
||||
fn files() -> Vec<(String, String)> {
|
||||
vec![
|
||||
("shaders.properties".into(), "blend.gbuffers_skybasic=off\n".into()),
|
||||
(
|
||||
"gbuffers_skybasic.vsh".into(),
|
||||
"#version 120\nvarying vec4 color;\nvarying vec3 at;\nvoid main() {\n\
|
||||
\x20 gl_Position = ftransform();\n color = gl_Color;\n at = gl_Vertex.xyz;\n}\n"
|
||||
.into(),
|
||||
),
|
||||
(
|
||||
"gbuffers_skybasic.fsh".into(),
|
||||
"#version 120\nvarying vec4 color;\nvarying vec3 at;\n/* DRAWBUFFERS:01 */\n\
|
||||
void main() {\n gl_FragData[0] = color;\n gl_FragData[1] = vec4(at, 1.0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
"#version 130\n/*\nconst int colortex1Format = RGBA32F;\n*/\n\
|
||||
uniform sampler2D colortex0;\nuniform sampler2D colortex1;\nvarying vec2 uv;\n\
|
||||
void main() {\n\
|
||||
\x20 float y = texelFetch(colortex1, ivec2(gl_FragCoord.xy) - ivec2(0, 8), 0).y;\n\
|
||||
\x20 uint n = min(uint((y + 64.0) * 65536.0 + 0.5), 16777215u);\n\
|
||||
\x20 vec4 at = vec4(uvec4(n >> 16u, (n >> 8u) & 255u, n & 255u, 255u)) / 255.0;\n\
|
||||
\x20 gl_FragColor = uv.y > 0.5 ? at : texelFetch(colortex0, ivec2(gl_FragCoord.xy) + ivec2(0, 8), 0);\n}\n"
|
||||
.into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The pack's picture of the fixture's frame (16 pixels square, 90° field of view, looking level
|
||||
/// toward +Z) in the End, the world having left the far plane everywhere.
|
||||
fn picture(gpu: &Gpu, boss_world_fog: bool) -> Vec<[u8; 4]> {
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut post = gpu.load(
|
||||
files(),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
sky: Some(super::super::pack_sky::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.sky.is_some(), "{:?}", post.notes);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.title = false;
|
||||
frame.end_sky = true;
|
||||
frame.boss_world_fog = boss_world_fog;
|
||||
frame.detail_distance = 192.0;
|
||||
frame.fog = Some(crate::fog::FogData {
|
||||
color: [0.2, 0.4, 0.6, 1.0],
|
||||
environmental_start: 0.0,
|
||||
environmental_end: 96.0,
|
||||
render_distance_start: 172.8,
|
||||
render_distance_end: 192.0,
|
||||
sky_end: 96.0,
|
||||
cloud_end: 96.0,
|
||||
});
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// Where the view ray through pixel (`column`, `row`) meets the fan: from (0, -16, 0) to eight
|
||||
/// points 16 above the eye at radius 192, every 45° from +Z. None when it passes above the rim.
|
||||
fn fan_height(column: usize, row: usize) -> Option<f64> {
|
||||
let side = |i: usize| (i as f64 + 0.5) / 8.0 - 1.0;
|
||||
// The fan is mirrored across x = 0, so either sign of the view's x does.
|
||||
let ray = glam::DVec3::new(side(column), -side(row), 1.0);
|
||||
let azimuth = ray.x.atan2(ray.z).to_degrees();
|
||||
let edge = (azimuth / 45.0).floor();
|
||||
let rim = |k: f64| {
|
||||
let a = (k * 45.0).to_radians();
|
||||
glam::DVec3::new(192.0 * a.sin(), 16.0, 192.0 * a.cos())
|
||||
};
|
||||
let apex = glam::DVec3::new(0.0, -16.0, 0.0);
|
||||
let normal = (rim(edge) - apex).cross(rim(edge + 1.0) - apex);
|
||||
let along = apex.dot(normal) / ray.dot(normal);
|
||||
let hit = ray * along;
|
||||
(along > 0.0 && hit.y <= 16.0).then_some(hit.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_under_the_dragons_fog_has_the_packs_fan() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let pixels = picture(&gpu, true);
|
||||
let width = SIZE[0] as usize;
|
||||
// Below the horizon: every pixel sees the fan, at the height the planes give (to 0.02:
|
||||
// the GPU interpolates `gl_Vertex` across triangles 192 blocks wide; it came within 0.008).
|
||||
for row in 8..16 {
|
||||
for column in 0..width {
|
||||
let p = pixels[(row - 8) * width + column];
|
||||
let got = f64::from(u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
/ 65536.0
|
||||
- 64.0;
|
||||
let expected = fan_height(column, row).expect("below the horizon");
|
||||
assert!(
|
||||
(got - expected).abs() < 0.02,
|
||||
"row {row}, column {column}: gl_Vertex.y {got}, expected {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Above it: only the row just over the horizon reaches the fan (the rim stands 4.8° up),
|
||||
// drawn in the fog colour; above that, the world's grey stays.
|
||||
for row in 0..8 {
|
||||
for column in 0..width {
|
||||
let p = pixels[(row + 8) * width + column];
|
||||
let expected = match fan_height(column, row) {
|
||||
Some(_) => [51, 102, 153, 255],
|
||||
None => [16, 16, 16, 255],
|
||||
};
|
||||
assert_eq!(p, expected, "row {row}, column {column}");
|
||||
}
|
||||
assert_eq!(fan_height(0, row).is_some(), row == 7, "row {row}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The End without the fog: vanilla's End sky, which no `gbuffers_skybasic` draws.
|
||||
#[test]
|
||||
fn the_end_without_the_fog_has_no_fan() {
|
||||
let Some(gpu) = Gpu::new() else { return };
|
||||
let pixels = picture(&gpu, false);
|
||||
let width = SIZE[0] as usize;
|
||||
for (i, p) in pixels[8 * width..].iter().enumerate() {
|
||||
assert_eq!(*p, [16, 16, 16, 255], "pixel {i}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! `PackPost::reset_history`: a pack reset after some frames draws as a freshly loaded one.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU with compute and fragment storage"]
|
||||
fn a_reset_pack_draws_its_buffers_images_storage_and_shadow_from_the_start() {
|
||||
let gpu = Fixture::new().expect("a GPU is required for this reset regression");
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let vertex = "#version 120\nvarying vec2 uv;\nvoid main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}\n";
|
||||
let state = "layout(std430,binding=0) buffer State { uint setups; uint frames; } state;\n";
|
||||
let compute = "#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(1);\n";
|
||||
add(
|
||||
"setup.csh",
|
||||
&format!("{compute}{state}void main(){{state.setups+=1u;}}\n"),
|
||||
);
|
||||
add(
|
||||
"begin.csh",
|
||||
&format!(
|
||||
"{compute}{state}layout(r32ui) uniform uimage2D imgCounter;\n\
|
||||
void main(){{state.frames+=1u;imageStore(imgCounter,ivec2(0),uvec4(imageLoad(imgCounter,ivec2(0)).r+1u));}}\n"
|
||||
),
|
||||
);
|
||||
for name in ["composite", "shadowcomp"] {
|
||||
add(&format!("{name}.vsh"), vertex);
|
||||
let target = if name == "composite" { 2 } else { 0 };
|
||||
let (declarations, value) = if name == "composite" {
|
||||
("", "vec4(0.125)")
|
||||
} else {
|
||||
(
|
||||
"uniform sampler2D shadowcolor0;varying vec2 uv;",
|
||||
"texture2D(shadowcolor0,uv)+vec4(0.125)",
|
||||
)
|
||||
};
|
||||
add(
|
||||
&format!("{name}.fsh"),
|
||||
&format!(
|
||||
"#version 120\n/* RENDERTARGETS:{target} */\n\
|
||||
{declarations}\nvoid main(){{gl_FragData[0]={value};}}\n"
|
||||
),
|
||||
);
|
||||
}
|
||||
add("final.vsh", vertex);
|
||||
add(
|
||||
"final.fsh",
|
||||
&format!(
|
||||
"#version 430 compatibility\n{state}\
|
||||
uniform sampler2D colortex2;uniform sampler2D shadowcolor0;uniform usampler2D texCounter;\n\
|
||||
const bool colortex2Clear=false;const bool shadowcolor0Clear=false;\n\
|
||||
const int shadowMapResolution=16;uniform float baseline=0.375;varying vec2 uv;\n\
|
||||
void main(){{gl_FragColor=gl_FragCoord.x<8.0?\
|
||||
vec4(texture(colortex2,uv).r,float(texelFetch(texCounter,ivec2(0),0).r)/8.0,float(state.frames)/8.0,1):\
|
||||
vec4(texture(shadowcolor0,uv).r,float(state.setups)/8.0,baseline,1);}}\n"
|
||||
),
|
||||
);
|
||||
add(
|
||||
"shaders.properties",
|
||||
"shadow.enabled=true\nbufferObject.0=8\n\
|
||||
image.imgCounter=texCounter RED_INTEGER R32UI UNSIGNED_INT false false 1 1\n\
|
||||
blend.composite=ONE ONE ONE ONE\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("original-reset", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let load = || horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.try_build(load()).expect("reset fixture compiles");
|
||||
let mut fresh = gpu.try_build(load()).expect("fresh control compiles");
|
||||
let check = |pixels: &[[u8; 4]], frame: u8| {
|
||||
let counter = 32 * frame;
|
||||
for (i, pixel) in pixels.iter().enumerate() {
|
||||
let expected = if i % 16 < 8 {
|
||||
[counter, counter, counter, 255]
|
||||
} else {
|
||||
[counter, 32, 96, 255]
|
||||
};
|
||||
assert!(
|
||||
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 1),
|
||||
"frame {frame}, pixel {i}: {pixel:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
};
|
||||
for frame in 1..=3 {
|
||||
check(&gpu.render(&mut post, [16, 16]), frame);
|
||||
}
|
||||
// At the same size too: the retained colortex2 and shadowcolor0, the image and
|
||||
// the storage buffer start over, and setup runs once again. The control is a pack never
|
||||
// reset.
|
||||
for _ in 0..2 {
|
||||
post.reset_history(&gpu.device);
|
||||
for frame in 1..=2 {
|
||||
let pixels = gpu.render(&mut post, [16, 16]);
|
||||
check(&pixels, frame);
|
||||
if frame == 1 {
|
||||
assert_eq!(pixels, gpu.render(&mut fresh, [16, 16]));
|
||||
}
|
||||
}
|
||||
fresh.reset_history(&gpu.device);
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,7 @@ fn pack(
|
||||
weather: true,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("weather pack runs");
|
||||
assert!(
|
||||
|
||||
@@ -304,6 +304,7 @@ fn pack_with(
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
@@ -400,6 +401,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -76,7 +76,50 @@ fn target(
|
||||
(texture, view)
|
||||
}
|
||||
|
||||
/// The depth stand-in is initialized to no shadow on the next pack render.
|
||||
pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
|
||||
let mut colors = 0;
|
||||
let mut doubled = 0;
|
||||
let formats = std::array::from_fn(|n| match &self.color[n] {
|
||||
Some([a, b]) => {
|
||||
colors |= 1 << n;
|
||||
if a.texture() != b.texture() {
|
||||
doubled |= 1 << n;
|
||||
}
|
||||
a.texture().format()
|
||||
}
|
||||
None => wgpu::TextureFormat::Rgba8Unorm,
|
||||
});
|
||||
*self = Self::new(
|
||||
device,
|
||||
shadow,
|
||||
formats,
|
||||
colors,
|
||||
doubled,
|
||||
self.depth1.is_some(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`, `doubled` (bit n: shadowcolor n needs a B texture). The
|
||||
/// resolution is kept within the device's limit.
|
||||
|
||||
@@ -22,6 +22,8 @@ const SUNSET_APEX: u32 = 1;
|
||||
const SUNSET_RIM: u32 = 2;
|
||||
const STAR: u32 = 3;
|
||||
const VOID: u32 = 4;
|
||||
/// The End's fan (`fan`): `pos.xz` of a rim vertex is its direction, scaled by the radius.
|
||||
const FAN: u32 = 5;
|
||||
|
||||
/// The frame's sky (std140).
|
||||
#[repr(C)]
|
||||
@@ -33,7 +35,7 @@ struct Uniform {
|
||||
color: [f32; 4],
|
||||
/// Sunset colour and alpha.
|
||||
sunrise: [f32; 4],
|
||||
/// cos and sin of the sunset fan's turn, star brightness.
|
||||
/// cos and sin of the sunset fan's turn, star brightness, the End fan's radius.
|
||||
sun: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -77,6 +79,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}} else if (hz_v_kind == {STAR}u) {{\n \
|
||||
hz_p = (hz_sky_stars * vec4(hz_v_pos, 1.0)).xyz;\n \
|
||||
hz_Color = vec4(hz_sky_sun.z);\n \
|
||||
}} else if (hz_v_kind == {FAN}u) {{\n \
|
||||
hz_p = vec3(hz_v_pos.x * hz_sky_sun.w, hz_v_pos.y, hz_v_pos.z * hz_sky_sun.w);\n \
|
||||
}} else if (hz_v_kind == {VOID}u) {{\n \
|
||||
hz_Color = vec4(0.0, 0.0, 0.0, 1.0);\n \
|
||||
}}\n \
|
||||
@@ -188,14 +192,37 @@ fn horizon(out: &mut Vec<Vertex>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fan a pack's `gbuffers_skybasic` draws in the End while the dragon fight's fog leaves out
|
||||
/// vanilla's sky (`super::end_fog_sky`): from (0, -16, 0) under the eye to eight rim vertices 16
|
||||
/// above it, every 45° from +Z, at the render distance (the `far` uniform; scaled in the vertex
|
||||
/// stage). Fitted to the `gl_Vertex` our probe packs' fragments read in Iris 1.11.7 at render
|
||||
/// distances 12 and 8: 289 of 291 samples lie within 0.05 block of these eight planes (one of
|
||||
/// the other two, checked, sat on a terrain edge), and nothing drew above the rim.
|
||||
fn fan(out: &mut Vec<Vertex>) {
|
||||
let rim = |i: i32| {
|
||||
let a = (i as f32 * 45.0).to_radians();
|
||||
Vertex {
|
||||
pos: [a.sin(), 16.0, a.cos()],
|
||||
kind: FAN,
|
||||
}
|
||||
};
|
||||
let apex = Vertex {
|
||||
pos: [0.0, -16.0, 0.0],
|
||||
kind: FAN,
|
||||
};
|
||||
for i in 0..8 {
|
||||
out.extend([apex, rim(i), rim(i + 1)]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PackSky {
|
||||
vertices: wgpu::Buffer,
|
||||
celestial: wgpu::Buffer,
|
||||
uniform: wgpu::Buffer,
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
/// Vertex ranges of the sky disc, the sunset fan, the stars and the void.
|
||||
ranges: [Range<u32>; 4],
|
||||
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
|
||||
ranges: [Range<u32>; 5],
|
||||
}
|
||||
|
||||
impl PackSky {
|
||||
@@ -227,6 +254,8 @@ impl PackSky {
|
||||
let stars = sunset.end..v.len() as u32;
|
||||
disc(-4.0, VOID, &mut v);
|
||||
let void = stars.end..v.len() as u32;
|
||||
fan(&mut v);
|
||||
let end_fan = void.end..v.len() as u32;
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack sky"),
|
||||
contents: bytemuck::cast_slice(&v),
|
||||
@@ -271,7 +300,7 @@ impl PackSky {
|
||||
uniform,
|
||||
layout,
|
||||
group,
|
||||
ranges: [sky, sunset, stars, void],
|
||||
ranges: [sky, sunset, stars, void, end_fan],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +323,8 @@ impl PackSky {
|
||||
/// Writes the frame's sky and returns its draws in vanilla's order: the disc, the sunset
|
||||
/// fan while the sun is near the horizon, the sun and the moon, the stars and, with the
|
||||
/// camera low, the void. `tilt` is the pack's `sunPathRotation` (degrees): the sun, moon
|
||||
/// and stars turn with it (7.1).
|
||||
/// and stars turn with it (7.1). In the End under the dragon fight's fog, only the End's fan,
|
||||
/// in the fog colour (`fogColor`), as stage SKY.
|
||||
pub fn prepare(&self, queue: &wgpu::Queue, frame: &super::Frame, tilt: f32) -> Vec<Part> {
|
||||
let turn = super::pack_uniforms::tilt_matrix(tilt);
|
||||
let sun_angle = frame.sky.sun_angle.to_radians();
|
||||
@@ -302,12 +332,22 @@ impl PackSky {
|
||||
let flip = if sun_angle.sin() < 0.0 { 180f32 } else { 0.0 };
|
||||
let theta = (flip + 90.0).to_radians();
|
||||
let (rotation, brightness) = frame.stars.unwrap_or_default();
|
||||
let [r, g, b] = frame.sky.sky_color;
|
||||
let end_fan = super::end_fog_sky(frame);
|
||||
let [r, g, b] = match &frame.fog {
|
||||
Some(f) if end_fan => [f.color[0], f.color[1], f.color[2]],
|
||||
None if end_fan => frame.sky.fog_color,
|
||||
_ => frame.sky.sky_color,
|
||||
};
|
||||
let uniform = Uniform {
|
||||
stars: (turn * rotation).to_cols_array_2d(),
|
||||
color: [r, g, b, 1.0],
|
||||
sunrise: frame.sky.sunrise_color,
|
||||
sun: [theta.cos(), theta.sin(), brightness.clamp(0.0, 1.0), 0.0],
|
||||
sun: [
|
||||
theta.cos(),
|
||||
theta.sin(),
|
||||
brightness.clamp(0.0, 1.0),
|
||||
frame.detail_distance,
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
// The sun's quad, then the moon's (Game::celestial_geometry), as triangles.
|
||||
@@ -327,12 +367,15 @@ impl PackSky {
|
||||
})
|
||||
.collect();
|
||||
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
|
||||
let [sky, sunset, stars, void] = self.ranges.clone();
|
||||
let [sky, sunset, stars, void, fan] = self.ranges.clone();
|
||||
let part = |stage, textured, range| Part {
|
||||
stage,
|
||||
textured,
|
||||
range,
|
||||
};
|
||||
if end_fan {
|
||||
return vec![part("SKY", false, fan)];
|
||||
}
|
||||
let mut parts = vec![part("SKY", false, sky)];
|
||||
if frame.sky.sunrise_color[3] > 0.001 {
|
||||
parts.push(part("SUNSET", false, sunset));
|
||||
|
||||
@@ -709,6 +709,16 @@ fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
}
|
||||
|
||||
impl FrameUniforms {
|
||||
/// Forgets the frames before (the previous camera, smoothed values, the clock and the
|
||||
/// centre depth), keeping the pack's atlas size and shadow settings.
|
||||
pub(super) fn reset_history(&mut self) {
|
||||
*self = Self {
|
||||
atlas: self.atlas,
|
||||
shadow: std::mem::take(&mut self.shadow),
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -2367,6 +2377,61 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!((sixty.elapsed, sixty.delta), (1.0, 1.0 / 60.0));
|
||||
}
|
||||
|
||||
/// A reset pack draws its next frame as its first: no previous camera, smoothed values
|
||||
/// at their targets, the clock from 0; the atlas size it was given stays.
|
||||
#[test]
|
||||
fn a_reset_forgets_the_uniforms_history_and_keeps_the_pack_s_settings() {
|
||||
let hud = HudBatch::new([16, 16]);
|
||||
let mut f = frame(&hud);
|
||||
let mut uniforms = FrameUniforms {
|
||||
atlas: [256, 128],
|
||||
..Default::default()
|
||||
};
|
||||
let mut inputs = PackInputs {
|
||||
rain: 1.0,
|
||||
eye_light: [15, 15],
|
||||
frame_timing: Some(FrameTiming {
|
||||
counter: 800,
|
||||
elapsed: 99.0,
|
||||
delta: 1.0,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
uniforms.center_depth_raw = Some(0.25);
|
||||
uniforms.update(&f, &inputs, [16, 16]);
|
||||
f.camera = glam::DVec3::new(7.0, 12.0, -30.0);
|
||||
f.yaw = 77.0;
|
||||
inputs.rain = 0.25;
|
||||
inputs.eye_light = [3, 8];
|
||||
inputs.frame_timing = None;
|
||||
uniforms.reset_history();
|
||||
uniforms.update(&f, &inputs, [16, 16]);
|
||||
let float = |name| match uniforms.values[index(name).unwrap()] {
|
||||
Value::F(v) => v,
|
||||
_ => panic!("float uniform expected"),
|
||||
};
|
||||
let int = |name| match uniforms.values[index(name).unwrap()] {
|
||||
Value::I(v) => v,
|
||||
_ => panic!("integer uniform expected"),
|
||||
};
|
||||
assert_eq!(int("frameCounter")[0], 0);
|
||||
assert_eq!(float("frameTime")[0], 0.0);
|
||||
assert_eq!(int("atlasSize")[..2], [256, 128]);
|
||||
assert_eq!(float("wetness")[0], 0.25);
|
||||
assert_eq!(int("eyeBrightnessSmooth")[..2], [48, 128]);
|
||||
assert_eq!(float("centerDepthSmooth")[0], 1.0);
|
||||
for (previous, current) in [
|
||||
("previousCameraPosition", "cameraPosition"),
|
||||
("gbufferPreviousModelView", "gbufferModelView"),
|
||||
("gbufferPreviousProjection", "gbufferProjection"),
|
||||
] {
|
||||
assert_eq!(
|
||||
float(previous).map(f32::to_bits),
|
||||
float(current).map(f32::to_bits)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
|
||||
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
|
||||
#[test]
|
||||
@@ -2411,6 +2476,7 @@ pub(super) mod initial_history_tests {
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
|
||||
@@ -324,8 +324,9 @@ mod tests {
|
||||
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
|
||||
}
|
||||
|
||||
/// `Rgba16Float`, the frame of a shader API 0.2 pack under `HORIZON_HDR_FRAME=float`
|
||||
/// (`Renderer::frame_format`): what the tonemap left over 1 is clipped on the way.
|
||||
/// `Rgba16Float`, the frame of a shader API 0.2 pack, or of HDR on without a pack, under
|
||||
/// `HORIZON_HDR_FRAME=float` (`Renderer::frame_format`): what is left over 1 is clipped on
|
||||
/// the way.
|
||||
#[test]
|
||||
fn a_float_frame_reaches_an_8_bit_swapchain() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Opaque/cutout terrain-sheet native models, with ordinary culling and depth writes.
|
||||
pub(super) fn pipelines(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
sprite_constants: &[(&str, f64)],
|
||||
) -> [wgpu::RenderPipeline; 3] {
|
||||
std::array::from_fn(|i| {
|
||||
let mut constants = sprite_constants.to_vec();
|
||||
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
|
||||
// The ordinary Vertex ABI is unchanged. This material's private payload uses
|
||||
// normal's four bytes for a float z, and glint UV for float normal x/y.
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Uint32,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(
|
||||
[
|
||||
"Native model solid",
|
||||
"Native model cutout",
|
||||
"Native model cutout mipmapped",
|
||||
][i],
|
||||
),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_native_model"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_native_model"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -257,14 +257,23 @@ impl Post {
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
let upscale = if self.taa_on { self.upscale } else { 1.0 };
|
||||
self.sizes_with_upscale(window, upscale)
|
||||
}
|
||||
|
||||
fn sizes_with_upscale(&self, window: [u32; 2], upscale: f32) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
(
|
||||
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
|
||||
output,
|
||||
)
|
||||
}
|
||||
|
||||
/// The next TAA frame's input size, even before its first jitter or resize.
|
||||
pub(super) fn taa_render_size(&self, window: [u32; 2]) -> [u32; 2] {
|
||||
self.sizes_with_upscale(window, self.upscale).0
|
||||
}
|
||||
|
||||
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
@@ -604,7 +613,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn jitter_stays_inside_the_pixel() {
|
||||
for i in 1..=8 {
|
||||
for i in 1..=128 {
|
||||
let (x, y) = (halton(i, 2), halton(i, 3));
|
||||
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
|
||||
}
|
||||
@@ -614,6 +623,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn culling_uses_the_next_taa_size_before_the_first_jitter_or_resize() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
assert!(!post.taa_on);
|
||||
assert!(post.size().is_none());
|
||||
for (window, scale, upscale, expected) in [
|
||||
([1280, 720], 1.0, 1.0, [1280, 720]),
|
||||
([1281, 721], 0.5, 2.0, [321, 181]),
|
||||
([3840, 2160], 0.25, 4.0, [240, 135]),
|
||||
([10000, 10000], 2.0, 4.0, [2048, 2048]),
|
||||
([0, 0], 0.5, 4.0, [1, 1]),
|
||||
] {
|
||||
post.scale = scale;
|
||||
post.upscale = upscale;
|
||||
post.taa_on = false;
|
||||
assert_eq!(post.taa_render_size(window), expected);
|
||||
post.taa_on = true;
|
||||
assert_eq!(post.sizes(window).0, expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -1092,8 +1092,9 @@ impl Graphics {
|
||||
// (horizon_threads: niced on Linux; on macOS and Windows the server
|
||||
// and render threads are raised above four cores, where vanilla
|
||||
// raises its server thread). Leaving two idled a core of a 4-thread
|
||||
// laptop while a world loaded: a teleport there took ~15% longer.
|
||||
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
|
||||
// laptop while a world loaded: a teleport there took ~15% longer. On Apple
|
||||
// silicon only the performance cores count (horizon_threads::default_workers).
|
||||
let auto = horizon_threads::jobs::default_threads();
|
||||
if self.battery { auto.div_ceil(2) } else { auto }
|
||||
}
|
||||
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
|
||||
@@ -1476,7 +1477,10 @@ mod tests {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = horizon_threads::worker_count();
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
assert!((1..=cores.saturating_sub(1).max(1)).contains(&g.worker_threads()));
|
||||
if !cfg!(target_vendor = "apple") {
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
}
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
assert_eq!(manual.worker_threads(), 3);
|
||||
|
||||
@@ -1663,6 +1663,7 @@ impl GraphicsUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, cols, y, &view);
|
||||
}
|
||||
|
||||
@@ -61,9 +61,10 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
|
||||
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
|
||||
// would read differently (an alpha of 0.001 would count as the hand); its colour
|
||||
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
|
||||
// keeps the gradients 8 bits would band, and a float frame's (LINEAR_FRAME) the
|
||||
// light over 1 that blends added past white. On an 8-bit frame nothing changes.
|
||||
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
|
||||
return vec4(max(v.rgb, vec3(0.0)), round(saturate(v.a) * 255.0) / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -161,15 +162,12 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
@@ -179,11 +177,19 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && w.a >= 0.75 && w.a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
@@ -214,6 +220,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
light = min(light, vec3(params.peak));
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
|
||||
@@ -1333,6 +1333,36 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
if full {
|
||||
// Official Recipes artwork from Just Enough Items, under MIT.
|
||||
for (name, png) in [
|
||||
(
|
||||
"button_enabled_v2",
|
||||
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_disabled_v2",
|
||||
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_highlight_v2",
|
||||
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_previous_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_next_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"search_background_v2",
|
||||
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
|
||||
),
|
||||
] {
|
||||
let (width, height, rgba) = decode_bytes(png)?;
|
||||
images.push((format!("horizon/jei/{name}"), width, height, rgba));
|
||||
}
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
// and black quarters.
|
||||
@@ -1354,6 +1384,11 @@ impl HudAtlas {
|
||||
bordered.insert(image.0.clone());
|
||||
}
|
||||
}
|
||||
if full {
|
||||
let (width, height, rgba) =
|
||||
decode_bytes(include_bytes!("jei_recipes/config_button.png"))?;
|
||||
images.push(("horizon/recipes/settings".to_owned(), width, height, rgba));
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
|
||||
@@ -115,8 +115,7 @@ impl From<&crate::effects::EffectInstance> for EffectIcon {
|
||||
color: def.map_or(0, |d| d.color as i32),
|
||||
ambient: effect.ambient,
|
||||
duration: effect.duration,
|
||||
// Horizon keeps no showIcon flag: every effect shows its icon.
|
||||
show_icon: true,
|
||||
show_icon: effect.show_icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2015 mezz
|
||||
|
||||
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.
|
||||
|
After Width: | Height: | Size: 200 B |
|
After Width: | Height: | Size: 276 B |
|
After Width: | Height: | Size: 284 B |
|
After Width: | Height: | Size: 169 B |
|
After Width: | Height: | Size: 113 B |
|
After Width: | Height: | Size: 107 B |
|
After Width: | Height: | Size: 111 B |
@@ -36,12 +36,15 @@ pub mod connected;
|
||||
pub mod containers;
|
||||
pub mod controller_ui;
|
||||
pub mod crash;
|
||||
pub mod custom_sky;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub(crate) mod extras_ui;
|
||||
pub mod fast_hash;
|
||||
pub mod fluid_render;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
@@ -56,6 +59,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod terrain_model;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
|
||||
@@ -90,6 +90,11 @@ fn round_to_u16(v: f32) -> u16 {
|
||||
}
|
||||
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
|
||||
pub const UV_STEPS: f32 = 32768.0;
|
||||
/// A model quad's own UVs in `TerrainVertex` fixed point, the packing
|
||||
/// `TerrainVertex::new` does, baked once per face (`BakedFace::uv_fixed`).
|
||||
pub(crate) fn baked_uv(uv: [[f32; 2]; 4]) -> [[u16; 2]; 4] {
|
||||
uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)))
|
||||
}
|
||||
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
|
||||
/// which span `[0, 2048)`: merged faces repeat their sprite once per block.
|
||||
pub const TILED_UV_STEPS: f32 = 32.0;
|
||||
@@ -103,6 +108,23 @@ impl TerrainVertex {
|
||||
color,
|
||||
}
|
||||
}
|
||||
/// A vertex whose UV is already in `UV_STEPS` fixed point (a model face's
|
||||
/// own UVs, baked in `BakedFace::uv_fixed`).
|
||||
pub(crate) fn packed_uv(
|
||||
pos: [f32; 3],
|
||||
uv: [u16; 2],
|
||||
layer: u16,
|
||||
color: u32,
|
||||
light: u16,
|
||||
) -> Self {
|
||||
Self {
|
||||
pos,
|
||||
uv,
|
||||
layer,
|
||||
light,
|
||||
color,
|
||||
}
|
||||
}
|
||||
/// A vertex of a merged distant-terrain face, its UV in blocks from the
|
||||
/// face's corner. Drawn with an instance UV scale of `1 / TILED_UV_STEPS`.
|
||||
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
|
||||
@@ -122,6 +144,63 @@ impl TerrainVertex {
|
||||
#[cfg(test)]
|
||||
mod vertex_tests {
|
||||
use super::TerrainVertex;
|
||||
|
||||
#[test]
|
||||
fn baked_face_keeps_shape_bounds_and_fixed_point_uvs() {
|
||||
use crate::models::catalog::BakedFace;
|
||||
use horizon_assets::{
|
||||
bake::ModelQuad,
|
||||
model::{Direction, Material},
|
||||
};
|
||||
|
||||
let face = BakedFace::new(
|
||||
ModelQuad {
|
||||
positions: [
|
||||
[-0.25, 0.5, 0.125],
|
||||
[-0.25, 0.5, 0.875],
|
||||
[0.625, 0.5, 0.875],
|
||||
[0.625, 0.5, 0.125],
|
||||
],
|
||||
uv: [
|
||||
[-0.25, f32::NAN],
|
||||
[1.0 / 65536.0, 3.0 / 65536.0],
|
||||
[1.5, 2.0],
|
||||
[f32::INFINITY, f32::NEG_INFINITY],
|
||||
],
|
||||
facing: Direction::Up,
|
||||
cull: None,
|
||||
material: Material {
|
||||
sprite: "test:face".into(),
|
||||
force_translucent: false,
|
||||
},
|
||||
tint_index: -1,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
light: [0; 2],
|
||||
color: None,
|
||||
ambient_occlusion: None,
|
||||
render_type: None,
|
||||
shade: true,
|
||||
},
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// Literal expectations: axis-ordered extrema and complements, plus
|
||||
// clamping and half-step rounding in 1/32768 UV units.
|
||||
let bounds: [f32; 12] = [
|
||||
0.5, 0.5, 0.125, 0.875, -0.25, 0.625, 0.5, 0.5, 0.875, 0.125, 1.25, 0.375,
|
||||
];
|
||||
for shape in face.shapes {
|
||||
assert_eq!(shape.bounds.map(f32::to_bits), bounds.map(f32::to_bits));
|
||||
assert!(shape.partial);
|
||||
}
|
||||
assert!(!face.shapes[0].cubic);
|
||||
assert!(face.shapes[1].cubic);
|
||||
assert_eq!(face.uv_fixed, [[0, 0], [1, 2], [49152, 65535], [65535, 0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_uvs_pack_exactly() {
|
||||
for i in 0..=32 {
|
||||
@@ -957,6 +1036,12 @@ const P: usize = 16 + 2 * B as usize;
|
||||
fn pi(x: i32, y: i32, z: i32) -> usize {
|
||||
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
|
||||
}
|
||||
/// `pi`'s index offset of a step `n`: `pi(p + n) - pi(p)`, so the samples
|
||||
/// around a cell are `base + step_index(n)` rather than another `pi`.
|
||||
#[inline]
|
||||
const fn step_index(n: [i32; 3]) -> isize {
|
||||
(n[0] + P as i32 * n[2] + (P * P) as i32 * n[1]) as isize
|
||||
}
|
||||
/// Blocks and light of one section plus a `B`-block border.
|
||||
#[derive(Default)]
|
||||
struct Padded {
|
||||
@@ -1079,6 +1164,17 @@ const DIRECTIONS: [(Direction, [i32; 3], u8); 6] = [
|
||||
const fn direction_info(d: Direction) -> (Direction, [i32; 3], u8) {
|
||||
(d, d.normal(), d.opposite() as u8)
|
||||
}
|
||||
/// `step_index` of each direction's normal, indexed like `DIRECTIONS`: the
|
||||
/// padded index of a block's neighbour is its own plus one of these.
|
||||
const OFFSETS: [isize; 6] = {
|
||||
let mut out = [0isize; 6];
|
||||
let mut i = 0;
|
||||
while i < 6 {
|
||||
out[i] = step_index(DIRECTIONS[i].1);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
};
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
@@ -1540,8 +1636,12 @@ impl Mesher<'_> {
|
||||
let mut models = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
// The padded index of the row's x = 0 cell: `pi(x, y, z)` is
|
||||
// `row + x` and a neighbour adds `OFFSETS[d]`.
|
||||
let row = pi(0, y, z);
|
||||
for x in 0..16 {
|
||||
let id = pad.blocks[pi(x, y, z)];
|
||||
let cell = row + x as usize;
|
||||
let id = pad.blocks[cell];
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -1596,7 +1696,7 @@ impl Mesher<'_> {
|
||||
&& self.leaf_culling.is_interior()
|
||||
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
|
||||
for (d, [dx, dy, dz], opposite) in DIRECTIONS {
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let neighbor = pad.blocks[(cell as isize + OFFSETS[d as usize]) as usize];
|
||||
// `should_render_face`'s first test, without its lookups: a
|
||||
// full occlusion face toward this block hides the face.
|
||||
if full_faces[neighbor as usize] >> opposite & 1 != 0 {
|
||||
@@ -1656,7 +1756,8 @@ impl Mesher<'_> {
|
||||
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
|
||||
let centre =
|
||||
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
|
||||
let other = pad.blocks[pi(x, y + dy, z)];
|
||||
let other =
|
||||
pad.blocks[(cell as isize + step_index([0, dy, 0])) as usize];
|
||||
if on_edge && connected.stacked_pane_hides(id, other, centre) {
|
||||
continue;
|
||||
}
|
||||
@@ -1666,6 +1767,7 @@ impl Mesher<'_> {
|
||||
&mut tints,
|
||||
&mut out,
|
||||
[x, y, z],
|
||||
cell,
|
||||
origin,
|
||||
face,
|
||||
ao,
|
||||
@@ -1706,6 +1808,7 @@ impl Mesher<'_> {
|
||||
tints: &mut Tints,
|
||||
out: &mut SectionMesh,
|
||||
pos: [i32; 3],
|
||||
cell: usize,
|
||||
origin: [f32; 3],
|
||||
face: &BakedFace,
|
||||
ao: bool,
|
||||
@@ -1730,11 +1833,14 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
// The quad's shape (bounds, partial, cubic), baked per face for full and
|
||||
// non-full blocks: a pure function of the quad and the block's `full`.
|
||||
let shape = &face.shapes[usize::from(state.full)];
|
||||
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
|
||||
let (gray, coords, ao_factor) = if ao {
|
||||
let info = &self.tables.info;
|
||||
let (brightness, coords) =
|
||||
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
|
||||
lighting::ambient_occlusion(pad, info, state, pos, shape, q.facing);
|
||||
let gray = if self.separate_ao {
|
||||
[lighting::gray(directional); 4]
|
||||
} else {
|
||||
@@ -1744,7 +1850,7 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
let coords = match q.cull {
|
||||
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
|
||||
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
|
||||
None => lighting::flat_shaped(pad, state, pos, q.facing, shape),
|
||||
};
|
||||
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
|
||||
};
|
||||
@@ -1768,8 +1874,7 @@ impl Mesher<'_> {
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let neighbour =
|
||||
|[dx, dy, dz]: [i32; 3]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)];
|
||||
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
|
||||
let fused = face.fusion.and_then(|rule| {
|
||||
fusion.pick(
|
||||
rule,
|
||||
@@ -1810,44 +1915,41 @@ impl Mesher<'_> {
|
||||
&mut push,
|
||||
)
|
||||
};
|
||||
let vertex = |tint: Option<u32>, i: usize, uv: [u16; 2]| {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
let color = if self.separate_ao {
|
||||
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
|
||||
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
|
||||
} else {
|
||||
emitting(pack_rgb(rgb), tier)
|
||||
};
|
||||
TerrainVertex::packed_uv(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
uv,
|
||||
0,
|
||||
color,
|
||||
pack_coords(lighting::with_min(coords[i], q.light)),
|
||||
)
|
||||
};
|
||||
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
|
||||
std::array::from_fn::<_, 4, _>(|i| {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
let color = if self.separate_ao {
|
||||
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
|
||||
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
|
||||
} else {
|
||||
emitting(pack_rgb(rgb), tier)
|
||||
};
|
||||
(
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
uv[i],
|
||||
0,
|
||||
color,
|
||||
pack_coords(lighting::with_min(coords[i], q.light)),
|
||||
),
|
||||
uv[i],
|
||||
)
|
||||
})
|
||||
let fixed = uv.map(|uv| uv.map(|v| round_to_u16(v * UV_STEPS)));
|
||||
std::array::from_fn::<_, 4, _>(|i| (vertex(tint, i, fixed[i]), uv[i]))
|
||||
};
|
||||
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
|
||||
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
|
||||
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
|
||||
let tiled = !matches!(pick, Pick::Keep);
|
||||
let corners = vertices(
|
||||
tint,
|
||||
if state.pane && tiled {
|
||||
standard()
|
||||
} else {
|
||||
q.uv
|
||||
},
|
||||
);
|
||||
let corners = if state.pane && tiled {
|
||||
vertices(tint, standard())
|
||||
} else {
|
||||
// The face's own UVs, already packed: no per-vertex rounding.
|
||||
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
|
||||
};
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
|
||||
@@ -19,6 +19,11 @@ use std::{convert::Infallible, sync::OnceLock};
|
||||
/// `FluidRenderer.MAX_FLUID_HEIGHT`.
|
||||
const MAX_HEIGHT: f32 = 0.888_888_9;
|
||||
const EPSILON: f32 = 0.001;
|
||||
/// `super::step_index` of the unit steps of the padded index: one block east
|
||||
/// (+x), one south (+z), one up (+y).
|
||||
const X_STEP: isize = 1;
|
||||
const P_STEP: isize = super::P as isize;
|
||||
const UP: isize = super::step_index([0, 1, 0]);
|
||||
|
||||
/// For the occlusion face toward `direction`: the height from the bottom over
|
||||
/// which its slice flush with the `direction` side covers the whole width
|
||||
@@ -224,14 +229,14 @@ pub(super) fn tesselate(
|
||||
}
|
||||
};
|
||||
let info = &tables.info;
|
||||
let at = |p: [i32; 3]| &info[pad.blocks[pi(p[0], p[1], p[2])] as usize];
|
||||
let me = at([x, y, z]);
|
||||
// The cell's own padded index: every other cell sampled here is a constant
|
||||
// step from it (`super::step_index`), so no `pi` per sample.
|
||||
let cell = pi(x, y, z) as isize;
|
||||
let at = |step: isize| &info[pad.blocks[(cell + step) as usize] as usize];
|
||||
let me = at(0);
|
||||
let kind = me.fluid.kind();
|
||||
let same = |s: &Info| s.fluid.kind().same_family(kind);
|
||||
let neighbor = |d: Direction| {
|
||||
let [dx, dy, dz] = super::DIRECTIONS[d as usize].1;
|
||||
at([x + dx, y + dy, z + dz])
|
||||
};
|
||||
let neighbor = |d: Direction| at(super::OFFSETS[d as usize]);
|
||||
let (down, up) = (neighbor(Direction::Down), neighbor(Direction::Up));
|
||||
let should_render = |d: Direction| !same(neighbor(d)) && !occluded_by_self(me, d);
|
||||
let render_up = !same(up);
|
||||
@@ -265,10 +270,10 @@ pub(super) fn tesselate(
|
||||
crate::mesher::emitting(pack_rgb(rgb), emission)
|
||||
}
|
||||
};
|
||||
let height = |p: [i32; 3]| -> f32 {
|
||||
let s = at(p);
|
||||
let height = |step: isize| -> f32 {
|
||||
let s = at(step);
|
||||
if same(s) {
|
||||
if same(at([p[0], p[1] + 1, p[2]])) {
|
||||
if same(at(step + UP)) {
|
||||
1.0
|
||||
} else {
|
||||
s.fluid.own_height()
|
||||
@@ -279,15 +284,15 @@ pub(super) fn tesselate(
|
||||
-1.0
|
||||
}
|
||||
};
|
||||
let own = height([x, y, z]);
|
||||
let own = height(0);
|
||||
let (mut ne, mut nw, mut se, mut sw) = (1.0f32, 1.0f32, 1.0f32, 1.0f32);
|
||||
if own < 1.0 {
|
||||
let n = height([x, y, z - 1]);
|
||||
let s = height([x, y, z + 1]);
|
||||
let e = height([x + 1, y, z]);
|
||||
let w = height([x - 1, y, z]);
|
||||
let n = height(-P_STEP);
|
||||
let s = height(P_STEP);
|
||||
let e = height(X_STEP);
|
||||
let w = height(-X_STEP);
|
||||
// calculateAverageHeight(self, height2, height1, corner)
|
||||
let average = |h2: f32, h1: f32, corner: [i32; 3]| -> f32 {
|
||||
let average = |h2: f32, h1: f32, corner: isize| -> f32 {
|
||||
if h1 >= 1.0 || h2 >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -313,16 +318,17 @@ pub(super) fn tesselate(
|
||||
add(h2);
|
||||
sum[0] / sum[1]
|
||||
};
|
||||
ne = average(n, e, [x + 1, y, z - 1]);
|
||||
nw = average(n, w, [x - 1, y, z - 1]);
|
||||
se = average(s, e, [x + 1, y, z + 1]);
|
||||
sw = average(s, w, [x - 1, y, z + 1]);
|
||||
ne = average(n, e, X_STEP - P_STEP);
|
||||
nw = average(n, w, -X_STEP - P_STEP);
|
||||
se = average(s, e, X_STEP + P_STEP);
|
||||
sw = average(s, w, -X_STEP + P_STEP);
|
||||
}
|
||||
let light = |p: [i32; 3]| {
|
||||
let above = [p[0], p[1] + 1, p[2]];
|
||||
let light = |step: isize| {
|
||||
let i = (cell + step) as usize;
|
||||
let above = (cell + step + UP) as usize;
|
||||
pack_coords(lighting::max_coords(
|
||||
lighting::coords(pad, at(p), p),
|
||||
lighting::coords(pad, at(above), above),
|
||||
lighting::coords_at(pad, lighting::state_at(pad, info, i), i),
|
||||
lighting::coords_at(pad, lighting::state_at(pad, info, above), above),
|
||||
))
|
||||
};
|
||||
let (still, flow, overlay) = if water {
|
||||
@@ -393,7 +399,7 @@ pub(super) fn tesselate(
|
||||
// FluidState.shouldRenderBackwardUpFace over the 3x3 cells above.
|
||||
let backward = (-1..=1).any(|ox| {
|
||||
(-1..=1).any(|oz| {
|
||||
let s = at([x + ox, y + 1, z + oz]);
|
||||
let s = at(UP + ox as isize * X_STEP + oz as isize * P_STEP);
|
||||
!same(s) && !s.solid
|
||||
})
|
||||
});
|
||||
@@ -412,7 +418,7 @@ pub(super) fn tesselate(
|
||||
corners,
|
||||
layer,
|
||||
color(cardinal(D::Up)),
|
||||
light([x, y, z]),
|
||||
light(0),
|
||||
backward,
|
||||
Direction::Up as usize,
|
||||
);
|
||||
@@ -428,12 +434,12 @@ pub(super) fn tesselate(
|
||||
],
|
||||
still,
|
||||
color(cardinal(D::Down)),
|
||||
light([x, y - 1, z]),
|
||||
light(-UP),
|
||||
false,
|
||||
Direction::Down as usize,
|
||||
);
|
||||
}
|
||||
let side_light = light([x, y, z]);
|
||||
let side_light = light(0);
|
||||
let up_shade = cardinal(D::Up);
|
||||
for (i, d) in [
|
||||
Direction::North,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! and `ModelBlockRenderer`'s integer colour arithmetic (`ARGB.gray`,
|
||||
//! `scaleRGB`, `multiply`).
|
||||
use super::{Info, Padded, pi};
|
||||
use crate::models::catalog::QuadShape;
|
||||
use horizon_assets::model::Direction as D;
|
||||
|
||||
/// `LightCoordsUtil.FULL_BRIGHT`.
|
||||
@@ -25,28 +26,37 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
|
||||
pub(super) fn pack_flat(nether: bool) -> f32 {
|
||||
cardinal(nether, D::Up)
|
||||
}
|
||||
/// The normals of `D`'s variants, in the order [`ADJACENCY`] is indexed by.
|
||||
const NORMALS: [[i32; 3]; 6] = [
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
];
|
||||
fn normal(face: D) -> [i32; 3] {
|
||||
match face {
|
||||
D::Down => [0, -1, 0],
|
||||
D::Up => [0, 1, 0],
|
||||
D::North => [0, 0, -1],
|
||||
D::South => [0, 0, 1],
|
||||
D::West => [-1, 0, 0],
|
||||
D::East => [1, 0, 0],
|
||||
}
|
||||
NORMALS[face as usize]
|
||||
}
|
||||
pub(super) fn step(p: [i32; 3], face: D) -> [i32; 3] {
|
||||
let n = normal(face);
|
||||
[p[0] + n[0], p[1] + n[1], p[2] + n[2]]
|
||||
}
|
||||
fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
&info[pad.blocks[pi(p[0], p[1], p[2])] as usize]
|
||||
/// The block state of the padded cell at index `i` (see `pi`, `step_index`).
|
||||
#[inline]
|
||||
pub(super) fn state_at<'a>(pad: &Padded, info: &'a [Info], i: usize) -> &'a Info {
|
||||
&info[pad.blocks[i] as usize]
|
||||
}
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of the cell at index `i`, as seen through `state`.
|
||||
#[inline]
|
||||
pub(super) fn coords_at(pad: &Padded, state: &Info, i: usize) -> u32 {
|
||||
packed_coords(state, pad.light[i])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
@@ -238,64 +248,21 @@ const ADJACENCY: [Adjacency; 6] = [
|
||||
adjacency(D::West),
|
||||
adjacency(D::East),
|
||||
];
|
||||
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
|
||||
/// the block face) and whether it is cubic (flush with the block boundary, or
|
||||
/// flat on a full collision shape).
|
||||
struct Shape {
|
||||
bounds: [f32; 12],
|
||||
partial: bool,
|
||||
cubic: bool,
|
||||
}
|
||||
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> Shape {
|
||||
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
|
||||
for p in positions {
|
||||
for i in 0..3 {
|
||||
min[i] = min[i].min(p[i]);
|
||||
max[i] = max[i].max(p[i]);
|
||||
}
|
||||
}
|
||||
let mut bounds = [0f32; 12];
|
||||
for (index, value) in [
|
||||
(WEST, min[0]),
|
||||
(EAST, max[0]),
|
||||
(DOWN, min[1]),
|
||||
(UP, max[1]),
|
||||
(NORTH, min[2]),
|
||||
(SOUTH, max[2]),
|
||||
] {
|
||||
bounds[index as usize] = value;
|
||||
bounds[index as usize + 6] = 1.0 - value;
|
||||
}
|
||||
let (lo, hi) = (1.0e-4f32, 0.9999f32);
|
||||
let partial = |a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
|
||||
let (partial, cubic) = match face {
|
||||
D::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
|
||||
D::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
|
||||
D::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
|
||||
D::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
|
||||
D::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
|
||||
D::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
|
||||
};
|
||||
Shape {
|
||||
bounds,
|
||||
partial,
|
||||
cubic,
|
||||
}
|
||||
fn shape(positions: &[[f32; 3]; 4], face: D, full: bool) -> QuadShape {
|
||||
QuadShape::new(positions, face, full)
|
||||
}
|
||||
/// `prepareQuadFlat` for a quad without a cull face: light from the
|
||||
/// neighbour when the quad is flush with the face, else from the block itself.
|
||||
pub(super) fn flat(
|
||||
/// neighbour when the quad is flush with the face (cubic), else from the block
|
||||
/// itself. `shape` is the quad's baked [`QuadShape`].
|
||||
pub(super) fn flat_shaped(
|
||||
pad: &Padded,
|
||||
own: &Info,
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
shape: &QuadShape,
|
||||
) -> u32 {
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
let at = if shape.cubic { step(pos, face) } else { pos };
|
||||
coords(pad, own, at)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
@@ -314,6 +281,51 @@ pub(super) fn flat_position(
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// A face's AO sample offsets from the sample base, in `pi` index space, so a
|
||||
/// sample is `base + offset` rather than a `pi` per coordinate triple.
|
||||
struct Samples {
|
||||
/// The four corners (`adjacency`'s).
|
||||
corners: [isize; 4],
|
||||
/// Each corner stepped out of the face (the permeability sample).
|
||||
out: [isize; 4],
|
||||
/// Corner `a` stepped toward corner `b`.
|
||||
diagonal: [[isize; 4]; 4],
|
||||
/// `step(pos, face)` from the block's own cell.
|
||||
step: isize,
|
||||
}
|
||||
const fn samples(face: D) -> Samples {
|
||||
let mut corners = [0isize; 4];
|
||||
let mut out = [0isize; 4];
|
||||
let mut diagonal = [[0isize; 4]; 4];
|
||||
let step = super::step_index(NORMALS[face as usize]);
|
||||
let mut i = 0;
|
||||
while i < 4 {
|
||||
let c = super::step_index(NORMALS[ADJACENCY[face as usize].corners[i] as usize]);
|
||||
corners[i] = c;
|
||||
out[i] = c + step;
|
||||
let mut j = 0;
|
||||
while j < 4 {
|
||||
diagonal[i][j] =
|
||||
c + super::step_index(NORMALS[ADJACENCY[face as usize].corners[j] as usize]);
|
||||
j += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Samples {
|
||||
corners,
|
||||
out,
|
||||
diagonal,
|
||||
step,
|
||||
}
|
||||
}
|
||||
const SAMPLES: [Samples; 6] = [
|
||||
samples(D::Down),
|
||||
samples(D::Up),
|
||||
samples(D::North),
|
||||
samples(D::South),
|
||||
samples(D::West),
|
||||
samples(D::East),
|
||||
];
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
pub(super) fn ambient_occlusion(
|
||||
@@ -321,26 +333,34 @@ pub(super) fn ambient_occlusion(
|
||||
info: &[Info],
|
||||
own: &Info,
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
shape: &QuadShape,
|
||||
face: D,
|
||||
) -> ([f32; 4], [u32; 4]) {
|
||||
let shape = shape(positions, face, own.full);
|
||||
let base = if shape.cubic { step(pos, face) } else { pos };
|
||||
let samples = &SAMPLES[face as usize];
|
||||
// The block's own cell and the sample base (the neighbour when the quad is
|
||||
// cubic): every corner and diagonal is a constant step from it.
|
||||
let cell = pi(pos[0], pos[1], pos[2]) as isize;
|
||||
let base = if shape.cubic {
|
||||
cell + samples.step
|
||||
} else {
|
||||
cell
|
||||
};
|
||||
let adjacency = &ADJACENCY[face as usize];
|
||||
let corner = |i: usize| step(base, adjacency.corners[i]);
|
||||
let sample = |p: [i32; 3]| {
|
||||
let s = state(pad, info, p);
|
||||
(coords(pad, s, p), s.shade)
|
||||
let corner = |i: usize| base + samples.corners[i];
|
||||
let sample = |i: isize| {
|
||||
let i = i as usize;
|
||||
let s = state_at(pad, info, i);
|
||||
(coords_at(pad, s, i), s.shade)
|
||||
};
|
||||
let (light0, shade0) = sample(corner(0));
|
||||
let (light1, shade1) = sample(corner(1));
|
||||
let (light2, shade2) = sample(corner(2));
|
||||
let (light3, shade3) = sample(corner(3));
|
||||
let permeable = |i: usize| state(pad, info, step(corner(i), face)).permeable;
|
||||
let permeable = |i: usize| state_at(pad, info, (base + samples.out[i]) as usize).permeable;
|
||||
let (p0, p1, p2, p3) = (permeable(0), permeable(1), permeable(2), permeable(3));
|
||||
let diagonal = |a: usize, b: usize, open: bool| {
|
||||
if open {
|
||||
sample(step(corner(a), adjacency.corners[b]))
|
||||
sample(base + samples.diagonal[a][b])
|
||||
} else {
|
||||
(light0, shade0)
|
||||
}
|
||||
@@ -349,15 +369,15 @@ pub(super) fn ambient_occlusion(
|
||||
let (light03, shade03) = diagonal(0, 3, p3 || p0);
|
||||
let (light12, shade12) = diagonal(1, 2, p2 || p1);
|
||||
let (light13, shade13) = diagonal(1, 3, p3 || p1);
|
||||
let next = step(pos, face);
|
||||
let next_state = state(pad, info, next);
|
||||
let next = (cell + samples.step) as usize;
|
||||
let next_state = state_at(pad, info, next);
|
||||
let light_center = if shape.cubic || !next_state.solid {
|
||||
coords(pad, next_state, next)
|
||||
coords_at(pad, next_state, next)
|
||||
} else {
|
||||
coords(pad, own, pos)
|
||||
coords_at(pad, own, cell as usize)
|
||||
};
|
||||
let shade_center = if shape.cubic {
|
||||
state(pad, info, base).shade
|
||||
state_at(pad, info, base as usize).shade
|
||||
} else {
|
||||
own.shade
|
||||
};
|
||||
@@ -393,6 +413,57 @@ pub(super) fn ambient_occlusion(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ao_offsets_match_coordinate_samples_at_section_edges() {
|
||||
// Explicit coordinate directions, without NORMALS, ADJACENCY or step_index.
|
||||
let (down, up, north, south, west, east) = (
|
||||
[0, -1, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, -1],
|
||||
[0, 0, 1],
|
||||
[-1, 0, 0],
|
||||
[1, 0, 0],
|
||||
);
|
||||
let faces = [
|
||||
(D::Down, down, [west, east, north, south]),
|
||||
(D::Up, up, [east, west, north, south]),
|
||||
(D::North, north, [up, down, east, west]),
|
||||
(D::South, south, [west, east, down, up]),
|
||||
(D::West, west, [up, down, north, south]),
|
||||
(D::East, east, [down, up, north, south]),
|
||||
];
|
||||
let add = |a: [i32; 3], b: [i32; 3]| std::array::from_fn(|i| a[i] + b[i]);
|
||||
let index = |p: [i32; 3]| pi(p[0], p[1], p[2]);
|
||||
for x in [0, 1, 14, 15] {
|
||||
for y in [0, 1, 14, 15] {
|
||||
for z in [0, 1, 14, 15] {
|
||||
let pos = [x, y, z];
|
||||
for (face, normal, corners) in faces {
|
||||
let samples = &SAMPLES[face as usize];
|
||||
let cell = index(pos) as isize;
|
||||
assert_eq!((cell + samples.step) as usize, index(add(pos, normal)));
|
||||
for cubic in [false, true] {
|
||||
let base_pos = if cubic { add(pos, normal) } else { pos };
|
||||
let base = if cubic { cell + samples.step } else { cell };
|
||||
for (i, corner) in corners.into_iter().enumerate() {
|
||||
let p = add(base_pos, corner);
|
||||
assert_eq!((base + samples.corners[i]) as usize, index(p));
|
||||
assert_eq!((base + samples.out[i]) as usize, index(add(p, normal)));
|
||||
}
|
||||
for (a, b) in [(0, 2), (0, 3), (1, 2), (1, 3)] {
|
||||
assert_eq!(
|
||||
(base + samples.diagonal[a][b]) as usize,
|
||||
index(add(add(base_pos, corners[a]), corners[b]))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn packed(block: u32, sky: u32) -> u32 {
|
||||
block << 4 | sky << 20
|
||||
}
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
use super::*;
|
||||
use crate::chunk::Light;
|
||||
|
||||
#[test]
|
||||
fn padded_steps_match_coordinate_lookup_through_the_border() {
|
||||
// Coordinate lookup is the old addressing path, independent of step_index
|
||||
// and the direction table. Include every cell and the full two-cell halo.
|
||||
let neighbors = [
|
||||
(Direction::Down, [0, -1, 0]),
|
||||
(Direction::Up, [0, 1, 0]),
|
||||
(Direction::North, [0, 0, -1]),
|
||||
(Direction::South, [0, 0, 1]),
|
||||
(Direction::West, [-1, 0, 0]),
|
||||
(Direction::East, [1, 0, 0]),
|
||||
];
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
let cell = (pi(0, y, z) + x as usize) as isize;
|
||||
assert_eq!(cell as usize, pi(x, y, z));
|
||||
for (face, [dx, dy, dz]) in neighbors {
|
||||
assert_eq!(
|
||||
(cell + OFFSETS[face as usize]) as usize,
|
||||
pi(x + dx, y + dy, z + dz)
|
||||
);
|
||||
}
|
||||
for dy in -2..=2 {
|
||||
for dz in -2..=2 {
|
||||
for dx in -2..=2 {
|
||||
assert_eq!(
|
||||
(cell + step_index([dx, dy, dz])) as usize,
|
||||
pi(x + dx, y + dy, z + dz)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
let mut pad = Padded::default();
|
||||
|
||||
@@ -9,6 +9,7 @@ use horizon_assets::{
|
||||
bind::{Properties, PropertySchema, PropertyValues, SelectedModels},
|
||||
choice::CompiledChoices,
|
||||
},
|
||||
model::Direction,
|
||||
};
|
||||
use horizon_core::{
|
||||
position::BlockPos,
|
||||
@@ -26,6 +27,65 @@ use std::{
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
/// `prepareQuadShape`: the quad's bounds, whether it is partial (smaller than
|
||||
/// the block face) and whether it is cubic (flush with the block boundary, or
|
||||
/// flat on a full collision shape). A function of the quad's positions and the
|
||||
/// block's `isCollisionShapeFullBlock` only, so it is baked per face, once for
|
||||
/// each.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct QuadShape {
|
||||
/// The quad's bounds by face index (`mesher::lighting`'s `SizeInfo`
|
||||
/// order: down, up, north, south, west, east), and `1.0 - the bound`.
|
||||
pub bounds: [f32; 12],
|
||||
pub partial: bool,
|
||||
pub cubic: bool,
|
||||
}
|
||||
impl QuadShape {
|
||||
pub fn new(positions: &[[f32; 3]; 4], face: Direction, full: bool) -> Self {
|
||||
// `SizeInfo` indices into the face shape.
|
||||
const DOWN: usize = 0;
|
||||
const UP: usize = 1;
|
||||
const NORTH: usize = 2;
|
||||
const SOUTH: usize = 3;
|
||||
const WEST: usize = 4;
|
||||
const EAST: usize = 5;
|
||||
let (mut min, mut max) = ([32f32; 3], [-32f32; 3]);
|
||||
for p in positions {
|
||||
for i in 0..3 {
|
||||
min[i] = min[i].min(p[i]);
|
||||
max[i] = max[i].max(p[i]);
|
||||
}
|
||||
}
|
||||
let mut bounds = [0f32; 12];
|
||||
for (index, value) in [
|
||||
(WEST, min[0]),
|
||||
(EAST, max[0]),
|
||||
(DOWN, min[1]),
|
||||
(UP, max[1]),
|
||||
(NORTH, min[2]),
|
||||
(SOUTH, max[2]),
|
||||
] {
|
||||
bounds[index] = value;
|
||||
bounds[index + 6] = 1.0 - value;
|
||||
}
|
||||
let (lo, hi) = (1.0e-4f32, 0.9999f32);
|
||||
let partial =
|
||||
|a: usize, b: usize| min[a] >= lo || min[b] >= lo || max[a] <= hi || max[b] <= hi;
|
||||
let (partial, cubic) = match face {
|
||||
Direction::Down => (partial(0, 2), min[1] == max[1] && (min[1] < lo || full)),
|
||||
Direction::Up => (partial(0, 2), min[1] == max[1] && (max[1] > hi || full)),
|
||||
Direction::North => (partial(0, 1), min[2] == max[2] && (min[2] < lo || full)),
|
||||
Direction::South => (partial(0, 1), min[2] == max[2] && (max[2] > hi || full)),
|
||||
Direction::West => (partial(1, 2), min[0] == max[0] && (min[0] < lo || full)),
|
||||
Direction::East => (partial(1, 2), min[0] == max[0] && (max[0] > hi || full)),
|
||||
};
|
||||
QuadShape {
|
||||
bounds,
|
||||
partial,
|
||||
cubic,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct BakedFace {
|
||||
pub quad: ModelQuad,
|
||||
pub layer: u32,
|
||||
@@ -37,6 +97,33 @@ pub struct BakedFace {
|
||||
/// The Fusion connecting texture rule its sprite joins by ([`crate::fusion`]), plus one:
|
||||
/// two bytes, within the padding every face already has.
|
||||
pub fusion: Option<std::num::NonZeroU16>,
|
||||
/// `prepareQuadShape` of the quad, indexed by the block's `full` flag: the
|
||||
/// mesher would otherwise recompute it for every face of every block.
|
||||
pub shapes: [QuadShape; 2],
|
||||
/// The quad's own UVs in `TerrainVertex` fixed point, which the mesh
|
||||
/// otherwise recomputes for every face of every block.
|
||||
pub uv_fixed: [[u16; 2]; 4],
|
||||
}
|
||||
impl BakedFace {
|
||||
pub fn new(
|
||||
quad: ModelQuad,
|
||||
layer: u32,
|
||||
opaque: bool,
|
||||
translucent: bool,
|
||||
fusion: Option<std::num::NonZeroU16>,
|
||||
) -> Self {
|
||||
let shapes = [false, true].map(|full| QuadShape::new(&quad.positions, quad.facing, full));
|
||||
let uv_fixed = crate::mesher::baked_uv(quad.uv);
|
||||
Self {
|
||||
quad,
|
||||
layer,
|
||||
opaque,
|
||||
translucent,
|
||||
fusion,
|
||||
shapes,
|
||||
uv_fixed,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct BakedModel {
|
||||
pub ambient_occlusion: bool,
|
||||
@@ -97,18 +184,12 @@ fn bake_resolved(
|
||||
Some(RenderType::Translucent | RenderType::Tripwire) => (true, false),
|
||||
Some(RenderType::Solid) => (false, true),
|
||||
Some(RenderType::Cutout | RenderType::CutoutMipped) => (false, false),
|
||||
None => (
|
||||
None | Some(RenderType::Unsupported) => (
|
||||
quad.material.force_translucent || sprites.translucent[layer as usize],
|
||||
sprites.region_opaque(layer, &quad.uv),
|
||||
),
|
||||
};
|
||||
Ok(BakedFace {
|
||||
translucent,
|
||||
opaque,
|
||||
quad,
|
||||
layer,
|
||||
fusion: None,
|
||||
})
|
||||
Ok(BakedFace::new(quad, layer, opaque, translucent, None))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
|
||||
@@ -118,13 +118,7 @@ mod tests {
|
||||
)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|quad| BakedFace {
|
||||
quad,
|
||||
layer: 7,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.map(|quad| BakedFace::new(quad, 7, true, false, None))
|
||||
.collect();
|
||||
let Some(styles) = cube_styles(&faces) else {
|
||||
assert!(lock);
|
||||
@@ -153,13 +147,7 @@ mod tests {
|
||||
bake_model_transformed(&model, BlockModelTransform::new([0; 3], false))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|quad| BakedFace {
|
||||
quad,
|
||||
layer: 0,
|
||||
opaque: true,
|
||||
translucent: false,
|
||||
fusion: None,
|
||||
})
|
||||
.map(|quad| BakedFace::new(quad, 0, true, false, None))
|
||||
.collect();
|
||||
faces[0].opaque = false;
|
||||
assert!(cube_styles(&faces).is_none());
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
|
||||
lang::{self, LanguageInfo},
|
||||
settings::{SOUND_SOURCES, Settings},
|
||||
settings_shell as shell,
|
||||
text::{self, Style, Text},
|
||||
ui,
|
||||
};
|
||||
@@ -19,6 +20,14 @@ use crate::{
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
|
||||
/// False only in a game built without the `horizon-extras` feature (docs/forking.md), which says
|
||||
/// so once at startup: the Options screen then has vanilla's Telemetry Data slot instead of
|
||||
/// Horizon Settings and Mods, and Credits & Attribution names the official build.
|
||||
pub static HORIZON_EXTRAS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
fn horizon_extras() -> bool {
|
||||
HORIZON_EXTRAS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
fn generic(caption: &str, value: Text) -> Text {
|
||||
Text::translate("options.generic_value", &[tr(caption), value])
|
||||
}
|
||||
@@ -293,6 +302,7 @@ impl Opt {
|
||||
// the namespace the client bundles (assets/horizon/lang).
|
||||
Opt::PadEnabled => "horizon.options.controller",
|
||||
Opt::PadVibration => "horizon.options.vibration",
|
||||
Opt::PadPlayInBackground => "horizon.options.play_in_background",
|
||||
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
|
||||
Opt::PadLightbar => "horizon.options.lightbar",
|
||||
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
|
||||
@@ -308,6 +318,7 @@ impl Opt {
|
||||
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
@@ -828,7 +839,13 @@ impl Opt {
|
||||
Opt::JapaneseGlyphs => on_off(s.japanese_glyphs),
|
||||
Opt::PadEnabled => on_off(s.controller.enabled),
|
||||
Opt::PadVibration => on_off(s.controller.vibration),
|
||||
Opt::PadPlayInBackground => on_off(s.controller.play_in_background),
|
||||
Opt::PadAdaptiveTriggers => on_off(s.controller.adaptive_triggers),
|
||||
Opt::PadGameplayGuide => tr(match s.controller.guide_detail {
|
||||
gamepad::GuideDetail::Full => "horizon.options.gameplay_guide.full",
|
||||
gamepad::GuideDetail::Basic => "horizon.options.gameplay_guide.basic",
|
||||
gamepad::GuideDetail::Off => "horizon.options.gameplay_guide.off",
|
||||
}),
|
||||
Opt::PadInvertX => on_off(s.controller.invert_x),
|
||||
Opt::PadInvertY => on_off(s.controller.invert_y),
|
||||
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
|
||||
@@ -1125,6 +1142,9 @@ impl Opt {
|
||||
Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs,
|
||||
Opt::PadEnabled => s.controller.enabled = !s.controller.enabled,
|
||||
Opt::PadVibration => s.controller.vibration = !s.controller.vibration,
|
||||
Opt::PadPlayInBackground => {
|
||||
s.controller.play_in_background = !s.controller.play_in_background
|
||||
}
|
||||
Opt::PadAdaptiveTriggers => {
|
||||
s.controller.adaptive_triggers = !s.controller.adaptive_triggers
|
||||
}
|
||||
@@ -1146,6 +1166,14 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
|
||||
}
|
||||
Opt::PadGameplayGuide => {
|
||||
let all = gamepad::GuideDetail::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|d| *d == s.controller.guide_detail)
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.guide_detail = all[usize::from(next(i, 3))];
|
||||
}
|
||||
Opt::PadKeyboard => {
|
||||
let all = gamepad::TextKeyboard::ALL;
|
||||
let i = all
|
||||
@@ -1515,6 +1543,8 @@ pub enum Event {
|
||||
PlayMusicTrack(String, String),
|
||||
/// Open the recordings folder in the system file browser.
|
||||
OpenStudioFolder,
|
||||
/// Start playback from the selected Studio recording.
|
||||
OpenStudioReplay(std::path::PathBuf),
|
||||
}
|
||||
/// A row of the Music page: the track, and the pool it is played from.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -1617,6 +1647,8 @@ pub struct OptionsUi {
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
pub controller: crate::controller_ui::ControllerUi,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
pub shader: crate::shader_ui::ShaderUi,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
@@ -1649,7 +1681,7 @@ pub struct OptionsUi {
|
||||
/// The list's rows as last laid out: each row's top and bottom (GUI
|
||||
/// pixels), for the narration's row under the mouse.
|
||||
row_bands: Vec<[f32; 2]>,
|
||||
/// The Extras or PvP & HUD items that take a whole row (`wide_extras`), measured
|
||||
/// The PvP & HUD items that take a whole row (`wide_extras`), measured
|
||||
/// when the page is drawn.
|
||||
wide_extras: Vec<crate::extras::Item>,
|
||||
}
|
||||
@@ -1695,6 +1727,7 @@ impl Default for OptionsUi {
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
shader: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
@@ -1763,24 +1796,23 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
Kind::Cycle => button(x, y, w, opt.label(s), Act::Cycle(opt)),
|
||||
}
|
||||
}
|
||||
/// The Extras or PvP & HUD page's rows: its items under their headers.
|
||||
fn extras_rows(page: Page) -> Vec<Result<crate::extras::Item, &'static str>> {
|
||||
match page {
|
||||
Page::Pvp => crate::pvp::rows()
|
||||
.into_iter()
|
||||
.map(|r| r.map(crate::extras::Item::Pvp))
|
||||
.collect(),
|
||||
_ => crate::extras::rows(),
|
||||
}
|
||||
/// The least GUI size a shell screen lays its keys out for, before it has been drawn at its own.
|
||||
const MIN_KEY_GUI: (f32, f32) = (480.0, 240.0);
|
||||
/// The PvP & HUD page's rows: its items under their headers.
|
||||
fn pvp_rows() -> Vec<Result<crate::extras::Item, &'static str>> {
|
||||
crate::pvp::rows()
|
||||
.into_iter()
|
||||
.map(|r| r.map(crate::extras::Item::Pvp))
|
||||
.collect()
|
||||
}
|
||||
/// The items of `page`'s rows whose label, with any of their first values, is wider than a
|
||||
/// The items of PvP & HUD's rows whose label, with any of their first values, is wider than a
|
||||
/// half-width button holds (`width` measures text with the screen's font): those take the
|
||||
/// whole row, so no label slides past its edges, in any language.
|
||||
fn wide_extras(s: &Settings, page: Page, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
|
||||
fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::Item> {
|
||||
// A button's label keeps 2 px off each edge (`AbstractButton.renderString`'s margin),
|
||||
// past which it slides back and forth.
|
||||
let room = 150.0 - 4.0;
|
||||
extras_rows(page)
|
||||
pvp_rows()
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|&item| {
|
||||
@@ -2004,6 +2036,9 @@ impl OptionsUi {
|
||||
if !self.focus_from_keyboard {
|
||||
self.focus = None;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.blur_search();
|
||||
}
|
||||
}
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
@@ -2056,7 +2091,10 @@ impl OptionsUi {
|
||||
}
|
||||
Page::Controller | Page::PadBinds => self.controller.typing(),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => self.settings_search_active(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Jump straight to a page (control socket / tests).
|
||||
@@ -2172,6 +2210,9 @@ impl OptionsUi {
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.open(at);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.open();
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
@@ -2182,6 +2223,34 @@ impl OptionsUi {
|
||||
self.studio.refresh();
|
||||
}
|
||||
}
|
||||
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
|
||||
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
|
||||
/// through them, so no input path can miss one.
|
||||
fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::BindKey(name) => self.selected_key = Some(name),
|
||||
shell::Output::Push(page) => self.push(page),
|
||||
shell::Output::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the graphics screen's actions do to these options.
|
||||
fn graphics_events(
|
||||
&mut self,
|
||||
@@ -2532,41 +2601,6 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// The row that reaches the Zoom mod that replaced the extra: its switch and settings page
|
||||
/// when it is built in, else a note saying where it is installed (Options > Mods). Zoom
|
||||
/// became an official native mod, so Extras has no setting of its own for it (#8713).
|
||||
fn zoom_mod_row(s: &Settings) -> Row {
|
||||
let Some(i) = s
|
||||
.mods
|
||||
.installed
|
||||
.iter()
|
||||
.position(|m| m.id == "zoom" && m.native.is_some())
|
||||
else {
|
||||
// A note, not a button: Options > Mods already has the one way into the Mods page
|
||||
// (`every_setting_and_page_is_reached_from_one_place`).
|
||||
return Row::Note(lang::get("horizon.extras.zoom_mod_install"));
|
||||
};
|
||||
let label = || Text::option_name_value(tr("horizon.extras.zoom"), on_off(s.mods.is_on(i)));
|
||||
let toggle = |width| button(0.0, 0.0, width, label(), Act::ToggleMod(i));
|
||||
let settings = s.mods.installed[i]
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty());
|
||||
if settings {
|
||||
Row::Buttons(
|
||||
toggle(150.0),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
Row::Buttons(toggle(310.0), None)
|
||||
}
|
||||
}
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
@@ -2607,7 +2641,7 @@ impl OptionsUi {
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => Vec::new(),
|
||||
Page::Extras | Page::Pvp => {
|
||||
Page::Pvp => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen; one whose label would run
|
||||
// past half a row in this language (`wide_extras`) gets the whole row.
|
||||
@@ -2635,21 +2669,12 @@ impl OptionsUi {
|
||||
widget
|
||||
}
|
||||
};
|
||||
// PvP & HUD opens with its preset; Extras doesn't repeat it, nor the way there
|
||||
// (Horizon Settings has it).
|
||||
// PvP & HUD opens with its preset (Horizon Settings has the way there).
|
||||
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
|
||||
let mut rows = Vec::new();
|
||||
if self.page() == Page::Extras {
|
||||
// Zoom, the FPS counter, Keystrokes and the Minimap are official mods now;
|
||||
// Extras keeps a way to the one players look for here (#8713).
|
||||
rows.push(Self::zoom_mod_row(s));
|
||||
}
|
||||
if self.page() == Page::Pvp {
|
||||
// The editor moves what the game shows: only from a world.
|
||||
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
|
||||
let edit = if self.in_world { edit } else { inactive(edit) };
|
||||
rows.push(Row::Buttons(preset, Some(edit)));
|
||||
}
|
||||
// The editor moves what the game shows: only from a world.
|
||||
let edit = button(0.0, 0.0, 150.0, tr("horizon.pvp.editor"), Act::HudEditor);
|
||||
let edit = if self.in_world { edit } else { inactive(edit) };
|
||||
let mut rows = vec![Row::Buttons(preset, Some(edit))];
|
||||
let mut section = Vec::new();
|
||||
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
|
||||
let mut half = None;
|
||||
@@ -2665,7 +2690,7 @@ impl OptionsUi {
|
||||
}
|
||||
rows.extend(half.map(|h| Row::Buttons(extra(h, 150.0), None)));
|
||||
};
|
||||
for row in extras_rows(self.page()) {
|
||||
for row in pvp_rows() {
|
||||
match row {
|
||||
Err(header) => {
|
||||
flush(&mut rows, &mut section);
|
||||
@@ -3096,6 +3121,22 @@ impl OptionsUi {
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
// horizon-extras: without it, vanilla's last row instead of Horizon Settings and
|
||||
// Mods: Telemetry Data (greyed: nothing is sent) and Credits & Attribution.
|
||||
let plain;
|
||||
let entries: &[_] = if horizon_extras() {
|
||||
&entries
|
||||
} else {
|
||||
plain = [
|
||||
&entries[..entries.len() - 3],
|
||||
&[
|
||||
("options.telemetry", None),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
],
|
||||
]
|
||||
.concat();
|
||||
&plain
|
||||
};
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
|
||||
for (i, (key, page)) in entries.iter().enumerate() {
|
||||
@@ -3145,6 +3186,22 @@ impl OptionsUi {
|
||||
let y = y + 28.0 * (i + 1) as f32;
|
||||
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
|
||||
}
|
||||
// horizon-extras: a build without them says where the official one is.
|
||||
if !horizon_extras() {
|
||||
out.push(Widget {
|
||||
x: 0.0,
|
||||
y: (y + 92.0).min(h - 42.0),
|
||||
w,
|
||||
h: 9.0,
|
||||
kind: WKind::Text {
|
||||
text: tr("horizon.plain.official"),
|
||||
color: 0xFFA0A0A0,
|
||||
centered: true,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
});
|
||||
}
|
||||
out.push(done(h - 27.0));
|
||||
top = 0.0;
|
||||
bottom = 0.0;
|
||||
@@ -3647,6 +3704,12 @@ impl OptionsUi {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.selected_key;
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
self.split_setup.on = s.extras.splitscreen;
|
||||
// The controller picks a tile up between presses: the screen's size from the draw.
|
||||
@@ -3675,8 +3738,8 @@ impl OptionsUi {
|
||||
crate::screen_draw::paint(batch, atlas, &draws);
|
||||
return;
|
||||
}
|
||||
if matches!(self.page(), Page::Extras | Page::Pvp) {
|
||||
self.wide_extras = wide_extras(s, self.page(), |t| atlas.font.width(t));
|
||||
if self.page() == Page::Pvp {
|
||||
self.wide_extras = wide_extras(s, |t| atlas.font.width(t));
|
||||
}
|
||||
let (widgets, top, bottom, content) = self.layout(s, w, h);
|
||||
self.size = (w, h);
|
||||
@@ -3978,6 +4041,9 @@ impl OptionsUi {
|
||||
return match self.studio.click((x, y), size) {
|
||||
StudioAction::None => Vec::new(),
|
||||
StudioAction::Refresh => vec![Event::ButtonClick],
|
||||
StudioAction::OpenReplay(path) => {
|
||||
vec![Event::ButtonClick, Event::OpenStudioReplay(path)]
|
||||
}
|
||||
StudioAction::OpenFolder => vec![Event::ButtonClick, Event::OpenStudioFolder],
|
||||
StudioAction::Back => {
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
@@ -4010,6 +4076,22 @@ impl OptionsUi {
|
||||
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
|
||||
return self.graphics_events(s, actions);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// 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 shift = self.shift;
|
||||
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
|
||||
shell.click(s, (x, y), size, drag, right, shift)
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
// Waiting on a button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
@@ -4286,7 +4368,9 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let graphics_slider = self.graphics.release() | self.controller.release() | shader_slider;
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider =
|
||||
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
@@ -4364,6 +4448,13 @@ impl OptionsUi {
|
||||
) -> Vec<Event> {
|
||||
self.size = size;
|
||||
self.framebuffer = framebuffer;
|
||||
if self.resource_error.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.wheel(s, size, cursor, amount);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.graphics_events(s, actions);
|
||||
@@ -4455,10 +4546,18 @@ impl OptionsUi {
|
||||
if self.resource_error.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
self.graphics.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.scroll(s, size, amount);
|
||||
return;
|
||||
@@ -4647,6 +4746,10 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
} else if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles.type_text(text);
|
||||
} else if let Some(shell) = self.shell_mut() {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
|
||||
self.graphics.type_text(text);
|
||||
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
|
||||
@@ -4684,6 +4787,9 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles {
|
||||
return self.split_profiles.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
return self.graphics.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4817,6 +4923,14 @@ impl OptionsUi {
|
||||
}
|
||||
return events;
|
||||
}
|
||||
let gui = (
|
||||
self.size.0.max(MIN_KEY_GUI.0),
|
||||
self.size.1.max(MIN_KEY_GUI.1),
|
||||
);
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
|
||||
@@ -5175,6 +5289,7 @@ impl OptionsUi {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extras_ui::ExtraRow;
|
||||
|
||||
/// The window allows the input method only while `typing` says a search
|
||||
/// box has the focus (`Game::text_field_focused`), so a page without one
|
||||
@@ -5620,6 +5735,9 @@ mod tests {
|
||||
if ui.controller_open() {
|
||||
texts.extend(ui.controller.row_names(&s));
|
||||
}
|
||||
if ui.page() == Page::Extras {
|
||||
texts.extend(ui.extras.row_labels(&s));
|
||||
}
|
||||
}
|
||||
texts
|
||||
})
|
||||
@@ -5988,7 +6106,7 @@ mod tests {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
ui.show(Page::Pvp);
|
||||
let (widgets, _, _, _) = ui.layout(&s, 480.0, 480.0);
|
||||
let extra = widgets
|
||||
.iter()
|
||||
@@ -5998,7 +6116,7 @@ mod tests {
|
||||
WKind::Button {
|
||||
act: Act::Extra(item),
|
||||
..
|
||||
} if !matches!(item, crate::extras::Item::SortKey)
|
||||
} if item.key_name().is_none()
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -6044,19 +6162,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extras_reaches_the_zoom_mod_that_replaced_the_extra() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
let english = || lang::Language::load(&[], "en_us");
|
||||
// Without the mod, Extras says where it is installed (a note, not a second way into
|
||||
// Options > Mods).
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Text { text, .. } if text.plain() == "Zoom: install from Mods"
|
||||
)),
|
||||
"Extras has no hint for the Zoom mod"
|
||||
labels.iter().any(|l| l == "Zoom: install from Mods"),
|
||||
"Extras has no hint for the Zoom mod: {labels:?}"
|
||||
);
|
||||
// Built in, Zoom's settings live on its page: Extras opens that page.
|
||||
s.mods.installed.push(crate::mod_list::Installed {
|
||||
@@ -6070,30 +6186,28 @@ mod tests {
|
||||
buttons: Vec::new(),
|
||||
}),
|
||||
});
|
||||
let (widgets, _, _, _) = lang::scoped(english(), || ui.layout(&s, 480.0, 4000.0)).0;
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ModSettings(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain() == "Settings"
|
||||
)),
|
||||
"Extras doesn't open the Zoom mod's settings"
|
||||
let labels = lang::scoped(english(), || ui.extras.row_labels(&s)).0;
|
||||
assert!(labels.iter().any(|l| l == "Settings"), "{labels:?}");
|
||||
// The mod's switch is beside it, so Extras keeps the Zoom setting it had.
|
||||
assert!(labels.iter().any(|l| l.starts_with("Zoom: ")), "{labels:?}");
|
||||
let size = (480.0, 480.0);
|
||||
let on = s.mods.is_on(0);
|
||||
let at = ui.extras.point_at(&s, size, ExtraRow::Zoom(0));
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
// The mod's switch is on the same row, so Extras keeps the Zoom setting it had.
|
||||
assert!(
|
||||
widgets.iter().any(|w| matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::ToggleMod(0),
|
||||
label,
|
||||
..
|
||||
} if label.plain().starts_with("Zoom")
|
||||
)),
|
||||
"Extras doesn't switch the Zoom mod"
|
||||
assert_ne!(s.mods.is_on(0), on);
|
||||
let at = ui.extras.point_at(&s, size, ExtraRow::ZoomSettings(0));
|
||||
assert_eq!(
|
||||
ui.click(&mut s, &atlas, at.0, at.1, size, [960, 960], false, false),
|
||||
vec![
|
||||
Event::ButtonClick,
|
||||
Event::ButtonClick,
|
||||
Event::NativeSettings(0)
|
||||
]
|
||||
);
|
||||
assert_eq!(ui.page(), Page::NativeMod(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6437,32 +6551,27 @@ mod tests {
|
||||
);
|
||||
assert_eq!(ui.page(), page);
|
||||
}
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
assert_eq!(
|
||||
widgets
|
||||
.iter()
|
||||
.filter(|w| matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(_),
|
||||
..
|
||||
}
|
||||
))
|
||||
.count(),
|
||||
crate::extras::rows().iter().filter(|r| r.is_ok()).count()
|
||||
ui.extras.row_labels(&s).len(),
|
||||
// And the note about the Zoom mod over them.
|
||||
1 + crate::extras::rows().iter().filter(|r| r.is_ok()).count()
|
||||
);
|
||||
let press = |ui: &mut OptionsUi, s: &mut Settings, item: crate::extras::Item| {
|
||||
let at = ui.extras.point_at(s, size, ExtraRow::Item(item));
|
||||
ui.click(s, &atlas, at.0, at.1, size, [960, 960], false, false)
|
||||
};
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Sort)),
|
||||
press(&mut ui, &mut s, crate::extras::Item::Sort),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::Pregen)),
|
||||
press(&mut ui, &mut s, crate::extras::Item::Pregen),
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
);
|
||||
assert!(s.extras.pregen);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Act::Extra(crate::extras::Item::SortKey)),
|
||||
press(&mut ui, &mut s, crate::extras::Item::SortKey),
|
||||
vec![Event::ButtonClick]
|
||||
);
|
||||
assert_eq!(ui.selected_key, Some(crate::extras::SORT_KEY));
|
||||
@@ -6475,12 +6584,6 @@ mod tests {
|
||||
crate::extras::Extras::parse(&s.extras.serialize()),
|
||||
s.extras
|
||||
);
|
||||
// In a window too short for the page, the wheel scrolls to its end.
|
||||
let short = (480.0, 180.0);
|
||||
ui.scroll(&s, short, -1000.0);
|
||||
let (_, top, bottom, content) = ui.layout(&s, short.0, short.1);
|
||||
assert!(content > bottom - top);
|
||||
assert_eq!(ui.scroll, content - (bottom - top));
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None),
|
||||
vec![Event::Changed]
|
||||
@@ -6488,6 +6591,24 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Horizon);
|
||||
}
|
||||
|
||||
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
|
||||
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
|
||||
#[test]
|
||||
fn the_wheel_and_the_stick_scroll_a_page_on_the_shell_alike() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Extras);
|
||||
// Too short for the page.
|
||||
let short = (480.0, 180.0);
|
||||
ui.wheel(&mut s, short, (0.0, 0.0), [960, 360], -1.0);
|
||||
let one = ui.extras.scroll_amount();
|
||||
assert!(one > 0.0);
|
||||
ui.scroll_at(&s, short, (0.0, 0.0), -1.0);
|
||||
assert_eq!(ui.extras.scroll_amount(), 2.0 * one);
|
||||
ui.scroll_at(&s, short, (0.0, 0.0), 2.0);
|
||||
assert_eq!(ui.extras.scroll_amount(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_graphics_presets_set_the_distances() {
|
||||
let atlas = atlas();
|
||||
@@ -7318,7 +7439,7 @@ mod tests {
|
||||
/// An Extras or PvP & HUD label wider than half a row takes the whole row, so every label
|
||||
/// fits its button: "Keep channels for UI sounds: Off" did not.
|
||||
#[test]
|
||||
fn extras_labels_fit_their_buttons() {
|
||||
fn pvp_labels_fit_their_buttons() {
|
||||
// Vanilla's font: 6 px a glyph, a few narrower ones.
|
||||
let width = |t: &str| {
|
||||
t.chars()
|
||||
@@ -7333,9 +7454,9 @@ mod tests {
|
||||
};
|
||||
let s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
for page in [Page::Extras, Page::Pvp] {
|
||||
ui.stack = vec![Page::Main, page];
|
||||
ui.wide_extras = wide_extras(&s, page, width);
|
||||
{
|
||||
ui.stack = vec![Page::Main, Page::Pvp];
|
||||
ui.wide_extras = wide_extras(&s, width);
|
||||
let rows = ui.list_rows(&s);
|
||||
let buttons: Vec<&Widget> = rows
|
||||
.iter()
|
||||
@@ -7349,20 +7470,11 @@ mod tests {
|
||||
continue;
|
||||
};
|
||||
let label = label.plain();
|
||||
assert!(width(&label) <= b.w - 4.0, "{page:?}: {label} on {}", b.w);
|
||||
assert!(width(&label) <= b.w - 4.0, "{label} on {}", b.w);
|
||||
}
|
||||
// Short ones keep vanilla's two to a row.
|
||||
assert!(buttons.iter().any(|b| b.w == 150.0), "{page:?}");
|
||||
assert!(buttons.iter().any(|b| b.w == 150.0));
|
||||
}
|
||||
ui.stack = vec![Page::Main, Page::Extras];
|
||||
ui.wide_extras = wide_extras(&s, Page::Extras, width);
|
||||
for item in [
|
||||
crate::extras::Item::UiSoundReserve,
|
||||
crate::extras::Item::WheelCyclesSettings,
|
||||
] {
|
||||
assert!(ui.wide_extras.contains(&item), "{item:?}");
|
||||
}
|
||||
assert!(!ui.wide_extras.contains(&crate::extras::Item::AutoReconnect));
|
||||
}
|
||||
|
||||
/// Every setting a player reaches, from the Options screen through every button that opens
|
||||
@@ -7778,20 +7890,9 @@ mod tests {
|
||||
s.extras.wheel_cycles_settings = true;
|
||||
ui.show(Page::Extras);
|
||||
let size = (480.0, 600.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let sort_wd = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::Sort),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let cursor = (sort_wd.x + 5.0, sort_wd.y + 5.0);
|
||||
let cursor = ui
|
||||
.extras
|
||||
.point_at(&s, size, ExtraRow::Item(crate::extras::Item::Sort));
|
||||
assert!(!s.extras.sort);
|
||||
ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
|
||||
assert!(s.extras.sort);
|
||||
@@ -8274,55 +8375,5 @@ mod tests {
|
||||
ui.click(&mut s, &atlas, px, py, size, [854, 480], false, false);
|
||||
assert_eq!(s.particles, 0);
|
||||
ui.shift = false;
|
||||
|
||||
// Also verify Act::Extra on Page::Extras
|
||||
ui.show(Page::Extras);
|
||||
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
|
||||
if let Some(extra_btn) = widgets.iter().find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
}) {
|
||||
// Extras has enough rows to scroll: click the cycle button inside
|
||||
// the list's viewport, as a player would after scrolling to it.
|
||||
ui.scroll = (extra_btn.y - top).max(0.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let extra_btn = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
// Not chosen, nothing owned: shows Hidden.
|
||||
assert_eq!(s.extras.cosmetics_others, None);
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
|
||||
// Forward (wraps to Shown), now a choice
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
|
||||
// Backward with shift
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
|
||||
// Backward again
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
ui.shift = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ pub struct ParticleOptions {
|
||||
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
|
||||
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
|
||||
pub mod_particle: Option<String>,
|
||||
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
|
||||
pub legacy_entity_effect: Option<bool>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -72,6 +74,7 @@ impl ParticleOptions {
|
||||
geyser: None,
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3167,8 +3170,16 @@ impl ParticleEngine {
|
||||
q.set_color(b, 0.0, b);
|
||||
}
|
||||
T::EntityEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
if let Some(ambient) = o.legacy_entity_effect {
|
||||
q.set_color(v[0] as f32, v[1] as f32, v[2] as f32);
|
||||
// AmbientMobProvider overrides constructor alpha; MobProvider only sets RGB.
|
||||
if ambient {
|
||||
q.rgba[3] = 0.15;
|
||||
}
|
||||
} else {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
q.rgba[3] = o.color[3];
|
||||
}
|
||||
}
|
||||
T::Effect | T::InstantEffect => {
|
||||
q.set_color(o.color[0], o.color[1], o.color[2]);
|
||||
@@ -3177,7 +3188,12 @@ impl ParticleEngine {
|
||||
_ => {}
|
||||
}
|
||||
q.behaviour = Behaviour::Spell {
|
||||
original_alpha: q.rgba[3],
|
||||
// The old SpellParticle.tick always eased back to 1, including ambient effects.
|
||||
original_alpha: if o.legacy_entity_effect.is_some() {
|
||||
1.0
|
||||
} else {
|
||||
q.rgba[3]
|
||||
},
|
||||
};
|
||||
q.set = set;
|
||||
q
|
||||
@@ -3469,7 +3485,13 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
q.lifetime = duration;
|
||||
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
|
||||
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
|
||||
q.lifetime = if duration == -1 {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
q.has_physics = false;
|
||||
q.free_move = true;
|
||||
q.light = Light::FullBright;
|
||||
@@ -4941,6 +4963,45 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_spell_providers_use_velocity_rgb_and_their_original_alpha_rules() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let velocity = [0.25, 0.5, 0.75];
|
||||
for scoped in [false, true] {
|
||||
engine.set_scope_eye(scoped.then_some([0.0; 3]));
|
||||
for ambient in [false, true] {
|
||||
let mut options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
options.legacy_entity_effect = Some(ambient);
|
||||
let particle = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
let alpha = if ambient {
|
||||
0.15
|
||||
} else if scoped {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
assert_eq!(particle.rgba, [0.25, 0.5, 0.75, alpha]);
|
||||
assert_eq!(
|
||||
particle.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 1.0
|
||||
}
|
||||
);
|
||||
}
|
||||
let options =
|
||||
ParticleOptions::colored(ParticleType::EntityEffect, [1.0, 0.0, 0.0, 0.8]);
|
||||
let current = engine.make(&options, [0.0; 3], velocity, 7).unwrap();
|
||||
assert_eq!(current.rgba, options.color);
|
||||
assert_eq!(
|
||||
current.behaviour,
|
||||
Behaviour::Spell {
|
||||
original_alpha: 0.8
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
|
||||
let reads = std::cell::Cell::new(0);
|
||||
@@ -5700,6 +5761,33 @@ mod tests {
|
||||
assert!((0.15 * 0.4 - 1e-6..=0.45 * 0.4 + 1e-6).contains(&s));
|
||||
}
|
||||
#[test]
|
||||
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(1, 42, 2);
|
||||
for _ in 0..5 {
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
|
||||
assert_eq!(
|
||||
engine
|
||||
.pending
|
||||
.iter()
|
||||
.map(|p| p.lifetime)
|
||||
.collect::<Vec<_>>(),
|
||||
[20, 13, 18, 14, 20]
|
||||
);
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, 37),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
}
|
||||
#[test]
|
||||
fn sizes_follow_vanilla_curves() {
|
||||
let mut r = LegacyRandom::new(2);
|
||||
let mut p = Particle::at(&mut r, ParticleType::Flame, [0.0; 3]);
|
||||
|
||||
@@ -830,6 +830,7 @@ pub enum Opt {
|
||||
/// Horizon's controller settings (`horizon-controller.txt`).
|
||||
PadEnabled,
|
||||
PadVibration,
|
||||
PadPlayInBackground,
|
||||
PadAdaptiveTriggers,
|
||||
PadLightbar,
|
||||
PadLookSensitivity,
|
||||
@@ -845,6 +846,7 @@ pub enum Opt {
|
||||
PadTriggerThreshold,
|
||||
PadGlyphs,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
/// 3D skin layers: the player's outer layer as extruded slabs.
|
||||
|
||||
@@ -644,6 +644,7 @@ impl ShaderUi {
|
||||
hover,
|
||||
focused,
|
||||
held: self.dragging == Some(item),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
},
|
||||
|
||||
@@ -1126,18 +1126,29 @@ impl Streaming {
|
||||
pub fn lod_source(&self) -> Arc<dyn LodSource> {
|
||||
self.saves.clone()
|
||||
}
|
||||
/// Resource packs reloaded (`LevelRenderer.allChanged`): mesh with the new
|
||||
/// Resource packs reloaded (`LevelExtractor.allChanged`): mesh with the new
|
||||
/// models and sprites, and rebuild every loaded section. Meshes in flight
|
||||
/// were made with the old resources; their results are dropped.
|
||||
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
|
||||
*self.shared.context.write().unwrap() = context;
|
||||
self.reload_sections(world);
|
||||
}
|
||||
/// `LevelExtractor.allChanged`: discard mesh work made before the reload and
|
||||
/// rebuild every loaded, lit section.
|
||||
pub fn reload_sections(&mut self, world: &mut World) {
|
||||
self.meshing.clear();
|
||||
self.compile_started.clear();
|
||||
Self::mark_loaded_sections_dirty(world, &self.lit);
|
||||
}
|
||||
/// The resident sections `LevelExtractor.allChanged` queues again. An
|
||||
/// unlit column will queue itself when its light job completes.
|
||||
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if self.lit.contains(&(x, z)) {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
if !lit.contains(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4462,3 +4473,38 @@ mod cover_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod reload_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reload_marks_every_section_of_loaded_lit_columns() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
world.chunks.insert((2, -3), Arc::new(Chunk::empty(2, -3)));
|
||||
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
|
||||
let lit = FxHashSet::from_iter([(2, -3), (99, 99)]);
|
||||
|
||||
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
|
||||
|
||||
let sections = world.layout().sections() as i32;
|
||||
assert_eq!(world.dirty_sections.len(), sections as usize);
|
||||
for sy in 0..sections {
|
||||
assert!(world.dirty_sections.contains(&(2, sy, -3)));
|
||||
assert!(!world.dirty_sections.contains(&(4, sy, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_existing_work_and_ignores_unloaded_lit_columns() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
|
||||
world.dirty_sections.insert((4, 3, 5));
|
||||
let lit = FxHashSet::from_iter([(99, 99)]);
|
||||
|
||||
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
|
||||
|
||||
assert_eq!(world.dirty_sections.len(), 1);
|
||||
assert!(world.dirty_sections.contains(&(4, 3, 5)));
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ impl SavedRecordingItem {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum StudioAction {
|
||||
None,
|
||||
OpenReplay(PathBuf),
|
||||
OpenFolder,
|
||||
Refresh,
|
||||
Back,
|
||||
@@ -103,6 +104,12 @@ pub struct StudioUi {
|
||||
pub record_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// A recording's row and the gap under it, in GUI pixels.
|
||||
const ROW_STEP: f32 = 40.0;
|
||||
const ROW_GAP: f32 = 4.0;
|
||||
/// The panel's margins above and below its rows.
|
||||
const LIST_MARGINS: f32 = 10.0;
|
||||
|
||||
impl Default for StudioUi {
|
||||
fn default() -> Self {
|
||||
let dir = PathBuf::from("recordings");
|
||||
@@ -270,18 +277,19 @@ impl StudioUi {
|
||||
);
|
||||
} else {
|
||||
// Render scrollable cards
|
||||
let card_h = 36.0;
|
||||
let card_gap = 4.0;
|
||||
let row_step = card_h + card_gap;
|
||||
let visible_cards = (panel_h / row_step).floor() as usize;
|
||||
let card_h = ROW_STEP - ROW_GAP;
|
||||
let row_step = ROW_STEP;
|
||||
// Rows that fit whole between the panel's margins, as the loop below draws them.
|
||||
let visible_cards = ((panel_h - LIST_MARGINS + ROW_GAP) / row_step).floor() as usize;
|
||||
let max_scroll =
|
||||
(self.recordings.len().saturating_sub(visible_cards) as f32 * row_step).max(0.0);
|
||||
self.scroll = self.scroll.clamp(0.0, max_scroll);
|
||||
|
||||
let card_w = panel_w - 12.0;
|
||||
let card_x = panel_x + 6.0;
|
||||
let mut y = panel_y + 6.0 - (self.scroll % row_step);
|
||||
let start_idx = (self.scroll / row_step) as usize;
|
||||
// Whole rows: nothing here clips one that's partly scrolled off.
|
||||
let mut y = panel_y + 6.0;
|
||||
let start_idx = (self.scroll / row_step).round() as usize;
|
||||
|
||||
for (idx, item) in self.recordings.iter().enumerate().skip(start_idx) {
|
||||
if y + card_h > panel_y + panel_h - 4.0 {
|
||||
@@ -289,6 +297,8 @@ impl StudioUi {
|
||||
}
|
||||
|
||||
let rect = [card_x, y, card_w, card_h];
|
||||
crate::widgets::record_as(&item.title, rect, true, crate::widgets::Kind::Row);
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Row);
|
||||
let is_hovered = ui::hovered(cursor, card_x, y, card_w, card_h);
|
||||
let is_selected = self.selected == Some(idx);
|
||||
|
||||
@@ -334,6 +344,12 @@ impl StudioUi {
|
||||
y += row_step;
|
||||
}
|
||||
|
||||
crate::widgets::scroll_area(
|
||||
[panel_x, panel_y, panel_w, panel_h],
|
||||
self.scroll,
|
||||
max_scroll,
|
||||
);
|
||||
|
||||
// Scrollbar track & thumb if scrollable
|
||||
if max_scroll > 0.0 {
|
||||
let track_x = panel_x + panel_w - 4.0;
|
||||
@@ -357,35 +373,35 @@ impl StudioUi {
|
||||
// Button 1: Open Folder (Left)
|
||||
let f_btn = [panel_x, btn_y, 130.0, btn_h];
|
||||
let f_hover = ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]);
|
||||
style::button(
|
||||
batch,
|
||||
atlas,
|
||||
f_btn,
|
||||
&Text::tr("horizon.studio.open_folder", &[]),
|
||||
(f_hover, true, false),
|
||||
);
|
||||
let label = Text::tr("horizon.studio.open_folder", &[]);
|
||||
crate::widgets::record(&label.plain(), f_btn, true);
|
||||
style::button(batch, atlas, f_btn, &label, (f_hover, true, false));
|
||||
|
||||
// Button 2: Refresh (Center-Left)
|
||||
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
|
||||
let r_hover = ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]);
|
||||
let label = Text::tr("horizon.studio.refresh", &[]);
|
||||
crate::widgets::record(&label.plain(), r_btn, true);
|
||||
style::button(batch, atlas, r_btn, &label, (r_hover, true, false));
|
||||
|
||||
// Button 3: Open the selected recording for playback.
|
||||
let e_btn = [panel_x + panel_w - 180.0, btn_y, 110.0, btn_h];
|
||||
let e_hover = ui::hovered(cursor, e_btn[0], e_btn[1], e_btn[2], e_btn[3]);
|
||||
style::button(
|
||||
batch,
|
||||
atlas,
|
||||
r_btn,
|
||||
&Text::tr("horizon.studio.refresh", &[]),
|
||||
(r_hover, true, false),
|
||||
e_btn,
|
||||
&Text::tr("horizon.studio.open_replay", &[]),
|
||||
(e_hover, true, false),
|
||||
);
|
||||
|
||||
// Button 3: Done / Back (Right)
|
||||
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
|
||||
let d_hover = ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]);
|
||||
style::button(
|
||||
batch,
|
||||
atlas,
|
||||
d_btn,
|
||||
&Text::tr("gui.done", &[]),
|
||||
(d_hover, true, false),
|
||||
);
|
||||
let label = Text::tr("gui.done", &[]);
|
||||
crate::widgets::record(&label.plain(), d_btn, true);
|
||||
style::button(batch, atlas, d_btn, &label, (d_hover, true, false));
|
||||
crate::widgets::declare_action(crate::widgets::Action::Done, d_btn, true);
|
||||
}
|
||||
|
||||
/// Process mouse click on the Studio Browser screen.
|
||||
@@ -411,20 +427,32 @@ impl StudioUi {
|
||||
return StudioAction::Refresh;
|
||||
}
|
||||
|
||||
// Button 3: Done
|
||||
// Button 3: Open Replay
|
||||
let e_btn = [panel_x + panel_w - 180.0, btn_y, 110.0, btn_h];
|
||||
if ui::hovered(cursor, e_btn[0], e_btn[1], e_btn[2], e_btn[3]) {
|
||||
if let Some(idx) = self.selected
|
||||
&& let Some(item) = self.recordings.get(idx)
|
||||
{
|
||||
return StudioAction::OpenReplay(item.path.clone());
|
||||
} else if let Some(item) = self.recordings.first() {
|
||||
self.selected = Some(0);
|
||||
return StudioAction::OpenReplay(item.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Button 4: Done
|
||||
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
|
||||
if ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]) {
|
||||
return StudioAction::Back;
|
||||
}
|
||||
|
||||
// Card clicks
|
||||
let card_h = 36.0;
|
||||
let card_gap = 4.0;
|
||||
let row_step = card_h + card_gap;
|
||||
let card_h = ROW_STEP - ROW_GAP;
|
||||
let row_step = ROW_STEP;
|
||||
let card_w = panel_w - 12.0;
|
||||
let card_x = panel_x + 6.0;
|
||||
let start_idx = (self.scroll / row_step) as usize;
|
||||
let mut y = panel_y + 6.0 - (self.scroll % row_step);
|
||||
let start_idx = (self.scroll / row_step).round() as usize;
|
||||
let mut y = panel_y + 6.0;
|
||||
|
||||
for (idx, _) in self.recordings.iter().enumerate().skip(start_idx) {
|
||||
if y + card_h > panel_y + panel_h - 4.0 {
|
||||
@@ -444,7 +472,8 @@ impl StudioUi {
|
||||
|
||||
/// Scroll list by mouse wheel delta.
|
||||
pub fn wheel(&mut self, amount: f32) {
|
||||
self.scroll = (self.scroll - amount * 24.0).max(0.0);
|
||||
// A notch is a row, as a focus step needs (`gamepad::list_step`).
|
||||
self.scroll = (self.scroll - amount * ROW_STEP).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +545,11 @@ mod tests {
|
||||
assert_eq!(action, StudioAction::None);
|
||||
assert_eq!(ui.selected, Some(0));
|
||||
|
||||
// Click the bottom-bar Open Replay button.
|
||||
// Panel width = 387, panel_x = 20, button x = 227..337, y = 692..712.
|
||||
let open_replay_action = ui.click((280.0, 700.0), (427.0, 720.0));
|
||||
assert!(matches!(open_replay_action, StudioAction::OpenReplay(_)));
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -527,6 +561,6 @@ mod tests {
|
||||
assert_eq!(ui.scroll, 0.0); // clamped at 0
|
||||
ui.scroll = 100.0;
|
||||
ui.wheel(1.0);
|
||||
assert_eq!(ui.scroll, 76.0);
|
||||
assert_eq!(ui.scroll, 100.0 - ROW_STEP);
|
||||
}
|
||||
}
|
||||
@@ -723,6 +723,72 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
|
||||
// packing or entity-model diffuse shading. The absolute sprite layer occupies glint UV x.
|
||||
struct NativeFluidOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) @interpolate(flat) sprite: u32,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
}
|
||||
@vertex fn vs_native_fluid(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(6) sprite: vec2<f32>) -> NativeFluidOut {
|
||||
let block = max(f32(light.y), dynamic_block_light(position));
|
||||
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
|
||||
}
|
||||
return NativeFluidOut(globals.view_proj * vec4(position, 1.0), uv, lit, u32(sprite.x),
|
||||
length(position), max(length(position.xz), abs(position.y)));
|
||||
}
|
||||
@fragment fn fs_native_fluid(in: NativeFluidOut) -> @location(0) vec4<f32> {
|
||||
// The source translucent shader has no alpha discard; transparent texels write depth too.
|
||||
let color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
// Native terrain models retain floating-point transformed normals. Fullbright still receives
|
||||
// entity directional shading; the material's alpha test and mip policy are independent.
|
||||
// Diffuse/normal policy: Flywheel 1.0.6, copyright2021-2024 Jozufozu (MIT);
|
||||
// full permission/warranty notice in THIRD_PARTY_NOTICES.md.
|
||||
override NATIVE_MODEL_ALPHA_CUTOFF: f32 = 0.0;
|
||||
struct NativeModelOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
@location(2) @interpolate(flat) sprite: u32,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
@location(5) normal: vec3<f32>,
|
||||
}
|
||||
@vertex fn vs_native_model(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal_z: u32, @location(6) normal_xy: vec2<f32>) -> NativeModelOut {
|
||||
let block = max(f32(light.y), dynamic_block_light(position));
|
||||
var lit = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
lit = vec4(min(lit.rgb + color.rgb * native_colored_light(position), vec3(1.0)), lit.a);
|
||||
}
|
||||
return NativeModelOut(globals.view_proj * vec4(position, 1.0), uv, lit, layer >> 16u,
|
||||
length(position), max(length(position.xz), abs(position.y)),
|
||||
vec3(normal_xy, bitcast<f32>(normal_z)));
|
||||
}
|
||||
@fragment fn fs_native_model(in: NativeModelOut) -> @location(0) vec4<f32> {
|
||||
var color = sprite_grad(in.uv, in.sprite, dpdx(in.uv), dpdy(in.uv)) * in.color;
|
||||
if NATIVE_MODEL_ALPHA_CUTOFF > 0.0 && color.a < NATIVE_MODEL_ALPHA_CUTOFF { discard; }
|
||||
if any(in.normal != vec3(0.0)) {
|
||||
let light0 = normalize(vec3(0.2, 1.0, -0.7));
|
||||
let light1 = normalize(vec3(-0.2, select(1.0, -1.0, globals.time.y > 0.5), 0.7));
|
||||
let shade = min(1.0, (max(0.0, dot(light0, in.normal))
|
||||
+ max(0.0, dot(light1, in.normal))) * 0.6 + 0.4);
|
||||
color = vec4(color.rgb * shade, color.a);
|
||||
}
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
|
||||
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
|
||||
// the sky colour with sky fog (sky.fsh: fog 0..FogSkyEnd on the spherical
|
||||
// distance, a step at FogSkyEnd on the cylindrical one), evaluated per pixel
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Native terrain-sheet models with explicit material and UV policy.
|
||||
use crate::{gfx, mesher::Vertex, models::catalog::BakedFace};
|
||||
use glam::{Mat3, Mat4, Vec3};
|
||||
use horizon_assets::bake::RenderType;
|
||||
fn same_sprite(left: &str, right: &str) -> bool {
|
||||
left.strip_prefix("minecraft:").unwrap_or(left)
|
||||
== right.strip_prefix("minecraft:").unwrap_or(right)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NativeModelMaterial {
|
||||
/// Use each baked quad's material; unsupported translucent/tripwire quads fail the whole call.
|
||||
#[default]
|
||||
Model,
|
||||
Solid,
|
||||
Cutout,
|
||||
CutoutMipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum NativeModelShade {
|
||||
/// Respect the baked quad's directional-shading flag.
|
||||
#[default]
|
||||
Model,
|
||||
Off,
|
||||
/// The level's two entity light directions, even at full block/sky light.
|
||||
Entity,
|
||||
}
|
||||
|
||||
/// Transform UVs only on the explicitly named original sprite, in unit-sprite coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct NativeModelUvShift<'a> {
|
||||
pub source_sprite: &'a str,
|
||||
pub target_sprite: &'a str,
|
||||
pub scale: [f32; 2],
|
||||
pub offset: [f32; 2],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct NativeModelRenderOptions<'a> {
|
||||
pub material: NativeModelMaterial,
|
||||
pub shade: NativeModelShade,
|
||||
pub uv_shift: Option<NativeModelUvShift<'a>>,
|
||||
/// Optional compatibility policy applied toward each face's UV bounds center before shifting.
|
||||
/// Zero retains the baked UVs. For an older JSON FaceBakery use its actual atlas shrink ratio;
|
||||
/// OBJ loaders that do not shrink UVs must keep zero.
|
||||
pub uv_shrink_ratio: f32,
|
||||
/// RGB multiplier on quads with a tint index; other quads keep their baked color.
|
||||
pub tint: u32,
|
||||
}
|
||||
impl Default for NativeModelRenderOptions<'_> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
material: NativeModelMaterial::Model,
|
||||
shade: NativeModelShade::Model,
|
||||
uv_shift: None,
|
||||
uv_shrink_ratio: 0.0,
|
||||
tint: 0xff_ffff,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(face: &BakedFace, policy: NativeModelMaterial) -> Option<u32> {
|
||||
use NativeModelMaterial as M;
|
||||
Some(match policy {
|
||||
M::Solid => gfx::TERRAIN_MODEL_SOLID_OVERLAY,
|
||||
M::Cutout => gfx::TERRAIN_MODEL_CUTOUT_OVERLAY,
|
||||
M::CutoutMipped => gfx::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
|
||||
M::Model => match face.quad.render_type.unwrap_or(RenderType::Solid) {
|
||||
RenderType::Solid if !face.quad.material.force_translucent => {
|
||||
gfx::TERRAIN_MODEL_SOLID_OVERLAY
|
||||
}
|
||||
RenderType::Cutout => gfx::TERRAIN_MODEL_CUTOUT_OVERLAY,
|
||||
RenderType::CutoutMipped => gfx::TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY,
|
||||
_ => return None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// A whole draw validates before its first vertex. No temporary geometry allocation, resource
|
||||
/// scans, partial fallback or change to the ordinary native model helper.
|
||||
pub fn draw_model(
|
||||
faces: &[BakedFace],
|
||||
pose: Mat4,
|
||||
light: u32,
|
||||
options: NativeModelRenderOptions<'_>,
|
||||
sprite: impl Fn(&str) -> Option<u32>,
|
||||
mut emit: impl FnMut(Vertex),
|
||||
) -> bool {
|
||||
if !pose.is_finite()
|
||||
|| !options.uv_shrink_ratio.is_finite()
|
||||
|| !(0.0..=1.0).contains(&options.uv_shrink_ratio)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let normal_pose = Mat3::from_mat4(pose).inverse().transpose();
|
||||
if !normal_pose.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let shift = match options.uv_shift {
|
||||
Some(shift) => {
|
||||
if shift
|
||||
.scale
|
||||
.iter()
|
||||
.chain(&shift.offset)
|
||||
.any(|v| !v.is_finite())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (Some(source), Some(target)) =
|
||||
(sprite(shift.source_sprite), sprite(shift.target_sprite))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if source > u16::MAX.into() || target > u16::MAX.into() {
|
||||
return false;
|
||||
}
|
||||
if !faces
|
||||
.iter()
|
||||
.any(|face| same_sprite(&face.quad.material.sprite, shift.source_sprite))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Some((source, target, shift))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let vertices = |face: &BakedFace| -> Option<[Vertex; 4]> {
|
||||
let quad = &face.quad;
|
||||
let overlay = overlay(face, options.material)?;
|
||||
let mut layer = sprite(&quad.material.sprite)?;
|
||||
if layer > u16::MAX.into() || layer != face.layer {
|
||||
return None;
|
||||
}
|
||||
let mut uv = quad.uv;
|
||||
if uv.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
if options.uv_shrink_ratio != 0.0 {
|
||||
let center = std::array::from_fn::<_, 2, _>(|i| {
|
||||
let min = uv.iter().map(|p| p[i]).fold(f32::INFINITY, f32::min);
|
||||
let max = uv.iter().map(|p| p[i]).fold(f32::NEG_INFINITY, f32::max);
|
||||
(min + min + max + max) / 4.0
|
||||
});
|
||||
for p in &mut uv {
|
||||
*p = std::array::from_fn(|i| p[i] + options.uv_shrink_ratio * (center[i] - p[i]));
|
||||
}
|
||||
}
|
||||
if let Some((_, target, shift)) = shift
|
||||
&& same_sprite(&quad.material.sprite, shift.source_sprite)
|
||||
{
|
||||
layer = target;
|
||||
for p in &mut uv {
|
||||
*p = std::array::from_fn(|i| p[i] * shift.scale[i] + shift.offset[i]);
|
||||
}
|
||||
}
|
||||
let shaded = match options.shade {
|
||||
NativeModelShade::Model => quad.shade,
|
||||
NativeModelShade::Off => false,
|
||||
NativeModelShade::Entity => true,
|
||||
};
|
||||
let normal = if shaded {
|
||||
let p = quad.positions.map(Vec3::from_array);
|
||||
let n = (p[1] - p[0]).cross(p[2] - p[0]).normalize_or_zero();
|
||||
// Baked model normals are signed bytes before the instance's normal matrix.
|
||||
// Keep the transformed normal as floats; quantizing it again changes rotated faces.
|
||||
let n = n.map(|v| ((v * 127.0) as i8) as f32 / 127.0);
|
||||
(normal_pose * n).normalize_or_zero()
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
let normal_bits = normal.z.to_ne_bytes().map(|b| b as i8);
|
||||
let argb = quad.color.map_or(u32::MAX, |v| v as u32);
|
||||
let mut color = (argb & 0xff00_ff00) | (argb >> 16 & 0xff) | (argb & 0xff) << 16;
|
||||
if quad.tint_index >= 0 {
|
||||
let tint = options.tint;
|
||||
let rgb = (tint >> 16 & 0xff) | (tint & 0xff00) | (tint & 0xff) << 16;
|
||||
color = (color & 0xff00_0000)
|
||||
| ((color & 0xff) * (rgb & 0xff) / 255)
|
||||
| (((color >> 8 & 0xff) * (rgb >> 8 & 0xff) / 255) << 8)
|
||||
| (((color >> 16 & 0xff) * (rgb >> 16 & 0xff) / 255) << 16);
|
||||
}
|
||||
let light = (light & 0xff).max(u32::from(quad.light[1]) * 16)
|
||||
| ((light >> 8 & 0xff).max(u32::from(quad.light[0].max(quad.light_emission)) * 16)
|
||||
<< 8);
|
||||
let positions = quad
|
||||
.positions
|
||||
.map(|p| pose.transform_point3(Vec3::from_array(p)));
|
||||
if positions.iter().any(|p| !p.is_finite()) || uv.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(std::array::from_fn(|i| Vertex {
|
||||
pos: positions[i].to_array(),
|
||||
uv: uv[i],
|
||||
layer: overlay | layer << 16,
|
||||
color,
|
||||
light,
|
||||
normal: normal_bits,
|
||||
glint_uv: [normal.x, normal.y],
|
||||
}))
|
||||
};
|
||||
if faces.iter().any(|face| vertices(face).is_none()) {
|
||||
return false;
|
||||
}
|
||||
for face in faces {
|
||||
for vertex in vertices(face).expect("validated immutable model") {
|
||||
emit(vertex);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "terrain_model_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,171 @@
|
||||
use super::*;
|
||||
use horizon_assets::{
|
||||
bake::bake_flat_face,
|
||||
model::{Direction, Material},
|
||||
};
|
||||
|
||||
fn face() -> BakedFace {
|
||||
BakedFace::new(
|
||||
bake_flat_face(
|
||||
[0.0; 3],
|
||||
[16.0; 3],
|
||||
[0.0, 0.0, 16.0, 16.0],
|
||||
Direction::South,
|
||||
Material {
|
||||
sprite: "test:source".into(),
|
||||
force_translucent: false,
|
||||
},
|
||||
-1,
|
||||
)
|
||||
.unwrap(),
|
||||
7,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
}
|
||||
fn sprite(name: &str) -> Option<u32> {
|
||||
match name {
|
||||
"test:source" => Some(7),
|
||||
"test:target" => Some(11),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
faces: &[BakedFace],
|
||||
pose: Mat4,
|
||||
options: NativeModelRenderOptions<'_>,
|
||||
) -> (bool, Vec<Vertex>) {
|
||||
let mut output = Vec::new();
|
||||
let accepted = draw_model(faces, pose, 0xf0f0, options, sprite, |v| output.push(v));
|
||||
(accepted, output)
|
||||
}
|
||||
#[test]
|
||||
fn late_unknown_material_or_sprite_rejects_the_whole_model() {
|
||||
let first = face();
|
||||
let mut second = face();
|
||||
second.quad.render_type = Some(RenderType::Unsupported);
|
||||
let mut faces = [first, second];
|
||||
let (accepted, output) = draw(&faces, Mat4::IDENTITY, Default::default());
|
||||
assert!(!accepted);
|
||||
assert!(output.is_empty());
|
||||
let (accepted, output) = draw(
|
||||
&faces,
|
||||
Mat4::IDENTITY,
|
||||
NativeModelRenderOptions {
|
||||
material: NativeModelMaterial::Cutout,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(accepted);
|
||||
assert_eq!(output.len(), 8);
|
||||
faces[1].quad.material.sprite = "test:missing".into();
|
||||
let (accepted, output) = draw(&faces, Mat4::IDENTITY, Default::default());
|
||||
assert!(!accepted);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn exact_sprite_shift_and_explicit_json_shrink_precede_array_mapping() {
|
||||
let options = NativeModelRenderOptions {
|
||||
uv_shrink_ratio: 4.0 / 2048.0,
|
||||
uv_shift: Some(NativeModelUvShift {
|
||||
source_sprite: "test:source",
|
||||
target_sprite: "test:target",
|
||||
scale: [0.5; 2],
|
||||
offset: [0.25, 0.125],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let (accepted, output) = draw(&[face()], Mat4::IDENTITY, options);
|
||||
assert!(accepted);
|
||||
assert_eq!(output[0].uv, [0.25048828, 0.12548828]);
|
||||
assert_eq!(output[2].uv, [0.7495117, 0.6245117]);
|
||||
assert!(output.iter().all(|v| v.layer >> 16 == 11));
|
||||
let (accepted, output) = draw(
|
||||
&[face()],
|
||||
Mat4::IDENTITY,
|
||||
NativeModelRenderOptions {
|
||||
uv_shift: Some(NativeModelUvShift {
|
||||
source_sprite: "test:target",
|
||||
target_sprite: "test:source",
|
||||
..options.uv_shift.unwrap()
|
||||
}),
|
||||
..options
|
||||
},
|
||||
);
|
||||
assert!(!accepted);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn transformed_slope_normal_stays_float_and_fullbright_keeps_shading() {
|
||||
let mut face = face();
|
||||
face.quad.positions = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
];
|
||||
let pose = Mat4::from_rotation_y(0.372) * Mat4::from_scale(Vec3::new(-2.0, 3.0, 1.0));
|
||||
let (accepted, output) = draw(&[face], pose, Default::default());
|
||||
assert!(accepted);
|
||||
let normal = Vec3::new(
|
||||
output[0].glint_uv[0],
|
||||
output[0].glint_uv[1],
|
||||
f32::from_ne_bytes(output[0].normal.map(|v| v as u8)),
|
||||
);
|
||||
assert!((normal.length() - 1.0).abs() < 1e-6);
|
||||
assert!((normal.y - 0.31622776).abs() < 1e-6);
|
||||
assert!((normal.x + 0.3448316).abs() < 1e-5, "{normal:?}");
|
||||
assert_eq!(output[0].light, 0xf0f0);
|
||||
assert_eq!(output[0].color, u32::MAX);
|
||||
// Float payload, not a second signed-byte normal quantization.
|
||||
assert_ne!(normal.y, (normal.y * 127.0) as i8 as f32 / 127.0);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_shift_and_singular_pose_emit_nothing() {
|
||||
let options = NativeModelRenderOptions {
|
||||
uv_shrink_ratio: f32::NAN,
|
||||
..Default::default()
|
||||
};
|
||||
let (ok, output) = draw(&[face()], Mat4::IDENTITY, options);
|
||||
assert!(!ok);
|
||||
assert!(output.is_empty());
|
||||
let (ok, output) = draw(
|
||||
&[face()],
|
||||
Mat4::from_scale(Vec3::new(1.0, 0.0, 1.0)),
|
||||
Default::default(),
|
||||
);
|
||||
assert!(!ok);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deduplicated_array_layer_is_not_the_same_named_source_sprite() {
|
||||
let mut alias = face();
|
||||
alias.quad.material.sprite = "test:alias".into();
|
||||
let mut output = Vec::new();
|
||||
assert!(draw_model(
|
||||
&[face(), alias],
|
||||
Mat4::IDENTITY,
|
||||
0xf0f0,
|
||||
NativeModelRenderOptions {
|
||||
uv_shift: Some(NativeModelUvShift {
|
||||
source_sprite: "test:source",
|
||||
target_sprite: "test:target",
|
||||
scale: [0.5; 2],
|
||||
offset: [0.25; 2]
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|name| if name == "test:alias" {
|
||||
Some(7)
|
||||
} else {
|
||||
sprite(name)
|
||||
},
|
||||
|v| output.push(v)
|
||||
));
|
||||
assert_eq!(output[0].layer >> 16, 11);
|
||||
assert_eq!(output[4].layer >> 16, 7);
|
||||
assert_eq!(output[4].uv, [0.0, 0.0]);
|
||||
assert_eq!(output[6].uv, [1.0, 1.0]);
|
||||
}
|
||||
@@ -134,6 +134,10 @@ fn scaling(sprite: &str, r: &Rect) -> NineSlice {
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
border: match sprite {
|
||||
"horizon/jei/button_enabled_v2"
|
||||
| "horizon/jei/button_disabled_v2"
|
||||
| "horizon/jei/button_highlight_v2" => [6; 4],
|
||||
"horizon/jei/search_background_v2" => [6; 4],
|
||||
"gui/sprites/widget/button"
|
||||
| "gui/sprites/widget/button_highlighted"
|
||||
| "gui/sprites/widget/button_disabled" => [3; 4],
|
||||
@@ -1636,10 +1640,19 @@ impl EditBox {
|
||||
}
|
||||
/// Draw the field; `format` styles the visible text (command colouring).
|
||||
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, format: Option<&Text>) {
|
||||
self.draw_with_skin(batch, atlas, format, None);
|
||||
}
|
||||
|
||||
pub fn draw_with_skin(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
format: Option<&Text>,
|
||||
skin: Option<&str>,
|
||||
) {
|
||||
self.layout(atlas);
|
||||
crate::widgets::target_as(
|
||||
self.frame
|
||||
.unwrap_or([self.x, self.y, self.width, self.height]),
|
||||
[self.x, self.y, self.width, self.height],
|
||||
crate::widgets::Kind::Field,
|
||||
);
|
||||
crate::widgets::narratable(|| {
|
||||
@@ -1651,11 +1664,11 @@ impl EditBox {
|
||||
.focused(self.focused)
|
||||
});
|
||||
if self.bordered {
|
||||
let sprite = if self.focused {
|
||||
let sprite = skin.unwrap_or(if self.focused {
|
||||
"gui/sprites/widget/text_field_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/text_field"
|
||||
};
|
||||
});
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||