Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8642db5c99 | ||
|
|
1453e0e614 | ||
|
|
9b4258fecc | ||
|
|
2d610e9482 | ||
|
|
e6eccbd3b9 | ||
|
|
537539fe58 | ||
|
|
cbbd362eed | ||
|
|
1430b9990a | ||
|
|
64a6b82dd0 | ||
|
|
a84f637dd6 | ||
|
|
f2d50d4a75 | ||
|
|
efab4d1bbf | ||
|
|
604fb90bf1 | ||
|
|
b2d72cea9e | ||
|
|
7b50bae250 | ||
|
|
3780c215a7 | ||
|
|
3c853b46eb | ||
|
|
e64af82d00 | ||
|
|
37bf47f477 | ||
|
|
03d8150bdb | ||
|
|
c89cd3f9e1 | ||
|
|
0b8d44458a | ||
|
|
75a016b1b0 | ||
|
|
c21f627431 | ||
|
|
691a39cab6 | ||
|
|
3c06105a0e | ||
|
|
726726a1fc | ||
|
|
82da4379bd | ||
|
|
7627e37dba | ||
|
|
cd8eb96cef | ||
|
|
829aec1dba | ||
|
|
82a6e896d1 | ||
|
|
62e62c21e6 | ||
|
|
a6350aa8f9 | ||
|
|
c817ed7fff | ||
|
|
b020ab26b9 | ||
|
|
0300ba0a76 | ||
|
|
3a3c182aed | ||
|
|
35f095bd41 | ||
|
|
e76394c2cf | ||
|
|
d9cfca426c | ||
|
|
763d4868d7 | ||
|
|
110a92a1aa | ||
|
|
3a8d647c8c | ||
|
|
4e22c1ad9a | ||
|
|
3c346ba1a4 | ||
|
|
b41db51bf9 | ||
|
|
498d9bebcf | ||
|
|
1ecc5a9bdc | ||
|
|
d05675d755 | ||
|
|
2568835cc6 | ||
|
|
e1aa435188 | ||
|
|
0341ad3d3a | ||
|
|
128575e4e5 | ||
|
|
6cc30fbed4 | ||
|
|
cb870cf80d | ||
|
|
e138304d2e | ||
|
|
5385996153 | ||
|
|
f0cb1e42c0 | ||
|
|
83a9845928 | ||
|
|
3563a7f986 | ||
|
|
cc4933eaed | ||
|
|
38174db59b | ||
|
|
093f5efda8 | ||
|
|
b444a6b64f | ||
|
|
e8d192f2ac | ||
|
|
6ad39aa412 | ||
|
|
6342ce83b2 | ||
|
|
f4e5fbdfd0 | ||
|
|
6d5b520591 | ||
|
|
dff332b257 | ||
|
|
c1dc47e41f | ||
|
|
7311870047 | ||
|
|
595421d8ee | ||
|
|
7169886142 | ||
|
|
a8bf4c1474 | ||
|
|
cdcac3463e | ||
|
|
e71c626484 | ||
|
|
194ede9376 | ||
|
|
12578352dc | ||
|
|
11ebc3f199 | ||
|
|
29ac1e17b9 | ||
|
|
bc08885276 | ||
|
|
bc7b8889de | ||
|
|
7180a52bf2 | ||
|
|
8e37c4a4b7 | ||
|
|
94ba80f90d | ||
|
|
238fc99add | ||
|
|
4be9343eca | ||
|
|
03fd6705a1 | ||
|
|
ac96d3fa35 | ||
|
|
115f5235b9 | ||
|
|
ceff25fca1 | ||
|
|
36d0695742 | ||
|
|
94484675a8 | ||
|
|
9097d2973d | ||
|
|
00e573cf87 | ||
|
|
a5a60598e4 | ||
|
|
0fd4e22139 | ||
|
|
ec19b6320a | ||
|
|
16e29a2774 | ||
|
|
e2afd13715 | ||
|
|
00aba43d81 | ||
|
|
e893fd3eac | ||
|
|
ee986c3e62 | ||
|
|
19aabc08c2 | ||
|
|
d016678e24 | ||
|
|
ca3649534d | ||
|
|
a3bf6531e0 | ||
|
|
0ddd7ec41d | ||
|
|
cea1a54e31 | ||
|
|
23dd21e928 | ||
|
|
6cf95bb4d7 | ||
|
|
cf09a6610a | ||
|
|
3d9df204e9 | ||
|
|
094bb83dff | ||
|
|
dfc91826bd | ||
|
|
984b7d8635 | ||
|
|
2782c5e810 | ||
|
|
86f1749664 | ||
|
|
d149cc1dca | ||
|
|
4881a0862f | ||
|
|
8df100e589 | ||
|
|
a416f1dfcd | ||
|
|
4c52129f7d | ||
|
|
afa60454b2 | ||
|
|
243d765452 | ||
|
|
ff52e244ae | ||
|
|
bb712cca1a | ||
|
|
ae963b0a98 | ||
|
|
c30911cddf | ||
|
|
38eac394be | ||
|
|
5546650bd7 | ||
|
|
1498fdecd3 | ||
|
|
8cf1e6b4d6 | ||
|
|
341a92902c | ||
|
|
aa8592fbf9 | ||
|
|
c277636360 | ||
|
|
68a01ee7ca | ||
|
|
15e83578ee | ||
|
|
66e8c0d04d | ||
|
|
e859476293 | ||
|
|
6bab737cae | ||
|
|
7093c79eeb | ||
|
|
7a4aedf231 | ||
|
|
8e45334760 | ||
|
|
a4da356144 | ||
|
|
89235891ce | ||
|
|
2081a0dd95 | ||
|
|
6c2c706959 | ||
|
|
cef35e64e7 | ||
|
|
3a9b05d21d | ||
|
|
6769422c88 | ||
|
|
ebe7b4a4fd | ||
|
|
1cce1cbf31 | ||
|
|
ed5da43953 | ||
|
|
3295c54097 | ||
|
|
d0da7ec157 | ||
|
|
042d2ce46a | ||
|
|
8f756e8487 | ||
|
|
3f1a50ccb2 | ||
|
|
8534f12db0 | ||
|
|
8aed306751 | ||
|
|
7e95a1f57f | ||
|
|
e0e4c5e9db | ||
|
|
0c77cd5898 | ||
|
|
4848a9eff5 | ||
|
|
95b0cb05e7 | ||
|
|
3dcfd7276b | ||
|
|
da800dff55 | ||
|
|
67f61f1c24 | ||
|
|
bfbf06a439 | ||
|
|
723c4e7c2b | ||
|
|
b7b137a829 | ||
|
|
c32f43b4b0 | ||
|
|
75071c8b9b | ||
|
|
641a5859a2 | ||
|
|
725208d8de | ||
|
|
b26a050b2b | ||
|
|
3516aa9641 | ||
|
|
971f341977 | ||
|
|
bb19f898e4 | ||
|
|
bbc17333bd | ||
|
|
8630bbed74 | ||
|
|
cd362fb354 | ||
|
|
7d17f516e5 | ||
|
|
216eca2f51 | ||
|
|
0f0ca381d4 | ||
|
|
7dccd1fa39 | ||
|
|
e84e5b2dd3 | ||
|
|
7b9f3f8e83 | ||
|
|
c9d73d5bea | ||
|
|
2c5baa6a60 | ||
|
|
e3d24ab1a5 | ||
|
|
ed35d477be | ||
|
|
7a236ba054 | ||
|
|
bf125a1a69 | ||
|
|
56b703899c | ||
|
|
7e6d2121b7 | ||
|
|
a89865b1c5 | ||
|
|
63a6a92bef | ||
|
|
8902d94a19 | ||
|
|
385dc78afc | ||
|
|
ca6140d05e | ||
|
|
661a253ec4 | ||
|
|
cf93d0dd3a | ||
|
|
ac0f7d6030 | ||
|
|
c51a5eec3f | ||
|
|
2e9f807458 | ||
|
|
21aabee4c0 | ||
|
|
a7f07a08de | ||
|
|
7be13f1e9a | ||
|
|
d56b769df5 | ||
|
|
ff83eccf6a | ||
|
|
c8e48f9cea | ||
|
|
bdb6452da9 | ||
|
|
2ddd9705d9 | ||
|
|
868d3b76a5 | ||
|
|
f2718e086d | ||
|
|
42026ce09f | ||
|
|
0e528f7287 | ||
|
|
8d7ca30ea4 | ||
|
|
7bfa76577f | ||
|
|
e39ec11e59 | ||
|
|
dc382b898a | ||
|
|
b5f396461e | ||
|
|
e963d24058 | ||
|
|
3eec96a8b1 | ||
|
|
249e8db13c | ||
|
|
6bb4c0eda9 | ||
|
|
b4f24319ac | ||
|
|
1d2dd56a06 | ||
|
|
2f06f532e5 | ||
|
|
ac0a2f1275 | ||
|
|
092dee299b | ||
|
|
963782a17b | ||
|
|
22f3da10c0 | ||
|
|
c992f24496 | ||
|
|
7ca4fd68bd | ||
|
|
005249b984 | ||
|
|
63dae1216c | ||
|
|
23ca4a855a | ||
|
|
16172da6b7 | ||
|
|
962f5949ac | ||
|
|
5e9024e0a1 | ||
|
|
559c2d4e81 | ||
|
|
ee449e0ef2 | ||
|
|
dc3d97ce25 | ||
|
|
cab5f23234 | ||
|
|
2c5d1990ce | ||
|
|
5720b15eff | ||
|
|
2e754b1297 | ||
|
|
70c8d7089d | ||
|
|
eb3157d251 | ||
|
|
1ffa9ed25f | ||
|
|
d577d49a78 | ||
|
|
4643ea9357 | ||
|
|
8a9bd15a3d | ||
|
|
a40f3d82c1 | ||
|
|
0022d3f42d | ||
|
|
547b1d4959 | ||
|
|
db8048a1b6 | ||
|
|
7383ff1908 | ||
|
|
4f4c87df64 | ||
|
|
6676c3a9a9 | ||
|
|
e2918de5aa | ||
|
|
e6b0a03270 | ||
|
|
c2fb43b761 | ||
|
|
6cfffc5daa | ||
|
|
af3c22b9f1 | ||
|
|
182eec2cb7 | ||
|
|
38865e907e | ||
|
|
b5e7227fba | ||
|
|
dbebf0f615 | ||
|
|
2377d42c58 | ||
|
|
7607e92fea | ||
|
|
fdc6ffdd39 | ||
|
|
efca8dab86 | ||
|
|
39377abc12 | ||
|
|
53d1afc5b1 | ||
|
|
b7615761ee | ||
|
|
a94b5a1b2e | ||
|
|
3d00e7a360 | ||
|
|
04d5e418bf | ||
|
|
b4c7f409d2 | ||
|
|
3365fd6bc4 | ||
|
|
8f198d9cbd | ||
|
|
c0da1ac2e1 | ||
|
|
93e0536aca | ||
|
|
45cefe3278 | ||
|
|
cc606585ad | ||
|
|
396353c6b1 | ||
|
|
4c28a9b5b9 | ||
|
|
032ae31f34 | ||
|
|
47d55b5690 | ||
|
|
6d1235ca18 | ||
|
|
db4a145e6b | ||
|
|
59d958c09e | ||
|
|
1a2015e305 | ||
|
|
197172ef3c | ||
|
|
ac1f560622 | ||
|
|
6a4172369c | ||
|
|
06130ae40d | ||
|
|
8ef5c055b0 | ||
|
|
6be9c07ce4 | ||
|
|
c3c0e2d868 | ||
|
|
528d98bf36 | ||
|
|
8ac6a79035 | ||
|
|
153e0d20a4 | ||
|
|
d74c95f088 | ||
|
|
9d6d292348 | ||
|
|
1e789800af | ||
|
|
74e0245c47 | ||
|
|
e5eacec45c | ||
|
|
cf23e5001a | ||
|
|
0e9233b230 | ||
|
|
9d5dbbd2f5 | ||
|
|
3a2bf1f4f8 | ||
|
|
d49eeccd3f | ||
|
|
7e13db2271 | ||
|
|
7973f9935b | ||
|
|
01343707af | ||
|
|
d119d6da2d | ||
|
|
4bc93d9a5f | ||
|
|
671af567f4 | ||
|
|
389d15bda0 | ||
|
|
4ae696550f | ||
|
|
e92dc8108d | ||
|
|
1f19e94d74 | ||
|
|
fcbeaefd37 | ||
|
|
466201a864 | ||
|
|
8bf0614d9d | ||
|
|
1d6e973ab8 | ||
|
|
83b3c9306f | ||
|
|
ddb4f9df58 | ||
|
|
e6184afbe2 | ||
|
|
96c5616505 | ||
|
|
7766619ecb | ||
|
|
ec3e4df6e3 | ||
|
|
a0f02b61ea | ||
|
|
7f2e64b603 | ||
|
|
6e21ce9476 | ||
|
|
c873af989a | ||
|
|
84baabff3c | ||
|
|
6622e459b2 | ||
|
|
600a863354 | ||
|
|
b77e4c4924 | ||
|
|
861b9e7b16 | ||
|
|
365a44ff91 | ||
|
|
358456bb02 | ||
|
|
07068153c6 | ||
|
|
e2a17164ca | ||
|
|
0d24192a23 | ||
|
|
b75193985b | ||
|
|
1a07a02990 | ||
|
|
f5c6b7dfd1 | ||
|
|
8d5dd89421 | ||
|
|
6280d2630c | ||
|
|
e5814ae449 | ||
|
|
c221aa9ec9 | ||
|
|
0568b2beff | ||
|
|
aa741b3570 | ||
|
|
3e77ec425f | ||
|
|
c80f5ae218 | ||
|
|
bd90908a72 | ||
|
|
1b4f056692 | ||
|
|
21860eefba | ||
|
|
15d2dd04c4 | ||
|
|
0ccd2a4397 | ||
|
|
dd8214d3a3 | ||
|
|
3b03b98836 | ||
|
|
4e4767bbfe | ||
|
|
a2d34c7dbd | ||
|
|
e5dfe2162a | ||
|
|
92dfd34610 | ||
|
|
dea884f9c2 | ||
|
|
2d3ee9d7ef | ||
|
|
2b80362795 | ||
|
|
9c10e5bfe1 | ||
|
|
b524e47aa8 | ||
|
|
e42acfc7ea | ||
|
|
9a8927755c | ||
|
|
f6d492dbb4 | ||
|
|
9fd16efb45 | ||
|
|
34b5df1397 | ||
|
|
2a018699ac | ||
|
|
a8d445f56c | ||
|
|
515a26c926 | ||
|
|
74eaac0c21 | ||
|
|
64d6abf771 | ||
|
|
950b8ed68b | ||
|
|
c703484705 | ||
|
|
4d300f6aed | ||
|
|
b9efd702dc | ||
|
|
93cee47a13 | ||
|
|
627ac3c0ba | ||
|
|
d178d86b4d | ||
|
|
c8c0157d79 | ||
|
|
c6c60fc163 | ||
|
|
20c4ed3274 | ||
|
|
1610735fcd | ||
|
|
139a42ba8b | ||
|
|
9477cb6d1e | ||
|
|
60f42e7cdf | ||
|
|
5757de7e74 | ||
|
|
79cd63b8bc | ||
|
|
a2fb9bc76e | ||
|
|
1acc70eb43 | ||
|
|
916ab6b8b1 | ||
|
|
b56e58fde3 | ||
|
|
633169ea0f | ||
|
|
0df07ea3cf | ||
|
|
e527784bc3 | ||
|
|
69ea634469 | ||
|
|
5dd06d27cb | ||
|
|
72c6784172 | ||
|
|
3c5a95239d | ||
|
|
678d6da047 | ||
|
|
24c9ad202b | ||
|
|
d82a6941d1 | ||
|
|
f7772b587f | ||
|
|
d1098c9a91 | ||
|
|
d50d56d837 | ||
|
|
6020c75004 | ||
|
|
f34a261c77 | ||
|
|
6ee08b4a5a | ||
|
|
8e2780a8f0 | ||
|
|
1feb0c7b0b | ||
|
|
91426ff30e | ||
|
|
1215645c4e | ||
|
|
96aa9791e6 | ||
|
|
dd0f9c629b | ||
|
|
1636336f99 | ||
|
|
50a11a1107 | ||
|
|
ca1ed5fac5 | ||
|
|
7eb057384c | ||
|
|
9976e2053c | ||
|
|
1875af1a00 | ||
|
|
b9fc2c5539 | ||
|
|
cee8d447f8 | ||
|
|
04880cebe6 | ||
|
|
c3f0df9033 | ||
|
|
ad1ec8e4f6 | ||
|
|
ff66433c34 | ||
|
|
61f922fbd5 | ||
|
|
511e971adc | ||
|
|
211512aee8 | ||
|
|
8a5af429d7 | ||
|
|
edc5f94957 | ||
|
|
af593b6559 | ||
|
|
761704ce11 | ||
|
|
74d6a69a0e | ||
|
|
5edd79feaa | ||
|
|
c5121f0f10 | ||
|
|
2cd4ec9888 | ||
|
|
c2414d0c5d | ||
|
|
0b5a2c1e4f | ||
|
|
c587403015 | ||
|
|
3ebcf19eed | ||
|
|
f2f02e57bd | ||
|
|
18a2d812a7 | ||
|
|
c10aea9eb8 | ||
|
|
aa060c5fe1 | ||
|
|
07ff96c50c | ||
|
|
19c23a96c6 | ||
|
|
29d8c49bc2 | ||
|
|
bf22beb825 | ||
|
|
f0c1ec9d84 | ||
|
|
510bb6f135 | ||
|
|
10fb981317 | ||
|
|
443a42f97a | ||
|
|
d84e8fa460 | ||
|
|
098efef8e2 | ||
|
|
c66936f747 | ||
|
|
daf537e0d4 | ||
|
|
7f67dc8333 | ||
|
|
260051a190 | ||
|
|
755b7800c1 | ||
|
|
1d52dd41ba | ||
|
|
66dafd2bb6 | ||
|
|
ada4756065 | ||
|
|
0a6874877d | ||
|
|
2fe3429a9a | ||
|
|
3db7aa7cac | ||
|
|
30149bfb41 | ||
|
|
5e6181b8f3 | ||
|
|
93ecc8d0c1 | ||
|
|
230b167744 | ||
|
|
e2ad02a58a | ||
|
|
08f7f87f23 | ||
|
|
0031c67d04 | ||
|
|
5b4984aa32 | ||
|
|
8396106b2e | ||
|
|
d1d212ef32 | ||
|
|
f6eff6b43a | ||
|
|
7abf696d29 | ||
|
|
a94ef019be | ||
|
|
1c9ebb2318 | ||
|
|
e64dc6ad91 | ||
|
|
2726614191 | ||
|
|
3c710450fb | ||
|
|
50295a160c | ||
|
|
308bf3dae0 | ||
|
|
6a6aa75cb9 | ||
|
|
470f9d01e0 | ||
|
|
8559f9ccd2 | ||
|
|
9b72080c32 | ||
|
|
ed3fabb7b4 | ||
|
|
c93db2be5f | ||
|
|
e1cd3420ce | ||
|
|
fb23266b6e | ||
|
|
d7d1e72afc | ||
|
|
83c50cf720 | ||
|
|
2a5ec67594 | ||
|
|
21d6f5610b | ||
|
|
df6c3384fb | ||
|
|
2e45a0750d | ||
|
|
8ad2c758ad | ||
|
|
c71978372d | ||
|
|
0660817c30 | ||
|
|
16341f4d04 | ||
|
|
50901bae92 | ||
|
|
c23c0056a1 | ||
|
|
76c3d4d262 | ||
|
|
c024816c37 | ||
|
|
987dc976d3 | ||
|
|
c9246a4fc5 | ||
|
|
5152376c4b | ||
|
|
282992c1ec | ||
|
|
eee2cd0db1 | ||
|
|
448ba30415 | ||
|
|
b82eeb4f95 | ||
|
|
4f311197bd | ||
|
|
6187796e59 | ||
|
|
49c2e8c55f | ||
|
|
447514aa1d | ||
|
|
8f331a10f5 | ||
|
|
c5897ab467 | ||
|
|
70db97e662 | ||
|
|
d87da7928c | ||
|
|
481afc2e7b | ||
|
|
0deeb5cb29 | ||
|
|
cf28dc7745 | ||
|
|
b05af110f7 | ||
|
|
e8196d58c4 | ||
|
|
0ef952921d | ||
|
|
6e45279301 | ||
|
|
9698eed74f | ||
|
|
d3adab07d4 | ||
|
|
f9dfc692e5 | ||
|
|
d0dcb33382 | ||
|
|
233e09221d | ||
|
|
af9edec8b9 | ||
|
|
3bca3ed851 | ||
|
|
cbba6ead3a | ||
|
|
2a44a86383 | ||
|
|
24c8f66b32 | ||
|
|
de9ab4646d | ||
|
|
97ab5f100a | ||
|
|
2982b03bdf | ||
|
|
15da067a21 | ||
|
|
c391c8971c | ||
|
|
2d2a2621f2 | ||
|
|
067ca7dfb3 | ||
|
|
c73a1df2a5 | ||
|
|
0f4cd026d3 | ||
|
|
382776e2ee | ||
|
|
652accb89b | ||
|
|
93e3025e31 | ||
|
|
b3dd537570 | ||
|
|
4cada3276c | ||
|
|
8d38384310 | ||
|
|
d2dbe9227c | ||
|
|
92886cd964 | ||
|
|
b768990c0c | ||
|
|
938365b9e8 | ||
|
|
89fe5eb063 | ||
|
|
e0b91d71bf | ||
|
|
56728e147f | ||
|
|
382cf33dd9 | ||
|
|
01ef7b1657 | ||
|
|
630258d2a0 | ||
|
|
9f376f7061 | ||
|
|
4a70193562 | ||
|
|
e2482f0e1d | ||
|
|
0d6aaca77d | ||
|
|
f2be6f018c | ||
|
|
5d5f14b7c5 | ||
|
|
d2efe0374c | ||
|
|
278db50cfd | ||
|
|
12dbbe2251 | ||
|
|
134b443675 | ||
|
|
7903537503 | ||
|
|
0c989e88e1 | ||
|
|
0e5d9f1d12 | ||
|
|
327a39bc56 | ||
|
|
9163335028 | ||
|
|
0e687efd6e | ||
|
|
8a2a85a683 | ||
|
|
3319947bdf | ||
|
|
3a6e1aabe2 | ||
|
|
d3392cb35b | ||
|
|
db07aba271 | ||
|
|
75bb6a09ff | ||
|
|
95a3721df1 | ||
|
|
a9a14c810e | ||
|
|
0f8225e608 | ||
|
|
9c1aca3474 | ||
|
|
a62af71cef | ||
|
|
0b38e07d1d | ||
|
|
7df5c204c4 | ||
|
|
b2ba0cb395 | ||
|
|
72f748a785 | ||
|
|
2d9576f84d | ||
|
|
3e7da21a05 | ||
|
|
92dd7886f0 | ||
|
|
8c154acd4a | ||
|
|
8ff04112a2 | ||
|
|
d595ebdd5e | ||
|
|
9ad798b74b | ||
|
|
bf28b98a03 | ||
|
|
779fb404f5 | ||
|
|
196f313399 | ||
|
|
6fea5b936a | ||
|
|
fd04e8e867 | ||
|
|
687f1bfd12 | ||
|
|
3b735090e4 | ||
|
|
dde35f2b2e | ||
|
|
4a296fffb2 | ||
|
|
704c3906e6 | ||
|
|
0413376752 | ||
|
|
c26fb23197 | ||
|
|
50db6475fe | ||
|
|
a84771f764 | ||
|
|
58ac340c1c | ||
|
|
d2612fd508 | ||
|
|
a01f9d5883 | ||
|
|
562175df6c | ||
|
|
13f692f8f0 | ||
|
|
bc16f9aedc | ||
|
|
3eb0cdc6f5 | ||
|
|
fb354374e3 | ||
|
|
644f4d80f7 | ||
|
|
8125ddb602 | ||
|
|
1a26268488 | ||
|
|
f3e4420699 | ||
|
|
db3f211cfa | ||
|
|
dc2aaacea3 | ||
|
|
53c6cd4fa5 | ||
|
|
26d55e5d39 | ||
|
|
7bd4244e2f | ||
|
|
41fc640974 | ||
|
|
f82a1bd4fb | ||
|
|
60b7307b3f | ||
|
|
300d65c317 | ||
|
|
18c8cc5eb9 | ||
|
|
f9baaaed27 | ||
|
|
72352e0006 | ||
|
|
d4da822ac2 | ||
|
|
26f8689238 | ||
|
|
5ed7535acd | ||
|
|
1bb2149e88 | ||
|
|
d59333ab3a | ||
|
|
9a43f998c1 | ||
|
|
2cdde29154 | ||
|
|
7816e1adbb | ||
|
|
d94ceb2eb9 | ||
|
|
4843a2b975 | ||
|
|
0d7c032320 | ||
|
|
932fea900e | ||
|
|
a53de4943f | ||
|
|
2aee551905 | ||
|
|
599ef2acd3 | ||
|
|
1788fb7b84 | ||
|
|
9969321db4 | ||
|
|
c9592a87f6 | ||
|
|
3b34446f84 | ||
|
|
acfa08d9e7 | ||
|
|
91d32f40bc | ||
|
|
1691d8d65b | ||
|
|
408b3dc47d | ||
|
|
d55b4b0a78 | ||
|
|
1a1e4c6f46 | ||
|
|
7008bc32ba | ||
|
|
2ccc6f7ffe | ||
|
|
a14242eb15 | ||
|
|
d747ae6c26 | ||
|
|
a9e60a6782 | ||
|
|
7a92b5064c | ||
|
|
87346bb92f | ||
|
|
d16c9137f9 | ||
|
|
0e4163f67f | ||
|
|
7fe4eb3931 | ||
|
|
44e8e374b9 | ||
|
|
218b421e16 | ||
|
|
f4d97fff39 | ||
|
|
d1b447525a | ||
|
|
4b502a3c48 |
No files matched your search
@@ -13,4 +13,3 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -80,21 +80,24 @@ jobs:
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
rustup default stable
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
|
||||
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
|
||||
# changes since then would count as the pull request's (#13874).
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
|
||||
@@ -201,6 +201,9 @@ jobs:
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
|
||||
- name: Unrelated DLLs cannot intercept launcher or setup startup
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/test_launcher_dll_search.py "$BIN/${{ matrix.binary }}"
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
|
||||
@@ -13,6 +13,14 @@ else (videos, Discord and other community material) lives elsewhere.
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
|
||||
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
|
||||
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
|
||||
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
|
||||
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
|
||||
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
|
||||
here.
|
||||
|
||||
## Build it
|
||||
|
||||
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
|
||||
@@ -190,3 +198,5 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-server-files",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -1528,8 +1529,6 @@ dependencies = [
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
@@ -1587,12 +1586,12 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-render-pipeline",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
@@ -1651,6 +1650,15 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1668,6 +1676,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1793,13 +1802,25 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
@@ -1814,8 +1835,13 @@ dependencies = [
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-hooks",
|
||||
"horizon-prepare",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-system",
|
||||
"horizon-world",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1886,10 +1912,6 @@ dependencies = [
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-render-pipeline"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
@@ -1901,11 +1923,9 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-core",
|
||||
"horizon-mcp",
|
||||
"horizon-platform",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
"horizon-world",
|
||||
"libc",
|
||||
"p12-keystore",
|
||||
"png",
|
||||
@@ -1954,7 +1974,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"horizon-core",
|
||||
"horizon-render-pipeline",
|
||||
"naga",
|
||||
"png",
|
||||
"zip",
|
||||
|
||||
@@ -34,6 +34,8 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
@@ -43,7 +45,6 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-render-pipeline",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
# Horizon governance
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
the Horizon Team, for example.
|
||||
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 The path to a role
|
||||
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
@@ -80,8 +80,8 @@ pub fn main(args: &[String]) -> Result<(), String> {
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
let (head, revoked) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review_with_revoked(&base, &head, &revoked, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
|
||||
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
@@ -26,18 +26,23 @@ pub struct AnimationMetadata {
|
||||
pub enum ClipMode {
|
||||
/// `day_time`: frame 0 at time 0, the last at the cycle's end, then again.
|
||||
DayTime { cycle: i64 },
|
||||
/// `screen_time`: the ticks the open screen has been open, over the cycle, held on the last
|
||||
/// frame once it has run (it doesn't loop, measured on the real mod).
|
||||
ScreenTime { cycle: i64 },
|
||||
/// `weather`: frame 0 in clear weather, 1 in rain, 2 in a storm (the rain and thunder levels
|
||||
/// added up), at most the last.
|
||||
Weather,
|
||||
/// A Polytone expression: its value's fraction is how far through the animation it is.
|
||||
Expression(String),
|
||||
/// `game_time`, `screen_time`, `season`: not driven yet; the animation runs on ticks.
|
||||
/// `game_time`, `season`: not driven; the animation runs on ticks.
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// The values [`ClipMode`]s read this frame.
|
||||
pub struct ModeInputs<'a> {
|
||||
pub day_time: i64,
|
||||
/// The ticks the open screen has been open (0 without one).
|
||||
pub screen_time: i64,
|
||||
/// The rain level plus the thunder level, 0 to 2.
|
||||
pub weather: f32,
|
||||
/// An expression's value this frame, by its source.
|
||||
@@ -116,7 +121,8 @@ impl AnimationMetadata {
|
||||
match mode.as_str() {
|
||||
"day_time" => ClipMode::DayTime { cycle },
|
||||
"weather" => ClipMode::Weather,
|
||||
"game_time" | "screen_time" | "season" => ClipMode::Unsupported(mode),
|
||||
"screen_time" => ClipMode::ScreenTime { cycle },
|
||||
"game_time" | "season" => ClipMode::Unsupported(mode),
|
||||
_ => ClipMode::Expression(mode),
|
||||
}
|
||||
}),
|
||||
@@ -239,6 +245,9 @@ impl AnimationClip {
|
||||
pub fn sample_mode(&self, inputs: &ModeInputs) -> Option<AnimationSample> {
|
||||
let fraction = match self.mode.as_ref()? {
|
||||
ClipMode::DayTime { cycle } => day_fraction(inputs.day_time, *cycle),
|
||||
ClipMode::ScreenTime { cycle } => {
|
||||
(inputs.screen_time.max(0) as f64 / *cycle as f64).min(1.0 - f64::EPSILON)
|
||||
}
|
||||
ClipMode::Weather => {
|
||||
let last = self.sequence.len() - 1;
|
||||
let index = (inputs.weather.max(0.0) as usize).min(last);
|
||||
@@ -288,6 +297,7 @@ mod mode_tests {
|
||||
let expression = move |_: &str| Some(value);
|
||||
clip.sample_mode(&ModeInputs {
|
||||
day_time,
|
||||
screen_time: day_time,
|
||||
weather,
|
||||
expression: &expression,
|
||||
})
|
||||
@@ -302,6 +312,13 @@ mod mode_tests {
|
||||
.map(|t| frame(&day, t, 0.0, 0.0))
|
||||
.into();
|
||||
assert_eq!(frames, [0, 1, 2, 3, 1]);
|
||||
// 26.3-6.8.6, four frames over 200 ticks: 40 ticks open show the first, and from ten
|
||||
// seconds on the last, even after 27 (it doesn't loop).
|
||||
let screen = clip(r#"{"frametime": 1, "mode": "screen_time", "time_cycle_duration": 200}"#);
|
||||
let frames: Vec<u32> = [0, 40, 200, 360, 540]
|
||||
.map(|t| frame(&screen, t, 0.0, 0.0))
|
||||
.into();
|
||||
assert_eq!(frames, [0, 0, 3, 3, 3]);
|
||||
let value = clip(r#"{"frametime": 1, "mode": "0.6"}"#);
|
||||
assert_eq!(frame(&value, 0, 0.0, 0.6), 2);
|
||||
assert_eq!(frame(&value, 0, 0.0, 2.0), 0);
|
||||
@@ -321,6 +338,7 @@ mod mode_tests {
|
||||
clip(r#"{"frametime": 1}"#)
|
||||
.sample_mode(&ModeInputs {
|
||||
day_time: 0,
|
||||
screen_time: 0,
|
||||
weather: 0.0,
|
||||
expression: &|_| None
|
||||
})
|
||||
@@ -330,6 +348,7 @@ mod mode_tests {
|
||||
clip(r#"{"frametime": 1, "mode": "season"}"#)
|
||||
.sample_mode(&ModeInputs {
|
||||
day_time: 0,
|
||||
screen_time: 0,
|
||||
weather: 0.0,
|
||||
expression: &|_| None
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ 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]
|
||||
# The moderation durations, shared with `/tempban`.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
@@ -474,61 +477,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
|
||||
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ actions! {
|
||||
Emote, "key.emote", Some(KeyCode::KeyB);
|
||||
HotbarPrevious, "pad.hotbarPrevious", None;
|
||||
HotbarNext, "pad.hotbarNext", None;
|
||||
Radial, "pad.radial", None;
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
||||
@@ -253,6 +253,17 @@ impl Activity {
|
||||
/// Horizon's pad-only actions (no vanilla key mapping).
|
||||
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
|
||||
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
|
||||
/// Held, opens the radial menu (`radial.rs`).
|
||||
pub const RADIAL: &str = "pad.radial";
|
||||
|
||||
mod radial_file;
|
||||
pub use radial_file::{RadialMode, RadialStick, opener_refused};
|
||||
|
||||
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
|
||||
/// are; 2: the radial menu's opener resolved against the player's own buttons.
|
||||
const BIND_LAYOUT: u32 = 2;
|
||||
/// The layout from which the file, not the default, says where the opener is.
|
||||
const OPENER_LAYOUT: u32 = 2;
|
||||
|
||||
/// Bindable actions and their default buttons: vanilla key mappings (the
|
||||
/// game acts as their default key) and Horizon's pad-only actions. Start is
|
||||
@@ -271,6 +282,7 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.togglePerspective", Some(Button::Select)),
|
||||
("key.pickItem", Some(Button::DPadLeft)),
|
||||
("key.chat", Some(Button::DPadUp)),
|
||||
(RADIAL, Some(Button::DPadRight)),
|
||||
("key.advancements", None),
|
||||
("key.playerlist", None),
|
||||
("key.command", None),
|
||||
@@ -303,11 +315,14 @@ pub enum Route {
|
||||
HotbarStep {
|
||||
next: bool,
|
||||
},
|
||||
Radial,
|
||||
/// Held, or flipped when the pad's toggle sneak is on.
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Fullscreen,
|
||||
/// F3's debug screen, shown or hidden as F3 alone does.
|
||||
DebugOverlay,
|
||||
/// Does nothing by itself: what reads it asks whether the button is held
|
||||
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
|
||||
Held,
|
||||
@@ -320,10 +335,12 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
|
||||
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
|
||||
RADIAL => Some(Route::Radial),
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
"key.fullscreen" => Some(Route::Fullscreen),
|
||||
"key.debug.overlay" => Some(Route::DebugOverlay),
|
||||
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
@@ -334,12 +351,16 @@ pub fn route(action: &str) -> Option<Route> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's opener, as the binds list and the gameplay guide name it.
|
||||
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
|
||||
|
||||
/// An action's name in the binds list: vanilla's translation key, or
|
||||
/// Horizon's own English for the pad-only actions.
|
||||
pub fn action_caption(action: &str) -> &str {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
|
||||
HOTBAR_NEXT => "Hotbar: Next Slot",
|
||||
RADIAL => RADIAL_CAPTION,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -654,6 +675,21 @@ pub struct PadSettings {
|
||||
pub repeat_interval: f32,
|
||||
/// Action name -> button, `None` for unbound; missing means the default.
|
||||
bindings: BTreeMap<&'static str, Option<Button>>,
|
||||
/// The radial menu's pages.
|
||||
pub radial: crate::radial::Pages,
|
||||
/// Whether the radial menu's opener is held or pressed once.
|
||||
pub radial_mode: RadialMode,
|
||||
/// The stick that selects in it.
|
||||
pub radial_stick: RadialStick,
|
||||
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
|
||||
/// button it doesn't know), kept as they were so a save here loses nothing.
|
||||
extra: Vec<(String, String)>,
|
||||
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
|
||||
/// kept so that one doesn't resolve its file again as older than it is.
|
||||
bind_layout: u32,
|
||||
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
|
||||
/// With row and back gives each stick's own opener again.
|
||||
openers: radial_file::StickOpeners,
|
||||
}
|
||||
impl Default for PadSettings {
|
||||
fn default() -> Self {
|
||||
@@ -680,6 +716,12 @@ impl Default for PadSettings {
|
||||
repeat_delay: 0.35,
|
||||
repeat_interval: 0.09,
|
||||
bindings: BTreeMap::new(),
|
||||
radial: Default::default(),
|
||||
radial_mode: RadialMode::Hold,
|
||||
radial_stick: RadialStick::Right,
|
||||
extra: Vec::new(),
|
||||
bind_layout: BIND_LAYOUT,
|
||||
openers: radial_file::StickOpeners::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +748,7 @@ impl PadSettings {
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut s = Self::default();
|
||||
let mut saved_opener = None;
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
@@ -754,22 +797,85 @@ impl PadSettings {
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
|
||||
"radialMode" => s.radial_mode = RadialMode::parse(value),
|
||||
"radialStick" => s.radial_stick = RadialStick::parse(value),
|
||||
// Read after the loop, from the whole text.
|
||||
"bindLayout" => {}
|
||||
// The radial menu's pages (`radial::Pages::parse`).
|
||||
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
|
||||
other => {
|
||||
if let Some(action) = other.strip_prefix("bind_") {
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
if let Some(button) = button {
|
||||
s.bind(action, button);
|
||||
}
|
||||
let known = other
|
||||
.strip_prefix("bind_")
|
||||
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
|
||||
let button = if value == "none" {
|
||||
Some(None)
|
||||
} else {
|
||||
Button::parse(value).map(Some)
|
||||
};
|
||||
match (known, button) {
|
||||
// The opener once the selecting stick is known.
|
||||
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
|
||||
(Some(action), Some(button)) => s.bind(action, button),
|
||||
_ => s.extra.push((other.to_owned(), value.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A file from before these defaults: its player's buttons stay where they were.
|
||||
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
|
||||
// An opener on a button it can't be with this stick reads as never saved.
|
||||
let refused_opener = match saved_opener {
|
||||
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
|
||||
Some(button) => {
|
||||
s.bind(RADIAL, button);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if let Some(pages) = crate::radial::Pages::parse(
|
||||
text.lines()
|
||||
.filter_map(|line| line.split_once(':'))
|
||||
.map(|(key, value)| (key.trim(), value.trim())),
|
||||
) {
|
||||
s.radial = pages;
|
||||
}
|
||||
let lines = || text.lines().map(str::trim);
|
||||
let saved = |action: &str| {
|
||||
let prefix = format!("bind_{action}:");
|
||||
lines().any(|line| line.starts_with(&prefix))
|
||||
};
|
||||
// The layout the file was written in; none in a file from before these defaults.
|
||||
let layout = lines()
|
||||
.find_map(|line| line.strip_prefix("bindLayout:"))
|
||||
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
|
||||
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
|
||||
// Once, for a file from before the radial menu's opener, or one with the opener on a
|
||||
// button it can no longer be: a button the player bound by hand stays theirs, and the
|
||||
// opener's default doesn't take it. From then on the file says where the opener is, and
|
||||
// a conflict made since stays shown in the binds list.
|
||||
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
|
||||
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
|
||||
let default_opener = ACTIONS
|
||||
.iter()
|
||||
.find(|(action, _)| *action == RADIAL)
|
||||
.and_then(|(_, button)| *button);
|
||||
let taken = s.bindings.iter().any(|(action, button)| {
|
||||
*action != RADIAL && button.is_some() && *button == default_opener
|
||||
});
|
||||
if taken {
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
}
|
||||
// The default too can be under the selecting thumb (D-pad Right, the left stick
|
||||
// selecting): no button opens the menu until the player picks one.
|
||||
if s.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(s.radial_stick, b))
|
||||
{
|
||||
s.bindings.insert(RADIAL, None);
|
||||
}
|
||||
// A file from before these defaults: its player's buttons stay where they were, and
|
||||
// nothing they saved shares a button with the radial menu's opener. They can bind one
|
||||
// in the binds list.
|
||||
if !text.is_empty() && layout.is_none() {
|
||||
s.bindings.entry(RADIAL).or_insert(None);
|
||||
for (action, button) in [
|
||||
("key.drop", Button::East),
|
||||
("key.togglePerspective", Button::DPadUp),
|
||||
@@ -778,10 +884,7 @@ impl PadSettings {
|
||||
("key.advancements", Button::DPadDown),
|
||||
] {
|
||||
// A button saved by hand stays, even one that's now this action's default.
|
||||
if !text
|
||||
.lines()
|
||||
.any(|line| line.starts_with(&format!("bind_{action}:")))
|
||||
{
|
||||
if !saved(action) {
|
||||
s.bindings.entry(action).or_insert(Some(button));
|
||||
}
|
||||
}
|
||||
@@ -822,7 +925,25 @@ impl PadSettings {
|
||||
"playInBackground:{}\n",
|
||||
bool_text(self.play_in_background)
|
||||
));
|
||||
text.push_str("bindLayout:1\n");
|
||||
text.push_str(&format!(
|
||||
"radialMode:{}\nradialStick:{}\n",
|
||||
self.radial_mode.name(),
|
||||
self.radial_stick.name()
|
||||
));
|
||||
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
|
||||
for (key, value) in &self.extra {
|
||||
// A button this game didn't know, for an action the player has bound here since.
|
||||
let rebound = key
|
||||
.strip_prefix("bind_")
|
||||
.is_some_and(|action| self.bindings.contains_key(action));
|
||||
if !rebound {
|
||||
text.push_str(&format!("{key}:{value}\n"));
|
||||
}
|
||||
}
|
||||
// The default pages aren't written: they can improve for a player who never changed them.
|
||||
if self.radial != crate::radial::Pages::default() {
|
||||
text.push_str(&self.radial.serialize());
|
||||
}
|
||||
text
|
||||
}
|
||||
pub fn default_of(action: &str) -> Option<Button> {
|
||||
@@ -835,11 +956,15 @@ impl PadSettings {
|
||||
None => Self::default_of(action),
|
||||
}
|
||||
}
|
||||
/// Bind an action (`None` unbinds); unknown actions are ignored.
|
||||
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
|
||||
/// (`can_bind`), changes nothing.
|
||||
pub fn bind(&mut self, action: &str, button: Option<Button>) {
|
||||
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
|
||||
return;
|
||||
};
|
||||
if button.is_some_and(|b| !self.can_bind(action, b)) {
|
||||
return;
|
||||
}
|
||||
if button == *default {
|
||||
self.bindings.remove(name);
|
||||
} else {
|
||||
@@ -1440,6 +1565,24 @@ pub enum Rumble {
|
||||
radius: f32,
|
||||
},
|
||||
Totem,
|
||||
/// The radial menu's stick reached a slot that would run: a tick.
|
||||
RadialSlot,
|
||||
/// The radial menu's page turned: a shorter tick.
|
||||
RadialPage,
|
||||
/// The page turn went round an end: a second tick after the first.
|
||||
RadialWrap,
|
||||
/// A release ran nothing after something had been selected: a soft buzz.
|
||||
RadialNothing,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
use crate::input_source::Feedback;
|
||||
match event {
|
||||
Feedback::BlockBroken => Rumble::BlockBroken,
|
||||
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
|
||||
Feedback::Totem => Rumble::Totem,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
|
||||
/// long it lasts.
|
||||
@@ -1449,6 +1592,7 @@ pub struct Pulse {
|
||||
pub weak: f32,
|
||||
pub millis: u32,
|
||||
}
|
||||
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
|
||||
impl Rumble {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -1456,6 +1600,10 @@ impl Rumble {
|
||||
Rumble::BlockBroken => "block",
|
||||
Rumble::Explosion { .. } => "explosion",
|
||||
Rumble::Totem => "totem",
|
||||
Rumble::RadialSlot => "radial",
|
||||
Rumble::RadialPage => "radial_page",
|
||||
Rumble::RadialWrap => "radial_wrap",
|
||||
Rumble::RadialNothing => "radial_nothing",
|
||||
}
|
||||
}
|
||||
/// The pulse, or `None` when it is too faint to feel.
|
||||
@@ -1482,6 +1630,9 @@ impl Rumble {
|
||||
millis: 350,
|
||||
})
|
||||
}
|
||||
Rumble::RadialSlot => Some(RADIAL_TICK),
|
||||
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
|
||||
Rumble::RadialNothing => Some(RADIAL_NOTHING),
|
||||
Rumble::Totem => Some(Pulse {
|
||||
strong: 0.7,
|
||||
weak: 0.7,
|
||||
@@ -1503,6 +1654,10 @@ impl Rumble {
|
||||
};
|
||||
match self {
|
||||
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
|
||||
Rumble::RadialSlot
|
||||
| Rumble::RadialPage
|
||||
| Rumble::RadialWrap
|
||||
| Rumble::RadialNothing => vec![p],
|
||||
Rumble::Hurt(_) => {
|
||||
let tail = p.millis.saturating_sub(50) / 2;
|
||||
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
|
||||
@@ -1863,7 +2018,55 @@ bind_key.hotbar.2:north
|
||||
let odd = PadSettings::parse(
|
||||
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
|
||||
);
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
assert_eq!(
|
||||
odd.look_sensitivity,
|
||||
PadSettings::default().look_sensitivity
|
||||
);
|
||||
assert_eq!(odd.binding("key.jump"), Some(Button::South));
|
||||
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_this_game_doesnt_read_survive_a_save() {
|
||||
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
|
||||
let text =
|
||||
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
|
||||
let s = PadSettings::parse(text);
|
||||
assert_eq!(
|
||||
s.binding("key.jump"),
|
||||
Some(Button::South),
|
||||
"the default meanwhile"
|
||||
);
|
||||
let saved = s.serialize();
|
||||
for line in [
|
||||
"hapticStrength:0.4\n",
|
||||
"bind_horizon:dash:south\n",
|
||||
"bind_key.jump:paddle1\n",
|
||||
] {
|
||||
assert!(saved.contains(line), "{line} lost from\n{saved}");
|
||||
}
|
||||
assert_eq!(PadSettings::parse(&saved), s);
|
||||
// Bound here since, the action's own line is the one written.
|
||||
let mut s = s;
|
||||
s.bind("key.jump", Some(Button::West));
|
||||
let saved = s.serialize();
|
||||
assert!(!saved.contains("paddle1"), "{saved}");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&saved).binding("key.jump"),
|
||||
Some(Button::West)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_bind_layout_is_kept() {
|
||||
let newer = BIND_LAYOUT + 1;
|
||||
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
|
||||
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert!(
|
||||
s.serialize()
|
||||
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
|
||||
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
|
||||
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
|
||||
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
|
||||
use super::{Button, PadSettings, RADIAL};
|
||||
|
||||
/// The buttons under the right thumb, which steers the right stick.
|
||||
const RIGHT_THUMB: [Button; 4] = [
|
||||
Button::South,
|
||||
Button::West,
|
||||
Button::North,
|
||||
Button::RightStick,
|
||||
];
|
||||
/// The buttons under the left thumb, which steers the left stick.
|
||||
const LEFT_THUMB: [Button; 5] = [
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
Button::LeftStick,
|
||||
];
|
||||
|
||||
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
|
||||
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
|
||||
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
|
||||
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
|
||||
/// with a bumper for the opener the other bumper turns the pages.
|
||||
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
|
||||
button == Button::East
|
||||
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|
||||
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
|
||||
}
|
||||
|
||||
/// How the radial menu's opener holds it open.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialMode {
|
||||
/// Held: letting go runs what is selected.
|
||||
#[default]
|
||||
Hold,
|
||||
/// A press opens it; A or the opener again runs what is selected, B closes it.
|
||||
Toggle,
|
||||
}
|
||||
impl RadialMode {
|
||||
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hold => "hold",
|
||||
Self::Toggle => "toggle",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The stick that selects in the radial menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum RadialStick {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
/// The last stick to lean past the select tilt.
|
||||
Either,
|
||||
}
|
||||
impl RadialStick {
|
||||
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Right => "right",
|
||||
Self::Left => "left",
|
||||
Self::Either => "either",
|
||||
}
|
||||
}
|
||||
pub fn parse(value: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|stick| stick.name() == value)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The opener each stick had, for this session: what the player stepped through, not a setting,
|
||||
/// so it is neither saved nor compared.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct StickOpeners([Option<Button>; 3]);
|
||||
impl PartialEq for StickOpeners {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
impl StickOpeners {
|
||||
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
|
||||
let at = RadialStick::ALL
|
||||
.iter()
|
||||
.position(|s| *s == stick)
|
||||
.unwrap_or(0);
|
||||
&mut self.0[at]
|
||||
}
|
||||
}
|
||||
|
||||
impl PadSettings {
|
||||
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
|
||||
/// a thumb that selects, or on B (`opener_refused`).
|
||||
pub fn can_bind(&self, action: &str, button: Button) -> bool {
|
||||
action != RADIAL || !opener_refused(self.radial_stick, button)
|
||||
}
|
||||
/// The stick the radial menu selects with. An opener under the thumb that now selects is
|
||||
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
|
||||
/// stick had before in this session, or else its default where nothing else has it (D-pad
|
||||
/// Right, back on the right stick).
|
||||
pub fn set_radial_stick(&mut self, stick: RadialStick) {
|
||||
let was = self.radial_stick;
|
||||
*self.openers.slot(was) = self.binding(RADIAL);
|
||||
self.radial_stick = stick;
|
||||
if self
|
||||
.binding(RADIAL)
|
||||
.is_some_and(|b| opener_refused(stick, b))
|
||||
{
|
||||
self.bindings.insert(RADIAL, None);
|
||||
}
|
||||
if self.binding(RADIAL).is_some() {
|
||||
return;
|
||||
}
|
||||
// The player's own, shared or not: they put it there.
|
||||
if let Some(own) = self
|
||||
.openers
|
||||
.slot(stick)
|
||||
.filter(|b| !opener_refused(stick, *b))
|
||||
{
|
||||
self.bind(RADIAL, Some(own));
|
||||
return;
|
||||
}
|
||||
let default = Self::default_of(RADIAL);
|
||||
let free =
|
||||
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
|
||||
if free {
|
||||
self.bindings.remove(RADIAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::Rumble;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
|
||||
for s in [PadSettings::default(), PadSettings::parse("")] {
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(!s.conflicts(RADIAL));
|
||||
}
|
||||
let s = PadSettings::parse(&PadSettings::default().serialize());
|
||||
assert_eq!(s, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
|
||||
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert!(!s.conflicts("key.playerlist"));
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_opener_stays_where_it_was_saved() {
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
|
||||
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind("key.playerlist", Some(Button::DPadRight));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2"));
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
assert!(read.conflicts(RADIAL));
|
||||
assert_eq!(read, s);
|
||||
// The same, hand-written with spaces around the lines.
|
||||
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
|
||||
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_one_file_resolves_the_opener_once() {
|
||||
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
|
||||
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
|
||||
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
|
||||
let text = s.serialize();
|
||||
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
|
||||
assert_eq!(PadSettings::parse(&text), s);
|
||||
// Without a conflict the opener keeps its default.
|
||||
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// A line with leading spaces is still a saved opener.
|
||||
let s =
|
||||
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
|
||||
use Button as B;
|
||||
let face = [B::South, B::West, B::North, B::RightStick];
|
||||
let dpad = [
|
||||
B::DPadUp,
|
||||
B::DPadDown,
|
||||
B::DPadLeft,
|
||||
B::DPadRight,
|
||||
B::LeftStick,
|
||||
];
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(stick);
|
||||
for button in Button::ALL {
|
||||
let refused = button == B::East
|
||||
|| (stick != RadialStick::Left && face.contains(&button))
|
||||
|| (stick != RadialStick::Right && dpad.contains(&button));
|
||||
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
|
||||
// Other actions still take any of them.
|
||||
assert!(s.can_bind("key.chat", button));
|
||||
}
|
||||
// A bumper is always free, and stays one through the file.
|
||||
s.bind(RADIAL, Some(B::LeftBumper));
|
||||
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
|
||||
}
|
||||
// A file with a refused opener reads as never saved: the default, unless taken.
|
||||
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
|
||||
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
|
||||
let read = PadSettings::parse(&text);
|
||||
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
|
||||
let taken = format!("{text}bind_key.playerlist:right\n");
|
||||
assert_eq!(
|
||||
PadSettings::parse(&taken).binding(RADIAL),
|
||||
None,
|
||||
"{button:?}"
|
||||
);
|
||||
}
|
||||
// Read with the stick it was saved for, wherever the stick's line is.
|
||||
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
|
||||
assert_eq!(left.binding(RADIAL), Some(B::North));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(
|
||||
s.binding(RADIAL),
|
||||
None,
|
||||
"D-pad Right is under the left thumb"
|
||||
);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// An opener the player picked that both sticks allow stays.
|
||||
s.bind(RADIAL, Some(Button::RightBumper));
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
|
||||
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
|
||||
let mut s = PadSettings::default();
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
s.bind(RADIAL, Some(Button::North));
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
|
||||
// And Left again has its Y back, shared with Inventory as the player left it.
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::North));
|
||||
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
|
||||
let mut s = PadSettings::default();
|
||||
s.bind(RADIAL, Some(Button::DPadLeft));
|
||||
s.set_radial_stick(RadialStick::Left);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Either);
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
s.set_radial_stick(RadialStick::Right);
|
||||
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_the_radial_menu_binds_no_opener() {
|
||||
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
|
||||
assert_eq!(s.binding(RADIAL), None);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
|
||||
let tick = Rumble::RadialSlot.pulse().unwrap();
|
||||
assert!((40..=50).contains(&tick.millis), "{tick:?}");
|
||||
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
|
||||
let page = Rumble::RadialPage.pulse().unwrap();
|
||||
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
|
||||
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
|
||||
let nothing = Rumble::RadialNothing.pulse().unwrap();
|
||||
assert!(
|
||||
nothing.strong > 0.0 && nothing.millis > tick.millis,
|
||||
"{nothing:?}"
|
||||
);
|
||||
assert!(
|
||||
nothing.strong.max(nothing.weak) < 0.3,
|
||||
"a soft buzz: {nothing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
|
||||
let s = PadSettings::default();
|
||||
assert_eq!(
|
||||
(s.radial_mode, s.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
for mode in RadialMode::ALL {
|
||||
for stick in RadialStick::ALL {
|
||||
let mut s = PadSettings {
|
||||
radial_mode: mode,
|
||||
..PadSettings::default()
|
||||
};
|
||||
s.set_radial_stick(stick);
|
||||
assert_eq!(PadSettings::parse(&s.serialize()), s);
|
||||
}
|
||||
}
|
||||
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
|
||||
assert_eq!(
|
||||
(read.radial_mode, read.radial_stick),
|
||||
(RadialMode::Hold, RadialStick::Right)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_pages_survive_the_settings_file() {
|
||||
let mut s = PadSettings::default();
|
||||
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
|
||||
s.radial =
|
||||
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
|
||||
let read = PadSettings::parse(&s.serialize());
|
||||
assert_eq!(read.radial, s.radial);
|
||||
assert_ne!(read.radial, crate::radial::Pages::default());
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,13 @@ impl Guide {
|
||||
pub fn at_detail(self, detail: GuideDetail) -> Self {
|
||||
match detail {
|
||||
GuideDetail::Full => self,
|
||||
// Nothing else in play names the radial menu.
|
||||
GuideDetail::Basic => Guide {
|
||||
left: Vec::new(),
|
||||
left: self
|
||||
.left
|
||||
.into_iter()
|
||||
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
|
||||
.collect(),
|
||||
right: self.right,
|
||||
},
|
||||
GuideDetail::Off => Guide {
|
||||
@@ -178,6 +183,8 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
"key.swapOffhand",
|
||||
(player && any_item).then_some("horizon.controller.guide.swap"),
|
||||
),
|
||||
// Nothing else in play says the menu is there; it opens for a spectator too.
|
||||
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -566,6 +573,7 @@ mod tests {
|
||||
(Button::North, "key.inventory"),
|
||||
(Button::DPadDown, "horizon.controller.guide.drop"),
|
||||
(Button::West, "horizon.controller.guide.swap"),
|
||||
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -736,12 +744,12 @@ mod tests {
|
||||
gliding: true,
|
||||
..Facts::default()
|
||||
}),
|
||||
["key.inventory"]
|
||||
["key.inventory", crate::gamepad::RADIAL_CAPTION]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
|
||||
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
|
||||
let facts = Facts {
|
||||
spectator: true,
|
||||
flying: true,
|
||||
@@ -765,7 +773,8 @@ mod tests {
|
||||
captions(&guide.left),
|
||||
[
|
||||
"horizon.controller.guide.fly_up",
|
||||
"horizon.controller.guide.fly_down"
|
||||
"horizon.controller.guide.fly_down",
|
||||
crate::gamepad::RADIAL_CAPTION
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
|
||||
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
|
||||
//! types, never a controller's; the controller code answers.
|
||||
|
||||
/// Something that happened to the player that a device may let them feel (a controller's
|
||||
/// vibration). The device decides how, or whether.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Feedback {
|
||||
/// The player broke a block.
|
||||
BlockBroken,
|
||||
/// An explosion this far away (blocks) with this power.
|
||||
Explosion { distance: f64, radius: f32 },
|
||||
/// A totem of undying saved the player.
|
||||
Totem,
|
||||
}
|
||||
@@ -9,11 +9,13 @@ pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod input_source;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod radial;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -670,6 +691,39 @@ pub fn with_horizon_icon(
|
||||
attributes
|
||||
}
|
||||
|
||||
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
|
||||
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// [`monitor`]'s size, refresh rate and scale.
|
||||
pub fn display(window: &winit::window::Window) -> Option<Display> {
|
||||
monitor(window).map(|m| Display {
|
||||
width: m.size().width,
|
||||
height: m.size().height,
|
||||
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -706,7 +760,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,8 +18,6 @@ horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
|
||||
@@ -44,8 +42,6 @@ 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" }
|
||||
# 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).
|
||||
@@ -74,7 +70,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
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).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -113,16 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "discord", "friends"]
|
||||
default = ["audio", "controllers"]
|
||||
# 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", "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"]
|
||||
# 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.
|
||||
@@ -134,6 +125,8 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -7,6 +7,8 @@ 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]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
@@ -24,11 +26,9 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -75,10 +75,16 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Audio occlusion, attenuation and mixing benchmark.
|
||||
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
|
||||
use horizon_client_render::{
|
||||
audio::Audio,
|
||||
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
|
||||
};
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
fn main() {
|
||||
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
|
||||
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent"));
|
||||
audio.engine.loaded = true;
|
||||
audio.backend_live_for_bench();
|
||||
audio.record_heard = false;
|
||||
|
||||
// Register a sound event with attenuation distance 16
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Insert 1 second of audio into cache (48000 mono samples)
|
||||
let samples: Vec<f32> = (0..48_000)
|
||||
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
|
||||
.collect();
|
||||
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
|
||||
|
||||
// 1. Benchmark set_listener (called every client render frame)
|
||||
let listener_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for i in 0..listener_iterations {
|
||||
let angle = (i as f32) * 0.01;
|
||||
audio.set_listener(
|
||||
[
|
||||
100.0 + (angle.cos() as f64) * 5.0,
|
||||
64.0,
|
||||
200.0 + (angle.sin() as f64) * 5.0,
|
||||
],
|
||||
angle,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
let listener_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
|
||||
listener_elapsed,
|
||||
listener_iterations,
|
||||
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
|
||||
);
|
||||
|
||||
// 1b. Benchmark set_listener stationary (lock elimination early-out)
|
||||
let stationary_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..stationary_iterations {
|
||||
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
|
||||
}
|
||||
let stationary_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
|
||||
stationary_elapsed,
|
||||
stationary_iterations,
|
||||
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
|
||||
);
|
||||
|
||||
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
|
||||
// 64 sounds within attenuation distance (<16 blocks)
|
||||
// 64 sounds outside attenuation distance (>16 blocks)
|
||||
for i in 0..128 {
|
||||
let dist = if i < 64 {
|
||||
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
|
||||
} else {
|
||||
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
|
||||
};
|
||||
let pos = [100.0 + dist, 64.0, 200.0];
|
||||
let instance = SoundInstance::new(
|
||||
"minecraft:bench.loop",
|
||||
crate_sound_source(i),
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_core::random::LegacyRandom::new(i as i64),
|
||||
true, // looping
|
||||
0,
|
||||
horizon_client_render::sound_engine::Attenuation::Linear,
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
audio.play_instance(instance, false);
|
||||
}
|
||||
|
||||
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
|
||||
let mut buffer = vec![0.0f32; 1024 * 2];
|
||||
let mix_iterations = 5_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..mix_iterations {
|
||||
audio.mix_buffer(&mut buffer, 2, 48_000);
|
||||
}
|
||||
let mix_elapsed = start.elapsed();
|
||||
println!(
|
||||
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
|
||||
mix_elapsed,
|
||||
mix_iterations,
|
||||
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
|
||||
);
|
||||
|
||||
// 4. Benchmark SoundEngine::tick with ticking mob sounds
|
||||
let mut mobs = Vec::new();
|
||||
for i in 0..64 {
|
||||
mobs.push(MobSound {
|
||||
id: 2000 + i,
|
||||
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
|
||||
speed: 0.25,
|
||||
baby: false,
|
||||
angry: i % 2 == 0,
|
||||
silent: false,
|
||||
attack_scale: 0.5,
|
||||
sniffer_digging: false,
|
||||
});
|
||||
}
|
||||
|
||||
let tick_ctx = TickContext {
|
||||
player_underwater: false,
|
||||
player_removed: false,
|
||||
vehicle: None,
|
||||
carts: Vec::new(),
|
||||
mobs: mobs.clone(),
|
||||
frozen: false,
|
||||
player_position: [100.0, 64.0, 200.0],
|
||||
player_speed_sqr: 0.1,
|
||||
player_fall_flying: false,
|
||||
ridden: None,
|
||||
camera_position: [100.0, 64.0, 200.0],
|
||||
};
|
||||
|
||||
let tick_iterations = 10_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..tick_iterations {
|
||||
audio.tick(false, &tick_ctx);
|
||||
}
|
||||
let tick_elapsed = start.elapsed();
|
||||
println!(
|
||||
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
|
||||
tick_elapsed,
|
||||
tick_iterations,
|
||||
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
|
||||
);
|
||||
}
|
||||
|
||||
fn crate_sound_source(i: u32) -> usize {
|
||||
match i % 6 {
|
||||
0 => 0, // master
|
||||
1 => 1, // music
|
||||
2 => 2, // records
|
||||
3 => 3, // weather
|
||||
4 => 4, // blocks
|
||||
_ => 5, // hostile
|
||||
}
|
||||
}
|
||||
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
for _ in 0..32 {
|
||||
draw(&mut renderer)?;
|
||||
|
||||
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("cpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
let expected = read(&out.join("cpu.png"));
|
||||
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("gpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
|
||||
@@ -382,8 +382,8 @@ impl RenderAssets {
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -478,6 +478,7 @@ pub struct Audio {
|
||||
pub record_heard: bool,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub listener_pitch: f32,
|
||||
pub master: f32,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.gain = volume;
|
||||
c.pitch = pitch;
|
||||
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
|
||||
}
|
||||
}
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
if let Ok(m) = self.mixer.lock() {
|
||||
for &(h, c) in live {
|
||||
if m.channels
|
||||
.get(&c)
|
||||
.is_none_or(|ch| ch.state == State::Stopped)
|
||||
{
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopped.extend(live.iter().map(|&(h, _)| h));
|
||||
}
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
|
||||
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
|
||||
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
|
||||
let length = (right * right + up * up + forward * forward).sqrt();
|
||||
stereo_gains_with_length(right, up, forward, length)
|
||||
}
|
||||
#[inline]
|
||||
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
|
||||
if length <= f32::EPSILON {
|
||||
return stereo_gains(0.0, 0.0, 1.0);
|
||||
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
let elevation = (up / length).clamp(-1.0, 1.0).asin();
|
||||
let u = (up / length).clamp(-1.0, 1.0);
|
||||
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
|
||||
let mut azimuth = right.atan2(forward);
|
||||
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
|
||||
azimuth = (azimuth.abs() * 1.5)
|
||||
.min(std::f32::consts::FRAC_PI_2)
|
||||
.copysign(azimuth);
|
||||
}
|
||||
let side = azimuth.sin() * elevation.cos();
|
||||
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
|
||||
let (sin_az, cos_az) = azimuth.sin_cos();
|
||||
let side = sin_az * cos_elev;
|
||||
let front = cos_az * cos_elev * 0.095_660_8;
|
||||
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// and OpenAL's right = forward x up.
|
||||
let basis = |yaw: f32, pitch: f32| {
|
||||
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let (yaw_sin, yaw_cos) = yaw.sin_cos();
|
||||
let (pitch_sin, pitch_cos) = pitch.sin_cos();
|
||||
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
|
||||
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
for (id, c) in &mut mixer.channels {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
at[2] - listener[2],
|
||||
]
|
||||
};
|
||||
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// The nearest camera hears it (one camera but in splitscreen).
|
||||
let camera = mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1));
|
||||
// Unheard sources still advance: returning to a world must not resume stale sounds.
|
||||
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let camera = if !shared {
|
||||
let cam = &mixer.cameras[0];
|
||||
if c.relative || cam.2 == c.world {
|
||||
Some((cam.0, cam.1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1))
|
||||
};
|
||||
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
distance_gain(c.linear, distance)
|
||||
let d_sqr = length_sq(d);
|
||||
|
||||
let (gain, [left, right]) = if camera.is_none() {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else if direct {
|
||||
let gain = c.gain.clamp(0.0, 1.0);
|
||||
(gain, [gain, gain])
|
||||
} else {
|
||||
let mut zero = false;
|
||||
if let Some(max) = c.linear {
|
||||
let max_d = max as f64;
|
||||
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
|
||||
zero = true;
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
if zero {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else {
|
||||
let distance = d_sqr.sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
|
||||
} else {
|
||||
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
|
||||
};
|
||||
if shared {
|
||||
let middle = (left + right) * 0.5;
|
||||
left = middle + (left - middle) * 0.5;
|
||||
right = middle + (right - middle) * 0.5;
|
||||
}
|
||||
(gain, [left * gain, right * gain])
|
||||
}
|
||||
};
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
let inaudible = if channels >= 2 {
|
||||
left == 0.0 && right == 0.0
|
||||
} else {
|
||||
gain == 0.0
|
||||
};
|
||||
if inaudible {
|
||||
let buffer_frames = data.len() / channels;
|
||||
// As the loop below: a sound of one frame or none stops, looping or not.
|
||||
if !c.looping || frames <= 1 {
|
||||
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
|
||||
// reaches the end, as in the loop below.
|
||||
let needed = frames as f64 - 1.0 - c.position;
|
||||
if needed <= 0.0 {
|
||||
c.state = State::Stopped;
|
||||
} else if step > 0.0 {
|
||||
let steps_to_stop = needed / step;
|
||||
if (buffer_frames as f64) >= steps_to_stop {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.position += step * (buffer_frames as f64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..buffer_frames {
|
||||
let i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
c.position = 0.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
|
||||
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
@@ -1412,6 +1482,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: 1.0,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -1535,8 +1606,12 @@ impl Audio {
|
||||
/// `SoundManager.updateSource`: the camera position and rotation
|
||||
/// (`Listener.setTransform` takes its forward and up vectors).
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
|
||||
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
|
||||
return;
|
||||
}
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
self.listener_pitch = pitch;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
@@ -1576,6 +1651,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: self.master,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -2109,6 +2185,25 @@ impl Audio {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
|
||||
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
|
||||
if let Ok(mut c) = self.backend.cache.lock() {
|
||||
c.insert(
|
||||
key.to_owned(),
|
||||
Some((std::sync::Arc::new(samples), rate, channels)),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn backend_live_for_bench(&mut self) {
|
||||
self.backend.live = true;
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
@@ -2613,6 +2708,25 @@ mod tests {
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
/// A sound of one frame or none, heard or not, looping or not, stops at its first
|
||||
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
|
||||
#[test]
|
||||
fn sounds_too_short_to_mix_stop_even_unheard() {
|
||||
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
|
||||
source.gain = 0.0;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
mixer.channels[&1].state,
|
||||
State::Stopped,
|
||||
"{samples:?} {looping}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -16,6 +16,45 @@ use crate::{
|
||||
#[path = "chat_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// `FilterMask.applyWithFormatting` over a player message's signed content, as
|
||||
/// the packet's mask `(type, bit set)`: as it is when passed through, none when
|
||||
/// fully filtered, and when partly filtered each masked UTF-16 unit becomes a
|
||||
/// `#` in `FILTERED_STYLE` (dark grey, hover "chat.filtered").
|
||||
pub fn filtered_message(
|
||||
content: &str,
|
||||
(kind, bits): &(i32, Vec<i64>),
|
||||
) -> Option<serde_json::Value> {
|
||||
match kind {
|
||||
1 => return None,
|
||||
2 => {}
|
||||
_ => return Some(serde_json::Value::from(content)),
|
||||
}
|
||||
let masked = |i: usize| {
|
||||
bits.get(i / 64)
|
||||
.is_some_and(|w| (*w as u64 >> (i % 64)) & 1 != 0)
|
||||
};
|
||||
let units: Vec<u16> = content.encode_utf16().collect();
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < units.len() {
|
||||
let filtered = masked(start);
|
||||
let end = (start..units.len())
|
||||
.find(|&i| masked(i) != filtered)
|
||||
.unwrap_or(units.len());
|
||||
parts.push(if filtered {
|
||||
serde_json::json!({
|
||||
"text": "#".repeat(end - start),
|
||||
"color": "dark_gray",
|
||||
"hover_event": {"action": "show_text", "value": {"translate": "chat.filtered"}},
|
||||
})
|
||||
} else {
|
||||
serde_json::Value::from(String::from_utf16_lossy(&units[start..end]))
|
||||
});
|
||||
start = end;
|
||||
}
|
||||
Some(serde_json::json!({"text": "", "extra": parts}))
|
||||
}
|
||||
|
||||
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
|
||||
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
|
||||
@@ -1026,3 +1026,43 @@ fn a_compact_repeat_keeps_the_scrolled_view_still() {
|
||||
says(&mut chat, "new");
|
||||
assert_eq!(chat.scroll, 6);
|
||||
}
|
||||
|
||||
/// `FilterMask.applyWithFormatting`: a pass-through mask keeps the message, a
|
||||
/// fully filtered one hides it, and a partial one turns each masked UTF-16 unit
|
||||
/// into a dark grey `#` whose hover reads `chat.filtered`.
|
||||
#[test]
|
||||
fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
let text = |v: &serde_json::Value| crate::component::to_text(v, &mut |s| Text::literal(s));
|
||||
let shown = |content: &str, mask: (i32, Vec<i64>)| {
|
||||
filtered_message(content, &mask).map(|v| text(&v).plain())
|
||||
};
|
||||
assert_eq!(shown("hello", (0, vec![])), Some("hello".to_owned()));
|
||||
assert_eq!(shown("hello", (1, vec![])), None);
|
||||
assert_eq!(shown("hello", (2, vec![0b0110])), Some("h##lo".to_owned()));
|
||||
assert_eq!(
|
||||
shown("hello", (2, vec![0b1_0001])),
|
||||
Some("#ell#".to_owned())
|
||||
);
|
||||
// An emoji is two UTF-16 units, so two bits and two `#`.
|
||||
assert_eq!(
|
||||
shown("a\u{1F600}b", (2, vec![0b0110])),
|
||||
Some("a##b".to_owned())
|
||||
);
|
||||
// Bit 65 is in the second word.
|
||||
let long = "x".repeat(70);
|
||||
let mut expected = long.clone();
|
||||
expected.replace_range(65..66, "#");
|
||||
assert_eq!(shown(&long, (2, vec![0, 0b10])), Some(expected));
|
||||
let masked = text(&filtered_message("hello", &(2, vec![0b0110])).unwrap());
|
||||
let hash = masked.spans.iter().find(|s| s.text == "##").unwrap();
|
||||
assert_eq!(hash.style.color, Some(text::DARK_GRAY));
|
||||
let hover = hash.style.hover.as_ref().unwrap();
|
||||
assert_eq!(hover.spans[0].text, "chat.filtered");
|
||||
assert!(
|
||||
masked
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.text != "##")
|
||||
.all(|s| s.style.color.is_none())
|
||||
);
|
||||
}
|
||||
@@ -121,8 +121,8 @@ impl CommandBlock {
|
||||
condition_met: flag("conditionMet", false),
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
|
||||
/// plain string component.
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is the component
|
||||
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![
|
||||
("Command", Tag::String(self.command.as_str().into())),
|
||||
@@ -135,7 +135,7 @@ impl CommandBlock {
|
||||
if self.track_output
|
||||
&& let Some(last) = &self.last_output
|
||||
{
|
||||
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
|
||||
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
|
||||
@@ -57,6 +57,8 @@ pub enum Item {
|
||||
/// Wait for a button to bind to this action.
|
||||
Bind(&'static str),
|
||||
ResetBinds,
|
||||
/// Opens the radial menu's pages (`radial_editor`).
|
||||
Radial,
|
||||
Info(Info),
|
||||
}
|
||||
|
||||
@@ -106,6 +108,15 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
}
|
||||
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
|
||||
},
|
||||
Opt::PadRadialMode => match pad.radial_mode {
|
||||
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
|
||||
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
|
||||
},
|
||||
Opt::PadRadialStick => match pad.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -123,6 +134,11 @@ fn info(info: Info, text: &'static str) -> Setting {
|
||||
}
|
||||
}
|
||||
|
||||
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
|
||||
const RADIAL_GROUP: &str = "Radial Menu";
|
||||
/// The row that opens the radial menu's pages.
|
||||
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
|
||||
|
||||
/// Shown on a binding's row until it is focused, and in the search.
|
||||
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
|
||||
|
||||
@@ -201,6 +217,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![
|
||||
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
|
||||
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Menus",
|
||||
vec![
|
||||
@@ -231,6 +254,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
(
|
||||
Page::Bindings,
|
||||
vec![
|
||||
(
|
||||
RADIAL_GROUP,
|
||||
vec![Setting {
|
||||
item: Item::Radial,
|
||||
text: "Named pages of eight actions, held open with a button.",
|
||||
}],
|
||||
),
|
||||
("Actions", binds),
|
||||
(
|
||||
"Reset",
|
||||
@@ -437,6 +467,7 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
|
||||
Row::new(item, name, value).about(about)
|
||||
}
|
||||
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
|
||||
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
|
||||
Item::Info(which) => {
|
||||
let (name, value) = context.read(which);
|
||||
Row::new(item, lang::get(name), Value::Read(value)).about(about)
|
||||
@@ -496,6 +527,7 @@ impl Screen for ControllerScreen {
|
||||
s.controller.reset_bindings();
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
|
||||
Item::Bind(_) | Item::Info(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -533,15 +565,21 @@ impl Screen for ControllerScreen {
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
/// This player's controller settings, bindings included, as the screen found them. The
|
||||
/// radial menu's pages stay as they are: their own screen has its own Undo.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
let radial = std::mem::take(&mut s.controller.radial);
|
||||
s.controller = before.controller.clone();
|
||||
s.controller.radial = radial;
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo is there exactly while the controller settings differ from what the screen opened
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
|
||||
/// and nor do the radial menu's pages, which this Undo keeps.
|
||||
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
|
||||
Some(s.controller != before.controller)
|
||||
let mut now = s.controller.clone();
|
||||
now.radial = before.controller.radial.clone();
|
||||
Some(now != before.controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,6 +831,48 @@ mod tests {
|
||||
assert_eq!(arts(&batch, row), 0);
|
||||
}
|
||||
|
||||
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
|
||||
/// and another change's Undo keeps them as edited.
|
||||
#[test]
|
||||
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::General);
|
||||
draw(&mut ui, &s, None);
|
||||
s.controller.radial.remove(1);
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(
|
||||
!ui.changed(),
|
||||
"Undo here keeps the pages: it has nothing to do"
|
||||
);
|
||||
s.controller.invert_y = !s.controller.invert_y;
|
||||
draw(&mut ui, &s, None);
|
||||
assert!(ui.changed());
|
||||
let reached = (0..100).any(|_| {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
ui.focused() == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert_eq!(s.controller.radial.len(), 1);
|
||||
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
|
||||
}
|
||||
|
||||
/// The Bindings page's radial row opens the radial menu's pages.
|
||||
#[test]
|
||||
fn the_radial_row_opens_the_radial_pages() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui(Page::Bindings);
|
||||
let at = ui.point_at(&s, SIZE, Item::Radial);
|
||||
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(
|
||||
outputs,
|
||||
[
|
||||
CLICK,
|
||||
Output::Push(crate::options_ui::Page::ControllerRadial)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
|
||||
let s = Settings::default();
|
||||
@@ -816,8 +896,8 @@ mod tests {
|
||||
let mut s = Settings::default();
|
||||
s.controller.bind(JUMP, Some(Button::North));
|
||||
let mut ui = ui(Page::Bindings);
|
||||
// Search, the three pages, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 2 {
|
||||
// Search, the three pages, the radial menu's row, then the first action.
|
||||
for _ in 0..Page::ALL.len() + 3 {
|
||||
key(&mut ui, &mut s, K::Tab);
|
||||
}
|
||||
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
|
||||
@@ -939,6 +1019,8 @@ mod tests {
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
(Opt::PadRadialMode, 2),
|
||||
(Opt::PadRadialStick, 3),
|
||||
] {
|
||||
let setting = option(opt, "the fixed sentence");
|
||||
let mut seen = Vec::new();
|
||||
@@ -972,6 +1054,8 @@ mod tests {
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
Opt::PadRadialMode,
|
||||
Opt::PadRadialStick,
|
||||
] {
|
||||
for _ in 0..4 {
|
||||
lang::get(choice_description(opt, &settings.controller).unwrap());
|
||||
@@ -990,6 +1074,7 @@ mod tests {
|
||||
}
|
||||
ControllerScreen::default().side();
|
||||
lang::get(TITLE);
|
||||
lang::get(RADIAL_PAGES);
|
||||
for text in [
|
||||
"Press the button for this. %s cancels.",
|
||||
"This button also does: %s",
|
||||
|
||||
@@ -43,6 +43,11 @@ pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The native mod call this thread is currently inside, if any.
|
||||
pub fn current_native_mod() -> Option<NativeModCall> {
|
||||
IN_NATIVE_MOD.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Puts back the call that `enter_native_mod` found.
|
||||
#[must_use]
|
||||
pub struct NativeModGuard {
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
//! 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.
|
||||
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
|
||||
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
|
||||
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
|
||||
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
|
||||
//! vanilla sky. A pack without layers keeps the vanilla sky.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
use glam::Mat4;
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
|
||||
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
|
||||
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>,
|
||||
/// How a layer mixes with what is drawn below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Blend {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Dodge,
|
||||
Burn,
|
||||
Screen,
|
||||
Replace,
|
||||
Overlay,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
@@ -34,62 +41,44 @@ pub struct Draw {
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// What a frame's layers may depend on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Conditions<'a> {
|
||||
/// Ticks of the day, counting the days.
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
/// The biome at the camera, namespaced.
|
||||
pub biome: &'a str,
|
||||
/// The camera's block height.
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
/// The world the layers were last drawn for.
|
||||
pub world: Option<i32>,
|
||||
/// Each layer's eased visibility, 0 to 1.
|
||||
pub eased: Vec<f32>,
|
||||
}
|
||||
|
||||
/// 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(
|
||||
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
|
||||
pub trait SkyLayers: Send + Sync {
|
||||
/// Every layer's texture: [`Draw::image`] indexes it.
|
||||
fn images(&self) -> &[Image];
|
||||
/// Whether `world` has layers.
|
||||
fn has(&self, world: i32) -> bool;
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
|
||||
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
@@ -97,147 +86,30 @@ impl CustomSky {
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
);
|
||||
}
|
||||
|
||||
/// No layers: the vanilla sky.
|
||||
struct NoLayers;
|
||||
|
||||
impl SkyLayers for NoLayers {
|
||||
fn images(&self) -> &[Image] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn has(&self, _: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &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,
|
||||
});
|
||||
}
|
||||
s.world = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
|
||||
crate::pack_hooks::formats()
|
||||
.custom_sky(root)
|
||||
.unwrap_or_else(|| Arc::new(NoLayers))
|
||||
}
|
||||
@@ -305,6 +305,9 @@ pub(crate) struct Colormap {
|
||||
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
|
||||
/// fades over a biome edge.
|
||||
pub(crate) biome_blend: bool,
|
||||
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
|
||||
/// one biome can sample it once per column.
|
||||
pub(crate) column: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
@@ -323,6 +326,18 @@ enum Axis {
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// Whether the axis reads only what stays the same up a column of one biome.
|
||||
fn column(&self) -> bool {
|
||||
use horizon_polytone::expr::Root;
|
||||
let flat = |root: Root, name: &str| match root {
|
||||
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
|
||||
Root::Random => name == "noise",
|
||||
_ => false,
|
||||
};
|
||||
match self {
|
||||
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
|
||||
}
|
||||
}
|
||||
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
|
||||
match self {
|
||||
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
|
||||
@@ -373,6 +388,23 @@ fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
|
||||
}
|
||||
|
||||
impl Colormap {
|
||||
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
|
||||
pub(crate) fn flat(rgb: u32) -> Self {
|
||||
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
Self {
|
||||
x: zero(),
|
||||
y: zero(),
|
||||
triangular: false,
|
||||
rounds: true,
|
||||
biome_blend: false,
|
||||
column: true,
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba: vec![r, g, b, 255],
|
||||
}
|
||||
}
|
||||
|
||||
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
|
||||
/// one, the axes are the biome's temperature and downfall).
|
||||
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
@@ -424,9 +456,11 @@ impl Colormap {
|
||||
axis("x_axis", "o.temperature")?,
|
||||
axis("y_axis", "o.downfall")?,
|
||||
);
|
||||
let column = [&x, &y].iter().all(|axis| axis.column());
|
||||
Some(Self {
|
||||
x,
|
||||
y,
|
||||
column,
|
||||
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
|
||||
triangular: json["triangular"].as_bool() == Some(true),
|
||||
rounds: json["rounds"].as_bool() != Some(false),
|
||||
@@ -535,6 +569,25 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn field(
|
||||
&mut self,
|
||||
root: horizon_polytone::expr::Root,
|
||||
field: horizon_polytone::expr::Field,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
if root != horizon_polytone::expr::Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_polytone::expr::Value::Num(match field {
|
||||
F::X => f64::from(self.at[0]),
|
||||
F::Y => f64::from(self.at[1]),
|
||||
F::Z => f64::from(self.at[2]),
|
||||
F::Temperature => f64::from(self.biome.temperature),
|
||||
F::Downfall => f64::from(self.biome.downfall),
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
//! (No AI, Agent Control) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -145,8 +145,6 @@ pub struct Extras {
|
||||
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
|
||||
/// default: a recording copies the world).
|
||||
pub repro_keys: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
/// Another controller can join P1's world (`splitscreen`, off by default).
|
||||
pub splitscreen: bool,
|
||||
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
|
||||
@@ -233,7 +231,6 @@ impl Default for Extras {
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
repro_keys: false,
|
||||
discord_presence: true,
|
||||
splitscreen: false,
|
||||
splitscreen_two: 0,
|
||||
splitscreen_three: 0,
|
||||
@@ -338,7 +335,6 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"reproKeys" => e.repro_keys = flag(e.repro_keys),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
"splitscreen" => e.splitscreen = flag(e.splitscreen),
|
||||
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
|
||||
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
|
||||
@@ -376,7 +372,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.pregen),
|
||||
bool_text(self.fullbright),
|
||||
@@ -418,7 +414,6 @@ impl Extras {
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.repro_keys),
|
||||
bool_text(self.discord_presence),
|
||||
bool_text(self.splitscreen),
|
||||
self.splitscreen_two,
|
||||
self.splitscreen_three,
|
||||
@@ -482,13 +477,16 @@ impl Extras {
|
||||
true
|
||||
}
|
||||
/// Written only once something differs from the defaults, so a vanilla
|
||||
/// setup gains no file.
|
||||
/// setup gains no file. The settings stores of this file follow these lines
|
||||
/// (`horizon_hooks::settings`).
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let path = dir.join(FILE);
|
||||
if *self == Self::default() && !path.exists() {
|
||||
let mut text = self.serialize();
|
||||
let stores_default = horizon_hooks::settings::engine::append(FILE, &mut text);
|
||||
if *self == Self::default() && stores_default && !path.exists() {
|
||||
return;
|
||||
}
|
||||
let _ = fs::write(path, self.serialize());
|
||||
let _ = fs::write(path, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,8 +608,6 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
|
||||
ReproKeys,
|
||||
/// On the Splitscreen settings page.
|
||||
@@ -688,7 +684,6 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
@@ -721,7 +716,6 @@ impl Item {
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
@@ -765,7 +759,6 @@ impl Item {
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::ReproKeys => "Recording shortcuts",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::PackBrowser => "Resource pack browser",
|
||||
@@ -789,10 +782,9 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
|
||||
| Pregen => {
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
|
||||
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
|
||||
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -841,9 +833,6 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::Splitscreen => {
|
||||
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -924,7 +913,6 @@ impl Item {
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
@@ -983,7 +971,6 @@ impl Item {
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::ReproKeys => e.repro_keys = !e.repro_keys,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
@@ -1101,7 +1088,7 @@ mod tests {
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
|
||||
assert!(!d.hide_block_particles && !d.pregen);
|
||||
assert!(!d.music_page && d.music_off.is_empty());
|
||||
assert!(d.discord_presence && !d.wheel_cycles_settings);
|
||||
assert!(!d.wheel_cycles_settings);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -1140,7 +1127,6 @@ mod tests {
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 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`.
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
|
||||
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
@@ -7,6 +8,7 @@ use crate::{
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
use horizon_hooks::pages::{self as added, Act, Label};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
@@ -31,6 +33,125 @@ pub(crate) enum ExtraRow {
|
||||
/// 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,
|
||||
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
|
||||
Added(usize, usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered, all places.
|
||||
fn pages() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// The sections of this page, by their index among the registered pages.
|
||||
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
|
||||
let pages = pages().iter().copied().enumerate();
|
||||
pages.filter(|(_, page)| page.place() == added::Place::Extras)
|
||||
}
|
||||
|
||||
fn label_text(label: Label) -> String {
|
||||
match label {
|
||||
Label::Key(key) => lang::get(key),
|
||||
Label::Value(text) => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's group named `name`, made at the end if it has none.
|
||||
fn group_named<'a>(
|
||||
groups: &'a mut Vec<Group<ExtraRow>>,
|
||||
name: &'static str,
|
||||
) -> &'a mut Group<ExtraRow> {
|
||||
let at = groups.iter().position(|group| group.name == name);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
&mut groups[at]
|
||||
}
|
||||
|
||||
/// The sections' rows, in the groups their titles and headings name.
|
||||
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
for (page, section) in sections() {
|
||||
let mut rows = Vec::new();
|
||||
section.rows(&mut rows);
|
||||
let mut name = section.title();
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
let id = ExtraRow::Added(page, r);
|
||||
let row = match row {
|
||||
added::Row::Heading(key) => {
|
||||
name = key;
|
||||
continue;
|
||||
}
|
||||
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
|
||||
added::Row::Toggle { label, on } => {
|
||||
Row::new(id, lang::get(label), Value::Switch(on))
|
||||
}
|
||||
added::Row::Cycle { label, value } => {
|
||||
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
|
||||
}
|
||||
added::Row::Slider { label, value, text } => {
|
||||
let position = value.clamp(0.0, 1.0);
|
||||
let text = label_text(text);
|
||||
let value = Value::Slider {
|
||||
position,
|
||||
steps: None,
|
||||
text,
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
}
|
||||
// A row kind newer than this engine.
|
||||
_ => continue,
|
||||
};
|
||||
let row = match section.tooltip(r) {
|
||||
Some(about) => row.about(label_text(about)),
|
||||
None => row,
|
||||
};
|
||||
group_named(groups, name).rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
|
||||
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
|
||||
let Some(section) = pages().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
section.act(r, act);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
|
||||
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
|
||||
fn steps(page: usize, r: usize) -> bool {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(section) = pages().get(page) {
|
||||
section.rows(&mut rows);
|
||||
}
|
||||
matches!(
|
||||
rows.get(r),
|
||||
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
@@ -85,9 +206,14 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
let cosmetics = horizon_cosmetics::enabled();
|
||||
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -107,6 +233,7 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
add_sections(&mut groups);
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
@@ -125,16 +252,29 @@ impl Screen for ExtrasScreen {
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
ExtraRow::Added(page, r) => {
|
||||
act(page, r, if back { Act::PressBack } else { Act::Press })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
|
||||
if let ExtraRow::Added(page, r) = row {
|
||||
act(page, r, Act::Slide(position));
|
||||
}
|
||||
}
|
||||
/// 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)])
|
||||
match row {
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
ExtraRow::Added(page, r) if steps(page, r) => {
|
||||
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,4 +432,146 @@ mod tests {
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
|
||||
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
|
||||
/// number that steps either way, a note and a volume.
|
||||
struct Section {
|
||||
on: std::sync::Mutex<bool>,
|
||||
step: std::sync::Mutex<i32>,
|
||||
volume: std::sync::Mutex<f32>,
|
||||
}
|
||||
impl added::Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.section"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.section.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Heading("test.section.heading"));
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.section.step",
|
||||
value: Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Note("a note".into()));
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.section.volume",
|
||||
value: *self.volume.lock().unwrap(),
|
||||
text: Label::Value(String::new()),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
match (row, act) {
|
||||
(0, Act::Press | Act::PressBack) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page for Horizon Settings: never on Extras.
|
||||
struct Elsewhere;
|
||||
impl added::Page for Elsewhere {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
fn with_section() -> &'static Section {
|
||||
let section: &'static Section = Box::leak(Box::new(Section {
|
||||
on: false.into(),
|
||||
step: 0.into(),
|
||||
volume: 0.5.into(),
|
||||
}));
|
||||
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
|
||||
&Elsewhere as &dyn added::Page,
|
||||
section as &dyn added::Page,
|
||||
]));
|
||||
TEST_PAGES.set(Some(pages));
|
||||
section
|
||||
}
|
||||
|
||||
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
|
||||
/// starts a group after the page's; a page for elsewhere adds nothing.
|
||||
#[test]
|
||||
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
|
||||
with_section();
|
||||
let s = Settings::default();
|
||||
let sheets = ExtrasScreen.sheets(&s);
|
||||
let groups = &sheets[0].groups;
|
||||
let ids = |name: &str| -> Vec<ExtraRow> {
|
||||
let group = groups.iter().find(|g| g.name == name).unwrap();
|
||||
group.rows.iter().map(|r| r.id).collect()
|
||||
};
|
||||
let multiplayer = ids("horizon.extras.header.multiplayer");
|
||||
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
|
||||
assert_eq!(groups.last().unwrap().name, "test.section.heading");
|
||||
let own = [
|
||||
ExtraRow::Added(1, 2),
|
||||
ExtraRow::Added(1, 3),
|
||||
ExtraRow::Added(1, 4),
|
||||
];
|
||||
assert_eq!(ids("test.section.heading"), own);
|
||||
let all = rows_of(&s);
|
||||
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
|
||||
}
|
||||
|
||||
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
|
||||
/// note takes no click; the rows show what the section holds after each.
|
||||
#[test]
|
||||
fn a_sections_rows_act_on_it() {
|
||||
let section = with_section();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let switch = ExtraRow::Added(1, 0);
|
||||
let at = shell.point_at(&s, SIZE, switch);
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(*section.on.lock().unwrap());
|
||||
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
|
||||
assert_eq!(value(&s, switch), Value::Switch(true));
|
||||
let step = ExtraRow::Added(1, 2);
|
||||
let mut screen = ExtrasScreen;
|
||||
screen.press(&mut s, step, true);
|
||||
assert_eq!(value(&s, step), Value::Choice("-1".into()));
|
||||
assert!(screen.wheel_step(&mut s, step, false).is_some());
|
||||
assert_eq!(*section.step.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
|
||||
None
|
||||
);
|
||||
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, note, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
|
||||
assert_eq!(*section.volume.lock().unwrap(), 0.25);
|
||||
}
|
||||
}
|
||||
@@ -231,8 +231,10 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -495,6 +515,7 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -581,7 +602,7 @@ mod tests {
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
let environment = crate::pack_palette::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
@@ -611,9 +632,41 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
environment: crate::pack_palette::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
|
||||
@@ -74,6 +74,7 @@ pub use entity_batch::{
|
||||
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
|
||||
mark_oit,
|
||||
};
|
||||
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
/// Window.getAppropriateLineWidth, in framebuffer pixels.
|
||||
@@ -179,7 +180,7 @@ fn packed_wanted() -> bool {
|
||||
std::env::var("HORIZON_PACKED_QUADS").as_deref() != Ok("0")
|
||||
}
|
||||
|
||||
mod animate;
|
||||
pub(crate) mod animate;
|
||||
mod arena;
|
||||
pub mod capture;
|
||||
pub mod compositor;
|
||||
@@ -189,6 +190,7 @@ mod cull;
|
||||
mod dx12_compiler;
|
||||
mod end_sky;
|
||||
pub mod entity_skin;
|
||||
pub mod external;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod gpu_culling;
|
||||
@@ -201,6 +203,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
|
||||
pub use screenshot_save::Saved as SavedScreenshot;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -236,9 +239,11 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod screenshot_save;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
mod off_page;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
@@ -250,6 +255,7 @@ mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
use off_page::move_off_page;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -726,62 +732,6 @@ impl Part {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Starts moving a page of a large arena far larger than its ranges in use
|
||||
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
|
||||
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
|
||||
@@ -1138,6 +1088,9 @@ pub struct ScreenshotRequest {
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
|
||||
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
|
||||
pub background: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
@@ -1329,7 +1282,7 @@ pub struct Renderer {
|
||||
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 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`).
|
||||
@@ -1467,8 +1420,10 @@ pub struct Renderer {
|
||||
/// Per-frame scratch, kept to avoid allocating every frame.
|
||||
visible: Vec<resident::Visible>,
|
||||
visible_lods: Vec<lod::Visible>,
|
||||
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
|
||||
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
|
||||
shadow_visible: Vec<resident::Visible>,
|
||||
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
|
||||
external: Option<Box<dyn external::ExternalPipeline>>,
|
||||
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
|
||||
origins: Vec<[f32; 4]>,
|
||||
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
|
||||
@@ -1530,6 +1485,8 @@ pub struct Renderer {
|
||||
output_caps: output::OutputCapabilities,
|
||||
/// An output that failed to apply, not tried again until something it needs changes.
|
||||
output_rejected: Option<output_apply::Rejected>,
|
||||
/// Since when Auto keeps HDR on through the display's loss of its report.
|
||||
auto_off_since: Option<std::time::Instant>,
|
||||
/// The output applied (`update_output`), with the frame every pipeline that draws it
|
||||
/// targets: the swapchain's 8-bit format, or `Rgba16Unorm` when the renderer was made with
|
||||
/// HDR output on and Smooth Gradients and the device draws and blends into it, or
|
||||
@@ -1630,6 +1587,9 @@ pub struct Renderer {
|
||||
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>,
|
||||
/// `background` screenshots written since the caller last took them.
|
||||
pub saved_screenshots: Vec<SavedScreenshot>,
|
||||
screenshot_saves: screenshot_save::Saves,
|
||||
/// 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,
|
||||
@@ -4247,16 +4207,13 @@ impl Renderer {
|
||||
CRUMBLING_BLEND,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth
|
||||
// test without writes. The quads lie exactly on the block tops like
|
||||
// vanilla's, facing +Y so looking up from below culls them, but
|
||||
// terrain and shadows are separate draw paths, so their fp32 depth
|
||||
// interpolations disagree by a few dozen ulps of the fragment depth
|
||||
// and the shadow's rim z-fights with the block top (speckle that
|
||||
// re-rolls as the camera drifts a subpixel). A constant bias of 128
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth test without writes.
|
||||
// The quads lie exactly on the block tops like vanilla's, facing +Y so looking up from
|
||||
// below culls them, but terrain and shadows are separate draw paths, so their fp32 depth
|
||||
// interpolations disagree by a few dozen ulps of the fragment depth and the shadow's rim
|
||||
// z-fights with the block top (speckle that re-rolls as the camera drifts a subpixel). A
|
||||
// constant bias of 128 ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline_with(
|
||||
"Entity shadows",
|
||||
Some(wgpu::Face::Back),
|
||||
@@ -5132,6 +5089,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
|
||||
let screenshot_saves = screenshot_save::Saves::new(&device);
|
||||
Ok(Self {
|
||||
display: None,
|
||||
reading: display,
|
||||
@@ -5336,6 +5294,7 @@ impl Renderer {
|
||||
solid_runs: page_runs::PageRuns::default(),
|
||||
phase2_runs: page_runs::PageRuns::default(),
|
||||
shadow_runs: page_runs::PageRuns::default(),
|
||||
external: None,
|
||||
block_entities: Default::default(),
|
||||
block_entity_section_revisions: Default::default(),
|
||||
block_entity_membership_revisions: Default::default(),
|
||||
@@ -5355,6 +5314,7 @@ impl Renderer {
|
||||
emission_stats,
|
||||
output_caps,
|
||||
output_rejected: None,
|
||||
auto_off_since: None,
|
||||
// Nothing is presented as HDR yet: `update_output` applies the settings.
|
||||
output: output::resolve_output(settings, output_caps, display, frame, false),
|
||||
frame_kept_logged: false,
|
||||
@@ -5402,6 +5362,8 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_hdr: None,
|
||||
saved_screenshots: Vec::new(),
|
||||
screenshot_saves,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
@@ -5517,11 +5479,11 @@ impl Renderer {
|
||||
))
|
||||
});
|
||||
}
|
||||
/// 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) {
|
||||
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
|
||||
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
|
||||
/// The custom sky layers the next frame draws (`SkyLayers::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
|
||||
@@ -5716,6 +5678,9 @@ impl Renderer {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
self.depth = Self::depth(&self.device, &self.config);
|
||||
if let Some(pipeline) = &mut self.external {
|
||||
pipeline.resize([width, height]);
|
||||
}
|
||||
}
|
||||
/// Whether sections are drawn as one index list per frame (`flat`).
|
||||
pub fn flat_draws(&self) -> bool {
|
||||
@@ -7094,14 +7059,11 @@ impl Renderer {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.declared_peak(), hdr.black);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
output_apply::surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
pub fn output(&self) -> output::EffectiveOutput {
|
||||
let native = self.native.as_ref().map(|n| n.frame.api);
|
||||
self.output
|
||||
.with_pack(self.shader_pack_kind() == Some("iris"), native)
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -7154,7 +7116,7 @@ impl Renderer {
|
||||
settings,
|
||||
request,
|
||||
self.caps_for(settings),
|
||||
self.reading,
|
||||
output_apply::graced(self.auto_off_since, self.reading, &self.output),
|
||||
self.config.format,
|
||||
)
|
||||
}
|
||||
@@ -7335,12 +7297,19 @@ impl Renderer {
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
/// Sets the external shader pipeline world frames call (`external`), or none.
|
||||
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
|
||||
self.external = pipeline;
|
||||
}
|
||||
/// 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(pipeline) = &mut self.external {
|
||||
pipeline.reset_history();
|
||||
}
|
||||
if let Some((_, Some(post))) = &mut self.pack_post {
|
||||
post.reset_history(&self.device);
|
||||
}
|
||||
@@ -8034,6 +8003,8 @@ impl Renderer {
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
let saved = self.screenshot_saves.finished();
|
||||
self.saved_screenshots.extend(saved);
|
||||
if let Some(request) = self.capture.take() {
|
||||
let result = self.write_capture(&request, frame);
|
||||
self.capture_result = Some(result.map_err(|e| e.to_string()));
|
||||
@@ -8354,8 +8325,12 @@ impl Renderer {
|
||||
t.begin(&self.device, &self.queue);
|
||||
}
|
||||
if self.animated_textures {
|
||||
self.hud
|
||||
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
self.hud.animate(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
frame.ticks,
|
||||
&self.animations,
|
||||
);
|
||||
}
|
||||
self.update_lightmap(
|
||||
&mut encoder,
|
||||
@@ -8374,9 +8349,7 @@ impl Renderer {
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
let hdr_lighting = self.shader_pack_kind().is_none();
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -8671,6 +8644,13 @@ impl Renderer {
|
||||
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
|
||||
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
|
||||
// an Iris-format pack, only those whose shadow can fall into the camera's view.
|
||||
let state = external::FrameState {
|
||||
frame,
|
||||
output: &output,
|
||||
};
|
||||
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
|
||||
pipeline.begin_frame(&state);
|
||||
}
|
||||
let shadow_view = self
|
||||
.pack_post
|
||||
.as_ref()
|
||||
@@ -8682,6 +8662,12 @@ impl Renderer {
|
||||
let native = self.native.as_ref().filter(|_| !frame.title)?;
|
||||
native.shadow.as_ref()?;
|
||||
native.frame.shadow_view(frame)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.external
|
||||
.as_ref()
|
||||
.filter(|_| !frame.title)?
|
||||
.shadow_request(&state)
|
||||
});
|
||||
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
@@ -9293,13 +9279,11 @@ impl Renderer {
|
||||
}
|
||||
// Near water and distant water share one back-to-front draw order.
|
||||
if self.translucent_draws.len() > section_translucent {
|
||||
// Distant water goes after the near chunks', and replaced tiles'
|
||||
// after all: each is a hair behind the one before, so depth leaves
|
||||
// it only where that one has none. Back to front together, a far
|
||||
// tile's water under a near chunk's (one fading in, or brought
|
||||
// into view by a longer render distance) blended twice and looked
|
||||
// opaque. The cost: far water just behind near water shows no
|
||||
// surface of its own there.
|
||||
// Distant water goes after the near chunks', and replaced tiles' after all: each is a
|
||||
// hair behind the one before, so depth leaves it only where that one has none. Back to
|
||||
// front together, a far tile's water under a near chunk's (one fading in, or brought
|
||||
// into view by a longer render distance) blended twice and looked opaque. The cost: far
|
||||
// water just behind near water shows no surface of its own there.
|
||||
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
|
||||
None => 0,
|
||||
Some(tile) if visible_lods[tile].old => 2,
|
||||
@@ -10119,7 +10103,11 @@ impl Renderer {
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
|
||||
// after the world pass, as `GameRenderer.render` hands them the main target's.
|
||||
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
|
||||
let keep_depth = pack_reads_world
|
||||
|| post_reads_depth
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth
|
||||
@@ -12097,24 +12085,18 @@ 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());
|
||||
self.screenshot_hdr = Some(hdr_path);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let hdr = hdr_readback
|
||||
.filter(|_| self.hdr.is_some())
|
||||
.map(|b| (hdr_screenshot_path(&request.path), b));
|
||||
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
|
||||
if request.background {
|
||||
self.screenshot_saves.start(shot, buffer);
|
||||
} else {
|
||||
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
|
||||
self.screenshot_hdr = saved.hdr;
|
||||
self.screenshot_result = Some(saved.result);
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The frame's own waits for the submit thread (its queue writes and passes).
|
||||
@@ -12717,96 +12699,22 @@ impl Renderer {
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
});
|
||||
self.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string())?;
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
if self.hdr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
@@ -12891,61 +12799,6 @@ impl Cull for Frusta {
|
||||
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
|
||||
}
|
||||
}
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The block entities of `sections` whose box, a block wider on every side, comes within the
|
||||
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
|
||||
fn block_entities_within_reach<'a>(
|
||||
@@ -12968,17 +12821,6 @@ fn block_entities_within_reach<'a>(
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -13102,21 +12944,6 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &pack_post::ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
@@ -13143,68 +12970,6 @@ fn facing_runs_from(
|
||||
None => sides.every(part, draw),
|
||||
}
|
||||
}
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[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
|
||||
@@ -13717,6 +13482,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -13724,6 +13490,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
@@ -13732,6 +13499,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
@@ -15422,11 +15190,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
|
||||
/// white level the brightness slider sets), which is HDR output's scale there: SDR
|
||||
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
|
||||
/// the settings' ranges. The OS's figures often come from the display's EDID and may
|
||||
/// run optimistic; the player's own value always wins.
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15438,6 +15204,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
|
||||
// Windows' SDR brightness slider can set white above the reported peak (480 against
|
||||
// a calibrated 380): the highlights then stay at white instead of dropping below it.
|
||||
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
|
||||
peak_guessed: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15473,6 +15240,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
peak_guessed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15500,7 +15268,7 @@ mod shadow_face_tests {
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
let mut solid = block([7.0, 7.0, 7.0]);
|
||||
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
|
||||
let mut layers = Vec::new();
|
||||
@@ -15513,8 +15281,8 @@ mod shadow_face_tests {
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
fn view(every_face: bool) -> external::ShadowView {
|
||||
external::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
@@ -15721,14 +15489,14 @@ mod shadow_caster_tests {
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_render_pipeline::Shadow {
|
||||
let shadow = horizon_shaders::Shadow {
|
||||
distance: 128.0,
|
||||
near,
|
||||
far,
|
||||
..horizon_render_pipeline::Shadow::default()
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
let view = external::ShadowView {
|
||||
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
|
||||
@@ -64,7 +64,7 @@ enum Writer {
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
pub(crate) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
writer: Writer,
|
||||
@@ -86,6 +86,9 @@ struct AnimationModes {
|
||||
day_time: i64,
|
||||
weather: f32,
|
||||
values: std::collections::HashMap<String, f64>,
|
||||
/// The open screen (an identity the game picks) and the ticks it has been open.
|
||||
screen: Option<u64>,
|
||||
screen_time: i64,
|
||||
}
|
||||
|
||||
impl AnimationModes {
|
||||
@@ -116,8 +119,15 @@ impl super::Renderer {
|
||||
day_time: i64,
|
||||
[rain, thunder]: [f32; 2],
|
||||
frame: Option<&crate::environment::Frame>,
|
||||
screen: Option<u64>,
|
||||
) {
|
||||
let modes = &mut self.animations.modes;
|
||||
// `screen_time`: a tick more while the same screen stays open, from 0 on a new one.
|
||||
modes.screen_time = match (screen, modes.screen) {
|
||||
(Some(open), Some(was)) if open == was => modes.screen_time + 1,
|
||||
_ => 0,
|
||||
};
|
||||
modes.screen = screen;
|
||||
modes.day_time = day_time;
|
||||
modes.weather = rain + thunder;
|
||||
modes.values.clear();
|
||||
@@ -133,6 +143,16 @@ impl super::Renderer {
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// What the modes read this frame, for the GUI atlas's sprites (their expressions aren't
|
||||
/// compiled: those run on ticks).
|
||||
pub(crate) fn mode_inputs(&self) -> horizon_assets::animation::ModeInputs<'static> {
|
||||
horizon_assets::animation::ModeInputs {
|
||||
day_time: self.modes.day_time,
|
||||
screen_time: self.modes.screen_time,
|
||||
weather: self.modes.weather,
|
||||
expression: &|_| None,
|
||||
}
|
||||
}
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size. `compute`: whether
|
||||
/// the device says it runs compute shaders writing storage textures.
|
||||
@@ -361,6 +381,7 @@ impl SpriteAnimator {
|
||||
let expression = |source: &str| values.get(source).copied();
|
||||
let inputs = horizon_assets::animation::ModeInputs {
|
||||
day_time: self.modes.day_time,
|
||||
screen_time: self.modes.screen_time,
|
||||
weather: self.modes.weather,
|
||||
expression: &expression,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! Custom sky layers (`crate::custom_sky`) 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};
|
||||
use crate::custom_sky::{Blend, Draw, SkyLayers};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
|
||||
/// the top row, `West`, `North`, `East` below.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Face {
|
||||
Bottom,
|
||||
Top,
|
||||
South,
|
||||
West,
|
||||
North,
|
||||
East,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
const ALL: [Self; 6] = [
|
||||
Self::Bottom,
|
||||
Self::Top,
|
||||
Self::South,
|
||||
Self::West,
|
||||
Self::North,
|
||||
Self::East,
|
||||
];
|
||||
|
||||
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
|
||||
fn cell(self) -> [f32; 4] {
|
||||
let (col, row) = match self {
|
||||
Self::Bottom => (0, 0),
|
||||
Self::Top => (1, 0),
|
||||
Self::South => (2, 0),
|
||||
Self::West => (0, 1),
|
||||
Self::North => (1, 1),
|
||||
Self::East => (2, 1),
|
||||
};
|
||||
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
|
||||
[u, v, u + 1.0 / 3.0, v + 0.5]
|
||||
}
|
||||
|
||||
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
|
||||
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
|
||||
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
|
||||
/// its cell, the bottom face north at the top (the template's `N` marks).
|
||||
fn corners(self) -> [[f32; 3]; 4] {
|
||||
match self {
|
||||
// Seen looking north: west on the left.
|
||||
Self::North => [
|
||||
[-1., 1., -1.],
|
||||
[1., 1., -1.],
|
||||
[1., -1., -1.],
|
||||
[-1., -1., -1.],
|
||||
],
|
||||
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
|
||||
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
|
||||
Self::West => [
|
||||
[-1., 1., 1.],
|
||||
[-1., 1., -1.],
|
||||
[-1., -1., -1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
// Looking up with north toward the cell's bottom edge: east on the right.
|
||||
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
|
||||
// Looking down with north toward the cell's top edge: east on the right.
|
||||
Self::Bottom => [
|
||||
[-1., -1., -1.],
|
||||
[1., -1., -1.],
|
||||
[1., -1., 1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -256,10 +325,15 @@ impl CustomSky {
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
pub(super) fn set_pack(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pack: &dyn SkyLayers,
|
||||
) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images
|
||||
.images()
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
@@ -413,6 +487,38 @@ impl CustomSky {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grid_holds_each_face_once_and_faces_point_out() {
|
||||
let mut cells: Vec<_> = Face::ALL
|
||||
.iter()
|
||||
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
|
||||
.collect();
|
||||
cells.dedup();
|
||||
assert_eq!(cells.len(), 6);
|
||||
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
|
||||
for face in Face::ALL {
|
||||
let c = face.corners();
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
|
||||
let n = [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
let centre: [f32; 3] =
|
||||
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
|
||||
// Seen from inside, u runs right and v down, so u x v points away from the camera.
|
||||
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
|
||||
assert!(dot > 0.0, "{face:?} is mirrored");
|
||||
}
|
||||
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
|
||||
assert_eq!(Face::Top.corners()[2][2], -1.0);
|
||||
assert_eq!(Face::Top.corners()[3][2], -1.0);
|
||||
// The bottom cell's top edge (v0) is north.
|
||||
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
|
||||
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ 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.
|
||||
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
|
||||
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
|
||||
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
|
||||
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
|
||||
//!
|
||||
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
|
||||
//! has one implementer in this workspace, carries no stability promise and is never exposed to
|
||||
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
|
||||
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
|
||||
//! frame, and a pipeline draws into the frame it gives.
|
||||
//!
|
||||
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
|
||||
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
|
||||
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
|
||||
//! visibility.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
|
||||
|
||||
/// What a world frame gives an external pipeline at its points: the frame's own values, and
|
||||
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
|
||||
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
|
||||
pub struct FrameState<'a> {
|
||||
pub frame: &'a Frame<'a>,
|
||||
pub output: &'a EffectiveOutput,
|
||||
}
|
||||
|
||||
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
|
||||
/// title frames). Every method has a default that leaves vanilla's way in place.
|
||||
pub trait ExternalPipeline: Send {
|
||||
/// A world frame starts, before culling.
|
||||
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
|
||||
/// The shadow pass this frame wants; `None` draws none.
|
||||
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
|
||||
None
|
||||
}
|
||||
/// The window's drawable size changed.
|
||||
fn resize(&mut self, _size: [u32; 2]) {}
|
||||
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
|
||||
fn reset_history(&mut self) {}
|
||||
}
|
||||
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
|
||||
/// (`shadow_casters`): a box must meet all of them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ShadowCull {
|
||||
pub light: Frustum,
|
||||
pub casters: Option<[Frustum; 2]>,
|
||||
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
|
||||
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
|
||||
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
|
||||
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
|
||||
pub reach: Option<f32>,
|
||||
}
|
||||
impl ShadowCull {
|
||||
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
|
||||
/// within) `reach`'s cube.
|
||||
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
|
||||
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.reach.is_none_or(|d| {
|
||||
if whole {
|
||||
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
|
||||
} else {
|
||||
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
|
||||
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
|
||||
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
|
||||
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
|
||||
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
|
||||
/// the wedge lose a column of terrain.
|
||||
const SHADOW_CASTER_MARGIN: f32 = 1.125;
|
||||
impl Cull for ShadowCull {
|
||||
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, false)
|
||||
&& self.light.intersects(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
|
||||
}
|
||||
fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
|
||||
self.within_reach(min, max, true)
|
||||
&& self.light.contains(min, max)
|
||||
&& self
|
||||
.casters
|
||||
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
|
||||
}
|
||||
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
|
||||
fn tight(&self) -> bool {
|
||||
self.casters.is_none()
|
||||
}
|
||||
}
|
||||
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
|
||||
/// its near and far planes only with `depth_planes`.
|
||||
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
|
||||
let mut planes = frustum(view.cull);
|
||||
if !view.depth_planes {
|
||||
// `frustum`'s faces at depth 0 and 1 take in everything.
|
||||
planes[4] = Vec4::W;
|
||||
planes[5] = Vec4::W;
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
pub(super) fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
|
||||
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
|
||||
/// view. Its planes are the view's faces turned toward the light, and through each edge between
|
||||
/// such a face and one turned away, the plane along the light; unused ones take in everything.
|
||||
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
|
||||
let faces = frustum(view_proj);
|
||||
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
|
||||
let inverse = view_proj.inverse();
|
||||
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
|
||||
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
|
||||
let corner = |c: [usize; 3]| {
|
||||
inverse.project_point3(Vec3::new(
|
||||
c[0] as f32 * 2.0 - 1.0,
|
||||
c[1] as f32 * 2.0 - 1.0,
|
||||
c[2] as f32,
|
||||
))
|
||||
};
|
||||
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
|
||||
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
|
||||
let mut planes = [Vec4::W; 12];
|
||||
let mut n = 0;
|
||||
for (k, &face) in faces.iter().enumerate() {
|
||||
if toward[k] {
|
||||
planes[n] = face;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for a in &corners {
|
||||
for axis in 0..3 {
|
||||
if a[axis] == 1 {
|
||||
continue;
|
||||
}
|
||||
let mut b = *a;
|
||||
b[axis] = 1;
|
||||
// The edge from a to b lies on the faces of the two other axes.
|
||||
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
|
||||
if toward[f] == toward[g] {
|
||||
continue;
|
||||
}
|
||||
let (p, q) = (corner(*a), corner(b));
|
||||
let normal = (q - p).cross(light);
|
||||
if normal.length_squared() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let normal = normal.normalize();
|
||||
let side = if normal.dot(centre - p) < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// At most six such edges with at most four faces toward the light: 12 planes.
|
||||
if let Some(plane) = planes.get_mut(n) {
|
||||
*plane = (normal * side).extend(-normal.dot(p) * side);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = [0, 6].map(|i| Frustum {
|
||||
planes: planes[i..i + 6].try_into().expect("six planes"),
|
||||
});
|
||||
[a, b]
|
||||
}
|
||||
@@ -185,13 +185,13 @@ pub struct Hdr {
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
/// The HUD and menus' brightness, as a share of paper white: above 1 brighter than it.
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
@@ -956,7 +956,7 @@ impl Hdr {
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[6] = self.ui;
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
@@ -1001,12 +1001,14 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
|
||||
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
|
||||
/// given: an empty mask.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let (w, h, rgba) = horizon_client_input::window::icon_png()
|
||||
.and_then(|png| crate::hud::decode_bytes(png).ok())
|
||||
.unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
@@ -1906,9 +1908,11 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
// Covered: the HUD as drawn, at UI Brightness.
|
||||
(2.0, 0.3, 1.0, 1.0, 0.3),
|
||||
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
|
||||
// UI Brightness above paper white: brighter than drawn.
|
||||
(2.0, 0.3, 1.0, 2.0, 0.3 * 2f32.powf(1.0 / 2.2)),
|
||||
// Added over the world (the text highlight), which it doesn't hide.
|
||||
(0.6, 0.8, 0.0, 1.0, 0.8),
|
||||
// At or below white, at 100%: the HUD's picture as drawn.
|
||||
// At or below white, at paper white: the HUD's picture as drawn.
|
||||
(0.7, 0.45, 0.5, 1.0, 0.45),
|
||||
] {
|
||||
hdr.ui = ui;
|
||||
@@ -1928,6 +1932,50 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
}
|
||||
}
|
||||
|
||||
/// On the frame without the HUD's layer, the HUD at UI Brightness reaches its nits, and
|
||||
/// above the peak stops there, as on the layered frame.
|
||||
#[test]
|
||||
fn the_hud_at_ui_brightness_stops_at_the_peak() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba8Unorm, Output::ScRgb, false, None);
|
||||
(hdr.paper_white, hdr.peak) = (200.0, 300.0);
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Output::ScRgb.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
// UI Brightness (a share of paper white) and the white HUD's nits.
|
||||
for (ui, want) in [(0.5, 100.0), (1.25, 250.0), (2.0, 300.0)] {
|
||||
hdr.ui = ui;
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [0.2, 0.2, 0.2, 1.0]);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
// Opaque white HUD over the world: alpha 1 is full coverage.
|
||||
fill(&mut encoder, &frame, [1.0; 4]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.present(&queue, &mut encoder, &target, true, None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
for c in &got[..3] {
|
||||
assert!(
|
||||
(c * 80.0 - want).abs() < 0.5,
|
||||
"UI {ui}: {got:?}, not {want} nits"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_blur_keeps_the_light_it_blurs() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
|
||||
@@ -338,7 +338,7 @@ pub struct NativeFrame {
|
||||
reflection_sampler: wgpu::Sampler,
|
||||
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
|
||||
pub shadow_map: super::native_shadow::ShadowMap,
|
||||
shadow_camera: Option<horizon_render_pipeline::Shadow>,
|
||||
shadow_camera: Option<horizon_shaders::Shadow>,
|
||||
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
|
||||
/// is drawn and `has_reflection` stays 0.
|
||||
pub reflects: bool,
|
||||
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
|
||||
size: [u32; 2],
|
||||
seconds: f32,
|
||||
index: u32,
|
||||
shadow: Option<&horizon_render_pipeline::Shadow>,
|
||||
shadow: Option<&horizon_shaders::Shadow>,
|
||||
) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_render_pipeline::Shadow;
|
||||
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `move_off_page`, moved out of `gfx.rs` unchanged.
|
||||
use super::*;
|
||||
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
pub(super) fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -142,8 +142,11 @@ pub struct EffectiveOutput {
|
||||
/// Paper white, peak and black level in nits, as HDR output's last pass and its
|
||||
/// metadata take them, while it is on.
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white.
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
|
||||
/// Lighting has nothing to switch (`with_pack`).
|
||||
pub(crate) pack_lights: bool,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
fn default() -> Self {
|
||||
@@ -166,7 +169,22 @@ impl EffectiveOutput {
|
||||
},
|
||||
nits: [203.0, 1000.0, 0.0],
|
||||
ui_brightness: 1.0,
|
||||
pack_lights: false,
|
||||
};
|
||||
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
|
||||
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
|
||||
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
|
||||
/// `Renderer::render`); under the rest Horizon lifts nothing.
|
||||
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
|
||||
let pack_lights = shaders
|
||||
|| native.is_some_and(|api| {
|
||||
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
|
||||
});
|
||||
Self {
|
||||
pack_lights,
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Whether the destination offers HDR output.
|
||||
pub fn hdr_offered(&self) -> bool {
|
||||
self.offered.is_some()
|
||||
@@ -271,7 +289,9 @@ pub(crate) fn resolve_output(
|
||||
settings.peak_nits(reading.luminance) as f32,
|
||||
settings.black_level_nits(),
|
||||
],
|
||||
ui_brightness: settings.hdr_ui_brightness as f32 / 100.0,
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
pack_lights: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +319,7 @@ pub(crate) mod tests {
|
||||
luminance: Some(Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr: system,
|
||||
},
|
||||
@@ -566,6 +587,39 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reaches HDR output as its nits over paper white's: 1 on Auto, whatever
|
||||
/// paper white is; a value set by hand holds its nits, Paper White on Auto or set by hand.
|
||||
#[test]
|
||||
fn ui_brightness_is_its_nits_over_paper_whites() {
|
||||
let (caps, reading) = window(Some(Output::Pq), Some(true));
|
||||
let share = |paper_white, ui_nits, reading| {
|
||||
let settings = Graphics {
|
||||
hdr: HdrMode::On,
|
||||
hdr_paper_white: paper_white,
|
||||
hdr_ui_nits: ui_nits,
|
||||
..Graphics::default()
|
||||
};
|
||||
let frame = resolve_frame(
|
||||
&settings,
|
||||
FrameRequest::default(),
|
||||
caps,
|
||||
reading,
|
||||
Bgra8Unorm,
|
||||
);
|
||||
resolve_output(&settings, caps, reading, frame, true).ui_brightness
|
||||
};
|
||||
// Auto: the report's 400 nits, 203 without one, or the player's 250.
|
||||
let unreported = DisplayReading::default();
|
||||
for (paper_white, reading) in [(0, reading), (0, unreported), (250, reading)] {
|
||||
assert_eq!(share(paper_white, 0, reading), 1.0);
|
||||
}
|
||||
assert_eq!(share(0, 100, reading), 0.25);
|
||||
assert_eq!(share(0, 480, reading), 1.2);
|
||||
assert_eq!(share(0, 406, unreported), 2.0);
|
||||
assert_eq!(share(250, 100, reading), 0.4);
|
||||
assert_eq!(share(250, 480, reading), 1.92);
|
||||
}
|
||||
|
||||
/// Two renderers' states, a window's and an offscreen one's, made and changed in turn:
|
||||
/// neither's offer or report shows in the other's snapshot, and the offscreen one
|
||||
/// stopping its watch (HDR Off) leaves the window's report alone.
|
||||
@@ -616,6 +670,7 @@ pub(crate) mod tests {
|
||||
window_reading.luminance = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
//! changes only values (the luminance, the UI brightness, what the display reports) updates
|
||||
//! them in place. Another encoding or frame is built and the swapchain configured for it before
|
||||
//! the snapshot says so. One that fails is never published as active, and is not tried again
|
||||
//! until the request, the capabilities or the surface change.
|
||||
//! until the request, the capabilities or the surface change. Auto keeps HDR on through a
|
||||
//! moment's loss of the display's report ([`auto_grace`]).
|
||||
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
|
||||
use crate::graphics::HdrMode;
|
||||
use output::{EffectiveOutput, OutputCapabilities};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What an output's resources are made for: the encoding presented and the frame.
|
||||
type Key = (Option<hdr::Output>, output::FrameConfiguration);
|
||||
@@ -101,6 +104,98 @@ pub(super) fn surface_config(
|
||||
config
|
||||
}
|
||||
|
||||
/// How long Auto keeps HDR on once the display stops reporting it. A fixed window that
|
||||
/// coalesces the report's churn as a display wakes or the window moves to another one; not a
|
||||
/// measured sleep/wake time.
|
||||
const AUTO_OFF_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The display's report `update_output` resolves Auto with at `now`. While Auto presents HDR
|
||||
/// and the surface still offers that encoding, a report that turns false or unknown starts
|
||||
/// the timer `since`, once, and HDR stays on at the values last applied until it has held
|
||||
/// for [`AUTO_OFF_DELAY`]; a positive report before then clears it. Off and On, a changed
|
||||
/// offer, and entering HDR take `raw` at once.
|
||||
fn auto_grace(
|
||||
now: Instant,
|
||||
mode: HdrMode,
|
||||
offered: Option<hdr::Output>,
|
||||
raw: DisplayReading,
|
||||
applied: &EffectiveOutput,
|
||||
since: &mut Option<Instant>,
|
||||
) -> DisplayReading {
|
||||
let lost = mode == HdrMode::Auto
|
||||
&& applied.encoding.is_some()
|
||||
&& offered == applied.encoding
|
||||
&& raw.hdr != Some(true);
|
||||
if !lost {
|
||||
if since.take().is_some() && raw.hdr == Some(true) {
|
||||
log::info!("Horizon renderer: the display reports HDR again; HDR stays on");
|
||||
}
|
||||
} else if since.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: the display stopped reporting HDR; Auto keeps it on for {} ms",
|
||||
AUTO_OFF_DELAY.as_millis()
|
||||
);
|
||||
*since = Some(now);
|
||||
} else if grace_over(*since, now) {
|
||||
*since = None;
|
||||
}
|
||||
graced(*since, raw, applied)
|
||||
}
|
||||
|
||||
fn grace_over(since: Option<Instant>, now: Instant) -> bool {
|
||||
since.is_some_and(|since| now.saturating_duration_since(since) >= AUTO_OFF_DELAY)
|
||||
}
|
||||
|
||||
/// What the display reports as the output takes it: during Auto's grace (`since`), HDR at the
|
||||
/// luminance `applied` was resolved with; `raw` otherwise.
|
||||
pub(super) fn graced(
|
||||
since: Option<Instant>,
|
||||
raw: DisplayReading,
|
||||
applied: &EffectiveOutput,
|
||||
) -> DisplayReading {
|
||||
match since {
|
||||
Some(_) => DisplayReading {
|
||||
luminance: applied.reported,
|
||||
hdr: Some(true),
|
||||
},
|
||||
None => raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the output resolves again at `now`: a due poll (`read`, with the surface's
|
||||
/// `offer`; on Wayland, an event) changed `reading` or `caps`, or Auto's grace `since` is
|
||||
/// over. Its end reads the display once more first, however long until the next poll, unless
|
||||
/// a read since the deadline did (the last read is a `DISPLAY_POLL` before the next).
|
||||
fn poll_display(
|
||||
watch: Option<&mut DisplayWatch>,
|
||||
reading: &mut DisplayReading,
|
||||
caps: &mut OutputCapabilities,
|
||||
since: Option<Instant>,
|
||||
now: Instant,
|
||||
read: impl FnOnce() -> DisplayReading,
|
||||
offer: impl FnOnce() -> Option<Option<hdr::Output>>,
|
||||
) -> bool {
|
||||
let over = grace_over(since, now);
|
||||
let Some(watch) = watch else {
|
||||
return over;
|
||||
};
|
||||
if let (Some(since), Some(next)) = (since.filter(|_| over), &mut watch.polled)
|
||||
&& *next < since + AUTO_OFF_DELAY + super::DISPLAY_POLL
|
||||
{
|
||||
*next = now;
|
||||
}
|
||||
let Some(read) = watch.poll(*reading, now, read) else {
|
||||
return over;
|
||||
};
|
||||
observe(reading, caps, read, offer()) || over
|
||||
}
|
||||
|
||||
/// What `surface` offers now (`None` offscreen): Vulkan's offered formats can change with
|
||||
/// desktop HDR or the window's output, and a display can offer the other encoding.
|
||||
fn offer(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Option<Option<hdr::Output>> {
|
||||
Some(hdr::output(&surface?.get_capabilities(adapter)))
|
||||
}
|
||||
|
||||
/// Takes what a due poll read into `reading` and `caps`: the display's report `now`, and what
|
||||
/// the surface offers (`offer`, `None` without a surface). Whether either changed, the offer
|
||||
/// alone included.
|
||||
@@ -219,12 +314,26 @@ impl Renderer {
|
||||
settings: &crate::graphics::Graphics,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
self.watch_hdr(settings.hdr);
|
||||
// Auto's grace ends on a fresh read, also where a settings change comes before the
|
||||
// frame's `display_changed`.
|
||||
if grace_over(self.auto_off_since, now) {
|
||||
self.read_display(now);
|
||||
}
|
||||
self.hold_hdr_back(settings);
|
||||
let reading = auto_grace(
|
||||
now,
|
||||
settings.hdr,
|
||||
self.output_caps.hdr,
|
||||
self.reading,
|
||||
&self.output,
|
||||
&mut self.auto_off_since,
|
||||
);
|
||||
let candidate = output::resolve_output(
|
||||
settings,
|
||||
self.output_caps,
|
||||
self.reading,
|
||||
reading,
|
||||
self.output.frame,
|
||||
self.surface.is_some() || self.offscreen.is_some(),
|
||||
);
|
||||
@@ -269,24 +378,24 @@ impl Renderer {
|
||||
let (watch, now) =
|
||||
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
|
||||
self.display = Some(watch);
|
||||
self.output_caps.hdr = self.surface_offer().unwrap_or(self.output_caps.hdr);
|
||||
let offer = offer(self.surface.as_deref(), &self.adapter);
|
||||
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
|
||||
self.reading = now;
|
||||
self.display_fresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// What the window's surface offers now (`None` offscreen): Vulkan's offered formats can
|
||||
/// change with desktop HDR or the window's output, and a display can offer the other
|
||||
/// encoding.
|
||||
fn surface_offer(&self) -> Option<Option<hdr::Output>> {
|
||||
let surface = self.surface.as_ref()?;
|
||||
Some(hdr::output(&surface.get_capabilities(&self.adapter)))
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
pub(super) fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
|
||||
}
|
||||
|
||||
/// After a new window surface: what it offers, and an output that failed on the old one
|
||||
/// may be tried again. The settings apply again.
|
||||
pub(super) fn surface_recreated(&mut self) {
|
||||
self.output_caps.hdr = self.surface_offer().unwrap_or(self.output_caps.hdr);
|
||||
let offer = offer(self.surface.as_deref(), &self.adapter);
|
||||
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
|
||||
self.display_fresh = true;
|
||||
self.output_rejected = None;
|
||||
}
|
||||
@@ -295,22 +404,28 @@ impl Renderer {
|
||||
/// (another output, the player recalibrating the desktop, desktop HDR offering the other
|
||||
/// encoding): the settings' Auto values and the output follow it then, so apply them
|
||||
/// again. Each due poll reads the surface's capabilities as well, even when the display
|
||||
/// reports the same.
|
||||
/// reports the same. So does the end of Auto's grace (`auto_grace`), with or without a
|
||||
/// poll or an event then.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
super::flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(watch) = self.display.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
self.read_display(Instant::now())
|
||||
}
|
||||
|
||||
/// The display read where a poll is due or Auto's grace is over (`poll_display`).
|
||||
fn read_display(&mut self, now: Instant) -> bool {
|
||||
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
|
||||
let read = || DisplayReading::read(surface, adapter);
|
||||
let Some(now) = watch.poll(self.reading, std::time::Instant::now(), read) else {
|
||||
return false;
|
||||
};
|
||||
let offer = self.surface_offer();
|
||||
observe(&mut self.reading, &mut self.output_caps, now, offer)
|
||||
poll_display(
|
||||
self.display.as_mut(),
|
||||
&mut self.reading,
|
||||
&mut self.output_caps,
|
||||
self.auto_off_since,
|
||||
now,
|
||||
|| DisplayReading::read(surface, adapter),
|
||||
|| offer(surface, adapter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +549,10 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white: 250,
|
||||
peak: 600,
|
||||
peak_guessed: false,
|
||||
});
|
||||
settings.hdr_ui_brightness = 60;
|
||||
// 150 nits under the reported paper white of 250: 0.6 of it.
|
||||
settings.hdr_ui_nits = 150;
|
||||
let mut swapchain = Swapchain::default();
|
||||
let next = candidate(&settings, caps, reading, &applied);
|
||||
let published = commit(&mut swapchain, &applied, next, caps, &mut None);
|
||||
@@ -468,6 +585,7 @@ mod tests {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
let next = candidate(&settings, caps, reading, &published);
|
||||
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
|
||||
@@ -529,4 +647,299 @@ mod tests {
|
||||
commit(&mut swapchain, &pq, next, caps, &mut rejected);
|
||||
assert_eq!(swapchain.asked.len(), 4);
|
||||
}
|
||||
|
||||
/// A window in Auto presenting HDR10 at 400/1000 nits on the layered frame, followed on
|
||||
/// fake time as the game follows a renderer each frame: `display_changed`, then
|
||||
/// `update_output` where it says so.
|
||||
struct Auto {
|
||||
settings: Graphics,
|
||||
start: Instant,
|
||||
watch: DisplayWatch,
|
||||
reading: DisplayReading,
|
||||
caps: OutputCapabilities,
|
||||
applied: EffectiveOutput,
|
||||
since: Option<Instant>,
|
||||
swapchain: Swapchain,
|
||||
/// What the system reports when it is read, and when (ms) it was.
|
||||
report: DisplayReading,
|
||||
reads: Vec<u64>,
|
||||
}
|
||||
impl Auto {
|
||||
/// `polled`: the system is read every `DISPLAY_POLL` from 0, else it sends events
|
||||
/// (`event`), as on Wayland.
|
||||
fn new(polled: bool) -> Self {
|
||||
let settings = Graphics {
|
||||
hdr: HdrMode::Auto,
|
||||
..Graphics::default()
|
||||
};
|
||||
let (caps, reading) = window(Some(Pq), Some(true));
|
||||
let applied = snapshot(&settings, NO_PACK, caps, reading);
|
||||
assert!(applied.encoding == Some(Pq) && applied.frame.hud_layer);
|
||||
let start = Instant::now();
|
||||
Self {
|
||||
settings,
|
||||
start,
|
||||
watch: DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: None,
|
||||
polled: polled.then_some(start),
|
||||
},
|
||||
reading,
|
||||
caps,
|
||||
applied,
|
||||
since: None,
|
||||
swapchain: Swapchain::default(),
|
||||
report: reading,
|
||||
reads: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A frame at `ms`.
|
||||
fn frame(&mut self, ms: u64) {
|
||||
if self.read_display(ms) {
|
||||
self.update(ms);
|
||||
}
|
||||
}
|
||||
/// `Renderer::read_display` at `ms`.
|
||||
fn read_display(&mut self, ms: u64) -> bool {
|
||||
let now = self.start + Duration::from_millis(ms);
|
||||
let (report, offer, reads) = (self.report, Some(self.caps.hdr), &mut self.reads);
|
||||
let read = || {
|
||||
reads.push(ms);
|
||||
report
|
||||
};
|
||||
let (reading, caps) = (&mut self.reading, &mut self.caps);
|
||||
poll_display(
|
||||
Some(&mut self.watch),
|
||||
reading,
|
||||
caps,
|
||||
self.since,
|
||||
now,
|
||||
read,
|
||||
|| offer,
|
||||
)
|
||||
}
|
||||
/// Frames from `from` to `to` ms, every 10 ms.
|
||||
fn frames(&mut self, from: u64, to: u64) {
|
||||
for ms in (from..=to).step_by(10) {
|
||||
self.frame(ms);
|
||||
}
|
||||
}
|
||||
/// What the system reports from `ms` on: HDR `hdr`, with no luminance unless it is
|
||||
/// HDR, at 300/800 nits then.
|
||||
fn reports(&mut self, hdr: Option<bool>, offer: Option<hdr::Output>) {
|
||||
self.report = DisplayReading {
|
||||
luminance: (hdr == Some(true)).then_some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr,
|
||||
};
|
||||
self.caps.hdr = offer;
|
||||
}
|
||||
/// The system's event at `ms`, with what it now `reports`.
|
||||
fn event(&mut self, ms: u64, hdr: Option<bool>, offer: Option<hdr::Output>) {
|
||||
self.reports(hdr, offer);
|
||||
self.reading = self.report;
|
||||
self.update(ms);
|
||||
}
|
||||
/// `update_output` at `ms`, in its order.
|
||||
fn update(&mut self, ms: u64) {
|
||||
let now = self.start + Duration::from_millis(ms);
|
||||
if grace_over(self.since, now) {
|
||||
self.read_display(ms);
|
||||
}
|
||||
let applied = &self.applied;
|
||||
let reading = auto_grace(
|
||||
now,
|
||||
self.settings.hdr,
|
||||
self.caps.hdr,
|
||||
self.reading,
|
||||
applied,
|
||||
&mut self.since,
|
||||
);
|
||||
let next = candidate(&self.settings, self.caps, reading, applied);
|
||||
self.applied = commit(&mut self.swapchain, applied, next, self.caps, &mut None);
|
||||
// While HDR stays on, the frame the game asks for (`Renderer::requested_frame`) is
|
||||
// this one: no new renderer.
|
||||
let reading = graced(self.since, self.reading, &self.applied);
|
||||
let frame = output::resolve_frame(
|
||||
&self.settings,
|
||||
NO_PACK,
|
||||
self.caps,
|
||||
reading,
|
||||
wgpu::TextureFormat::Bgra8UnormSrgb,
|
||||
);
|
||||
assert_eq!(
|
||||
frame == self.applied.frame,
|
||||
self.applied.hdr_on(),
|
||||
"{ms} ms"
|
||||
);
|
||||
}
|
||||
fn presented(&self) -> Vec<Option<hdr::Output>> {
|
||||
self.swapchain.asked.iter().map(|a| a.0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The display's report flickers off and back, false or unknown: HDR stays on, nothing
|
||||
/// is presented again, and the shader `Frame`, what the settings show as active and the
|
||||
/// nits stay as they were applied, even while the report says nothing of them.
|
||||
#[test]
|
||||
fn a_report_that_flickers_off_and_back_swaps_nothing() {
|
||||
for lost in [Some(false), None] {
|
||||
let mut auto = Auto::new(false);
|
||||
let before = auto.applied;
|
||||
auto.event(0, Some(true), Some(Pq));
|
||||
auto.frames(0, 90);
|
||||
let fresh = auto.applied;
|
||||
assert_eq!(fresh.nits, [300.0, 800.0, 0.0], "a positive report applies");
|
||||
auto.event(100, lost, Some(Pq));
|
||||
auto.frames(100, 390);
|
||||
assert_eq!(auto.applied, fresh, "{lost:?}");
|
||||
assert!(auto.applied.requests_hdr(HdrMode::Auto) && auto.applied.display().hdr);
|
||||
auto.event(400, Some(true), Some(Pq));
|
||||
auto.frames(400, 1500);
|
||||
assert_eq!((auto.applied, auto.since), (fresh, None), "{lost:?}");
|
||||
assert!(auto.presented().is_empty(), "{lost:?}");
|
||||
assert_eq!(auto.applied.frame, before.frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// A loss held through 600 ms turns HDR off once, at 500 ms, and on an event-driven
|
||||
/// display with no event then. The same loss reported again doesn't restart the timer.
|
||||
#[test]
|
||||
fn a_loss_held_turns_hdr_off_once_at_the_deadline() {
|
||||
for lost in [Some(false), None] {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(0, lost, Some(Pq));
|
||||
auto.frames(0, 190);
|
||||
auto.event(200, lost, Some(Pq));
|
||||
auto.frames(200, 490);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq), "{lost:?}");
|
||||
auto.frame(500);
|
||||
assert_eq!(auto.applied.encoding, None, "{lost:?}");
|
||||
auto.frames(500, 3000);
|
||||
assert_eq!(auto.presented(), [None], "{lost:?}");
|
||||
assert_eq!(auto.since, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the system is polled, a false read by the poll that starts the timer is read
|
||||
/// again at the deadline, not two seconds after, before it counts: the display came back
|
||||
/// meanwhile, so HDR stays; where it didn't, HDR turns off then. The two-second cadence
|
||||
/// stays otherwise.
|
||||
#[test]
|
||||
fn a_polled_false_is_read_again_at_the_deadline() {
|
||||
for back in [true, false] {
|
||||
let mut auto = Auto::new(true);
|
||||
auto.frames(0, 1990);
|
||||
auto.reports(Some(false), Some(Pq));
|
||||
auto.frame(2000);
|
||||
assert_eq!(
|
||||
(auto.applied.encoding, auto.since.is_some()),
|
||||
(Some(Pq), true)
|
||||
);
|
||||
if back {
|
||||
auto.reports(Some(true), Some(Pq));
|
||||
}
|
||||
auto.frames(2010, 2490);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq), "never before the deadline");
|
||||
auto.frames(2500, 4990);
|
||||
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
|
||||
let after = if back { vec![] } else { vec![None] };
|
||||
assert_eq!(auto.presented(), after, "back {back}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings change at the deadline, before the frame's poll (a slider, F11), reads the
|
||||
/// display before the grace ends, and the frame after reads it no more: the display came
|
||||
/// back meanwhile, so HDR stays; where it didn't, HDR turns off then, once.
|
||||
#[test]
|
||||
fn a_settings_change_at_the_deadline_reads_the_display_first() {
|
||||
for back in [true, false] {
|
||||
let mut auto = Auto::new(true);
|
||||
auto.frames(0, 1990);
|
||||
auto.reports(Some(false), Some(Pq));
|
||||
auto.frame(2000);
|
||||
if back {
|
||||
auto.reports(Some(true), Some(Pq));
|
||||
}
|
||||
auto.frames(2010, 2490);
|
||||
auto.update(2500);
|
||||
assert_eq!(auto.applied.hdr_on(), back, "back {back}");
|
||||
auto.frames(2500, 4990);
|
||||
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
|
||||
let after = if back { vec![] } else { vec![None] };
|
||||
assert_eq!(auto.presented(), after, "back {back}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A positive report clears the timer: a later loss has its own 500 ms.
|
||||
#[test]
|
||||
fn a_positive_report_clears_the_timer() {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.event(300, Some(true), Some(Pq));
|
||||
auto.event(700, Some(false), Some(Pq));
|
||||
auto.frames(700, 1190);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq));
|
||||
auto.frame(1200);
|
||||
assert_eq!(auto.presented(), [None]);
|
||||
}
|
||||
|
||||
/// Off acts at once, and On or Off clear the timer: Auto again starts it anew.
|
||||
#[test]
|
||||
fn off_and_on_take_no_grace() {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.settings.hdr = HdrMode::Off;
|
||||
auto.update(200);
|
||||
assert_eq!((auto.presented(), auto.since), (vec![None], None));
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.settings.hdr = HdrMode::On;
|
||||
auto.update(200);
|
||||
assert_eq!((auto.applied.encoding, auto.since), (Some(Pq), None));
|
||||
auto.settings.hdr = HdrMode::Auto;
|
||||
auto.update(300);
|
||||
auto.frames(300, 790);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq));
|
||||
auto.frame(800);
|
||||
assert_eq!(auto.presented(), [None]);
|
||||
}
|
||||
|
||||
/// The surface no longer offering the encoding presented ends the grace at once: SDR
|
||||
/// without a report, the other encoding with one.
|
||||
#[test]
|
||||
fn an_encoding_no_longer_offered_is_not_kept() {
|
||||
for offer in [None, Some(ScRgb)] {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.event(200, Some(false), offer);
|
||||
assert_eq!((auto.presented(), auto.since), (vec![None], None));
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, None, offer);
|
||||
assert_eq!(auto.presented(), [None], "{offer:?}");
|
||||
}
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.event(200, Some(true), Some(ScRgb));
|
||||
assert_eq!(auto.presented(), [Some(ScRgb)]);
|
||||
}
|
||||
|
||||
/// Two windows' timers run apart.
|
||||
#[test]
|
||||
fn two_renderers_keep_their_own_timer() {
|
||||
let (mut a, mut b) = (Auto::new(false), Auto::new(false));
|
||||
b.start = a.start;
|
||||
a.event(100, Some(false), Some(Pq));
|
||||
b.event(300, Some(false), Some(Pq));
|
||||
a.event(400, Some(true), Some(Pq));
|
||||
for ms in (300..=900).step_by(10) {
|
||||
a.frame(ms);
|
||||
b.frame(ms);
|
||||
assert_eq!(b.applied.hdr_on(), ms < 800, "{ms} ms");
|
||||
}
|
||||
assert_eq!((a.presented(), b.presented()), (vec![], vec![None]));
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{BufferObject, CustomImage};
|
||||
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
|
||||
|
||||
pub(super) struct PackImage {
|
||||
pub(super) def: CustomImage,
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
|
||||
use horizon_shaders::{Group, Post};
|
||||
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -55,6 +54,10 @@ mod sky_disc_blend_tests;
|
||||
#[cfg(test)]
|
||||
mod undrawn_terrain_tests;
|
||||
|
||||
mod image_formats;
|
||||
use image_formats::{image_format, storage_formats_of};
|
||||
#[cfg(test)]
|
||||
mod full_outputs_tests;
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
@@ -889,7 +892,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
|
||||
}
|
||||
|
||||
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
|
||||
let shadow_color = horizon_render_pipeline::shadow_color(name);
|
||||
let shadow_color = horizon_shaders::shadow_color(name);
|
||||
let shadow_depth = match name {
|
||||
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
|
||||
"shadowtex1" | "shadowtex1HW" => Some(1),
|
||||
@@ -908,7 +911,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
};
|
||||
}
|
||||
}
|
||||
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
|
||||
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
|
||||
// colortex buffers are not this pass's.
|
||||
return Source::Dummy;
|
||||
}
|
||||
@@ -922,7 +925,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
horizon_shaders::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1198,31 +1201,7 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
|
||||
Option<(u32, &wgpu::BindGroupLayout)>,
|
||||
) + 'a;
|
||||
|
||||
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
|
||||
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
|
||||
/// terrain is drawn (`shadowDistanceRenderMul`).
|
||||
pub(super) struct ShadowView {
|
||||
pub cull: glam::Mat4,
|
||||
pub light: glam::Vec3,
|
||||
pub distance: Option<f32>,
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
/// Only the sections whose shadow can fall into the camera's view: those the camera's
|
||||
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
|
||||
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
|
||||
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
|
||||
pub view_casters: bool,
|
||||
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
|
||||
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
|
||||
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
|
||||
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
pub(super) use super::external::ShadowView;
|
||||
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
@@ -1579,7 +1558,7 @@ fn bindings(
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_render_pipeline::buffer_index(&t.name)
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
@@ -1826,98 +1805,6 @@ fn bindings(
|
||||
Some((entries, samplers, slots, reads, shadow_reads, images))
|
||||
}
|
||||
|
||||
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
|
||||
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
|
||||
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
|
||||
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
|
||||
for handle in globals {
|
||||
let var = &module.global_variables[handle];
|
||||
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
|
||||
continue;
|
||||
};
|
||||
let naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage { format, access },
|
||||
} = module.types[var.ty].inner
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bound = entries.iter().find_map(|e| match e.ty {
|
||||
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
|
||||
Some(format)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if format == naga::StorageFormat::Rg11b10Ufloat
|
||||
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
|
||||
{
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: module.types[var.ty].name.clone(),
|
||||
inner: naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage {
|
||||
format: naga::StorageFormat::Rgba16Float,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
module.global_variables[handle].ty = ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's format qualifier.
|
||||
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
|
||||
use wgpu::TextureFormat as T;
|
||||
Some(match qualifier {
|
||||
"rgba32f" => T::Rgba32Float,
|
||||
"rgba16f" => T::Rgba16Float,
|
||||
"rg32f" => T::Rg32Float,
|
||||
"rg16f" => T::Rg16Float,
|
||||
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
|
||||
"r32f" => T::R32Float,
|
||||
"r16f" => T::R16Float,
|
||||
"rgba16" => T::Rgba16Unorm,
|
||||
"rgb10_a2" => T::Rgb10a2Unorm,
|
||||
"rgba8" => T::Rgba8Unorm,
|
||||
"rg16" => T::Rg16Unorm,
|
||||
"rg8" => T::Rg8Unorm,
|
||||
"r16" => T::R16Unorm,
|
||||
"r8" => T::R8Unorm,
|
||||
"rgba16_snorm" => T::Rgba16Snorm,
|
||||
"rgba8_snorm" => T::Rgba8Snorm,
|
||||
"rg16_snorm" => T::Rg16Snorm,
|
||||
"rg8_snorm" => T::Rg8Snorm,
|
||||
"r16_snorm" => T::R16Snorm,
|
||||
"r8_snorm" => T::R8Snorm,
|
||||
"rgba32i" => T::Rgba32Sint,
|
||||
"rgba16i" => T::Rgba16Sint,
|
||||
"rgba8i" => T::Rgba8Sint,
|
||||
"rg32i" => T::Rg32Sint,
|
||||
"rg16i" => T::Rg16Sint,
|
||||
"rg8i" => T::Rg8Sint,
|
||||
"r32i" => T::R32Sint,
|
||||
"r16i" => T::R16Sint,
|
||||
"r8i" => T::R8Sint,
|
||||
"rgba32ui" => T::Rgba32Uint,
|
||||
"rgba16ui" => T::Rgba16Uint,
|
||||
"rgb10_a2ui" => T::Rgb10a2Uint,
|
||||
"rgba8ui" => T::Rgba8Uint,
|
||||
"rg32ui" => T::Rg32Uint,
|
||||
"rg16ui" => T::Rg16Uint,
|
||||
"rg8ui" => T::Rg8Uint,
|
||||
"r32ui" => T::R32Uint,
|
||||
"r16ui" => T::R16Uint,
|
||||
"r8ui" => T::R8Uint,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
|
||||
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
|
||||
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
|
||||
@@ -2521,7 +2408,7 @@ impl PackPost {
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
let half_lives = post.config.half_lives;
|
||||
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
|
||||
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
|
||||
if let Some(t) = ticks {
|
||||
notes.push(format!("`{name}` is {t} ticks"));
|
||||
}
|
||||
@@ -2805,10 +2692,21 @@ impl PackPost {
|
||||
.shadow
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
// A GPU that refuses translucent terrain's program with the boats' water patches gets it
|
||||
// without them (`Post::shadow_water_plain`), rather than no translucent shadows at all.
|
||||
let mut plain = post.shadow_water_plain;
|
||||
let shadow_water = post
|
||||
.shadow_water
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let unpatched = shadow_water.is_none().then(|| plain.take()).flatten();
|
||||
let unpatched_name = unpatched.as_ref().map(|p| p.name.clone());
|
||||
let shadow_water = shadow_water.or_else(|| {
|
||||
unpatched
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
});
|
||||
let water_unpatched = unpatched_name.filter(|_| shadow_water.is_some());
|
||||
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
|
||||
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
|
||||
let geometry = PackSky::new(device);
|
||||
@@ -3365,6 +3263,17 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
// Said as what it is, not as a program skipped.
|
||||
if let Some(name) = water_unpatched {
|
||||
let skipped = format!("{name}: skipped: ");
|
||||
if let Some(n) = notes.iter_mut().rev().find(|n| n.starts_with(&skipped)) {
|
||||
*n = format!(
|
||||
"{name}: translucent terrain without the boats' water patches, as the GPU \
|
||||
refused it with them ({})",
|
||||
&n[skipped.len()..]
|
||||
);
|
||||
}
|
||||
}
|
||||
let jobs = (pack.jobs.as_ref())
|
||||
.map(std::cell::RefCell::take)
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! A shadow program writing as many vertex outputs as the GPU takes leaves no room for the boats'
|
||||
//! water patches (`GbuffersInputs::water_patches`, one output more): translucent terrain gets
|
||||
//! the program without them, not no shadow program. The programs here are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn pack(outputs: u32) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let declared: String = (0..outputs)
|
||||
.map(|i| format!("varying vec4 v{i};\n"))
|
||||
.collect();
|
||||
let written: String = (0..outputs).map(|i| format!("v{i}=vec4({i}.0);")).collect();
|
||||
let read: String = (0..outputs).map(|i| format!("+v{i}")).collect();
|
||||
add(
|
||||
"shadow.vsh",
|
||||
&format!(
|
||||
"#version 120\n{declared}void main() {{ gl_Position=vec4(gl_Vertex.xy,0.5,1.0); {written} }}\n"
|
||||
),
|
||||
);
|
||||
add(
|
||||
"shadow.fsh",
|
||||
&format!("#version 120\n{declared}void main() {{ gl_FragData[0]=vec4(0.0){read}; }}\n"),
|
||||
);
|
||||
add(
|
||||
"final.vsh",
|
||||
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
|
||||
);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
|
||||
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-full-outputs",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shadow_program_with_every_output_drops_the_boats_patches_not_its_water() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let geometry = || {
|
||||
Some(Geometry {
|
||||
material_maps: false,
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: true,
|
||||
})
|
||||
};
|
||||
let outputs = gpu.device.limits().max_inter_stage_shader_variables;
|
||||
let post = gpu.build_with_terrain(pack(outputs), geometry());
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
|
||||
assert!(
|
||||
post.notes
|
||||
.iter()
|
||||
.any(|n| n.contains("without the boats' water patches")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
// With room for them, the patches stay and nothing is said.
|
||||
let post = gpu.build_with_terrain(pack(outputs - 1), geometry());
|
||||
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
|
||||
assert!(
|
||||
!post.notes.iter().any(|n| n.contains("water patches")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Image formats: a storage image's format qualifier as wgpu's, and the stand-in a module's
|
||||
//! storage images declare.
|
||||
|
||||
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
|
||||
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
|
||||
pub(super) fn storage_formats_of(
|
||||
module: &mut naga::Module,
|
||||
entries: &[wgpu::BindGroupLayoutEntry],
|
||||
) {
|
||||
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
|
||||
for handle in globals {
|
||||
let var = &module.global_variables[handle];
|
||||
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
|
||||
continue;
|
||||
};
|
||||
let naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage { format, access },
|
||||
} = module.types[var.ty].inner
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bound = entries.iter().find_map(|e| match e.ty {
|
||||
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
|
||||
Some(format)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if format == naga::StorageFormat::Rg11b10Ufloat
|
||||
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
|
||||
{
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: module.types[var.ty].name.clone(),
|
||||
inner: naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage {
|
||||
format: naga::StorageFormat::Rgba16Float,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
module.global_variables[handle].ty = ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's format qualifier.
|
||||
pub(super) fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
|
||||
use wgpu::TextureFormat as T;
|
||||
Some(match qualifier {
|
||||
"rgba32f" => T::Rgba32Float,
|
||||
"rgba16f" => T::Rgba16Float,
|
||||
"rg32f" => T::Rg32Float,
|
||||
"rg16f" => T::Rg16Float,
|
||||
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
|
||||
"r32f" => T::R32Float,
|
||||
"r16f" => T::R16Float,
|
||||
"rgba16" => T::Rgba16Unorm,
|
||||
"rgb10_a2" => T::Rgb10a2Unorm,
|
||||
"rgba8" => T::Rgba8Unorm,
|
||||
"rg16" => T::Rg16Unorm,
|
||||
"rg8" => T::Rg8Unorm,
|
||||
"r16" => T::R16Unorm,
|
||||
"r8" => T::R8Unorm,
|
||||
"rgba16_snorm" => T::Rgba16Snorm,
|
||||
"rgba8_snorm" => T::Rgba8Snorm,
|
||||
"rg16_snorm" => T::Rg16Snorm,
|
||||
"rg8_snorm" => T::Rg8Snorm,
|
||||
"r16_snorm" => T::R16Snorm,
|
||||
"r8_snorm" => T::R8Snorm,
|
||||
"rgba32i" => T::Rgba32Sint,
|
||||
"rgba16i" => T::Rgba16Sint,
|
||||
"rgba8i" => T::Rgba8Sint,
|
||||
"rg32i" => T::Rg32Sint,
|
||||
"rg16i" => T::Rg16Sint,
|
||||
"rg8i" => T::Rg8Sint,
|
||||
"r32i" => T::R32Sint,
|
||||
"r16i" => T::R16Sint,
|
||||
"r8i" => T::R8Sint,
|
||||
"rgba32ui" => T::Rgba32Uint,
|
||||
"rgba16ui" => T::Rgba16Uint,
|
||||
"rgb10_a2ui" => T::Rgb10a2Uint,
|
||||
"rgba8ui" => T::Rgba8Uint,
|
||||
"rg32ui" => T::Rg32Uint,
|
||||
"rg16ui" => T::Rg16Uint,
|
||||
"rg8ui" => T::Rg8Uint,
|
||||
"r32ui" => T::R32Uint,
|
||||
"r16ui" => T::R16Uint,
|
||||
"r8ui" => T::R8Uint,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
|
||||
//! Square, `shadowMapResolution` pixels.
|
||||
|
||||
use horizon_render_pipeline::SHADOW_COLORS;
|
||||
use horizon_shaders::SHADOW_COLORS;
|
||||
|
||||
type Ops<T> = wgpu::Operations<T>;
|
||||
type Color = wgpu::Color;
|
||||
@@ -97,11 +97,7 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
) {
|
||||
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] {
|
||||
@@ -129,7 +125,7 @@ impl ShadowTargets {
|
||||
/// resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
doubled: u16,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
|
||||
//! which of them a program's sampler reads.
|
||||
|
||||
use horizon_render_pipeline::Kind;
|
||||
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
|
||||
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
|
||||
|
||||
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
|
||||
pub(super) enum Image {
|
||||
@@ -119,12 +118,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_render_pipeline::buffer_index(name);
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
let staged = textures.iter().position(|t| {
|
||||
t.stage == stage(group)
|
||||
&& t.dimension == dimension
|
||||
&& (t.name == name
|
||||
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|
||||
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures.iter().position(|t| {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
//! [-w, w], and the depth buffers packs read hold GL window depth.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
|
||||
use horizon_render_pipeline::Shadow;
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::Frame;
|
||||
|
||||
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
|
||||
pub half_lives: [Option<f32>; 4],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
|
||||
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
|
||||
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
|
||||
//! extra, docs/layers.md), the device is opened exactly as before.
|
||||
|
||||
/// The ray query feature.
|
||||
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
|
||||
|
||||
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
|
||||
fn requested() -> bool {
|
||||
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
|
||||
/// build.
|
||||
fn asked(var: Option<&str>) -> bool {
|
||||
cfg!(feature = "horizon-extras") && var == Some("1")
|
||||
}
|
||||
|
||||
/// The features to request on `adapter`: ray queries, when asked for and offered.
|
||||
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let features = pick(requested(), adapter.features());
|
||||
if requested() {
|
||||
let info = adapter.get_info();
|
||||
if features.is_empty() {
|
||||
log::warn!(
|
||||
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
|
||||
queries; it stays off",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Horizon renderer: ray queries on ({} on {:?})",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
|
||||
if requested {
|
||||
offered & RAY_QUERY
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's opt-in to `features`' experimental ones.
|
||||
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
|
||||
if features.contains(RAY_QUERY) {
|
||||
horizon_hdr::experimental_features()
|
||||
} else {
|
||||
wgpu::ExperimentalFeatures::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_an_extras_build_asks_for_ray_tracing() {
|
||||
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
|
||||
assert!(!asked(None));
|
||||
assert!(!asked(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_queries_only_when_asked_and_offered() {
|
||||
let all = wgpu::Features::all();
|
||||
assert_eq!(pick(true, all), RAY_QUERY);
|
||||
assert!(pick(false, all).is_empty());
|
||||
assert!(pick(true, all - RAY_QUERY).is_empty());
|
||||
assert!(!experimental(wgpu::Features::empty()).is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
|
||||
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
|
||||
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
|
||||
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
|
||||
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
|
||||
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a frame's readback holds and where its PNG goes.
|
||||
pub(super) struct Shot {
|
||||
pub path: PathBuf,
|
||||
/// `ScreenshotRequest::size`: the world icon's square.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
|
||||
pub frame: [u32; 2],
|
||||
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
|
||||
pub bgra: bool,
|
||||
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
|
||||
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
}
|
||||
impl Shot {
|
||||
pub fn new(
|
||||
request: &ScreenshotRequest,
|
||||
frame: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: request.path.clone(),
|
||||
size: request.size,
|
||||
frame,
|
||||
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub result: Result<[u32; 2], String>,
|
||||
pub hdr: Option<PathBuf>,
|
||||
}
|
||||
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
|
||||
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
|
||||
let (sender, receiver) = channel();
|
||||
map(buffer, &sender);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &sender);
|
||||
}
|
||||
let waited = device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string());
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
|
||||
write(shot, buffer, mapped)
|
||||
}
|
||||
/// Screenshots being written on their thread.
|
||||
pub(super) struct Saves {
|
||||
device: wgpu::Device,
|
||||
sender: Sender<Saved>,
|
||||
receiver: Receiver<Saved>,
|
||||
in_flight: usize,
|
||||
}
|
||||
impl Saves {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let (sender, receiver) = channel();
|
||||
Self {
|
||||
device: device.clone(),
|
||||
sender,
|
||||
receiver,
|
||||
in_flight: 0,
|
||||
}
|
||||
}
|
||||
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
|
||||
/// PNG on a thread of its own once the GPU has finished the frame.
|
||||
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
|
||||
let (mapped, on_mapped) = channel();
|
||||
map(&buffer, &mapped);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &mapped);
|
||||
}
|
||||
let done = self.sender.clone();
|
||||
let path = shot.path.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Screenshot writer".into())
|
||||
.spawn(move || {
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let ready = wait_mapped(&on_mapped, count);
|
||||
let _ = done.send(write(shot, &buffer, ready));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let _ = self.sender.send(Saved {
|
||||
path,
|
||||
result: Err(format!("no thread to write it: {e}")),
|
||||
hdr: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.in_flight += 1;
|
||||
}
|
||||
/// The screenshots written since the last call. Polls the device so a finished frame's
|
||||
/// mapping reaches its writer: the render thread never waits for it.
|
||||
pub fn finished(&mut self) -> Vec<Saved> {
|
||||
if self.in_flight == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
let saved: Vec<Saved> = self.receiver.try_iter().collect();
|
||||
self.in_flight -= saved.len();
|
||||
saved
|
||||
}
|
||||
}
|
||||
impl Drop for Saves {
|
||||
/// A screenshot taken right before quitting is still written whole.
|
||||
fn drop(&mut self) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while self.in_flight > 0 && std::time::Instant::now() < deadline {
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
log_saved(&saved);
|
||||
self.in_flight -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn log_saved(saved: &Saved) {
|
||||
match &saved.result {
|
||||
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
|
||||
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
|
||||
}
|
||||
}
|
||||
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
|
||||
let mapped = mapped.clone();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = mapped.send(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
}
|
||||
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
|
||||
/// (a lost device) gives up after a while instead of keeping its thread.
|
||||
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
|
||||
for _ in 0..count {
|
||||
mapped
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.map_err(|_| "the frame never came back from the GPU".to_string())??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
|
||||
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
|
||||
let mut hdr_written = None;
|
||||
let result = mapped.and_then(|()| {
|
||||
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
|
||||
let pixels = read_rows(hdr_buffer, shot.frame);
|
||||
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
hdr_written = Some(hdr_path.clone());
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
let pixels = read_rows(buffer, shot.frame)?;
|
||||
write_png(&shot, &pixels)
|
||||
});
|
||||
Saved {
|
||||
path: shot.path,
|
||||
result,
|
||||
hdr: hdr_written,
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
|
||||
/// row; unmaps it.
|
||||
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
|
||||
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
|
||||
let [frame_w, frame_h] = shot.frame;
|
||||
let mut pixels = Vec::with_capacity(frame.len() * 4);
|
||||
for px in frame {
|
||||
let (r, b) = if shot.bgra {
|
||||
(px[2], px[0])
|
||||
} else {
|
||||
(px[0], px[2])
|
||||
};
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match shot.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, frame_w, frame_h, size),
|
||||
),
|
||||
None => (frame_w, frame_h, pixels),
|
||||
};
|
||||
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
|
||||
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.and_then(|mut w| w.write_image_data(&pixels))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_png(
|
||||
path: &std::path::Path,
|
||||
[width, height]: [u32; 2],
|
||||
frame: &[[u8; 4]],
|
||||
) -> Result<(), String> {
|
||||
let pixels = hdr10_to_rgb16(frame);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
|
||||
.and_then(|encoder| encoder.write_header())
|
||||
.and_then(|mut writer| {
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)
|
||||
});
|
||||
written.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_background_save_writes_the_png_off_the_render_thread() {
|
||||
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 dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("shot.png");
|
||||
let [w, h] = [3u32, 2];
|
||||
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
|
||||
let row = padded_row(w) as usize;
|
||||
let mut bytes = vec![0u8; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
|
||||
}
|
||||
}
|
||||
let source = wgpu::util::DeviceExt::create_buffer_init(
|
||||
&device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
},
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
|
||||
queue.submit([encoder.finish()]);
|
||||
let request = ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: true,
|
||||
};
|
||||
let mut saves = Saves::new(&device);
|
||||
saves.start(
|
||||
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
|
||||
buffer,
|
||||
);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let saved = loop {
|
||||
if let Some(saved) = saves.finished().pop() {
|
||||
break saved;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "never written");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(saved.path, path);
|
||||
assert_eq!(saved.result, Ok([w, h]));
|
||||
let decoder =
|
||||
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut out = vec![0; reader.output_buffer_size().unwrap()];
|
||||
reader.next_frame(&mut out).unwrap();
|
||||
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
|
||||
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
mod ray_query;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
|
||||
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
|
||||
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
|
||||
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
|
||||
memory_hints: wgpu::MemoryHints::MemoryUsage,
|
||||
experimental_features: ray_query::experimental(features),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -30,6 +30,22 @@ fn marker_driver(text: &str) -> &str {
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
@@ -52,6 +68,23 @@ pub fn peak_step_of(nits: f32) -> f32 {
|
||||
};
|
||||
step.clamp(0.0, PEAK_STEPS as f32)
|
||||
}
|
||||
/// The steps of Paper White's and UI Brightness's scale: Auto (0), then 10 to 480 nits by 10.
|
||||
pub const NITS_STEPS: u32 = 48;
|
||||
/// Paper White's or UI Brightness's setting at slider position `v`, 0 to 1 ([`NITS_STEPS`]).
|
||||
pub fn nits_at(v: f32) -> u32 {
|
||||
(v * NITS_STEPS as f32).round() as u32 * 10
|
||||
}
|
||||
/// Where a Paper White or UI Brightness setting sits on its slider, 0 to 1.
|
||||
pub fn nits_position(nits: u32) -> f32 {
|
||||
nits as f32 / (NITS_STEPS * 10) as f32
|
||||
}
|
||||
/// A saved Paper White or UI Brightness: Auto (0), or 10 to 480 nits.
|
||||
fn nits_setting(nits: u32) -> u32 {
|
||||
match nits {
|
||||
0 => 0,
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -504,8 +537,6 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
/// The Screenshot Quality choices, times the game's resolution.
|
||||
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -815,14 +846,12 @@ pub struct Graphics {
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit; 0, the default,
|
||||
/// lifts nothing. Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// HDR brightness of the HUD and menus, in nits; 0 is Auto: at paper white
|
||||
/// ([`Graphics::ui_nits`]). The first-person hand stays at the world's.
|
||||
pub hdr_ui_nits: u32,
|
||||
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
|
||||
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
|
||||
pub hdr_lighting: bool,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// The graphics driver whose last start ended before HDR output showed a frame (the
|
||||
/// marker found, [`HDR_MARKER`]): a window on it stays SDR whatever the mode, until the
|
||||
/// mode is changed or the window starts on another driver ([`Graphics::hdr_held`]).
|
||||
@@ -900,9 +929,8 @@ impl Default for Graphics {
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_ui_nits: 0,
|
||||
hdr_lighting: true,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
hdr_failed_start: None,
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
@@ -975,6 +1003,8 @@ impl Graphics {
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut g = Self::default();
|
||||
// UI Brightness: `hdrUiNits`, else an older file's share of Paper White.
|
||||
let (mut ui_nits, mut ui_share) = (None, None);
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
@@ -1064,12 +1094,9 @@ impl Graphics {
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
|
||||
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
"hdrPaperWhite" => g.hdr_paper_white = nits_setting(number(g.hdr_paper_white)),
|
||||
"hdrUiNits" => ui_nits = value.parse().ok().map(nits_setting),
|
||||
"hdrUiBrightness" => ui_share = value.parse::<u32>().ok(),
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
@@ -1077,10 +1104,6 @@ impl Graphics {
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"hdrFailedStart" => g.hdr_failed_start = Some(value.to_owned()),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
@@ -1098,11 +1121,20 @@ impl Graphics {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The share was 25 to 100% of Paper White. 100% is Auto, and so is any share of an
|
||||
// Auto Paper White, whose nits the file doesn't hold; another share of a Paper White set
|
||||
// by hand is its nits, to the nearest step.
|
||||
g.hdr_ui_nits = ui_nits.unwrap_or(match (ui_share, g.hdr_paper_white) {
|
||||
(Some(share), paper) if share < 100 && paper != 0 => {
|
||||
((paper * share.max(25) + 500) / 1000).max(1) * 10
|
||||
}
|
||||
_ => 0,
|
||||
});
|
||||
g
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -1149,9 +1181,8 @@ impl Graphics {
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_ui_nits,
|
||||
bool_text(self.hdr_lighting),
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
bool_text(self.shadow_cache),
|
||||
self.api.name(),
|
||||
@@ -1166,14 +1197,6 @@ impl Graphics {
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
HDR_KEY => self.hdr_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
@@ -1185,6 +1208,13 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The HUD and menus' brightness, in nits: the player's, or on Auto paper white's.
|
||||
pub fn ui_nits(&self, reported: Option<Reported>) -> u32 {
|
||||
match self.hdr_ui_nits {
|
||||
0 => self.paper_white_nits(reported),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system `reported`, else 1000.
|
||||
pub fn peak_nits(&self, reported: Option<Reported>) -> u32 {
|
||||
@@ -1193,6 +1223,15 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// How HDR is set up for the display the system `reported`.
|
||||
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
|
||||
match reported {
|
||||
None => HdrSetup::Unknown,
|
||||
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
|
||||
Some(r) if r.peak_guessed => HdrSetup::Guessed,
|
||||
Some(_) => HdrSetup::Automatic,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits. Not the system's: files from before saved 0 for that
|
||||
/// (Auto), and a reported black would lift every display's shadows by default.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
@@ -1265,6 +1304,83 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// UI Brightness is saved in nits, 0 for Auto. An older file's share of Paper White reads
|
||||
/// as Auto at 100% or with Paper White on Auto, else as that share of it to the nearest step.
|
||||
#[test]
|
||||
fn ui_brightness_round_trips_in_nits_and_reads_older_shares() {
|
||||
for (file, nits) in [
|
||||
("", 0),
|
||||
("hdrUiNits:0\n", 0),
|
||||
("hdrUiNits:160\n", 160),
|
||||
("hdrUiNits:5\n", 10),
|
||||
("hdrUiNits:9000\n", 480),
|
||||
("hdrUiNits:x\n", 0),
|
||||
// Older files: 100% is Auto,
|
||||
("hdrPaperWhite:200\nhdrUiBrightness:100\n", 0),
|
||||
// any share of Paper White's Auto too,
|
||||
("hdrUiBrightness:60\n", 0),
|
||||
("hdrPaperWhite:0\nhdrUiBrightness:25\n", 0),
|
||||
// and another share of a Paper White set by hand is its nits.
|
||||
("hdrPaperWhite:200\nhdrUiBrightness:60\n", 120),
|
||||
("hdrUiBrightness:50\nhdrPaperWhite:203\n", 100),
|
||||
("hdrPaperWhite:250\nhdrUiBrightness:75\n", 190),
|
||||
("hdrPaperWhite:10\nhdrUiBrightness:25\n", 10),
|
||||
("hdrPaperWhite:480\nhdrUiBrightness:10\n", 120),
|
||||
// The new key wins over the old one.
|
||||
(
|
||||
"hdrPaperWhite:200\nhdrUiBrightness:60\nhdrUiNits:300\n",
|
||||
300,
|
||||
),
|
||||
("hdrUiNits:0\nhdrPaperWhite:200\nhdrUiBrightness:60\n", 0),
|
||||
] {
|
||||
let g = Graphics::parse(file);
|
||||
assert_eq!(g.hdr_ui_nits, nits, "{file}");
|
||||
let saved = g.serialize();
|
||||
assert!(!saved.contains("hdrUiBrightness"), "{file}");
|
||||
assert!(saved.contains(&format!("\nhdrUiNits:{nits}\n")), "{file}");
|
||||
assert_eq!(Graphics::parse(&saved), g, "{file}");
|
||||
}
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.ui_nits(None), 203);
|
||||
let reported = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(g.ui_nits(reported), 300);
|
||||
g.hdr_paper_white = 120;
|
||||
assert_eq!(g.ui_nits(reported), 120);
|
||||
g.hdr_ui_nits = 400;
|
||||
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
peak_guessed: false,
|
||||
};
|
||||
let windows = Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: true,
|
||||
};
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
@@ -1430,9 +1546,8 @@ mod tests {
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_ui_nits: 160,
|
||||
hdr_lighting: false,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
hdr_failed_start: Some(
|
||||
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57".into(),
|
||||
),
|
||||
@@ -1574,6 +1689,10 @@ mod tests {
|
||||
Graphics::parse("shaderPack:Pack.zip\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
// The HDR page's old shortcut key loads as nothing, and the next save drops it.
|
||||
let old = Graphics::parse("hdr:true\nhdrKey:key.keyboard.h\nhdrPeak:1000\n");
|
||||
assert_eq!((old.hdr, old.hdr_peak), (HdrMode::On, 1000));
|
||||
assert!(!old.serialize().contains("hdrKey"));
|
||||
assert_eq!(
|
||||
Graphics::parse(
|
||||
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
|
||||
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
|
||||
//! with a thin edge, so the world shows through, flat controls, and the violet
|
||||
//! face on the one main button, in vanilla's font.
|
||||
//! Its colours come from `menu_style`, given at startup (in the official game the design
|
||||
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
|
||||
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
|
||||
//! the one main button, in vanilla's font.
|
||||
use crate::menu_style::{self, SettingsColours};
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,54 +41,91 @@ impl Cost {
|
||||
/// `0xRRGGBB`: the palette's success, warn and danger.
|
||||
pub fn rgb(self) -> u32 {
|
||||
match self {
|
||||
Cost::None => B.ink_soft,
|
||||
Cost::Low | Cost::Saves => B.success,
|
||||
Cost::Medium => B.warn,
|
||||
Cost::High => B.danger,
|
||||
Cost::None => b().ink_soft,
|
||||
Cost::Low | Cost::Saves => b().success,
|
||||
Cost::Medium => b().warn,
|
||||
Cost::High => b().danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
|
||||
const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
}
|
||||
use menu_style::hud_colour as hex;
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
/// Their 1 px edge.
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
/// The open page in the page list.
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
const BUTTON: u32 = hex(B.ink, 20);
|
||||
const BUTTON_HOVER: u32 = hex(B.ink, 41);
|
||||
const TRACK: u32 = hex(B.ink, 64);
|
||||
const HANDLE: u32 = hex(B.ink, 255);
|
||||
fn button_fill() -> u32 {
|
||||
hex(b().ink, 20)
|
||||
}
|
||||
fn button_hover() -> u32 {
|
||||
hex(b().ink, 41)
|
||||
}
|
||||
fn track_fill() -> u32 {
|
||||
hex(b().ink, 64)
|
||||
}
|
||||
fn handle_fill() -> u32 {
|
||||
hex(b().ink, 255)
|
||||
}
|
||||
/// The one main button.
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
/// A tick box's edge when off.
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const ACCENT: u32 = hex(B.accent, 255);
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
/// Vanilla's white text, and its grey for what can't be used now.
|
||||
pub const WHITE: u32 = u32::MAX;
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub const SOFT: u32 = hex(B.ink_soft, 255);
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
|
||||
pub const GREY: u32 = hex(0xaaaaaa, 255);
|
||||
/// A light veil over the backdrop, under the panels.
|
||||
pub const VEIL: u32 = hex(B.page, 77);
|
||||
pub fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
|
||||
/// Sizes in GUI pixels.
|
||||
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
|
||||
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
|
||||
}
|
||||
/// The screen's plate, and the strip under it: dark glass with a thin edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GLASS);
|
||||
ring(batch, atlas, rect, EDGE);
|
||||
fill(batch, atlas, rect, glass());
|
||||
ring(batch, atlas, rect, edge());
|
||||
}
|
||||
/// A group's panel on the plate, its rows on it.
|
||||
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
fill(batch, atlas, rect, group_fill());
|
||||
}
|
||||
/// A sunk well: the search field, the value stepper.
|
||||
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
}
|
||||
/// The scroller: a faint track and its thumb.
|
||||
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
fill(batch, atlas, track, row_rule());
|
||||
fill(batch, atlas, thumb, track_fill());
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
|
||||
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
|
||||
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
|
||||
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
let edge = match (on, live) {
|
||||
(true, true) => ACCENT,
|
||||
(true, false) => MUTED,
|
||||
(false, _) => BOX_EDGE,
|
||||
(true, true) => accent(),
|
||||
(true, false) => muted(),
|
||||
(false, _) => box_edge(),
|
||||
};
|
||||
ring(batch, atlas, rect, edge);
|
||||
if on {
|
||||
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
for (i, c) in row.chars().enumerate() {
|
||||
if c == '#' {
|
||||
let (px, py) = (x + i as f32, y + j as f32);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
|
||||
fill(batch, atlas, [px, py, 1.0, 1.0], color);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +240,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(true, false, true) => BUTTON_HOVER,
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
};
|
||||
fill(batch, atlas, rect, face);
|
||||
if hover && live {
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
@@ -252,7 +294,7 @@ pub fn slider(
|
||||
(hover, live): (bool, bool),
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -263,17 +305,22 @@ pub fn slider(
|
||||
);
|
||||
let line = y + h - 3.0;
|
||||
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
fill(batch, atlas, [x, line, w, 2.0], track_fill());
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x, line, at - x, 2.0],
|
||||
if live { ACCENT } else { MUTED },
|
||||
if live { accent() } else { muted() },
|
||||
);
|
||||
let handle = [at, line - 3.0, 4.0, 8.0];
|
||||
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
handle,
|
||||
if live { handle_fill() } else { muted() },
|
||||
);
|
||||
if hover && live {
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
|
||||
}
|
||||
}
|
||||
/// A value that a press steps through: in a well, between the arrows that say so.
|
||||
@@ -290,13 +337,13 @@ pub fn cycler(
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let arrows = if hover && live { WHITE } else { MUTED };
|
||||
let arrows = if hover && live { WHITE } else { muted() };
|
||||
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
|
||||
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
|
||||
let color = match (live, hover) {
|
||||
(false, _) => MUTED,
|
||||
(false, _) => muted(),
|
||||
(true, true) => WHITE,
|
||||
(true, false) => SOFT,
|
||||
(true, false) => soft(),
|
||||
};
|
||||
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ use crate::{
|
||||
gfx::EffectiveOutput,
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
|
||||
Preset, Upscaling,
|
||||
},
|
||||
graphics_style::Cost,
|
||||
lang,
|
||||
options_ui::{Event, Page as OptionsPage},
|
||||
settings::{Opt, Settings},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
@@ -106,8 +107,6 @@ pub enum H {
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
/// Starts HDR calibration (`crate::hdr_calibration`).
|
||||
HdrCalibrate,
|
||||
HdrPeak,
|
||||
@@ -614,7 +613,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
"How bright the HUD and menus are. Auto: at paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrLighting,
|
||||
@@ -623,14 +622,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Shortcut",
|
||||
vec![h(
|
||||
H::HdrKey,
|
||||
None,
|
||||
"A key that opens this screen from the game. Not bound at first.",
|
||||
)],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -685,28 +676,9 @@ const LOD_STEPS: [u32; 24] = [
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
|
||||
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
|
||||
setup_of(&s.horizon, out.reported.is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
s.horizon.hdr_setup(out.reported)
|
||||
}
|
||||
/// What the HDR page says while HDR output is held back on this window's graphics driver
|
||||
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
|
||||
@@ -724,13 +696,19 @@ fn description(
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
|
||||
@@ -819,14 +797,14 @@ impl H {
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
H::HdrPaperWhite => Some(crate::graphics::nits_position(g.hdr_paper_white)),
|
||||
H::HdrUiBrightness => Some(crate::graphics::nits_position(g.hdr_ui_nits)),
|
||||
// Auto, then the peak's scale.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -838,13 +816,26 @@ impl H {
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::UpscalingSharpness => Some(20),
|
||||
H::HdrPaperWhite => Some(48),
|
||||
H::HdrPaperWhite | H::HdrUiBrightness => Some(crate::graphics::NITS_STEPS),
|
||||
H::HdrPeak => Some(PEAK_POSITIONS),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
H::HdrUiBrightness => Some(15),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// "Paper White: Auto (203)" or "Paper White: 160": a setting in nits, 0 for Auto, which
|
||||
/// shows the nits Auto gives.
|
||||
fn nits_label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
let (g, r) = (&s.horizon, out.reported);
|
||||
let (caption, set, auto) = match self {
|
||||
H::HdrPaperWhite => ("Paper White", g.hdr_paper_white, g.paper_white_nits(r)),
|
||||
H::HdrPeak => ("Peak Brightness", g.hdr_peak, g.peak_nits(r)),
|
||||
_ => ("UI Brightness", g.hdr_ui_nits, g.ui_nits(r)),
|
||||
};
|
||||
match set {
|
||||
0 => labelled(caption, lang::tr("Auto (%s)", &[&auto])),
|
||||
n => labelled(caption, n),
|
||||
}
|
||||
}
|
||||
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
@@ -941,30 +932,13 @@ impl H {
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Unknown | Setup::Guessed => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
lang::tr("Auto (%s)", &[&g.paper_white_nits(out.reported)]),
|
||||
),
|
||||
n => labelled("Paper White", n.to_string()),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s)", &[&g.peak_nits(out.reported)]),
|
||||
),
|
||||
n => labelled("Peak Brightness", n.to_string()),
|
||||
},
|
||||
H::HdrPaperWhite | H::HdrPeak | H::HdrUiBrightness => self.nits_label(s, out),
|
||||
H::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
|
||||
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
|
||||
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
|
||||
H::HdrKey => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
// A press across the row, as Calibrate...: the list it opens shows the pick.
|
||||
@@ -1005,7 +979,8 @@ impl H {
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = crate::graphics::nits_at(v),
|
||||
H::HdrUiBrightness => g.hdr_ui_nits = crate::graphics::nits_at(v),
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
|
||||
0 => 0,
|
||||
@@ -1013,7 +988,6 @@ impl H {
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1136,7 +1110,8 @@ impl Item {
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1167,6 +1142,9 @@ impl Item {
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::HdrLighting) if out.pack_lights => {
|
||||
Some("The shader pack lights the world itself.")
|
||||
}
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
@@ -1189,10 +1167,7 @@ impl Item {
|
||||
fn steps(self, s: &Settings, out: &EffectiveOutput) -> bool {
|
||||
self.switch(s, out).is_none()
|
||||
&& self.slider(s).is_none()
|
||||
&& !matches!(
|
||||
self,
|
||||
Item::Horizon(H::HdrKey | H::HdrCalibrate | H::ShaderPacks)
|
||||
)
|
||||
&& !matches!(self, Item::Horizon(H::HdrCalibrate | H::ShaderPacks))
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings, out: &EffectiveOutput) -> Option<bool> {
|
||||
@@ -1347,12 +1322,6 @@ fn shown(item: Item, s: &Settings) -> bool {
|
||||
}
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
|
||||
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
|
||||
if item == Item::Horizon(H::HdrKey) {
|
||||
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
|
||||
return (lang::get("Shortcut"), Some(key));
|
||||
}
|
||||
shell::name_and_value(&item.label(s, out).plain())
|
||||
}
|
||||
|
||||
@@ -1361,13 +1330,9 @@ fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<Str
|
||||
fn row(setting: Setting, s: &Settings, out: &EffectiveOutput) -> Row<Item> {
|
||||
let item = setting.item;
|
||||
let (name, value) = parts(item, s, out);
|
||||
let value = match (item, item.switch(s, out), item.slider(s)) {
|
||||
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
|
||||
crate::graphics::HDR_KEY,
|
||||
value.unwrap_or_default(),
|
||||
)),
|
||||
(_, Some(on), _) => Value::Switch(on),
|
||||
(_, None, Some(position)) => Value::Slider {
|
||||
let value = match (item.switch(s, out), item.slider(s)) {
|
||||
(Some(on), _) => Value::Switch(on),
|
||||
(None, Some(position)) => Value::Slider {
|
||||
position,
|
||||
steps: item.slider_steps(),
|
||||
text: value.unwrap_or_default(),
|
||||
@@ -1583,7 +1548,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
settings_shell::{Capture, Columns, Hosted, Shell, Target},
|
||||
settings_shell::{Columns, Hosted, Shell, Target},
|
||||
};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
@@ -2049,10 +2014,6 @@ mod tests {
|
||||
precheck.why.as_deref(),
|
||||
Some("Only applies to asynchronous entity culling.")
|
||||
);
|
||||
let key = find(&s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(key.name, "Shortcut");
|
||||
let waits = Capture::Key(crate::graphics::HDR_KEY);
|
||||
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
|
||||
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(calibrate.value, Value::Press);
|
||||
}
|
||||
@@ -2737,7 +2698,7 @@ mod tests {
|
||||
H::HdrPaperWhite => s.horizon.hdr_paper_white,
|
||||
H::HdrPeak => s.horizon.hdr_peak,
|
||||
H::HdrBlackLevel => s.horizon.hdr_black_level,
|
||||
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
|
||||
H::HdrUiBrightness => s.horizon.hdr_ui_nits,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let fresh = || {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! reports.
|
||||
use super::tests::{CHANGED, CLICK, DRAGGED, FB, NONE, SIZE, Ui, offering, press, row, ui};
|
||||
use super::*;
|
||||
use crate::settings_shell::{Capture, Columns, Hosted};
|
||||
use crate::settings_shell::{Columns, Hosted};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
#[test]
|
||||
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
|
||||
use horizon_shader_api::Api;
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrLighting);
|
||||
let lights = Some("The shader pack lights the world itself.");
|
||||
let out = offering(true, None);
|
||||
// No pack running, as with one picked but refused, missing or its mod off: live.
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
|
||||
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
|
||||
lights
|
||||
);
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
|
||||
let float = EffectiveOutput {
|
||||
frame: crate::gfx::FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
..out.frame
|
||||
},
|
||||
..out
|
||||
};
|
||||
assert_eq!(
|
||||
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
|
||||
lights
|
||||
);
|
||||
// HDR's own reasons come first.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &NONE.with_pack(true, None)),
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
);
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(true, None)),
|
||||
Some("Turn HDR on to use it.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let mut s = Settings::default();
|
||||
@@ -64,17 +107,12 @@ fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
|
||||
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
|
||||
ui.release();
|
||||
// Calibrate starts the calibration; the shortcut row waits for a key.
|
||||
// Calibrate starts the calibration.
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(key)),
|
||||
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
|
||||
);
|
||||
assert!(ui.changed());
|
||||
}
|
||||
|
||||
@@ -125,14 +163,125 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
let mut guessed = trusted;
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let rows = |s: &Settings, out: &EffectiveOutput| {
|
||||
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
|
||||
let item = Item::Horizon(h);
|
||||
(item.label(s, out).plain(), description(item, s, out, "-"))
|
||||
})
|
||||
};
|
||||
let calibrate = "Calibrate...";
|
||||
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let none = (
|
||||
calibrate.into(),
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &NONE),
|
||||
[
|
||||
none.clone(),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (203)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &guessed),
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &trusted),
|
||||
[
|
||||
(
|
||||
"Calibrate Anyway...".into(),
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), set_up),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
"Use Automatic Setup".into(),
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
),
|
||||
("Peak Brightness: 800".into(), peak),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
};
|
||||
assert_eq!(rows(&s, &guessed), changed("-"));
|
||||
assert_eq!(rows(&s, &trusted), changed(set_up));
|
||||
assert_eq!(rows(&s, &NONE)[0], none);
|
||||
let mut ui = ui();
|
||||
ui.screen.output = guessed;
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
|
||||
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
|
||||
/// in use, and a value set by hand its own nits, whatever Paper White is. Its slider has Paper
|
||||
/// White's steps: Auto, then 10 to 480 nits by 10.
|
||||
#[test]
|
||||
fn ui_brightness_reads_in_nits_on_paper_whites_steps() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let reporting = offering(true, Some(true));
|
||||
let label = |s: &Settings, out| Item::Horizon(H::HdrUiBrightness).label(s, out).plain();
|
||||
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (400)");
|
||||
assert_eq!(label(&s, &NONE), "UI Brightness: Auto (203)");
|
||||
s.horizon.hdr_paper_white = 250;
|
||||
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (250)");
|
||||
s.horizon.hdr_ui_nits = 160;
|
||||
for paper_white in [0, 80, 250] {
|
||||
s.horizon.hdr_paper_white = paper_white;
|
||||
assert_eq!(label(&s, &reporting), "UI Brightness: 160");
|
||||
}
|
||||
let item = Item::Horizon(H::HdrUiBrightness);
|
||||
assert_eq!(
|
||||
H::HdrUiBrightness.slider_steps(),
|
||||
H::HdrPaperWhite.slider_steps()
|
||||
);
|
||||
H::HdrUiBrightness.set_slider(&mut s, 0.0);
|
||||
let mut reached = vec![s.horizon.hdr_ui_nits];
|
||||
for _ in 0..H::HdrUiBrightness.slider_steps().unwrap() {
|
||||
item.step_slider(&mut s, true, 200.0, &reporting);
|
||||
reached.push(s.horizon.hdr_ui_nits);
|
||||
}
|
||||
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
|
||||
assert_eq!(reached, paper);
|
||||
// Each value sits where Paper White's same value does.
|
||||
for nits in paper {
|
||||
s.horizon.hdr_ui_nits = nits;
|
||||
s.horizon.hdr_paper_white = nits;
|
||||
assert_eq!(item.slider(&s), Item::Horizon(H::HdrPaperWhite).slider(&s));
|
||||
}
|
||||
}
|
||||
|
||||
/// While HDR output is held back on this window's driver, the HDR row says so and keeps
|
||||
|
||||
@@ -206,8 +206,9 @@ fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
var light = lit(f.rgb, w.a, shows, true).light;
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
|
||||
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
|
||||
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
|
||||
return display(light);
|
||||
}
|
||||
|
||||
@@ -275,8 +276,8 @@ fn backdrop(w: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, with
|
||||
// UI Brightness at paper white, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = textureLoad(hud, at, 0).rgb;
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod animate;
|
||||
pub mod cit_glint;
|
||||
mod pack_text;
|
||||
pub mod pictures;
|
||||
@@ -2876,59 +2877,6 @@ impl HudRenderer {
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
|
||||
});
|
||||
}
|
||||
pub fn animate_sprites(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
tick: u64,
|
||||
) {
|
||||
let Some(texture) = &self.atlas_texture else {
|
||||
return;
|
||||
};
|
||||
for (rect, animation, previous) in &mut self.sprite_animations {
|
||||
// `SpriteContents.AnimationState`: the frame from the mcmeta's
|
||||
// list, blended into the next one by the sub-frame's thousandths
|
||||
// when the sprite interpolates.
|
||||
let blend = animation.blend(tick);
|
||||
let state = (blend.frame, blend.next, blend.millis);
|
||||
if state == *previous {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = if blend.millis == 0 {
|
||||
animation.resized_pixels(blend.frame, w, h)
|
||||
} else {
|
||||
let current = animation.resized_pixels(blend.frame, w, h);
|
||||
let next = animation.resized_pixels(blend.next, w, h);
|
||||
let millis = blend.millis;
|
||||
current
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(&a, b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
|
||||
as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (rect.u0 * texture.width() as f32).round() as u32,
|
||||
y: (rect.v0 * texture.height() as f32).round() as u32,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = state;
|
||||
}
|
||||
}
|
||||
/// Stage `patches` into the shared atlas texture.
|
||||
pub fn upload_patches(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The GUI atlas's animated sprites, frame by frame (vanilla's or a Polytone `mode`'s).
|
||||
|
||||
impl super::HudRenderer {
|
||||
/// Shows each animated sprite's frame: its mcmeta's, or what its Polytone `mode` picks.
|
||||
pub(crate) fn animate(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
tick: u64,
|
||||
animator: &crate::gfx::animate::SpriteAnimator,
|
||||
) {
|
||||
let inputs = animator.mode_inputs();
|
||||
let inputs = Some(&inputs);
|
||||
let Some(texture) = &self.atlas_texture else {
|
||||
return;
|
||||
};
|
||||
for (rect, animation, previous) in &mut self.sprite_animations {
|
||||
// `SpriteContents.AnimationState`: the frame from the mcmeta's
|
||||
// list, blended into the next one by the sub-frame's thousandths
|
||||
// when the sprite interpolates; or what a Polytone `mode` picks.
|
||||
let blend = animation.blend_with(tick, inputs);
|
||||
let state = (blend.frame, blend.next, blend.millis);
|
||||
if state == *previous {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = if blend.millis == 0 {
|
||||
animation.resized_pixels(blend.frame, w, h)
|
||||
} else {
|
||||
let current = animation.resized_pixels(blend.frame, w, h);
|
||||
let next = animation.resized_pixels(blend.next, w, h);
|
||||
let millis = blend.millis;
|
||||
current
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(&a, b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
|
||||
as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (rect.u0 * texture.width() as f32).round() as u32,
|
||||
y: (rect.v0 * texture.height() as f32).round() as u32,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
|
||||
//! no motion ever decides what input does.
|
||||
//!
|
||||
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
|
||||
//! of them, at the theme's duration or a shorter one of its own.
|
||||
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
|
||||
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
|
||||
//! own.
|
||||
//!
|
||||
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
|
||||
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
|
||||
@@ -16,7 +17,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! // Half way through: partly faded in, partly grown from 94%.
|
||||
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
|
||||
//! let alpha = motion::fade(open, clock, opened);
|
||||
@@ -26,7 +27,6 @@
|
||||
//! let still = Clock::new(opened, true);
|
||||
//! assert_eq!(motion::fade(open, still, opened), 1.0);
|
||||
//! ```
|
||||
use horizon_theme::Motion;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The time a frame shows, built once a frame by the game.
|
||||
@@ -50,28 +50,20 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and one of the design system's easing curves.
|
||||
/// A duration and an easing curve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
|
||||
/// the same curves.
|
||||
pub const fn new(duration: Duration, motion: Motion) -> Self {
|
||||
Self {
|
||||
duration,
|
||||
curve: motion.curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme motion as it is: its own duration and curve.
|
||||
pub const fn of(motion: Motion) -> Self {
|
||||
Self::new(motion.duration(), motion)
|
||||
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
|
||||
/// curves.
|
||||
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
|
||||
Self { duration, curve }
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -84,7 +76,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -98,6 +90,29 @@ impl Tween {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
|
||||
/// returns y.
|
||||
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 0.0 || t == 1.0 {
|
||||
return t;
|
||||
}
|
||||
let at = |a: f32, b: f32, s: f32| {
|
||||
let u = 1.0 - s;
|
||||
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
|
||||
};
|
||||
let (mut lo, mut hi) = (0f32, 1f32);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if at(x1, x2, mid) < t {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
at(y1, y2, (lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// An alpha from 0 to 1 as something fades in.
|
||||
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
|
||||
tween.progress(clock, since)
|
||||
@@ -145,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
|
||||
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
|
||||
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
|
||||
@@ -160,8 +175,7 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
assert_eq!(fade(tween, still, t0), 1.0);
|
||||
assert_eq!(fade_out(tween, still, t0), 0.0);
|
||||
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
|
||||
@@ -173,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -184,10 +198,25 @@ mod tests {
|
||||
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_the_design_systems() {
|
||||
for motion in horizon_theme::MOTION {
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
assert_eq!(
|
||||
bezier(motion.curve, t),
|
||||
motion.ease(t),
|
||||
"{} at {t}",
|
||||
motion.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_takes_the_short_way_round() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
|
||||
let from = 7.0 * FRAC_PI_4;
|
||||
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
|
||||
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
|
||||
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
|
||||
//! the hotbar at the bottom centre.
|
||||
//!
|
||||
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
|
||||
//! nothing showing it is empty, and every overlay stays exactly where it always was.
|
||||
@@ -34,6 +35,16 @@ pub const GAP: f32 = 2.0;
|
||||
pub enum Keep {
|
||||
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
|
||||
BossBars,
|
||||
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
|
||||
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
|
||||
/// bottom.
|
||||
StatusRows,
|
||||
}
|
||||
impl Keep {
|
||||
/// It sits at the top of the view, not the bottom.
|
||||
fn at_top(self) -> bool {
|
||||
matches!(self, Self::BossBars)
|
||||
}
|
||||
}
|
||||
|
||||
/// A view and the keep-outs its HUD shows this frame.
|
||||
@@ -90,10 +101,20 @@ impl Room {
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.filter(|(keep, _)| keep.at_top())
|
||||
.map(|(_, [_, y, _, h])| y + h)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
|
||||
/// rows' top, or the view's height with none.
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.keeps()
|
||||
.filter(|(keep, _)| !keep.at_top())
|
||||
.map(|(_, [_, y, _, _])| y)
|
||||
.fold(self.height, f32::min)
|
||||
}
|
||||
|
||||
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
|
||||
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
|
||||
/// `GAP` below that keep-out.
|
||||
@@ -134,4 +155,15 @@ mod tests {
|
||||
assert_eq!(bars(36.0).below_top(18.0), 38.0);
|
||||
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
|
||||
let room = Room::new(427.0, 240.0);
|
||||
assert_eq!(room.bottom(), 240.0);
|
||||
// Hearts and armour from y 191 down.
|
||||
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
|
||||
assert_eq!(rows.bottom(), 191.0);
|
||||
assert_eq!(rows.top(), 0.0);
|
||||
assert_eq!(rows.below_top(18.0), 18.0);
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::{colour, fit};
|
||||
use crate::menu_style::{self, OverlayColours};
|
||||
|
||||
/// The overlays' colours (`menu_style`, given at startup).
|
||||
fn overlay() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
|
||||
pub fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
|
||||
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
|
||||
/// The selected tab's fill and text, and the others'.
|
||||
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
|
||||
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
|
||||
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
|
||||
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub fn selected_text() -> u32 {
|
||||
colour(overlay().page, 255)
|
||||
}
|
||||
pub fn tab_fill() -> u32 {
|
||||
colour(overlay().page, 191)
|
||||
}
|
||||
pub fn tab_text() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
|
||||
for tab in &row.tabs {
|
||||
let [tx, ty, tw, th] = tab.area;
|
||||
let (fill, text) = if tab.selected {
|
||||
(SELECTED_FILL, SELECTED_TEXT)
|
||||
(SELECTED_FILL, selected_text())
|
||||
} else {
|
||||
(TAB_FILL, TAB_TEXT)
|
||||
(tab_fill(), tab_text())
|
||||
};
|
||||
let tab_start = batch.vertices.len();
|
||||
// Cut where the edges' fades start, so each fade runs over its own piece.
|
||||
@@ -380,15 +400,16 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -131,6 +131,23 @@ pub enum GuiDraw {
|
||||
|
||||
pub const WHITE: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
|
||||
/// of the HUD begins.
|
||||
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
|
||||
draws
|
||||
.iter()
|
||||
.filter_map(|d| match *d {
|
||||
GuiDraw::Sprite { y, .. }
|
||||
| GuiDraw::AtlasSprite { y, .. }
|
||||
| GuiDraw::Blit { y, .. }
|
||||
| GuiDraw::Text { y, .. }
|
||||
| GuiDraw::Item { y, .. } => Some(y),
|
||||
GuiDraw::Fill { y0, .. } => Some(y0),
|
||||
GuiDraw::Push(_) | GuiDraw::Pop => None,
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
|
||||
GuiDraw::Sprite {
|
||||
sprite: Cow::Borrowed(sprite),
|
||||
|
||||
@@ -583,6 +583,9 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
|
||||
pub const BOSS_BAR_HEIGHT: i32 = 5;
|
||||
/// A boss bar's sprite width, in GUI px; it is centred on the view.
|
||||
pub const BOSS_BAR_WIDTH: i32 = 182;
|
||||
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
|
||||
/// span the same.
|
||||
pub const HOTBAR_HALF_WIDTH: i32 = 91;
|
||||
|
||||
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
|
||||
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
|
||||
|
||||
|
After Width: | Height: | Size: 90 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 142 B |
|
After Width: | Height: | Size: 103 B |
@@ -27,4 +27,29 @@ pub(crate) const SPRITES: &[(&str, &[u8])] = &[
|
||||
"search_background_v2",
|
||||
include_bytes!("jei_recipes/search_background_v2.png"),
|
||||
),
|
||||
("slot", include_bytes!("jei_recipes/slot.png")),
|
||||
(
|
||||
"icons/list_badge",
|
||||
include_bytes!("jei_recipes/icons/list_badge.png"),
|
||||
),
|
||||
(
|
||||
"interactive_ingredient_tooltip_background",
|
||||
include_bytes!("jei_recipes/interactive_ingredient_tooltip_background.png"),
|
||||
),
|
||||
(
|
||||
"recipe_preview_background_v2",
|
||||
include_bytes!("jei_recipes/recipe_preview_background_v2.png"),
|
||||
),
|
||||
(
|
||||
"ingredient_list_slot_background_v2",
|
||||
include_bytes!("jei_recipes/ingredient_list_slot_background_v2.png"),
|
||||
),
|
||||
(
|
||||
"scrollbar_background_v2",
|
||||
include_bytes!("jei_recipes/scrollbar_background_v2.png"),
|
||||
),
|
||||
(
|
||||
"scrollbar_marker_v2",
|
||||
include_bytes!("jei_recipes/scrollbar_marker_v2.png"),
|
||||
),
|
||||
];
|
||||
@@ -67,13 +67,14 @@ mod jei_sprites;
|
||||
pub mod jigsaw_block;
|
||||
pub mod keypad;
|
||||
pub mod terrain_model;
|
||||
pub use horizon_polytone::colors as polytone_colors;
|
||||
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
@@ -88,6 +89,8 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_formats;
|
||||
pub mod pack_hooks;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_palette;
|
||||
pub mod pack_screen;
|
||||
@@ -107,6 +110,8 @@ pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod pvp_hud;
|
||||
pub mod radial_editor;
|
||||
pub mod radial_overlay;
|
||||
pub mod random_source;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
@@ -167,6 +172,7 @@ pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
pub use horizon_client_input::keymapping;
|
||||
pub use horizon_client_input::mouse;
|
||||
pub use horizon_client_input::radial;
|
||||
pub use horizon_client_input::window;
|
||||
pub use horizon_command as command;
|
||||
pub use horizon_items::blocks;
|
||||
|
||||
@@ -2593,8 +2593,7 @@ impl Lod {
|
||||
self.returned_at = std::time::Instant::now();
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
@@ -2680,6 +2679,7 @@ impl Lod {
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
self.sync_sample(key);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
/// A lit choice and its bright edge (the controller dial's sector).
|
||||
pub lit: u32,
|
||||
pub lit_edge: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
lit: 0xffffff,
|
||||
lit_edge: 0xffffff,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -761,6 +761,49 @@ pub struct MeshTables {
|
||||
colormaps: Vec<Arc<crate::environment::Colormap>>,
|
||||
water_colormap: Option<u16>,
|
||||
}
|
||||
/// Vanilla's four biome colour functions (`BiomeColors`) with what the packs replace them by:
|
||||
/// `biome_colors` (a colormap or a flat colour), else the colormap a block modifier gives
|
||||
/// `grass_block`, `oak_leaves` or `leaf_litter`, or a fluid modifier gives water, which Polytone
|
||||
/// spreads to every block of that function. Each with the tints that read it.
|
||||
fn biome_functions(
|
||||
pack_root: &std::path::Path,
|
||||
modifiers: &[(bool, horizon_polytone::Targets, String)],
|
||||
) -> Vec<(&'static [Tint], crate::environment::Colormap)> {
|
||||
use crate::environment::Colormap;
|
||||
use horizon_polytone::colors::BiomeColor;
|
||||
const FUNCTIONS: [(&str, &str, &[Tint]); 4] = [
|
||||
(
|
||||
"grass",
|
||||
"minecraft:grass_block",
|
||||
&[
|
||||
Tint::Grass,
|
||||
Tint::GrassBlock,
|
||||
Tint::TallGrassUpper,
|
||||
Tint::SugarCane,
|
||||
],
|
||||
),
|
||||
("foliage", "minecraft:oak_leaves", &[Tint::Foliage]),
|
||||
("dry_foliage", "minecraft:leaf_litter", &[Tint::DryFoliage]),
|
||||
("water", "minecraft:water", &[Tint::Water]),
|
||||
];
|
||||
let colors = horizon_polytone::colors::PackColors::read(pack_root);
|
||||
FUNCTIONS
|
||||
.into_iter()
|
||||
.filter_map(|(function, block, tints)| {
|
||||
let colormap = match colors.biome_color(function) {
|
||||
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
|
||||
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
|
||||
None => modifiers
|
||||
.iter()
|
||||
.find(|(fluid, targets, _)| {
|
||||
*fluid == (function == "water") && targets.matches(block, |_| false)
|
||||
})
|
||||
.and_then(|(_, _, id)| Colormap::load(pack_root, id)),
|
||||
}?;
|
||||
Some((tints, colormap))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
@@ -945,7 +988,8 @@ impl MeshTables {
|
||||
}
|
||||
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
|
||||
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent.
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent. Vanilla's
|
||||
/// four biome colour functions take the packs' `biome_colors` first (wiki "Custom Colors").
|
||||
#[must_use]
|
||||
pub fn with_polytone_colormaps(
|
||||
mut self,
|
||||
@@ -953,7 +997,8 @@ impl MeshTables {
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> Self {
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
|
||||
if modifiers.is_empty() {
|
||||
let biome_functions = biome_functions(pack_root, &modifiers);
|
||||
if modifiers.is_empty() && biome_functions.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
|
||||
@@ -964,7 +1009,24 @@ impl MeshTables {
|
||||
Some((colormaps.len() - 1) as u16)
|
||||
})
|
||||
};
|
||||
for (tints, colormap) in biome_functions {
|
||||
self.colormaps.push(Arc::new(colormap));
|
||||
let i = Some((self.colormaps.len() - 1) as u16);
|
||||
if tints == [Tint::Water] {
|
||||
self.water_colormap = i;
|
||||
}
|
||||
for info in &mut self.info {
|
||||
let own = &info.tints[..usize::from(info.tint_count).min(2)];
|
||||
if own.iter().any(|t| tints.contains(t)) {
|
||||
info.colormap = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
let water_function = self.water_colormap;
|
||||
for (fluid, targets, id) in &modifiers {
|
||||
if *fluid && water_function.is_some() {
|
||||
continue;
|
||||
}
|
||||
if *fluid {
|
||||
if targets.matches("minecraft:water", |_| false) {
|
||||
self.water_colormap = index(id, &mut self.colormaps);
|
||||
@@ -1495,9 +1557,9 @@ struct Tints<'a> {
|
||||
/// by colormap and section-local block.
|
||||
colormaps: &'a [Arc<crate::environment::Colormap>],
|
||||
water_colormap: Option<u16>,
|
||||
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
sampled: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
/// Each colormap's own colour by block, before the blend averages them.
|
||||
cells: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
cells: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1518,8 +1580,8 @@ impl<'a> Tints<'a> {
|
||||
uniform: None,
|
||||
colormaps: &[],
|
||||
water_colormap: None,
|
||||
sampled: std::collections::HashMap::new(),
|
||||
cells: std::collections::HashMap::new(),
|
||||
sampled: Default::default(),
|
||||
cells: Default::default(),
|
||||
}
|
||||
}
|
||||
/// With the packs' colormaps of `tables`.
|
||||
@@ -1531,10 +1593,16 @@ impl<'a> Tints<'a> {
|
||||
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
|
||||
/// is transparent.
|
||||
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
let colormap = self.colormaps.get(usize::from(index))?.clone();
|
||||
// A colormap that ignores the height, over one biome: one colour per column.
|
||||
let y = if colormap.column && self.uniform_biome().is_some() {
|
||||
0
|
||||
} else {
|
||||
y
|
||||
};
|
||||
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
|
||||
return known;
|
||||
}
|
||||
let colormap = self.colormaps.get(usize::from(index))?.clone();
|
||||
// ClientLevel.calculateBlockTint: the mean of the (2r+1)^2 colours around the block, each
|
||||
// channel summed and divided. A transparent pixel in the square leaves the block's own.
|
||||
let r = if colormap.biome_blend { self.radius } else { 0 };
|
||||
@@ -1573,7 +1641,11 @@ impl<'a> Tints<'a> {
|
||||
if let Some(&known) = self.cells.get(&(index, at)) {
|
||||
return known;
|
||||
}
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at);
|
||||
// One biome over the section and its blend margin: no zoom per block.
|
||||
let id = match self.uniform_biome() {
|
||||
Some(biome) => biome,
|
||||
None => Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at),
|
||||
};
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let block = [cx * 16 + at[0], base_y + at[1], cz * 16 + at[2]];
|
||||
let mut host = crate::environment::BlockHost::new(block, self.biomes.get(id));
|
||||
|
||||
@@ -251,3 +251,117 @@ fn a_biome_colormap_blends_over_a_biome_edge_as_the_vanilla_water_tint_does() {
|
||||
}
|
||||
assert!(edge_differs, "the square must cross the edge somewhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biome_colors_and_a_grass_block_colormap_replace_their_whole_biome_function() {
|
||||
let biomes = Biomes::from_jsons(&[(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
)]);
|
||||
// biome_colors gives water a flat green; a block modifier colours oak leaves with a map,
|
||||
// which Polytone spreads to the whole foliage function.
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colors.json",
|
||||
br#"{"biome_colors": {"water": "0x00ff00"}}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/leaves.png",
|
||||
png_row(&[[0, 0, 255, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/block_modifiers/oak.json",
|
||||
br#"{"targets": ["minecraft:oak_leaves"], "colormap": "mypack:leaves"}"#.to_vec(),
|
||||
),
|
||||
]);
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(&root);
|
||||
let functions = biome_functions(&root, &modifiers);
|
||||
let tints: Vec<_> = functions.iter().map(|(t, _)| *t).collect();
|
||||
assert_eq!(tints.len(), 2);
|
||||
assert!(tints[0] == [Tint::Foliage] && tints[1] == [Tint::Water]);
|
||||
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| Arc::new(c)).collect();
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
tints.water_colormap = Some(1);
|
||||
assert_eq!(tints.water(3, 4, 5), 0x00_FF00);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_colormap_that_ignores_the_height_is_shared_up_a_column() {
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/flat.png",
|
||||
png_row(&[[1, 2, 3, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/flat.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": "r.noise(o.x/60, o.z/60)"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/tall.png",
|
||||
png_row(&[[1, 2, 3, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/tall.json",
|
||||
br#"{"y_axis": "y_level"}"#.to_vec(),
|
||||
),
|
||||
]);
|
||||
let load = |id| crate::environment::Colormap::load(&root, id).unwrap();
|
||||
assert!(load("mypack:flat").column);
|
||||
assert!(!load("mypack:tall").column);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// Times a section's colormap tints with the freepack's kind of grass colormap (a biome id
|
||||
/// column and a noise row) over the default Biome Blend: `cargo test --release ... -- --ignored
|
||||
/// --nocapture colormap_tint_cost`.
|
||||
#[test]
|
||||
#[ignore = "timing, not a check"]
|
||||
fn colormap_tint_cost() {
|
||||
const RADIUS: i32 = 2;
|
||||
const LAYERS: i32 = 16;
|
||||
let biomes = Biomes::from_jsons(&[(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
)]);
|
||||
let pixels: Vec<[u8; 4]> = (0..16).map(|i| [i * 16, 200, 64, 255]).collect();
|
||||
let root = pack(&[
|
||||
("assets/mypack/polytone/colormaps/grass.png", png_row(&pixels)),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/grass.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": "0.5+(r.noise(o.x/60,5+o.z/60)*0.55)", "biome_id_mapper": {"minecraft:plains": 1, "texture_size": 16}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
]);
|
||||
let colormaps = vec![Arc::new(
|
||||
crate::environment::Colormap::load(&root, "mypack:grass").unwrap(),
|
||||
)];
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let rounds = 50;
|
||||
let start = std::time::Instant::now();
|
||||
let mut check = 0u64;
|
||||
for _ in 0..rounds {
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), RADIUS, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
for y in 0..LAYERS {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
check += u64::from(tints.colormap(0, x, y, z).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let per = start.elapsed() / rounds;
|
||||
println!("{LAYERS} layers of 256 blended colormap tints: {per:?} (checksum {check})");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
|
||||
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
|
||||
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
|
||||
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
|
||||
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
|
||||
/// (native mods keep their share of the table). A pack without such files adds nothing.
|
||||
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -204,7 +203,7 @@ mod tests {
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_e", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
@@ -215,9 +214,6 @@ mod tests {
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
@@ -234,7 +230,7 @@ mod tests {
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_glow", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
mod shell_pages;
|
||||
|
||||
fn tr(key: &str) -> Text {
|
||||
Text::translate(key, &[])
|
||||
}
|
||||
@@ -322,6 +324,8 @@ impl Opt {
|
||||
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::PadRadialMode => "horizon.options.radial_mode",
|
||||
Opt::PadRadialStick => "horizon.options.radial_stick",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
"options.modelPart.jacket",
|
||||
@@ -339,6 +343,7 @@ impl Opt {
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
Opt::Page(page, row) => pages::slider_caption(page, row),
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -402,6 +407,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
Opt::Page(page, row) => pages::slider_kind(page, row),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -878,6 +884,8 @@ impl Opt {
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
|
||||
Opt::Page(page, row) => pages::slider_text(page, row),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
@@ -952,6 +960,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
Opt::Page(page, row) => pages::slide(page, row, v),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1197,6 +1206,7 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.keyboard = all[usize::from(next(i, 3))];
|
||||
}
|
||||
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1331,6 +1341,8 @@ pub enum Page {
|
||||
ModProject,
|
||||
/// A native mod's settings page: its index in `mods.installed`.
|
||||
NativeMod(usize),
|
||||
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
|
||||
Added(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
|
||||
@@ -1346,6 +1358,11 @@ pub enum Page {
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
|
||||
ControllerRadial,
|
||||
ControllerRadialName,
|
||||
/// The list of every action for one of its slots.
|
||||
ControllerRadialPick,
|
||||
Extras,
|
||||
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
|
||||
Pvp,
|
||||
@@ -1390,6 +1407,8 @@ enum Act {
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
NativeButton(usize, usize),
|
||||
/// A registered page's row: (page, row).
|
||||
PageRow(usize, usize),
|
||||
OpenModFolder,
|
||||
/// Browse mods: the page of listing `.0` of `mod_browser`.
|
||||
ModProject(usize),
|
||||
@@ -1617,7 +1636,8 @@ pub struct OptionsUi {
|
||||
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
|
||||
/// mod's, `selected_native_key`).
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
|
||||
/// (page, row) ([`pages::PAGE_KEY`]).
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
@@ -1674,6 +1694,10 @@ pub struct OptionsUi {
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
/// The radial menu's pages, on the settings shell.
|
||||
radial: shell::Shell<crate::radial_editor::RadialScreen>,
|
||||
/// The list of actions for one of the radial menu's slots.
|
||||
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
@@ -1758,6 +1782,8 @@ impl Default for OptionsUi {
|
||||
graphics: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
radial: Default::default(),
|
||||
radial_pick: Default::default(),
|
||||
shader: Default::default(),
|
||||
pack_options: Default::default(),
|
||||
pack_configs: Default::default(),
|
||||
@@ -1861,25 +1887,6 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Rows of an OptionsList: (big?, options) or a header.
|
||||
enum Row {
|
||||
Big(Opt),
|
||||
@@ -1899,14 +1906,17 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
return None;
|
||||
};
|
||||
let key = opt.tooltip_key(s);
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
let Opt::Page(page, row) = opt else {
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
};
|
||||
return pages::tooltip(*page, *row);
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
&& let Some(text) = pages::horizon_page_tooltip(*page)
|
||||
{
|
||||
tip = Some(tr(text));
|
||||
}
|
||||
@@ -1926,6 +1936,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
}
|
||||
match act {
|
||||
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
|
||||
Act::PageRow(page, row) => pages::tooltip(*page, *row),
|
||||
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
|
||||
Some(installed) => {
|
||||
let description = installed
|
||||
@@ -2118,6 +2129,7 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::ControllerRadialName => self.radial_name_typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
@@ -2250,95 +2262,16 @@ 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),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
self.graphics.screen.output = self.output;
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
|
||||
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// 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::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration =
|
||||
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
|
||||
}
|
||||
self.push(page);
|
||||
}
|
||||
shell::Output::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
|
||||
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the controller screen opens for `page`, if it is one of its pages.
|
||||
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
|
||||
match page {
|
||||
Page::Controller => Some(crate::controller_ui::Page::General),
|
||||
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
|
||||
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
|
||||
pub fn video_open(&self) -> bool {
|
||||
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
|
||||
}
|
||||
/// Whether the controller screen is open: a controller button pressed now may bind.
|
||||
pub fn controller_open(&self) -> bool {
|
||||
Self::controller_page(self.page()).is_some()
|
||||
}
|
||||
/// Where the graphics screen opens for `page`, if it is one of its pages.
|
||||
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
|
||||
match page {
|
||||
@@ -2367,6 +2300,7 @@ impl OptionsUi {
|
||||
Page::ModBrowser => "horizon.mods.browse.title",
|
||||
Page::ModProject => "horizon.mods.browse.project",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::Added(i) => pages::title(i),
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::PackOptions => "horizon.pack.options.title",
|
||||
@@ -2374,6 +2308,9 @@ impl OptionsUi {
|
||||
"options.videoTitle"
|
||||
}
|
||||
Page::Controller | Page::PadBinds => "horizon.pages.controller",
|
||||
Page::ControllerRadial => "horizon.pages.controller_radial",
|
||||
Page::ControllerRadialName => "horizon.radial.name_title",
|
||||
Page::ControllerRadialPick => "horizon.radial.pick_title",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Pvp => "horizon.pages.pvp",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
@@ -2583,43 +2520,6 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// 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> {
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![Row::Note(m.title.clone())];
|
||||
let Some(page) = &m.native else {
|
||||
return rows;
|
||||
};
|
||||
let chosen = s.mods.keys.get(&m.id);
|
||||
for (k, [name, label, default]) in page.keys.iter().enumerate() {
|
||||
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
|
||||
let mut message = keybinds::display_name(key);
|
||||
if self.selected_native_key == Some((i, k)) {
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
|
||||
rows.push(Row::Buttons(inactive(caption), Some(change)));
|
||||
}
|
||||
for (b, label) in page.buttons.iter().enumerate() {
|
||||
let widget = button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal(label.clone()),
|
||||
Act::NativeButton(i, b),
|
||||
);
|
||||
rows.push(Row::Buttons(widget, None));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => Vec::new(),
|
||||
@@ -2684,47 +2584,7 @@ impl OptionsUi {
|
||||
flush(&mut rows, &mut section);
|
||||
rows
|
||||
}
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai).then(|| {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
}),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
Page::Horizon => self.horizon_rows(s),
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::AgentControl),
|
||||
@@ -3007,6 +2867,7 @@ impl OptionsUi {
|
||||
Page::ModBrowser => self.mod_browser_rows(),
|
||||
Page::ModProject => self.mod_project_rows(s),
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::Added(i) => self.added_rows(i),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -3683,6 +3544,9 @@ impl OptionsUi {
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if self.draw_radial_name(batch, atlas, cursor, in_world) {
|
||||
return;
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
// The keys lay the screen out at the size it was drawn at.
|
||||
@@ -4034,6 +3898,9 @@ impl OptionsUi {
|
||||
let outcome = c.click(&atlas.font, (x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a controller button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
@@ -4273,11 +4140,9 @@ impl OptionsUi {
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
@@ -4731,6 +4596,8 @@ impl OptionsUi {
|
||||
self.scroll = 0.0;
|
||||
} else if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles.type_text(text);
|
||||
} else if self.page() == Page::ControllerRadialName {
|
||||
self.type_radial_name(text);
|
||||
} else if let Some(shell) = self.shell_mut() {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
@@ -4766,6 +4633,9 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles {
|
||||
return self.split_profiles.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.radial_name_shortcut(shortcut, clipboard);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4781,19 +4651,10 @@ impl OptionsUi {
|
||||
let Some(name) = self.selected_key.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if name == NATIVE_KEY {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys = s.mods.keys.entry(m.id.clone()).or_default();
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
if name == NATIVE_KEY || name == pages::PAGE_KEY {
|
||||
return self.bind_native_key(s, name, value);
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
vec![Event::Changed]
|
||||
@@ -4878,6 +4739,9 @@ impl OptionsUi {
|
||||
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::ControllerRadialName {
|
||||
return self.radial_name_key(s, key, text);
|
||||
}
|
||||
|
||||
// The size it was drawn at; before the first draw, one its keys can lay out at.
|
||||
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
|
||||
@@ -5173,11 +5037,9 @@ impl OptionsUi {
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
@@ -7348,7 +7210,7 @@ mod tests {
|
||||
MouseButton::Back,
|
||||
MouseButton::Forward,
|
||||
] {
|
||||
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
|
||||
for name in [SORT_KEY, "key.jump"] {
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut s = Settings::default();
|
||||
ui.selected_key = Some(name);
|
||||
@@ -7357,7 +7219,6 @@ mod tests {
|
||||
let value = keybinds::mouse_name(button);
|
||||
match name {
|
||||
SORT_KEY => assert_eq!(s.extras.sort_key, value),
|
||||
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
|
||||
_ => assert_eq!(s.keys.get(name), value),
|
||||
}
|
||||
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
|
||||
@@ -8439,3 +8300,5 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "options_ui_graphics_tests.rs"]
|
||||
mod graphics_tests;
|
||||
#[path = "options_ui_pages.rs"]
|
||||
mod pages;
|
||||
@@ -0,0 +1,302 @@
|
||||
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
|
||||
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
|
||||
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
|
||||
use super::{Event, OptionsUi, Page, tr};
|
||||
use crate::{
|
||||
gamepad,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
radial_editor::Named,
|
||||
settings::{Opt, Settings},
|
||||
settings_shell as shell,
|
||||
text::Text,
|
||||
};
|
||||
|
||||
impl OptionsUi {
|
||||
/// 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.
|
||||
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
Page::ControllerRadial => Some(&self.radial),
|
||||
Page::ControllerRadialPick => Some(&self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
self.graphics.screen.output = self.output;
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
Page::ControllerRadial => Some(&mut self.radial),
|
||||
Page::ControllerRadialPick => Some(&mut self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
|
||||
/// action), for a controller's focus to land there.
|
||||
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
|
||||
self.shell()?.opened_focus()
|
||||
}
|
||||
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
|
||||
/// settings shell steps, rather than opens.
|
||||
pub fn pad_clicks_step(&mut self, on: bool) {
|
||||
if on {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.clicks_step(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Every shell: the click may have changed the page.
|
||||
let shells: [&mut dyn shell::Hosted; 6] = [
|
||||
&mut self.extras,
|
||||
&mut self.graphics,
|
||||
&mut self.controller,
|
||||
&mut self.radial,
|
||||
&mut self.radial_pick,
|
||||
&mut self.shader,
|
||||
];
|
||||
for shell in shells {
|
||||
shell.clicks_step(false);
|
||||
}
|
||||
}
|
||||
/// The controller's focus landed there.
|
||||
pub fn clear_shell_opened_focus(&mut self) {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.clear_opened_focus();
|
||||
}
|
||||
}
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
pub(super) fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
pub(super) fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
pub(super) 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::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration =
|
||||
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
|
||||
}
|
||||
self.push(page);
|
||||
// A slot's list, for the slot, opening on what it holds.
|
||||
if page == Page::ControllerRadialPick
|
||||
&& let Some(at) = self.radial.screen.picking.take()
|
||||
{
|
||||
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
|
||||
self.radial_pick.focus_soon(row);
|
||||
}
|
||||
}
|
||||
shell::Output::Done => {
|
||||
// Back from a slot's list, the focus is on the slot.
|
||||
let slot = (self.page() == Page::ControllerRadialPick)
|
||||
.then(|| self.radial_pick.screen.at());
|
||||
events.extend(self.back(s));
|
||||
if let Some((page, slot)) = slot {
|
||||
self.radial
|
||||
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
|
||||
if self.radial_pick.screen.take_picked() {
|
||||
self.radial.changed_elsewhere();
|
||||
}
|
||||
// A radial page added or deleted: the one to show now.
|
||||
if let Some(page) = self.radial.screen.take_shown() {
|
||||
self.radial.show(page);
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What a press on a radial page's name does here.
|
||||
pub(super) fn name_events(
|
||||
&mut self,
|
||||
named: crate::radial_editor::Named,
|
||||
s: &mut Settings,
|
||||
) -> Vec<Event> {
|
||||
use crate::radial_editor::Named;
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
match named {
|
||||
Named::Nothing => return Vec::new(),
|
||||
Named::Done => {
|
||||
let entry = self.radial.screen.naming.take();
|
||||
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
|
||||
self.radial.changed_elsewhere();
|
||||
}
|
||||
events.push(Event::Changed);
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
Named::Back => events.extend(self.back(s)),
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
|
||||
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Where the controller screen opens for `page`, if it is one of its pages.
|
||||
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
|
||||
match page {
|
||||
Page::Controller => Some(crate::controller_ui::Page::General),
|
||||
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Whether the controller screen is open: a controller button pressed now may bind.
|
||||
pub fn controller_open(&self) -> bool {
|
||||
Self::controller_page(self.page()).is_some()
|
||||
}
|
||||
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
|
||||
/// in place of a shell: whether it was.
|
||||
pub(super) fn draw_radial_name(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) -> bool {
|
||||
self.radial.screen.glyphs = self.pad_glyphs;
|
||||
if self.page() == Page::ControllerRadialName
|
||||
&& let Some(entry) = &mut self.radial.screen.naming
|
||||
{
|
||||
entry.draw(batch, atlas, cursor, in_world);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
|
||||
pub(super) fn click_radial_name(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
atlas: &HudAtlas,
|
||||
at: (f32, f32),
|
||||
size: (f32, f32),
|
||||
ignored: bool,
|
||||
) -> Vec<Event> {
|
||||
let entry = self.radial.screen.naming.as_mut();
|
||||
let named = match entry.filter(|_| !ignored) {
|
||||
Some(entry) => entry.click(atlas, at, size),
|
||||
None => Named::Nothing,
|
||||
};
|
||||
self.name_events(named, s)
|
||||
}
|
||||
/// Whether a radial page's name is being typed.
|
||||
pub(super) fn radial_name_typing(&self) -> bool {
|
||||
self.radial
|
||||
.screen
|
||||
.naming
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.typing())
|
||||
}
|
||||
/// Text typed into a radial page's name.
|
||||
pub(super) fn type_radial_name(&mut self, text: &str) {
|
||||
if let Some(entry) = &mut self.radial.screen.naming {
|
||||
entry.type_text(text);
|
||||
}
|
||||
}
|
||||
/// An edit shortcut in a radial page's name: whether it did something.
|
||||
pub(super) fn radial_name_shortcut(
|
||||
&mut self,
|
||||
shortcut: crate::clipboard::EditShortcut,
|
||||
clipboard: &mut dyn crate::clipboard::Clipboard,
|
||||
) -> bool {
|
||||
let entry = self.radial.screen.naming.as_mut();
|
||||
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
|
||||
}
|
||||
/// A key on a radial page's name entry.
|
||||
pub(super) fn radial_name_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
key: winit::keyboard::KeyCode,
|
||||
text: Option<&str>,
|
||||
) -> Vec<Event> {
|
||||
let named = match &mut self.radial.screen.naming {
|
||||
Some(entry) => entry.key(key, text),
|
||||
// Nothing to name: Escape still leaves.
|
||||
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
|
||||
None => Named::Nothing,
|
||||
};
|
||||
self.name_events(named, s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Opt {
|
||||
/// The radial menu's rows of Controller Settings: their value.
|
||||
pub(super) fn radial_text(self, s: &Settings) -> Text {
|
||||
match self {
|
||||
Opt::PadRadialMode => tr(match s.controller.radial_mode {
|
||||
gamepad::RadialMode::Hold => "options.key.hold",
|
||||
gamepad::RadialMode::Toggle => "options.key.toggle",
|
||||
}),
|
||||
Opt::PadRadialStick => tr(match s.controller.radial_stick {
|
||||
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
|
||||
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
|
||||
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
/// The radial menu's rows of Controller Settings: a step, back or on.
|
||||
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
|
||||
let next = |n: u8, count: u8| {
|
||||
if back {
|
||||
(n + count - 1) % count
|
||||
} else {
|
||||
(n + 1) % count
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Opt::PadRadialMode => {
|
||||
let all = gamepad::RadialMode::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|m| *m == s.controller.radial_mode)
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
|
||||
}
|
||||
Opt::PadRadialStick => {
|
||||
let all = gamepad::RadialStick::ALL;
|
||||
let i = all
|
||||
.iter()
|
||||
.position(|k| *k == s.controller.radial_stick)
|
||||
.unwrap_or(0) as u8;
|
||||
// An opener under the thumb that now selects goes unbound.
|
||||
s.controller
|
||||
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//! The pages crates add to the options screens: a native mod's settings page (its keys and
|
||||
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
|
||||
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
|
||||
//! may not grow (tools/check_file_size.py).
|
||||
use super::*;
|
||||
use horizon_hooks::pages as added;
|
||||
|
||||
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
|
||||
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered.
|
||||
fn registered() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// A registered page's title (a translation key).
|
||||
pub(super) fn title(page: usize) -> &'static str {
|
||||
registered().get(page).map_or("", |p| p.title())
|
||||
}
|
||||
|
||||
/// Row `r` of a registered page, as it is now.
|
||||
fn row(page: usize, r: usize) -> Option<added::Row> {
|
||||
let mut rows = Vec::new();
|
||||
registered().get(page)?.rows(&mut rows);
|
||||
rows.into_iter().nth(r)
|
||||
}
|
||||
|
||||
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
|
||||
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { label, .. }) => label,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: its position.
|
||||
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: the value its message shows after the caption.
|
||||
pub(super) fn slider_text(page: usize, r: usize) -> Text {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { text, .. }) => label_text(text),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider moved.
|
||||
pub(super) fn slide(page: usize, r: usize, value: f32) {
|
||||
if let Some(registered) = registered().get(page) {
|
||||
registered.act(r, added::Act::Slide(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a registered page's row says on hover.
|
||||
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
|
||||
registered().get(page)?.tooltip(r).map(label_text)
|
||||
}
|
||||
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn label_text(label: added::Label) -> Text {
|
||||
match label {
|
||||
added::Label::Key(key) => tr(key),
|
||||
added::Label::Value(value) => Text::literal(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsUi {
|
||||
/// Horizon Settings: its pages, then the pages crates registered for it.
|
||||
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
let mut rows: Vec<Row> = vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
if s.extras.no_ai {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
} else {
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.agent", Page::Agent),
|
||||
Some(self.extra_button(s, crate::extras::Item::NoAi)),
|
||||
)
|
||||
},
|
||||
];
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
|
||||
fn added_entries(&self, rows: &mut Vec<Row>) {
|
||||
let mut entries = registered()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
|
||||
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
|
||||
while let Some(first) = entries.next() {
|
||||
rows.push(Row::Buttons(first, entries.next()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's rows, asked of it each time the page is drawn.
|
||||
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
let act = Act::PageRow(page, r);
|
||||
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
|
||||
match row {
|
||||
added::Row::Heading(key) => Row::Heading(key.to_owned()),
|
||||
added::Row::Note(text) => Row::Note(text),
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
&& self.selected_native_key == Some((page, r))
|
||||
{
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, act);
|
||||
Row::Buttons(inactive(caption), Some(change))
|
||||
}
|
||||
// A row kind newer than this engine: shown empty.
|
||||
_ => Row::Note(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
|
||||
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
match rows.get(r) {
|
||||
Some(added::Row::Key { .. }) => {
|
||||
self.selected_key = Some(PAGE_KEY);
|
||||
self.selected_native_key = Some((page, r));
|
||||
Vec::new()
|
||||
}
|
||||
Some(_) => {
|
||||
registered.act(
|
||||
r,
|
||||
if shift {
|
||||
added::Act::PressBack
|
||||
} else {
|
||||
added::Act::Press
|
||||
},
|
||||
);
|
||||
vec![Event::Changed]
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![Row::Note(m.title.clone())];
|
||||
let Some(page) = &m.native else {
|
||||
return rows;
|
||||
};
|
||||
let chosen = s.mods.keys.get(&m.id);
|
||||
for (k, [name, label, default]) in page.keys.iter().enumerate() {
|
||||
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
|
||||
let mut message = keybinds::display_name(key);
|
||||
if self.selected_native_key == Some((i, k)) {
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
|
||||
rows.push(Row::Buttons(inactive(caption), Some(change)));
|
||||
}
|
||||
for (b, label) in page.buttons.iter().enumerate() {
|
||||
let widget = button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal(label.clone()),
|
||||
Act::NativeButton(i, b),
|
||||
);
|
||||
rows.push(Row::Buttons(widget, None));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// A native mod's key waits for the next key or button pressed.
|
||||
pub(super) fn wait_native_key(&mut self, i: usize, k: usize) {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
|
||||
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
|
||||
/// waited for one.
|
||||
pub(super) fn bind_native_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
name: &str,
|
||||
value: String,
|
||||
) -> Vec<Event> {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
if name == PAGE_KEY {
|
||||
if let Some(page) = registered().get(i) {
|
||||
page.act(k, added::Act::Bind(value));
|
||||
}
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys = s.mods.keys.entry(m.id.clone()).or_default();
|
||||
keys.insert(key.clone(), value);
|
||||
vec![Event::Changed]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
|
||||
struct Sample {
|
||||
on: Mutex<bool>,
|
||||
step: Mutex<i32>,
|
||||
key: Mutex<String>,
|
||||
volume: Mutex<f32>,
|
||||
}
|
||||
|
||||
impl added::Page for Sample {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.sample"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.sample.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Heading("test.sample.heading"));
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.sample.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.sample.step",
|
||||
value: added::Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Key {
|
||||
label: "test.sample.key",
|
||||
key: self.key.lock().unwrap().clone(),
|
||||
});
|
||||
let volume = *self.volume.lock().unwrap();
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.sample.volume",
|
||||
value: volume,
|
||||
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: added::Act) {
|
||||
match (row, act) {
|
||||
(1, added::Act::Press) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
|
||||
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
other => panic!("row and act the page doesn't expect: {other:?}"),
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, row: usize) -> Option<added::Label> {
|
||||
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
|
||||
}
|
||||
}
|
||||
|
||||
static SAMPLE: Sample = Sample {
|
||||
on: Mutex::new(false),
|
||||
step: Mutex::new(0),
|
||||
key: Mutex::new(String::new()),
|
||||
volume: Mutex::new(0.0),
|
||||
};
|
||||
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
|
||||
|
||||
/// Clicks the active button that does `act`, with Shift held or not.
|
||||
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let (x, y) = widgets
|
||||
.iter()
|
||||
.find_map(|w| match &w.kind {
|
||||
WKind::Button {
|
||||
act: a,
|
||||
active: true,
|
||||
..
|
||||
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no button does {act:?}"));
|
||||
ui.shift = shift;
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&PAGES));
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Horizon);
|
||||
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
|
||||
assert_eq!(ui.page(), Page::Added(0));
|
||||
// Rows: the heading (0), the switch (1), the step (2), the key (3).
|
||||
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
|
||||
assert_eq!(
|
||||
events.last(),
|
||||
Some(&Event::Changed),
|
||||
"the settings get saved"
|
||||
);
|
||||
assert!(*SAMPLE.on.lock().unwrap());
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
|
||||
// A key row waits for the next key, as on Key Binds, and the page gets its name.
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
let atlas = super::super::tests::atlas();
|
||||
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
|
||||
assert_eq!(events, [Event::Changed]);
|
||||
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
|
||||
assert_eq!(ui.selected_key, None);
|
||||
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the volume's slider");
|
||||
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
|
||||
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
|
||||
let volume = *SAMPLE.volume.lock().unwrap();
|
||||
assert!((0.45..0.55).contains(&volume), "{volume}");
|
||||
assert!(ui.release().contains(&Event::Changed));
|
||||
// The slider shows the page's value once let go.
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let shown = widgets.iter().find_map(|w| match w.kind {
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
value,
|
||||
..
|
||||
} => Some(value),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shown, Some(volume));
|
||||
// The page's tooltip for its switch.
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 1),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the switch");
|
||||
assert_eq!(
|
||||
tooltip_text(&s, switch).map(|t| t.plain()),
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
|
||||
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
|
||||
use std::path::Path;
|
||||
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
|
||||
pack_palette::FluidFog {
|
||||
color: fog.color,
|
||||
radius: fog.radius,
|
||||
fade: fog.fade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
|
||||
let env = colors.env;
|
||||
pack_palette::PackColors {
|
||||
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
|
||||
dye_text: by_dye(&|name| colors.dye(name, "text")),
|
||||
sheep: by_dye(&|name| colors.sheep(name)),
|
||||
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
|
||||
effects: colors.effects(),
|
||||
entity_shadows: colors.entity_shadows(),
|
||||
xp_bar: colors.xp_bar(),
|
||||
env: Environment {
|
||||
powder_snow_fog: env.powder_snow_fog,
|
||||
blindness_fog_distance: env.blindness_fog_distance,
|
||||
darkness_fog_distance: env.darkness_fog_distance,
|
||||
boss_fog_distance: env.boss_fog_distance,
|
||||
water_fog_brightening: env.water_fog_brightening,
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
fluid_fog: pack_palette::FluidFogs {
|
||||
water: colors.fluid_fog.water.map(fluid),
|
||||
lava: colors.fluid_fog.lava.map(fluid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
crate::pack_palette::set(&read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
crate::pack_palette::set(&Default::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
|
||||
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
|
||||
//! and each frame's choice of layers, their brightness and rotation. A pack without
|
||||
//! `optifine/sky` has none, and the sky stays vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
|
||||
|
||||
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's sky blend for OptiFine's.
|
||||
fn blend(blend: Blend) -> engine::Blend {
|
||||
match blend {
|
||||
Blend::Add => engine::Blend::Add,
|
||||
Blend::Subtract => engine::Blend::Subtract,
|
||||
Blend::Multiply => engine::Blend::Multiply,
|
||||
Blend::Dodge => engine::Blend::Dodge,
|
||||
Blend::Burn => engine::Blend::Burn,
|
||||
Blend::Screen => engine::Blend::Screen,
|
||||
Blend::Replace => engine::Blend::Replace,
|
||||
Blend::Overlay => engine::Blend::Overlay,
|
||||
Blend::Alpha => engine::Blend::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
impl SkyLayers for CustomSky {
|
||||
fn images(&self) -> &[Image] {
|
||||
&self.images
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
|
||||
/// their fade times.
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &engine::Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let conditions = &Conditions {
|
||||
day_time: conditions.day_time,
|
||||
weather: match conditions.weather {
|
||||
engine::Weather::Clear => Now::Clear,
|
||||
engine::Weather::Rain => Now::Rain,
|
||||
engine::Weather::Thunder => Now::Thunder,
|
||||
},
|
||||
biome: conditions.biome,
|
||||
y: conditions.y,
|
||||
};
|
||||
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(), 0.0);
|
||||
}
|
||||
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 = target;
|
||||
target
|
||||
} else {
|
||||
let mut t = Transition { value: *eased };
|
||||
*eased = t.step(target, seconds, layer.transition);
|
||||
*eased
|
||||
};
|
||||
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: blend(layer.blend),
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::custom_sky::{Conditions, Weather};
|
||||
|
||||
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: Weather::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, engine::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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Polytone's item modifiers' durability bar colours, on the engine's
|
||||
//! [`crate::pack_palette::ItemBars`].
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
|
||||
/// The packs' damage bars, the last read first.
|
||||
struct Bars(Vec<DamageBar>);
|
||||
|
||||
impl crate::pack_palette::ItemBars for Bars {
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32> {
|
||||
let bar = self
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
|
||||
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
let bars = read(&dir).unwrap();
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
|
||||
assert!(read(&dir.join("none")).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
|
||||
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
mod colors;
|
||||
mod custom_sky;
|
||||
mod item_bars;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pack_hooks::PackFormats;
|
||||
|
||||
/// OptiFine's and Polytone's formats.
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
pub fn install() {
|
||||
crate::pack_hooks::install(&Formats);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
|
||||
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
|
||||
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
|
||||
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
|
||||
//! packs load as vanilla reads them.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
|
||||
/// (its folder at `root`); the default is "nothing in this format".
|
||||
pub trait PackFormats: Send + Sync {
|
||||
/// The colours the overlay replaces where vanilla hardcodes them.
|
||||
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
|
||||
/// if its format has them.
|
||||
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
struct Vanilla;
|
||||
|
||||
impl PackFormats for Vanilla {}
|
||||
|
||||
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
|
||||
|
||||
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
|
||||
/// ignored, so each test or probe that needs the formats may install them.
|
||||
pub fn install(formats: &'static dyn PackFormats) {
|
||||
let _ = FORMATS.set(formats);
|
||||
}
|
||||
|
||||
/// The installed pack formats, or vanilla's alone.
|
||||
pub fn formats() -> &'static dyn PackFormats {
|
||||
FORMATS.get().copied().unwrap_or(&Vanilla)
|
||||
}
|
||||
@@ -1,12 +1,106 @@
|
||||
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
|
||||
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
|
||||
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
|
||||
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
|
||||
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
|
||||
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
|
||||
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
|
||||
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
|
||||
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
|
||||
//! and the mesher's workers read the dye, sheep and redstone tables per face.
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::signs::DYE_TEXT;
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Environment {
|
||||
/// The fog colour inside powder snow (RGB).
|
||||
pub powder_snow_fog: Option<u32>,
|
||||
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
|
||||
pub blindness_fog_distance: f32,
|
||||
pub darkness_fog_distance: f32,
|
||||
pub boss_fog_distance: f32,
|
||||
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
|
||||
pub water_fog_brightening: f32,
|
||||
/// The ticks under water it takes (vanilla's 600).
|
||||
pub water_fog_brightening_time: f32,
|
||||
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
|
||||
pub horizon_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
powder_snow_fog: None,
|
||||
blindness_fog_distance: 5.0,
|
||||
darkness_fog_distance: 15.0,
|
||||
boss_fog_distance: 96.0,
|
||||
water_fog_brightening: 1.0,
|
||||
water_fog_brightening_time: 600.0,
|
||||
horizon_height: 63.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackColors {
|
||||
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
|
||||
pub dye_diffuse: [Option<u32>; 16],
|
||||
pub dye_text: [Option<u32>; 16],
|
||||
pub sheep: [Option<u32>; 16],
|
||||
/// `RedStoneWireBlock.COLORS` by power.
|
||||
pub redstone: [Option<u32>; 16],
|
||||
/// Mob effects' colours, by effect id without vanilla's namespace.
|
||||
pub effects: Vec<(String, u32)>,
|
||||
/// False hides every entity's round shadow, whatever the video option says.
|
||||
pub entity_shadows: bool,
|
||||
/// The experience level number's colour and outline.
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
|
||||
/// vanilla's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dye_diffuse: [None; 16],
|
||||
dye_text: [None; 16],
|
||||
sheep: [None; 16],
|
||||
redstone: [None; 16],
|
||||
effects: Vec::new(),
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
/// Whether entities draw their round shadow, as far as the packs say.
|
||||
pub fn entity_shadows(&self) -> bool {
|
||||
self.entity_shadows
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 is unset, else `1 << 32 | rgb`.
|
||||
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
@@ -14,6 +108,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
|
||||
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
|
||||
/// and redstone colours go to the draw paths' tables ([`set`]).
|
||||
pub fn read(root: &Path) -> PackColors {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
let colors = formats.colors(root).unwrap_or_default();
|
||||
set(&colors);
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
|
||||
colors
|
||||
}
|
||||
|
||||
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
|
||||
pub trait ItemBars: Send + Sync {
|
||||
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32>;
|
||||
}
|
||||
|
||||
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
bars.as_ref()?.color(item, damage)
|
||||
}
|
||||
|
||||
/// Takes the colours of the packs just loaded (vanilla's where they set none).
|
||||
pub fn set(colors: &PackColors) {
|
||||
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
|
||||
@@ -22,39 +140,39 @@ pub fn set(colors: &PackColors) {
|
||||
Relaxed,
|
||||
);
|
||||
};
|
||||
for (i, (name, _)) in DYES.iter().enumerate() {
|
||||
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
|
||||
store(&TEXT, i, colors.dye(name, "text"));
|
||||
store(&SHEEP, i, colors.sheep(name));
|
||||
store(&REDSTONE, i, colors.redstone(i as u8));
|
||||
for i in 0..16 {
|
||||
store(&DIFFUSE, i, colors.dye_diffuse[i]);
|
||||
store(&TEXT, i, colors.dye_text[i]);
|
||||
store(&SHEEP, i, colors.sheep[i]);
|
||||
store(&REDSTONE, i, colors.redstone[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
let v = table[i & 15].load(Relaxed);
|
||||
(v != 0).then_some(v as u32)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
|
||||
pub fn diffuse(dye: usize) -> u32 {
|
||||
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
|
||||
pub fn text(dye: usize) -> u32 {
|
||||
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
}
|
||||
|
||||
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
|
||||
pub fn redstone(power: u8) -> Option<u32> {
|
||||
read(&REDSTONE, usize::from(power))
|
||||
get(&REDSTONE, usize::from(power))
|
||||
}
|
||||
|
||||
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
|
||||
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
|
||||
pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let i = usize::from(dye & 15);
|
||||
if let Some(color) = read(&SHEEP, i) {
|
||||
if let Some(color) = get(&SHEEP, i) {
|
||||
return color;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -64,36 +182,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
|
||||
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
set(&PackColors::read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
set(&PackColors::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
mod spawn;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -1222,8 +1223,9 @@ pub struct ParticleLevel<'a> {
|
||||
pub collision: &'a BlockCollisionRegistry<'a>,
|
||||
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
|
||||
pub player: Option<([f64; 3], f64)>,
|
||||
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
|
||||
}
|
||||
impl ParticleLevel<'_> {
|
||||
impl<'a> ParticleLevel<'a> {
|
||||
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
@@ -1231,9 +1233,6 @@ impl ParticleLevel<'_> {
|
||||
subject: None,
|
||||
};
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
@@ -1249,38 +1248,46 @@ impl ParticleLevel<'_> {
|
||||
p[2].floor() as i32,
|
||||
]
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let chunk = match self.cached_chunk.get() {
|
||||
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
|
||||
_ => {
|
||||
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
|
||||
self.cached_chunk.set(Some(((cx, cz), c)));
|
||||
c
|
||||
}
|
||||
};
|
||||
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
|
||||
}
|
||||
/// The fluid at a block and `FluidState.getHeight`.
|
||||
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
if s.fluid == Fluid::None {
|
||||
return (Fluid::None, 0.0);
|
||||
}
|
||||
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
|
||||
let height = if above.fluid == s.fluid {
|
||||
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
|
||||
let h = if above.fluid == s.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(s.fluid_level) / 9.0
|
||||
};
|
||||
(s.fluid, height)
|
||||
(s.fluid, h)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
|
||||
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
|
||||
let state = self.world.block(b[0], b[1], b[2]);
|
||||
let state = self.block(b[0], b[1], b[2]);
|
||||
match self.collision.cached_shapes().cached_shape(state) {
|
||||
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
|
||||
Ok(Some(_)) => 0.0,
|
||||
_ => {
|
||||
if self.facts.get(state).full_collision {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ if self.facts.get(state).full_collision => 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2020,73 +2027,6 @@ impl ParticleEngine {
|
||||
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
|
||||
})
|
||||
}
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if count == 0 {
|
||||
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
|
||||
self.add(options, at, v);
|
||||
return;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let r = &mut self.random;
|
||||
let p = [
|
||||
at[0] + r.next_gaussian() * spread[0],
|
||||
at[1] + r.next_gaussian() * spread[1],
|
||||
at[2] + r.next_gaussian() * spread[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
];
|
||||
self.add(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
|
||||
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
if let Some(requests) = &mut self.created_requests {
|
||||
requests.push((options.clone(), p, v));
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
if particle.ty == ParticleType::SporeBlossomAir
|
||||
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
|
||||
&& self
|
||||
.particles
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
|
||||
.count()
|
||||
>= SPORE_BLOSSOM_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
/// How many particles of each type were made so far.
|
||||
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
|
||||
&self.made
|
||||
@@ -4052,15 +3992,12 @@ impl ParticleEngine {
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
if !p.removed {
|
||||
let odds = p.age as f32 / p.lifetime as f32;
|
||||
if p.random.next_float() > odds {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
@@ -4144,9 +4081,7 @@ impl ParticleEngine {
|
||||
*c *= f64::from(0.98f32);
|
||||
}
|
||||
if p.on_ground {
|
||||
if p.random.next_float() < 0.5 {
|
||||
p.removed = true;
|
||||
}
|
||||
p.removed |= p.random.next_float() < 0.5;
|
||||
p.velocity[0] *= f64::from(0.7f32);
|
||||
p.velocity[2] *= f64::from(0.7f32);
|
||||
}
|
||||
@@ -4186,13 +4121,10 @@ impl ParticleEngine {
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
|
||||
if !(p.removed || (fluid == Fluid::Water && source)) {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] >= y_end {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] <= y_prev {
|
||||
if !(fluid == Fluid::Water && source)
|
||||
|| p.position[1] >= y_end
|
||||
|| p.position[1] <= y_prev
|
||||
{
|
||||
p.removed = true;
|
||||
}
|
||||
// randomHorizontalWiggling, then a second, horizontal move.
|
||||
@@ -4216,9 +4148,8 @@ impl ParticleEngine {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
@@ -4682,7 +4613,7 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(p);
|
||||
half(p);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.velocity[1] -= f64::from(0.0074f32);
|
||||
}
|
||||
}
|
||||
@@ -4690,7 +4621,7 @@ impl ParticleEngine {
|
||||
Behaviour::Firefly => {
|
||||
p.base_tick(level);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.removed = true;
|
||||
} else {
|
||||
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
|
||||
@@ -5154,11 +5085,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
return a[0];
|
||||
}
|
||||
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
a[0]
|
||||
} else {
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Starter {
|
||||
@@ -5589,6 +5521,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.set_sprites.push(vec![10, 11, 12]);
|
||||
@@ -5680,6 +5613,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
|
||||
|
||||
@@ -124,6 +124,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut e = engine();
|
||||
let mut p = e
|
||||
|
||||