mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
1134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5672f41859 | ||
|
|
d6e8bd67aa | ||
|
|
30615b890d | ||
|
|
4a475325b4 | ||
|
|
39fa71cfe8 | ||
|
|
d932f5d96d | ||
|
|
ed829ea5f3 | ||
|
|
bf0c334ddc | ||
|
|
8b4dc0dacc | ||
|
|
9fd6279f36 | ||
|
|
b1d9565677 | ||
|
|
0749fa97e6 | ||
|
|
ef1658625c | ||
|
|
e05a09220f | ||
|
|
571ec1b76e | ||
|
|
498aea7864 | ||
|
|
b8f8c2515d | ||
|
|
0b93c4e79e | ||
|
|
258a11a817 | ||
|
|
546c1c71fc | ||
|
|
07d7887bef | ||
|
|
29826a7558 | ||
|
|
e48e87a422 | ||
|
|
6efd5166c2 | ||
|
|
d7709584f0 | ||
|
|
4cac6f30db | ||
|
|
a19985b75f | ||
|
|
3eb3e44ba6 | ||
|
|
1363bd83b6 | ||
|
|
f21ce0241f | ||
|
|
1fed25618f | ||
|
|
236219d649 | ||
|
|
cf83188a6d | ||
|
|
06d1db6295 | ||
|
|
5595b0225f | ||
|
|
7fcec44100 | ||
|
|
b305a5a5a0 | ||
|
|
9816939333 | ||
|
|
9c9ab69366 | ||
|
|
87e3e1d155 | ||
|
|
5963751fe9 | ||
|
|
a56109fc75 | ||
|
|
9dba2508fa | ||
|
|
c90b0cd869 | ||
|
|
9c63ab14e9 | ||
|
|
995c7aa817 | ||
|
|
ed9c111323 | ||
|
|
be7490c9ba | ||
|
|
28cbd6c861 | ||
|
|
384197a498 | ||
|
|
b625ba5360 | ||
|
|
ffc8183484 | ||
|
|
5a23ab721e | ||
|
|
b15c3fad35 | ||
|
|
2a8a736b4a | ||
|
|
0181d47bcb | ||
|
|
35a69e1eb6 | ||
|
|
6d3f22638d | ||
|
|
fd7c46c225 | ||
|
|
73b547d762 | ||
|
|
bd71e987ef | ||
|
|
bba758386d | ||
|
|
54f11f1744 | ||
|
|
125a880ee5 | ||
|
|
0c41429b4b | ||
|
|
e4f07bb4c6 | ||
|
|
6daec26c3d | ||
|
|
ef0c899c29 | ||
|
|
d13980b43a | ||
|
|
ce44d05eea | ||
|
|
1574e9b150 | ||
|
|
910ba97315 | ||
|
|
ccbc71879c | ||
|
|
adc65f2983 | ||
|
|
cc332d5e7d | ||
|
|
8a2d942360 | ||
|
|
46ed0adc78 | ||
|
|
f80c0360d3 | ||
|
|
cda954ab2a | ||
|
|
2c2435d64d | ||
|
|
0d80a814b8 | ||
|
|
c64445140f | ||
|
|
048f0b280e | ||
|
|
a1d4937336 | ||
|
|
f3dd9dec81 | ||
|
|
993b05456b | ||
|
|
c9feaa1ecd | ||
|
|
d2da889891 | ||
|
|
4740c146cf | ||
|
|
428ad90224 | ||
|
|
9c97099864 | ||
|
|
0139c8596a | ||
|
|
ec151d869e | ||
|
|
3836642a67 | ||
|
|
9b74114b8a | ||
|
|
5092ba0f5a | ||
|
|
66ab78011a | ||
|
|
bb63d3f310 | ||
|
|
45b71aa863 | ||
|
|
005a8c3792 | ||
|
|
80a52907b0 | ||
|
|
6d353618d3 | ||
|
|
4031e5063b | ||
|
|
d4c3a8bd4c | ||
|
|
60134bd895 | ||
|
|
871c4691a3 | ||
|
|
fa7dbecb43 | ||
|
|
ad49dcc284 | ||
|
|
6e446359bd | ||
|
|
7027d1437b | ||
|
|
4b04fb40e8 | ||
|
|
5017aa5ab0 | ||
|
|
a543fffdc1 | ||
|
|
7c808bba52 | ||
|
|
e0b41b8481 | ||
|
|
db3e803087 | ||
|
|
d401b63cdc | ||
|
|
076582df26 | ||
|
|
562feb2581 | ||
|
|
fce73cb0b4 | ||
|
|
b0bede7fc6 | ||
|
|
6a7519dde9 | ||
|
|
b8fc236a5d | ||
|
|
8f76982036 | ||
|
|
138bf9d166 | ||
|
|
6e2102e2cb | ||
|
|
df7ef012e4 | ||
|
|
01049b3eab | ||
|
|
3faaef6693 | ||
|
|
f755be19d8 | ||
|
|
22ba7eac80 | ||
|
|
c820aaba1a | ||
|
|
f115979414 | ||
|
|
46a0cf3eea | ||
|
|
5062f22592 | ||
|
|
8c913dff6f | ||
|
|
d5ca93d09e | ||
|
|
6e09a5d817 | ||
|
|
2283c9cc25 | ||
|
|
5c7cf4f4c1 | ||
|
|
85be6c20bb | ||
|
|
31046b9b61 | ||
|
|
d91cab68de | ||
|
|
e47213a981 | ||
|
|
2f9b57f78e | ||
|
|
eccfe3d9b7 | ||
|
|
5c8b864105 | ||
|
|
4705bc2aa8 | ||
|
|
fa0de01c99 | ||
|
|
0d2a9d8402 | ||
|
|
9021ffb4f5 | ||
|
|
93d78e0c53 | ||
|
|
a257a4221e | ||
|
|
979aa64e51 | ||
|
|
e46f743fa4 | ||
|
|
1629fa2dfc | ||
|
|
dd0c770b86 | ||
|
|
85d1165657 | ||
|
|
3fde6d64dd | ||
|
|
873ca4fdaf | ||
|
|
8961611cbe | ||
|
|
640f459087 | ||
|
|
caf83890c8 | ||
|
|
53171431d0 | ||
|
|
fd091af76a | ||
|
|
d55e66d656 | ||
|
|
aa27eca326 | ||
|
|
7ccd9406af | ||
|
|
3f7e92d6ab | ||
|
|
97d4bbf06c | ||
|
|
722603631b | ||
|
|
976e3f3858 | ||
|
|
6d6715fd11 | ||
|
|
c8146cdba6 | ||
|
|
af55a73486 | ||
|
|
fdfa4fbfab | ||
|
|
bef1eff277 | ||
|
|
138b02a242 | ||
|
|
928cc155fb | ||
|
|
30365e778a | ||
|
|
da50e90690 | ||
|
|
125875b844 | ||
|
|
645ee4c546 | ||
|
|
095c6c48cd | ||
|
|
5b49acb01b | ||
|
|
39e0ed339e | ||
|
|
bbd87091bf | ||
|
|
23f6145aff | ||
|
|
68ac6f647e | ||
|
|
f684653658 | ||
|
|
57aa58c8f9 | ||
|
|
48ccd93267 | ||
|
|
21b8d1f527 | ||
|
|
d9c733419c | ||
|
|
c50f97ec8f | ||
|
|
6b333b2754 | ||
|
|
89a68f75de | ||
|
|
3bb2c7d3e4 | ||
|
|
27cb8f96bf | ||
|
|
baf72e46c1 | ||
|
|
c78d4495f0 | ||
|
|
5e0af16e10 | ||
|
|
17ca3981f0 | ||
|
|
c7dcaf2b7d | ||
|
|
fc9326b05e | ||
|
|
0a6ecf0231 | ||
|
|
e7c2951d4c | ||
|
|
ce12d07965 | ||
|
|
da62023123 | ||
|
|
7cec464d45 | ||
|
|
cf57da8885 | ||
|
|
9cf6a6d154 | ||
|
|
f5d8035626 | ||
|
|
bd541458cd | ||
|
|
d27432c461 | ||
|
|
f7f2747724 | ||
|
|
01bbb4c654 | ||
|
|
a4ecd4ebb6 | ||
|
|
38a97ea64e | ||
|
|
0b179e13dc | ||
|
|
57c54aac90 | ||
|
|
eb932b91a3 | ||
|
|
9a1dc67c0d | ||
|
|
46329b3cb6 | ||
|
|
8306da9cc3 | ||
|
|
9b6aa1342a | ||
|
|
c6c043e8b5 | ||
|
|
43a17d0af9 | ||
|
|
67979afe8d | ||
|
|
c37ca8bbc3 | ||
|
|
2abd1ac013 | ||
|
|
36f4bc00d6 | ||
|
|
3b77664bdb | ||
|
|
45cad03deb | ||
|
|
cf8ed9d8c3 | ||
|
|
6dd82d9d30 | ||
|
|
3c68c05d0a | ||
|
|
db171db98f | ||
|
|
5876fa5ff8 | ||
|
|
42f51485f4 | ||
|
|
d692215361 | ||
|
|
184a48b55b | ||
|
|
da185c6128 | ||
|
|
7f99b2e8cc | ||
|
|
80106eb50d | ||
|
|
7c0c1f6be3 | ||
|
|
66e3585fa8 | ||
|
|
361c9f9fff | ||
|
|
0877a0b76a | ||
|
|
37d6e42013 | ||
|
|
5896db7b9c | ||
|
|
770dd407d5 | ||
|
|
13b200f8c8 | ||
|
|
ecdcb91c27 | ||
|
|
47da9560bc | ||
|
|
c225acc128 | ||
|
|
a5280537ef | ||
|
|
d20172681d | ||
|
|
15e9f8caeb | ||
|
|
aee4f219e4 | ||
|
|
3f0aa12580 | ||
|
|
575f48fe5f | ||
|
|
9e11a4d7c8 | ||
|
|
562cbac5fa | ||
|
|
d467786e5b | ||
|
|
1d69ba5efa | ||
|
|
c556f5b5b8 | ||
|
|
90d02c5fff | ||
|
|
b546ffb877 | ||
|
|
a6f30aa763 | ||
|
|
bcbf367fc6 | ||
|
|
854c85b235 | ||
|
|
65d4d06ec5 | ||
|
|
d7c6e36cb9 | ||
|
|
a672a51ebd | ||
|
|
13fd2ee724 | ||
|
|
c3905131bf | ||
|
|
c5bf7a35cd | ||
|
|
5b25371912 | ||
|
|
456bbccd1d | ||
|
|
2d519f8808 | ||
|
|
a6d66cd98e | ||
|
|
cf2c6dd7cb | ||
|
|
7553d7356c | ||
|
|
9dba54f8fa | ||
|
|
fe0c9d5588 | ||
|
|
6dba457703 | ||
|
|
32a35d1225 | ||
|
|
a921e7b79e | ||
|
|
714e6e52bd | ||
|
|
0d476d32e7 | ||
|
|
830820b2cf | ||
|
|
d7d0ead29d | ||
|
|
c48886001b | ||
|
|
ea306f29eb | ||
|
|
7b9a18331d | ||
|
|
135c4a8872 | ||
|
|
67188b668c | ||
|
|
9b63c4c735 | ||
|
|
ef638cf256 | ||
|
|
13ce6bcc64 | ||
|
|
24831525c0 | ||
|
|
83189a8ae7 | ||
|
|
807a818395 | ||
|
|
51c9565411 | ||
|
|
9cccb97de1 | ||
|
|
e085a4edf5 | ||
|
|
3d9e3dbe6e | ||
|
|
05a059bde4 | ||
|
|
71d7921132 | ||
|
|
9194f37386 | ||
|
|
06f4fe173c | ||
|
|
d4b98b96b0 | ||
|
|
5943233ee5 | ||
|
|
49abcffb11 | ||
|
|
9d979caa48 | ||
|
|
09a4cd9baf | ||
|
|
34f24116bf | ||
|
|
c18ed2db67 | ||
|
|
c41a8d0d96 | ||
|
|
fc680ec318 | ||
|
|
c535285302 | ||
|
|
d4326f77c8 | ||
|
|
45c36d8721 | ||
|
|
f671f9a758 | ||
|
|
12a0e29610 | ||
|
|
fbf38dfc89 | ||
|
|
932cecc24e | ||
|
|
a72ce25b23 | ||
|
|
763528025d | ||
|
|
b8b6196026 | ||
|
|
5bd13dad80 | ||
|
|
03fc2d3f2d | ||
|
|
d6452eb44b | ||
|
|
1dfea0b84e | ||
|
|
eae1e21bd0 | ||
|
|
97b2e10df5 | ||
|
|
5bda60aa5d | ||
|
|
3bffd9b52e | ||
|
|
a648bef38c | ||
|
|
e3b213f8f9 | ||
|
|
8dadd9e02a | ||
|
|
530a1253e6 | ||
|
|
ae45f040b7 | ||
|
|
6ec32c5ec7 | ||
|
|
a368ded7f5 | ||
|
|
4dca2732a3 | ||
|
|
3d1b94f97c | ||
|
|
57bee439bb | ||
|
|
367d519e3c | ||
|
|
aae2414706 | ||
|
|
13aecbe1e5 | ||
|
|
c3246b78e4 | ||
|
|
7aa0e28fae | ||
|
|
202e37981f | ||
|
|
6ee48d0e47 | ||
|
|
836ea22364 | ||
|
|
368eea9f74 | ||
|
|
877d0dac10 | ||
|
|
95a4e8effa | ||
|
|
cbe755778c | ||
|
|
1e14c90711 | ||
|
|
94b3b4aca5 | ||
|
|
73958d8397 | ||
|
|
98a1726379 | ||
|
|
37ad1f5c9e | ||
|
|
73db376775 | ||
|
|
84f633b56b | ||
|
|
a3c5fe138e | ||
|
|
0510ebdfbf | ||
|
|
eeede72cf8 | ||
|
|
0faccb5be8 | ||
|
|
d114abe94b | ||
|
|
4db1495bfb | ||
|
|
ed5565713e | ||
|
|
90994feaca | ||
|
|
f88dcf6379 | ||
|
|
976de52032 | ||
|
|
f2a99d6238 | ||
|
|
e776e8e94a | ||
|
|
3d0512446d | ||
|
|
ede6a087d0 | ||
|
|
a595a30164 | ||
|
|
dffc94645e | ||
|
|
3edd56c5e4 | ||
|
|
b526d836a0 | ||
|
|
0b82e9f872 | ||
|
|
3f722b80a8 | ||
|
|
f554e16b02 | ||
|
|
92415234d9 | ||
|
|
f96b795bf6 | ||
|
|
2d6d006d39 | ||
|
|
18a3169447 | ||
|
|
e35e3e3ca0 | ||
|
|
6eca577874 | ||
|
|
c559d125b5 | ||
|
|
819cd2eebc | ||
|
|
1e62ad7914 | ||
|
|
b70cb22e9d | ||
|
|
57d8a70a2c | ||
|
|
0fb65197a7 | ||
|
|
d4dff9534c | ||
|
|
a73db6ad95 | ||
|
|
6718c7dd9a | ||
|
|
d331af9962 | ||
|
|
389a918c16 | ||
|
|
bdebd8103c | ||
|
|
5e7b9a5075 | ||
|
|
fae347ca67 | ||
|
|
28b8be4410 | ||
|
|
dd94337c2e | ||
|
|
7f969cb3a5 | ||
|
|
0c5074bba1 | ||
|
|
fcaaed088b | ||
|
|
4de90a13d4 | ||
|
|
cbda897a5e | ||
|
|
86a40451e8 | ||
|
|
17e6f6e8fa | ||
|
|
a0f42c81e0 | ||
|
|
017afee3a1 | ||
|
|
b2f0b58fc9 | ||
|
|
026d3fa47a | ||
|
|
7f884ad64b | ||
|
|
568159fa1e | ||
|
|
6bbc5cb3b0 | ||
|
|
6e263d79f6 | ||
|
|
2959076c75 | ||
|
|
ff5d14d4c2 | ||
|
|
c39db2247c | ||
|
|
e3cba0ac6f | ||
|
|
99efd0ccb4 | ||
|
|
e91d4e3f01 | ||
|
|
eac39ece47 | ||
|
|
b251abc575 | ||
|
|
09e315cb24 | ||
|
|
48709c8091 | ||
|
|
f8b5fed2f0 | ||
|
|
e41d76620e | ||
|
|
de6436fd88 | ||
|
|
476cfa77ae | ||
|
|
4141a127fb | ||
|
|
1ba05096b8 | ||
|
|
19021623be | ||
|
|
d27ec41434 | ||
|
|
5993df106f | ||
|
|
b804e8a290 | ||
|
|
81b30898d7 | ||
|
|
4fc225edb9 | ||
|
|
28754451ea | ||
|
|
142424b58f | ||
|
|
1ad4d8633b | ||
|
|
600058493e | ||
|
|
ed89678b47 | ||
|
|
fdd6f507ec | ||
|
|
7e170c6a64 | ||
|
|
0291f63727 | ||
|
|
27c38eb27b | ||
|
|
070046d358 | ||
|
|
3a88476964 | ||
|
|
48beef082a | ||
|
|
2ed80412b1 | ||
|
|
a49b9a6b88 | ||
|
|
53e565516f | ||
|
|
7ee89b0e4c | ||
|
|
9a4eb4add8 | ||
|
|
c58859aedf | ||
|
|
0d327cc093 | ||
|
|
45094a9007 | ||
|
|
cffc83bb8b | ||
|
|
44b9c67290 | ||
|
|
f253ba22d4 | ||
|
|
bf27153a24 | ||
|
|
78858b1d42 | ||
|
|
cb59fcd162 | ||
|
|
eda9860e1d | ||
|
|
5b492b240f | ||
|
|
0a28a10e93 | ||
|
|
9e06cafb83 | ||
|
|
7110152122 | ||
|
|
6c78d82c5d | ||
|
|
a82a28179d | ||
|
|
44d3e49db8 | ||
|
|
a3ba9256a4 | ||
|
|
a00475b910 | ||
|
|
e5c713c00b | ||
|
|
8b1758dc73 | ||
|
|
623e7b03dc | ||
|
|
a2bc79274b | ||
|
|
8d9d4d94e3 | ||
|
|
809dd639e2 | ||
|
|
970376b6c5 | ||
|
|
ffb3d2d1ea | ||
|
|
0d1e3dfd41 | ||
|
|
e5dde40e72 | ||
|
|
ff5db37b0e | ||
|
|
37fd66562f | ||
|
|
07042859e1 | ||
|
|
b499251187 | ||
|
|
6197116a9e | ||
|
|
ad531e6374 | ||
|
|
dfb6d1f0fb | ||
|
|
9f2a4433ec | ||
|
|
7c9efae60a | ||
|
|
c829600ac6 | ||
|
|
f4088409ee | ||
|
|
3063237a06 | ||
|
|
e75d4bc1b6 | ||
|
|
ceca6301a6 | ||
|
|
1b62d8d1f2 | ||
|
|
52e186749d | ||
|
|
ba40840da7 | ||
|
|
c97b85dc0b | ||
|
|
fd4a625f7e | ||
|
|
bceac87629 | ||
|
|
e005da2cc0 | ||
|
|
406648ebe2 | ||
|
|
477ae00887 | ||
|
|
3c481aa243 | ||
|
|
ddd00efcbb | ||
|
|
c6cf721a91 | ||
|
|
85c0d5fe3c | ||
|
|
048dafddad | ||
|
|
0feb6f945e | ||
|
|
bcbbdea7a8 | ||
|
|
d33d377090 | ||
|
|
f651954913 | ||
|
|
8f91f896a5 | ||
|
|
f6e255ef2b | ||
|
|
27888dc18d | ||
|
|
16fd80bdcf | ||
|
|
b4da90182f | ||
|
|
ed17feee57 | ||
|
|
87a9d03fd8 | ||
|
|
65e5c14a7a | ||
|
|
43e29d4556 | ||
|
|
480f6b3d1b | ||
|
|
8b10fe0f2a | ||
|
|
89f4d8e635 | ||
|
|
00ca93e19d | ||
|
|
d4c60e0ac4 | ||
|
|
4af27b50a8 | ||
|
|
bc310a28c8 | ||
|
|
0b83386c7e | ||
|
|
a10b26b9ef | ||
|
|
8acefbec58 | ||
|
|
da2acdad41 | ||
|
|
d020b5f93c | ||
|
|
7cc5e640f9 | ||
|
|
1641b3ecc0 | ||
|
|
0e076e0559 | ||
|
|
e8b180b01e | ||
|
|
c84b15d426 | ||
|
|
23f917e309 | ||
|
|
8ba17d98d9 | ||
|
|
e51c33efdd | ||
|
|
388bdfe072 | ||
|
|
e2b0b817d7 | ||
|
|
678f0ac6f4 | ||
|
|
de76c32fe3 | ||
|
|
af2df073d7 | ||
|
|
b7fef4337b | ||
|
|
465a8fda1e | ||
|
|
69ba6c5645 | ||
|
|
61883f815e | ||
|
|
a25e830b28 | ||
|
|
8bb6e0dc11 | ||
|
|
4bbb1d3b42 | ||
|
|
387d6be2a8 | ||
|
|
d993634c18 | ||
|
|
3dc5b432fe | ||
|
|
b043f3977b | ||
|
|
cb8feeeff9 | ||
|
|
d454cd53fb | ||
|
|
e5089d3fe4 | ||
|
|
dd68ab987a | ||
|
|
3bd2b8b820 | ||
|
|
d46cb2ca35 | ||
|
|
e3a24beec6 | ||
|
|
587b85d675 | ||
|
|
deff92036a | ||
|
|
230d0d5911 | ||
|
|
fcd9f1b401 | ||
|
|
68f554e7dc | ||
|
|
ee637b2567 | ||
|
|
cc6bfe2bc3 | ||
|
|
57b2aa470b | ||
|
|
c8ad6a8840 | ||
|
|
57064f15d0 | ||
|
|
21f460c630 | ||
|
|
d05e95409d | ||
|
|
f4852b0660 | ||
|
|
9304a5387e | ||
|
|
6b25a75238 | ||
|
|
36e376591a | ||
|
|
926875ace4 | ||
|
|
1b4ef1de92 | ||
|
|
91e1f8a8cd | ||
|
|
575c9b20df | ||
|
|
e65680f925 | ||
|
|
e24e645156 | ||
|
|
d5ff486d31 | ||
|
|
b477ff2689 | ||
|
|
8a65204b3a | ||
|
|
8115a3a4b1 | ||
|
|
d26c8fba0a | ||
|
|
65bc3d80b5 | ||
|
|
5c5107b72d | ||
|
|
0a6d8aa90a | ||
|
|
497e13808f | ||
|
|
90ca4e0491 | ||
|
|
23d5461fe0 | ||
|
|
65688ae0f0 | ||
|
|
8b718c5d5e | ||
|
|
c5d7bae5fe | ||
|
|
b296f7e2ea | ||
|
|
0f4db0c569 | ||
|
|
f541e240fb | ||
|
|
0ba58d9229 | ||
|
|
450d8c35dc | ||
|
|
e280d96cf2 | ||
|
|
241684332d | ||
|
|
59ec871592 | ||
|
|
b4a85e32e3 | ||
|
|
cbf9b8d5ab | ||
|
|
00643bca10 | ||
|
|
7ac8e3a30a | ||
|
|
1fdcf6b385 | ||
|
|
6d669b0a59 | ||
|
|
93b9775439 | ||
|
|
c723e0894c | ||
|
|
6f1e0a2463 | ||
|
|
08191e2f40 | ||
|
|
439e0ee259 | ||
|
|
a18e7d5d1f | ||
|
|
ec27a1e0b0 | ||
|
|
1d4b04ceb3 | ||
|
|
07c8bdbc0b | ||
|
|
adfda4bd26 | ||
|
|
d0076a5119 | ||
|
|
502a3026aa | ||
|
|
8c7779e17f | ||
|
|
9d9fe2298e | ||
|
|
717b49cbc9 | ||
|
|
3839d80076 | ||
|
|
26752debe1 | ||
|
|
7dd10acb86 | ||
|
|
2a8ae891ae | ||
|
|
65d68266e2 | ||
|
|
c418e970be | ||
|
|
8930bb3faf | ||
|
|
d01ff3b8a4 | ||
|
|
cd9867da5c | ||
|
|
1ee73d821d | ||
|
|
7680fee325 | ||
|
|
860dbf51e1 | ||
|
|
3ef3272ee2 | ||
|
|
9cc33bdda8 | ||
|
|
2d621405c6 | ||
|
|
a0b0a5ba50 | ||
|
|
5fbb18a139 | ||
|
|
1fdd195790 | ||
|
|
95fe89ca0d | ||
|
|
cfb61d623a | ||
|
|
711f099ac8 | ||
|
|
4f43f49cf7 | ||
|
|
ad7b45d3b6 | ||
|
|
35fbfb00dd | ||
|
|
9ca4d10841 | ||
|
|
9e2c0dfde2 | ||
|
|
7b52aa1f85 | ||
|
|
05a7663aab | ||
|
|
2f82051fb4 | ||
|
|
f1b2d7b86f | ||
|
|
223a97787d | ||
|
|
e5e27a7aac | ||
|
|
a65989354d | ||
|
|
83c79c4d4f | ||
|
|
5b372e2f35 | ||
|
|
e3eb634eba | ||
|
|
c00e5801aa | ||
|
|
b1f7c78d54 | ||
|
|
f9676a54ce | ||
|
|
b9f5a45c62 | ||
|
|
b357010174 | ||
|
|
9b611a200b | ||
|
|
2fc88ea632 | ||
|
|
10a4ec4e2d | ||
|
|
5ddb4828b5 | ||
|
|
9bb2c8818d | ||
|
|
40f8bd80da | ||
|
|
e44f25cae1 | ||
|
|
65d2b5e8fe | ||
|
|
c620da0f19 | ||
|
|
e1093d6800 | ||
|
|
148b33f41d | ||
|
|
b80768cd4a | ||
|
|
c8a8bf357b | ||
|
|
d914ceeb53 | ||
|
|
b213e5346b | ||
|
|
2509598c13 | ||
|
|
f0d20beff8 | ||
|
|
934136e813 | ||
|
|
b2bf03c749 | ||
|
|
22b8af021c | ||
|
|
111081b1bc | ||
|
|
1d383e73b3 | ||
|
|
bb674debd6 | ||
|
|
32b24a9e8e | ||
|
|
ee837e88be | ||
|
|
470fb25a89 | ||
|
|
40fee24b86 | ||
|
|
29c318b541 | ||
|
|
a01203c3b2 | ||
|
|
a8f57b1997 | ||
|
|
94e2666542 | ||
|
|
2541cba005 | ||
|
|
9c54d6b294 | ||
|
|
6eac540995 | ||
|
|
93892c4049 | ||
|
|
067b5877d2 | ||
|
|
52890d2f8f | ||
|
|
6521dffcee | ||
|
|
cbc12f96bf | ||
|
|
b1c5484243 | ||
|
|
59dc4a6f14 | ||
|
|
f1ed2f36e6 | ||
|
|
1028b8f19c | ||
|
|
c832b61617 | ||
|
|
4cc2631bc0 | ||
|
|
4e8bdad871 | ||
|
|
f77d6da3bd | ||
|
|
46a3761a41 | ||
|
|
8b4aa65bbd | ||
|
|
8676990b18 | ||
|
|
6f899414a7 | ||
|
|
e3bcd0fdba | ||
|
|
a52f904ff5 | ||
|
|
f4d91f2fbb | ||
|
|
cf8cab121c | ||
|
|
7bcd15e22d | ||
|
|
c17233b793 | ||
|
|
c644282b7b | ||
|
|
704dff73bd | ||
|
|
2fb0d5b493 | ||
|
|
b8511b4888 | ||
|
|
88fd421269 | ||
|
|
ce1500dbbb | ||
|
|
3811132bd2 | ||
|
|
4c6d49a02f | ||
|
|
4f17e42f93 | ||
|
|
ddd3d4d0e3 | ||
|
|
12e5483917 | ||
|
|
d7ff063035 | ||
|
|
c597f06d65 | ||
|
|
c904d2fa1c | ||
|
|
094a083540 | ||
|
|
5c188c619b | ||
|
|
cce03e9367 | ||
|
|
c08119ea7a | ||
|
|
088390b4fe | ||
|
|
ffb0e280b3 | ||
|
|
3d5aed2b20 | ||
|
|
4f5c8307e8 | ||
|
|
b3516f66e2 | ||
|
|
db83571428 | ||
|
|
c58580ead2 | ||
|
|
1aae74b8b1 | ||
|
|
270431ec55 | ||
|
|
6c9ab657fd | ||
|
|
4851a7ab4d | ||
|
|
7570dcff24 | ||
|
|
9e2440253f | ||
|
|
ee4108fdaf | ||
|
|
5d41303466 | ||
|
|
0e515dbfd3 | ||
|
|
5747b52d0c | ||
|
|
bc105393f0 | ||
|
|
0161c4d30b | ||
|
|
5fe97cd6d1 | ||
|
|
e1c4d5dc88 | ||
|
|
49b4dbec3d | ||
|
|
2e1da6db52 | ||
|
|
1f2ebdbd91 | ||
|
|
0820541751 | ||
|
|
cd53a3c0dd | ||
|
|
9fd99d5a4d | ||
|
|
4ecf521195 | ||
|
|
adf54b0c68 | ||
|
|
f554aa5a6c | ||
|
|
11eecf5a6a | ||
|
|
f472da81d1 | ||
|
|
d9517672ef | ||
|
|
0b912926a3 | ||
|
|
d65cd8af19 | ||
|
|
15dce73eb4 | ||
|
|
f98890b386 | ||
|
|
a089727181 | ||
|
|
9f4f39addc | ||
|
|
50cd60645b | ||
|
|
e40a53d871 | ||
|
|
d06b7d7fe3 | ||
|
|
b57d440412 | ||
|
|
253b1f8780 | ||
|
|
d671006122 | ||
|
|
bddb7c34be | ||
|
|
23bceb746d | ||
|
|
0c66d4678d | ||
|
|
9ad0a3abf1 | ||
|
|
90e269dc36 | ||
|
|
b355b3540c | ||
|
|
814a8592da | ||
|
|
43f142115b | ||
|
|
e115843756 | ||
|
|
0bfa991471 | ||
|
|
38eb9d612e | ||
|
|
ed7f8b3998 | ||
|
|
429fda5680 | ||
|
|
fe4b750827 | ||
|
|
f15483df83 | ||
|
|
551be35cc3 | ||
|
|
e35be2e5bc | ||
|
|
d72c67d81a | ||
|
|
2d0c6e984f | ||
|
|
a5ff7b1b31 | ||
|
|
45f155e801 | ||
|
|
bcb2087d2f | ||
|
|
337692aab3 | ||
|
|
c95194b676 | ||
|
|
e2de9a0b28 | ||
|
|
9bab876eb4 | ||
|
|
2f41aedb39 | ||
|
|
e8c951cb3a | ||
|
|
7192a53328 | ||
|
|
230ae976e1 | ||
|
|
e5f3507585 | ||
|
|
1f3ac390da | ||
|
|
a834bdb7cb | ||
|
|
a01414e035 | ||
|
|
eff016f55d | ||
|
|
09ecc06126 | ||
|
|
fbb8f4e6d7 | ||
|
|
53aa834866 | ||
|
|
19de197927 | ||
|
|
ce208cac3a | ||
|
|
ec731c699a | ||
|
|
2643889010 | ||
|
|
e5cbdb422e | ||
|
|
98ecd84b54 | ||
|
|
99050f7ac2 | ||
|
|
e73e31c709 | ||
|
|
890e625fc7 | ||
|
|
c094313726 | ||
|
|
703f9ec127 | ||
|
|
cd52b16c8f | ||
|
|
f935ff68a5 | ||
|
|
4d0630488d | ||
|
|
d9d8f5e2e8 | ||
|
|
5168e4db30 | ||
|
|
5e2e5b4291 | ||
|
|
05d810136c | ||
|
|
4b8f735c7b | ||
|
|
8036e0de7f | ||
|
|
874c4d1cdb | ||
|
|
88a19fab33 | ||
|
|
7fb5b6f214 | ||
|
|
0d0e9d4213 | ||
|
|
4c88da0096 | ||
|
|
6830f201d5 | ||
|
|
8b23993cfb | ||
|
|
16cb6a1204 | ||
|
|
3c257d870c | ||
|
|
907ae1bbb1 | ||
|
|
fd16400b91 | ||
|
|
23f553e285 | ||
|
|
25950c64ad | ||
|
|
31054f520e | ||
|
|
a62fa7375d | ||
|
|
fb4fcb04ec | ||
|
|
23200373f6 | ||
|
|
b6c585706f | ||
|
|
daa6d3b1ca | ||
|
|
468dd3c24b | ||
|
|
0c9b29d20a | ||
|
|
9b6a9a6b55 | ||
|
|
1698ff41f8 | ||
|
|
713c613140 | ||
|
|
7ee905eab2 | ||
|
|
2977badbb8 | ||
|
|
bfeb1e69a8 | ||
|
|
fc4641bc8e | ||
|
|
563ce7338e | ||
|
|
0d9dcfd247 | ||
|
|
e10efca1bc | ||
|
|
8ae79aaad4 | ||
|
|
3f38f88bee | ||
|
|
88e36f9220 | ||
|
|
355b5f099e | ||
|
|
f8f1679153 | ||
|
|
d049c7a175 | ||
|
|
56c8af8bd8 | ||
|
|
0c1b92ce82 | ||
|
|
d2839ff264 | ||
|
|
0a568f21aa | ||
|
|
24e36962aa | ||
|
|
da4ded1d7c | ||
|
|
669aa04e69 | ||
|
|
6c023bdca0 | ||
|
|
83e8e0ba1f | ||
|
|
496d04368d | ||
|
|
9765a0341d | ||
|
|
21ec14c0b2 | ||
|
|
01b88f1524 | ||
|
|
d63d89bb86 | ||
|
|
574c600551 | ||
|
|
f7dc8dd714 | ||
|
|
6fa052b4e0 | ||
|
|
bec89a8927 | ||
|
|
46c09f1c70 | ||
|
|
ae9a990929 | ||
|
|
a4a90ad0a1 | ||
|
|
e06a5ced37 | ||
|
|
d2c4e9ac87 | ||
|
|
29b58bb0fc | ||
|
|
4f76341bdd | ||
|
|
e7fae3d587 | ||
|
|
2e52f01d71 | ||
|
|
3033b8133f | ||
|
|
3fa621d254 | ||
|
|
e040b000b0 | ||
|
|
ddf1be8050 | ||
|
|
32e8e51cfc | ||
|
|
5f0ac95135 | ||
|
|
ca2955ef0c | ||
|
|
0d31254815 | ||
|
|
12a100719a | ||
|
|
6a528894ad | ||
|
|
d848b1e05d | ||
|
|
c727c26612 | ||
|
|
9e6453c457 | ||
|
|
af84c3a547 | ||
|
|
04f73f422a | ||
|
|
7886e3f5d7 | ||
|
|
e8e251df67 | ||
|
|
1fb244e0a5 | ||
|
|
0bc5e0de9c | ||
|
|
6557c0604c | ||
|
|
f3a05ae1a3 | ||
|
|
6f0c7b1441 | ||
|
|
bb1007fae9 | ||
|
|
d5a8d23be6 | ||
|
|
b5dace7edd | ||
|
|
e39901dc6f | ||
|
|
963f465dc6 | ||
|
|
0ada161e69 | ||
|
|
ca5315dc0d | ||
|
|
536fe04942 | ||
|
|
14b5b031d3 | ||
|
|
397e947fac | ||
|
|
db2c7b799d | ||
|
|
bc005e8ac2 | ||
|
|
be75260bc0 | ||
|
|
f5f968df5c | ||
|
|
fd4c3e01e9 | ||
|
|
bd19371acf | ||
|
|
4f3a68c450 | ||
|
|
78c2b92c5e | ||
|
|
fcfe236b92 | ||
|
|
52a0865aac | ||
|
|
19eeab4a2a | ||
|
|
714f7a76da | ||
|
|
87231ee654 | ||
|
|
ca27b2de97 | ||
|
|
1d4c9fa4db | ||
|
|
864f314abf | ||
|
|
971e8a90f0 | ||
|
|
4cf3a14ba0 | ||
|
|
234d5e1cd6 | ||
|
|
0416fe600c | ||
|
|
69d6254b70 | ||
|
|
fc485bb996 | ||
|
|
9ea981a2f3 | ||
|
|
a78bee4642 | ||
|
|
ffc2c2e4b2 | ||
|
|
1dde732325 | ||
|
|
756e03fb81 | ||
|
|
320778a3d2 | ||
|
|
b46c30af80 | ||
|
|
5700a61de3 | ||
|
|
e81001ce80 | ||
|
|
277b7ee5da | ||
|
|
ef4f8d3edc | ||
|
|
c83cf8cbed | ||
|
|
5355284d69 | ||
|
|
78db89ba16 | ||
|
|
8311857928 | ||
|
|
a91acdd348 | ||
|
|
ea678bc6e6 | ||
|
|
8dbde815ba | ||
|
|
83fadad7bf | ||
|
|
6f0f282cf1 | ||
|
|
95d06ac8cd | ||
|
|
8cca9cd942 | ||
|
|
bc92841fe6 | ||
|
|
e759c905f1 | ||
|
|
e2dbf8a03f | ||
|
|
21bdf4f9a3 | ||
|
|
dec8429c3d | ||
|
|
015fbc82c1 | ||
|
|
1bea267634 | ||
|
|
1338b93815 | ||
|
|
5f72e129b2 | ||
|
|
5a453aac0f | ||
|
|
5455bc98f4 | ||
|
|
7c5de46635 | ||
|
|
dd0a1e4972 | ||
|
|
a24fe033cd | ||
|
|
525d7366eb | ||
|
|
a2d7a9f6c5 | ||
|
|
50fd82435c | ||
|
|
d3be73f9d0 | ||
|
|
4d42e8b877 | ||
|
|
d12fd7341c | ||
|
|
9ca7f51d05 | ||
|
|
e17aabe322 | ||
|
|
fb2fb00ec4 | ||
|
|
c24f9a1693 | ||
|
|
b91fb97b1c | ||
|
|
64b7e511b1 | ||
|
|
d0c5f220bc | ||
|
|
dc3d702e01 | ||
|
|
e3e2404a1f | ||
|
|
3e536cb541 | ||
|
|
85b5ff7294 | ||
|
|
ffd04438e3 | ||
|
|
237b58d952 | ||
|
|
c38f433d59 | ||
|
|
531cb06614 | ||
|
|
df86a8dd4f | ||
|
|
58cf8aa10c | ||
|
|
6f654f9180 | ||
|
|
b636b37e9a | ||
|
|
10cc390c21 | ||
|
|
d2ad22fe4c | ||
|
|
e8c32f6610 | ||
|
|
d87ead957e | ||
|
|
56402b95f3 | ||
|
|
7037d586ca | ||
|
|
3acfebb2fe | ||
|
|
63325c9928 | ||
|
|
fbb1d24b53 | ||
|
|
77738c6356 | ||
|
|
300d048a1c | ||
|
|
fc26a5f40a | ||
|
|
4aa8663777 | ||
|
|
6aff9307fe | ||
|
|
d14b3e27ba | ||
|
|
5c4eb67ba2 | ||
|
|
7fe6f090e1 | ||
|
|
6297ea09f6 | ||
|
|
12e23c20a8 | ||
|
|
691f958bc4 | ||
|
|
8c079ed9d7 | ||
|
|
b246a02f55 | ||
|
|
3813fba81c | ||
|
|
93f42ead5b | ||
|
|
05f68f3fdc | ||
|
|
ef61ac7c6c | ||
|
|
0a72d58c25 | ||
|
|
decdf49937 | ||
|
|
a8b5151ce5 | ||
|
|
acce84e884 | ||
|
|
4c08d3e128 | ||
|
|
e9b2bdef24 | ||
|
|
4e6c5fc676 | ||
|
|
166ce92969 | ||
|
|
87ff3f24b1 | ||
|
|
d6653e2ee5 | ||
|
|
18da9c988a | ||
|
|
86ebc77e25 | ||
|
|
90823415c3 | ||
|
|
2017653aff | ||
|
|
4b004cf7d6 | ||
|
|
79360c5867 | ||
|
|
7b98a22fe1 | ||
|
|
0a8ac2e89c | ||
|
|
263ca3d80a | ||
|
|
89b737d88a | ||
|
|
80c9404cf4 | ||
|
|
f4c6f8dc29 | ||
|
|
65967c4324 | ||
|
|
9a8455d017 | ||
|
|
6f9a766556 | ||
|
|
06e43b14df | ||
|
|
12a8cc20b4 | ||
|
|
811545ed8d | ||
|
|
079b4bea68 | ||
|
|
4941dd40b2 | ||
|
|
910d5479eb | ||
|
|
955214ae1b | ||
|
|
e108d1d57f | ||
|
|
a5b45dead5 | ||
|
|
5430fa0f6a | ||
|
|
e743e4e418 | ||
|
|
837d409e31 | ||
|
|
4ce5d55664 | ||
|
|
c8a6cf4364 | ||
|
|
834cc83e85 | ||
|
|
f8eebefe0a | ||
|
|
24c84d13d5 | ||
|
|
b88b7976f6 | ||
|
|
32cce66761 | ||
|
|
f2a3a76c07 | ||
|
|
8ee30558da | ||
|
|
d9a2f157a3 | ||
|
|
abeb5845dd | ||
|
|
aba29be6af | ||
|
|
647ec89bfc | ||
|
|
d169963556 | ||
|
|
2b46e411f5 | ||
|
|
573f398a2a | ||
|
|
37de786282 | ||
|
|
d36f45e213 | ||
|
|
296603342f | ||
|
|
7f8c190b29 | ||
|
|
82cf8916e2 | ||
|
|
8dfd37428a | ||
|
|
da7aa1f73b | ||
|
|
32f99ed5f7 | ||
|
|
358b211e97 | ||
|
|
67591edf8f | ||
|
|
e2ca936487 | ||
|
|
cc2a2a8741 | ||
|
|
4eb85dbdf9 | ||
|
|
1102fa42df |
No files matched your search
@@ -21,6 +21,12 @@
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
|
||||
|
||||
## Other projects' code
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
|
||||
## Self-check
|
||||
|
||||
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
|
||||
|
||||
+70
-12
@@ -1,7 +1,7 @@
|
||||
name: checks
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
|
||||
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
@@ -24,7 +24,10 @@ env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
|
||||
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
|
||||
# verify runs always() and fails unless all three passed.
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -34,6 +37,28 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
fi
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
@@ -41,22 +66,16 @@ jobs:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Keep game artifacts out of Git
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
python tools/check_public_tree.py --history
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
shared-key: verify-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Check formatting and lint
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
@@ -65,8 +84,35 @@ jobs:
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
with:
|
||||
shared-key: verify
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
|
||||
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
|
||||
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
|
||||
# these 4-core runners it was no faster (#4978).
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
@@ -75,6 +121,18 @@ jobs:
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
verify:
|
||||
if: always()
|
||||
needs: [python, clippy, tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Did python, clippy and tests pass?
|
||||
env:
|
||||
RESULTS: ${{ join(needs.*.result, ' ') }}
|
||||
run: |
|
||||
echo "python, clippy, tests: $RESULTS"
|
||||
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
# which never runs what this one built. See docs/release-flow.md.
|
||||
@@ -104,6 +105,13 @@ jobs:
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
asset: horizon-macos-arm64.zip
|
||||
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
|
||||
# programs under Rosetta 2.
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
dir: horizon-macos-x64
|
||||
asset: horizon-macos-x64.zip
|
||||
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
|
||||
# Notarizing the zip can take a few minutes.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
@@ -114,6 +122,8 @@ jobs:
|
||||
HORIZON_VERSION: ${{ inputs.version }}
|
||||
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
|
||||
HORIZON_NIGHTLY: ${{ inputs.nightly }}
|
||||
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -128,6 +138,8 @@ jobs:
|
||||
# keeps its preparations outside the checkout.
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -138,11 +150,11 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
@@ -157,15 +169,15 @@ jobs:
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
|
||||
codesign --verify --strict --verbose=2 "target/release/$binary"
|
||||
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
|
||||
codesign --verify --strict --verbose=2 "$BIN/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
file="target/release/$binary${{ matrix.exe }}"
|
||||
file="$BIN/$binary${{ matrix.exe }}"
|
||||
if [[ "$RUNNER_OS" == macOS ]]; then
|
||||
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
|
||||
@@ -176,7 +188,7 @@ jobs:
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=target/release
|
||||
bin=$BIN
|
||||
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
|
||||
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
|
||||
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
|
||||
@@ -202,7 +214,7 @@ jobs:
|
||||
home="$RUNNER_TEMP/horizon-smoke"
|
||||
rm -rf "$home" && mkdir -p "$home"
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
|
||||
client="$PWD/target/release/horizon-client"
|
||||
client="$PWD/$BIN/horizon-client"
|
||||
resources="$PWD/.local/horizon-client/$version"
|
||||
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
|
||||
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
|
||||
@@ -217,8 +229,9 @@ jobs:
|
||||
EOF
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: runner.environment == 'self-hosted'
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
@@ -231,7 +244,7 @@ jobs:
|
||||
- name: Package
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -8,7 +8,8 @@ name: launcher
|
||||
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
|
||||
# commits.
|
||||
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
|
||||
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
@@ -105,6 +106,13 @@ jobs:
|
||||
mac: true
|
||||
asset: horizon-launcher-macos-arm64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: arm64
|
||||
- name: macos-x64
|
||||
mac: true
|
||||
target: x86_64-apple-darwin
|
||||
asset: horizon-launcher-macos-x64.zip
|
||||
binary: horizon-launcher
|
||||
mac_arch: x64
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
@@ -117,6 +125,8 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
env:
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
|
||||
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
|
||||
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
|
||||
# variable, not a secret. Unset, the launcher says sign-in is not configured.
|
||||
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
|
||||
@@ -140,6 +150,8 @@ jobs:
|
||||
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
|
||||
clean: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
@@ -160,17 +172,17 @@ jobs:
|
||||
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
|
||||
fi
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$(target/release/${{ matrix.binary }} --version)
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
echo "$out"
|
||||
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
|
||||
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
|
||||
- name: Only system libraries
|
||||
run: |
|
||||
file=target/release/${{ matrix.binary }}
|
||||
file="$BIN/${{ matrix.binary }}"
|
||||
case "$RUNNER_OS" in
|
||||
Windows) python ../tools/check_imports.py "$file" ;;
|
||||
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
|
||||
@@ -185,13 +197,28 @@ jobs:
|
||||
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
|
||||
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
launcher="$PWD/target/release/${{ matrix.binary }}"
|
||||
launcher="$PWD/$BIN/${{ matrix.binary }}"
|
||||
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
|
||||
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
|
||||
retry "$launcher" versions
|
||||
"$launcher" links --json
|
||||
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
|
||||
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
|
||||
# (exit 3); a launcher or a script that fails still fails the step.
|
||||
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
|
||||
versions=$(retry "$launcher" --json versions)
|
||||
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
|
||||
status=0
|
||||
python3 -c "$no_build" <<<"$versions" || status=$?
|
||||
if [[ $status == 3 ]]; then
|
||||
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
|
||||
rm -rf "$HORIZON_LAUNCHER_HOME"
|
||||
exit 0
|
||||
fi
|
||||
[[ $status == 0 ]] || exit $status
|
||||
fi
|
||||
retry "$launcher" install latest
|
||||
"$launcher" versions --installed
|
||||
"$launcher" links --json
|
||||
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
|
||||
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
|
||||
client="${clients[0]}"
|
||||
@@ -224,11 +251,11 @@ jobs:
|
||||
# Developer ID): packaging/macos.
|
||||
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
|
||||
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
|
||||
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
|
||||
rm -rf out/Horizon.app
|
||||
else
|
||||
mkdir -p dist
|
||||
cp target/release/${{ matrix.binary }} dist/
|
||||
cp "$BIN/${{ matrix.binary }}" dist/
|
||||
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
|
||||
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
|
||||
else
|
||||
|
||||
@@ -172,7 +172,8 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signature
|
||||
@@ -235,7 +236,8 @@ jobs:
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
|
||||
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
.DS_Store
|
||||
# What the client writes to its working directory when run from the repo root.
|
||||
/options.txt
|
||||
/debug-profile.json
|
||||
/horizon.txt
|
||||
/horizon-graphics.txt
|
||||
/horizon-controller.txt
|
||||
|
||||
+28
-10
@@ -2,7 +2,7 @@
|
||||
|
||||
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
|
||||
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
|
||||
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
welcome on [Discord](https://discord.gg/playhorizon).
|
||||
|
||||
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
|
||||
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
@@ -47,14 +47,31 @@ them (`git config core.hooksPath .githooks` runs it before every commit).
|
||||
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
|
||||
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
|
||||
|
||||
## Only original code
|
||||
## Only original code, or code whose license allows it
|
||||
|
||||
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
|
||||
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
|
||||
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
|
||||
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
|
||||
had been written from another project's source; they come back only as a clean-room rewrite from a written
|
||||
description of the pack format.
|
||||
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
|
||||
another project's source may also be read and used, **only when that project's license allows it**:
|
||||
|
||||
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
|
||||
public-domain dedication. That code may be read, copied or adapted.
|
||||
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
|
||||
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
|
||||
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
|
||||
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
|
||||
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
|
||||
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
|
||||
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
|
||||
at your commit counts. In doubt, don't: ask in #contributors first.
|
||||
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
|
||||
pull request that used another project's source without saying so is closed.
|
||||
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
|
||||
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
|
||||
was changed.
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
|
||||
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
|
||||
another project's source; they come back only as a clean-room rewrite from a written description of the pack
|
||||
format.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
@@ -82,7 +99,8 @@ claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
|
||||
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
|
||||
pull requests already fix. Agents claim the same way.
|
||||
|
||||
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
|
||||
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
|
||||
@@ -118,5 +136,5 @@ new pushes, we add it again.
|
||||
|
||||
## Talk to us
|
||||
|
||||
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
[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)).
|
||||
Generated
+25
-26
@@ -118,15 +118,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -605,17 +596,6 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -830,6 +810,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -904,8 +894,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1117,6 +1109,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"fs4",
|
||||
"gilrs",
|
||||
"glam",
|
||||
"horizon-anticheat",
|
||||
@@ -3464,6 +3457,12 @@ version = "2.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -4661,18 +4660,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
version = "9.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.21",
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
|
||||
+4
-1
@@ -53,7 +53,10 @@ large_enum_variant = "allow"
|
||||
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
|
||||
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
|
||||
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
|
||||
# Drop this and the folder once a wgpu release has both fixes.
|
||||
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
|
||||
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
|
||||
# frame wait for the GPU to finish the one before (#4604).
|
||||
# Drop this and the folder once a wgpu release has these fixes.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
|
||||
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,34 +24,32 @@
|
||||
|
||||
# Horizon
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
|
||||
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
|
||||
|
||||
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
|
||||
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
|
||||
|
||||
## Download
|
||||
|
||||
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
|
||||
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## What works today
|
||||
|
||||
The [documentation](https://playhorizon.dev/docs/) covers each of these.
|
||||
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
|
||||
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
|
||||
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
|
||||
|
||||
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
|
||||
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
|
||||
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
|
||||
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
|
||||
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
|
||||
|
||||
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
|
||||
In the Rust client, with vanilla assets and no mods or shaders:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -69,15 +67,15 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
|
||||
|
||||
## Host a server
|
||||
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
|
||||
|
||||
## Modpacks
|
||||
|
||||
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
|
||||
|
||||
## Agent control
|
||||
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
|
||||
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
|
||||
|
||||
```sh
|
||||
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
|
||||
@@ -88,16 +86,14 @@ horizon-ctl break -1 65 -3 # mines it; replies with
|
||||
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
|
||||
```
|
||||
|
||||
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
|
||||
|
||||
## Build from source
|
||||
|
||||
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
|
||||
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
|
||||
|
||||
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
|
||||
|
||||
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
|
||||
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
|
||||
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
|
||||
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/horizon-rust/horizon.git
|
||||
@@ -107,62 +103,49 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
|
||||
cargo run --release -p horizon-client
|
||||
```
|
||||
|
||||
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
|
||||
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
python3 tools/pr_ready.py
|
||||
```
|
||||
|
||||
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
|
||||
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
|
||||
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
|
||||
|
||||
### Java parity: 100%
|
||||
|
||||
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
|
||||
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
|
||||
and verified against vanilla by differential tests). The percentage is the
|
||||
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
|
||||
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
|
||||
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
|
||||
them too (Rust uses Java's own float maths on each platform).
|
||||
|
||||
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
|
||||
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
|
||||
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
|
||||
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
|
||||
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
|
||||
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
|
||||
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
|
||||
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
|
||||
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
|
||||
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
|
||||
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
|
||||
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
|
||||
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
|
||||
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
|
||||
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
|
||||
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
|
||||
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
|
||||
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
|
||||
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
|
||||
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
|
||||
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
|
||||
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
|
||||
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
|
||||
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
|
||||
|
||||
### Known issues
|
||||
|
||||
@@ -170,9 +153,7 @@ Skins served over https show the default skin unless already cached; pack icons
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
|
||||
|
||||
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
|
||||
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
|
||||
|
||||
## Author
|
||||
|
||||
|
||||
+2
-2
@@ -326,11 +326,11 @@ A mapping table from intermediary and SRG names so pre-26.1 jars can be classifi
|
||||
|
||||
#### A modpack's own resource and shader packs
|
||||
|
||||
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
|
||||
Imported modpacks carry their resource packs into a profile of their own. Shader-pack conversion is still pending.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
|
||||
Resource-pack carry-over is implemented locally: Modrinth common/client overrides, CurseForge's override folder, installed instances (ZIP and directory packs), and hash-checked indexed ZIP downloads. The launcher keeps profile edits and lets resource-only profiles play; packs are enabled in Resource Packs. Synthetic import and profile tests cover the file pipeline; in-game rendering has not been verified. Shader-pack conversion is still pending, so this combined item remains partial ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
## wgpu-hal
|
||||
|
||||
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
|
||||
developers, with one upstream fix applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
|
||||
developers, with upstream fixes applied
|
||||
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
|
||||
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
|
||||
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## Pixelify Sans
|
||||
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
|
||||
|
||||
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
|
||||
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
|
||||
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
|
||||
|
||||
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
|
||||
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
|
||||
|
||||
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
|
||||
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
|
||||
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
|
||||
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
|
||||
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
|
||||
|
||||
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
|
||||
|
||||
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
|
||||
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
|
||||
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
|
||||
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
|
||||
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EXE = '.exe' if os.name == 'nt' else ''
|
||||
SERVERS = ('horizon', 'vanilla', 'pumpkin')
|
||||
|
||||
PROPERTIES = {
|
||||
'level-seed': '12345',
|
||||
'view-distance': '10',
|
||||
'simulation-distance': '10',
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'gamemode': 'creative',
|
||||
'force-gamemode': 'true',
|
||||
'spawn-protection': '0',
|
||||
'max-players': '200',
|
||||
'difficulty': 'easy',
|
||||
'server-ip': '127.0.0.1',
|
||||
'enable-query': 'false',
|
||||
'enable-rcon': 'false',
|
||||
}
|
||||
|
||||
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
|
||||
PUMPKIN_CONFIGURATION = '''\
|
||||
server_address = "127.0.0.1:{port}"
|
||||
seed = "{seed}"
|
||||
max_players = 200
|
||||
view_distance = {view}
|
||||
simulation_distance = {view}
|
||||
online_mode = false
|
||||
default_difficulty = "Easy"
|
||||
allow_chat_reports = false
|
||||
encryption = false
|
||||
lan_broadcast = false
|
||||
|
||||
[networking.bedrock]
|
||||
enabled = false
|
||||
|
||||
[networking.lan_broadcast]
|
||||
enabled = false
|
||||
|
||||
[networking.query]
|
||||
enabled = false
|
||||
|
||||
[networking.rcon]
|
||||
enabled = false
|
||||
'''
|
||||
PUMPKIN_FEATURES = '''\
|
||||
[telemetry]
|
||||
enabled = false
|
||||
|
||||
[plugins]
|
||||
enabled = false
|
||||
'''
|
||||
|
||||
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
|
||||
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
|
||||
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
|
||||
GAMETIME = re.compile(r'The time is (-?\d+)')
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def parse_tick_query(lines):
|
||||
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
|
||||
average = percentiles = None
|
||||
for line in lines:
|
||||
if (m := TICK_AVERAGE.search(line)) and average is None:
|
||||
average = float(m.group(1))
|
||||
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
|
||||
percentiles = tuple(float(g) for g in m.groups())
|
||||
if average is None or percentiles is None:
|
||||
return None
|
||||
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
|
||||
|
||||
|
||||
def parse_gametime(lines):
|
||||
"""The game time `time query gametime` printed, or None."""
|
||||
for line in lines:
|
||||
if m := GAMETIME.search(line):
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def tps(start_ticks, end_ticks, seconds):
|
||||
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
|
||||
if start_ticks is None or end_ticks is None or seconds <= 0:
|
||||
return None
|
||||
return round((end_ticks - start_ticks) / seconds, 2)
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
|
||||
|
||||
|
||||
def cpu_seconds(text):
|
||||
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
|
||||
days, _, rest = text.rpartition('-')
|
||||
seconds = 0.0
|
||||
for part in rest.split(':'):
|
||||
seconds = seconds * 60 + float(part)
|
||||
return seconds + (float(days) * 86400 if days else 0)
|
||||
|
||||
|
||||
def tree_usage(root):
|
||||
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
|
||||
try:
|
||||
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
|
||||
except OSError:
|
||||
return None
|
||||
rows = {}
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 4:
|
||||
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
|
||||
tree, frontier = {root}, [root]
|
||||
while frontier:
|
||||
parent = frontier.pop()
|
||||
for pid, (ppid, _, _) in rows.items():
|
||||
if ppid == parent and pid not in tree:
|
||||
tree.add(pid)
|
||||
frontier.append(pid)
|
||||
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
|
||||
|
||||
|
||||
def prepare(kind, directory, port, args):
|
||||
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
|
||||
directory = Path(directory)
|
||||
if args.pre_generated:
|
||||
shutil.copytree(args.pre_generated, directory / 'world')
|
||||
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
|
||||
'view-distance': str(args.view_distance),
|
||||
'simulation-distance': str(args.view_distance)})
|
||||
if kind == 'pumpkin':
|
||||
config = directory / 'config'
|
||||
config.mkdir()
|
||||
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
|
||||
port=port, seed=args.seed, view=args.view_distance))
|
||||
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
|
||||
if args.pumpkin_config:
|
||||
with (config / 'configuration.toml').open('a') as f:
|
||||
f.write('\n' + Path(args.pumpkin_config).read_text())
|
||||
return [Path(args.exe).resolve()]
|
||||
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
|
||||
(directory / 'eula.txt').write_text('eula=true\n')
|
||||
if kind == 'vanilla':
|
||||
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
|
||||
server = Path(args.exe).resolve()
|
||||
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
|
||||
capture_output=True)
|
||||
toml = directory / 'server.toml'
|
||||
# No web panel in a bench.
|
||||
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
|
||||
return [server, '--dir', directory, 'run']
|
||||
|
||||
|
||||
class Server:
|
||||
"""A server with its console on stdin and its output collected."""
|
||||
|
||||
def __init__(self, command, directory, log_path):
|
||||
self.lines = []
|
||||
self.lock = threading.Lock()
|
||||
self.log = log_path.open('w', encoding='utf-8')
|
||||
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
|
||||
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
|
||||
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
encoding='utf-8', errors='replace', env=env)
|
||||
threading.Thread(target=self._read, daemon=True).start()
|
||||
|
||||
def _read(self):
|
||||
for line in self.process.stdout:
|
||||
with self.lock:
|
||||
self.lines.append(line.rstrip('\n'))
|
||||
self.log.write(line)
|
||||
self.log.flush()
|
||||
|
||||
def count(self):
|
||||
with self.lock:
|
||||
return len(self.lines)
|
||||
|
||||
def since(self, n):
|
||||
with self.lock:
|
||||
return self.lines[n:]
|
||||
|
||||
def wait_for(self, regex, timeout):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if any(regex.search(line) for line in self.since(0)):
|
||||
return
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f'the server was not up within {timeout} s')
|
||||
|
||||
def ask(self, command, parse, timeout=5):
|
||||
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
|
||||
start = self.count()
|
||||
try:
|
||||
self.process.stdin.write(command + '\n')
|
||||
self.process.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
return None
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if (value := parse(self.since(start))) is not None:
|
||||
return value
|
||||
time.sleep(0.05)
|
||||
return None
|
||||
|
||||
def stop(self):
|
||||
if self.process.poll() is None:
|
||||
try:
|
||||
self.process.stdin.write('stop\n')
|
||||
self.process.stdin.flush()
|
||||
self.process.wait(timeout=60)
|
||||
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
self.log.close()
|
||||
|
||||
|
||||
def bots_command(args, port, total):
|
||||
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
|
||||
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
|
||||
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
|
||||
|
||||
|
||||
def split_bot_output(lines):
|
||||
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
|
||||
samples, rest = [], []
|
||||
for line in lines:
|
||||
try:
|
||||
sample = json.loads(line)
|
||||
except ValueError:
|
||||
sample = None
|
||||
if isinstance(sample, dict) and 't' in sample:
|
||||
samples.append(sample)
|
||||
else:
|
||||
rest.append(line)
|
||||
text = ''.join(rest)
|
||||
return samples, (json.loads(text) if text.strip() else {})
|
||||
|
||||
|
||||
def bytes_per_player_second(per_bot, run_seconds, join_every):
|
||||
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
|
||||
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
|
||||
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
|
||||
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
|
||||
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
|
||||
if not counted or online <= 0:
|
||||
return None
|
||||
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
|
||||
|
||||
|
||||
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
|
||||
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
|
||||
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
|
||||
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
|
||||
return {
|
||||
'server': kind,
|
||||
'bots': args.bots,
|
||||
'behaviour': args.behaviour,
|
||||
'start_seconds': start_seconds,
|
||||
'tps': tps(*gametime, window_seconds),
|
||||
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
|
||||
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
|
||||
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
|
||||
'cpu_percent': mean(cpu),
|
||||
'bytes_per_player_second': bandwidth,
|
||||
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
|
||||
'bots_joined': summary.get('joined'),
|
||||
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
|
||||
'chunks_total': summary.get('chunks'),
|
||||
'tick_queries': len(ticks),
|
||||
}
|
||||
|
||||
|
||||
def run_once(kind, args, out):
|
||||
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
|
||||
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
|
||||
server = None
|
||||
try:
|
||||
port = free_port()
|
||||
command = prepare(kind, scratch, port, args)
|
||||
started = time.time()
|
||||
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
|
||||
server.wait_for(DONE, args.start_timeout)
|
||||
start_seconds = round(time.time() - started, 2)
|
||||
total = args.bots * args.join_every + args.warmup + args.seconds
|
||||
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
bot_lines = []
|
||||
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
|
||||
time.sleep(args.bots * args.join_every + args.warmup)
|
||||
first_time = server.ask('time query gametime', parse_gametime)
|
||||
began = time.time()
|
||||
rss, cpu, ticks = [], [], []
|
||||
usage = tree_usage(server.process.pid)
|
||||
last_cpu, last_time = (usage[1] if usage else 0), time.time()
|
||||
next_query = time.time()
|
||||
while time.time() - began < args.seconds:
|
||||
time.sleep(1)
|
||||
now = time.time()
|
||||
if usage and (u := tree_usage(server.process.pid)):
|
||||
rss.append(u[0])
|
||||
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
|
||||
last_cpu, last_time = u[1], now
|
||||
if now >= next_query:
|
||||
if (t := server.ask('tick query', parse_tick_query)) is not None:
|
||||
ticks.append(t)
|
||||
next_query = now + 5
|
||||
window = time.time() - began
|
||||
last = server.ask('time query gametime', parse_gametime)
|
||||
bot_process.wait(timeout=total + 120)
|
||||
time.sleep(0.5)
|
||||
_, summary = split_bot_output(list(bot_lines))
|
||||
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
|
||||
total))
|
||||
except Exception as e: # a failed run is reported, and the next one still runs
|
||||
result['error'] = str(e)
|
||||
finally:
|
||||
if server:
|
||||
server.stop()
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
return result
|
||||
|
||||
|
||||
def table(runs):
|
||||
"""The Markdown table of the design: one column per server, one row per measure."""
|
||||
ok = [r for r in runs if 'error' not in r]
|
||||
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
|
||||
def cell(r, path):
|
||||
value = r
|
||||
for key in path:
|
||||
value = value.get(key) if isinstance(value, dict) else None
|
||||
return '-' if value is None else str(value)
|
||||
rows = [
|
||||
('Players', ('bots',)),
|
||||
('TPS', ('tps',)),
|
||||
('MSPT average', ('mspt', 'average')),
|
||||
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
|
||||
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
|
||||
('RSS mean MB', ('rss_mb', 'mean')),
|
||||
('CPU %', ('cpu_percent',)),
|
||||
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
|
||||
('Join p50 ms', ('join_ms', 'p50')),
|
||||
('Join worst ms', ('join_ms', 'worst')),
|
||||
('Start s', ('start_seconds',)),
|
||||
]
|
||||
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
|
||||
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
|
||||
return head + body + failed
|
||||
|
||||
|
||||
def check(args):
|
||||
for kind in args.server:
|
||||
if kind not in SERVERS:
|
||||
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
|
||||
if 'vanilla' in args.server and not args.jar:
|
||||
sys.exit('vanilla needs --jar, your own server.jar')
|
||||
if any(k != 'vanilla' for k in args.server) and not args.exe:
|
||||
sys.exit('horizon and pumpkin need --exe, the server binary')
|
||||
if not Path(args.bots_bin).exists():
|
||||
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
|
||||
if args.pre_generated and not Path(args.pre_generated).is_dir():
|
||||
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
|
||||
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
|
||||
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
|
||||
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
|
||||
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
|
||||
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
|
||||
parser.add_argument('--bots', type=int, default=50)
|
||||
parser.add_argument('--behaviour', default='mixed')
|
||||
parser.add_argument('--join-every', type=float, default=0.5)
|
||||
parser.add_argument('--warmup', type=int, default=30)
|
||||
parser.add_argument('--seconds', type=int, default=60)
|
||||
parser.add_argument('--seed', type=int, default=12345)
|
||||
parser.add_argument('--view-distance', type=int, default=10)
|
||||
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
|
||||
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
|
||||
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
|
||||
parser.add_argument('--start-timeout', type=int, default=600)
|
||||
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
|
||||
args = parser.parse_args(argv)
|
||||
check(args)
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
runs = []
|
||||
for kind in args.server:
|
||||
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
|
||||
runs.append(run_once(kind, args, args.out))
|
||||
print(json.dumps(runs[-1]), flush=True)
|
||||
report = {
|
||||
'kind': 'horizon-bench',
|
||||
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
|
||||
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
|
||||
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
|
||||
'runs': runs,
|
||||
}
|
||||
path = args.out / 'report.json'
|
||||
path.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print('\n' + table(runs) + f'\nWrote {path}')
|
||||
return 1 if any('error' in r for r in runs) else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -3,8 +3,8 @@ name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
|
||||
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks and items to your worlds".
|
||||
# registries: "May add blocks, items and mob effects to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
|
||||
//!
|
||||
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
|
||||
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
|
||||
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
|
||||
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
|
||||
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
|
||||
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
|
||||
//! first; a swing without an attack (a miss, a block) is fine.
|
||||
|
||||
/// What breaks vanilla's way of attacking.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Foul {
|
||||
/// A client tick with more attacks than swings: how many more.
|
||||
NoSwing(u32),
|
||||
/// A second entity attacked in the same client tick: the first one and this one.
|
||||
TwoTargets(i32, i32),
|
||||
}
|
||||
|
||||
/// The attacks of the client tick being heard.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Attacks {
|
||||
/// The entity this tick attacked first.
|
||||
target: Option<i32>,
|
||||
attacks: u32,
|
||||
swings: u32,
|
||||
}
|
||||
|
||||
impl Attacks {
|
||||
/// ServerboundAttackPacket on entity `target`.
|
||||
pub fn attack(&mut self, target: i32) -> Option<Foul> {
|
||||
self.attacks = self.attacks.saturating_add(1);
|
||||
let first = *self.target.get_or_insert(target);
|
||||
(first != target).then_some(Foul::TwoTargets(first, target))
|
||||
}
|
||||
|
||||
/// ServerboundPunchPacket: the arm swung.
|
||||
pub fn swing(&mut self) {
|
||||
self.swings = self.swings.saturating_add(1);
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
|
||||
/// target again.
|
||||
pub fn tick_end(&mut self) -> Option<Foul> {
|
||||
let unswung = self.attacks.saturating_sub(self.swings);
|
||||
*self = Self::default();
|
||||
(unswung > 0).then_some(Foul::NoSwing(unswung))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_tick_is_clean() {
|
||||
let mut a = Attacks::default();
|
||||
// Two clicks in one tick on the same entity, each swung for.
|
||||
for _ in 0..2 {
|
||||
assert_eq!(a.attack(7), None);
|
||||
a.swing();
|
||||
}
|
||||
assert_eq!(a.tick_end(), None);
|
||||
// The next tick may attack another entity, swinging first (an older client).
|
||||
a.swing();
|
||||
assert_eq!(a.attack(8), None);
|
||||
assert_eq!(a.tick_end(), None);
|
||||
// A swing on its own (a miss, a block) is fine.
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attack_not_swung_for_is_caught() {
|
||||
let mut a = Attacks::default();
|
||||
a.attack(7);
|
||||
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
|
||||
// A swing in the tick before doesn't count for this one.
|
||||
a.swing();
|
||||
a.tick_end();
|
||||
a.attack(7);
|
||||
a.attack(7);
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_entities_in_one_tick_are_caught() {
|
||||
let mut a = Attacks::default();
|
||||
a.attack(7);
|
||||
a.swing();
|
||||
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
|
||||
a.swing();
|
||||
// Still against the tick's first target.
|
||||
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), None);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,22 @@
|
||||
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
|
||||
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
|
||||
//!
|
||||
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
|
||||
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
|
||||
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
|
||||
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
|
||||
//! Any threshold at 0 turns that action off.
|
||||
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
|
||||
//! their own (`combat`); the others log only until they have shown no false positive with real
|
||||
//! players (their `setback`, `alert` and `kick` are 0). `enabled = false` turns the anticheat, or one
|
||||
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
|
||||
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
|
||||
//! player is sent back where the server last had them); from `alert` on, online operators and the
|
||||
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
|
||||
//! that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
pub mod prediction;
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
@@ -71,7 +76,7 @@ macro_rules! checks {
|
||||
}
|
||||
)*
|
||||
|
||||
/// `server.toml`'s `[anticheat]`: off by default.
|
||||
/// `server.toml`'s `[anticheat]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
@@ -88,7 +93,7 @@ macro_rules! checks {
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
@@ -119,7 +124,8 @@ checks! {
|
||||
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
|
||||
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
@@ -128,9 +134,31 @@ checks! {
|
||||
}
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back.
|
||||
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
|
||||
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
|
||||
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
|
||||
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
|
||||
/// `kick = 60`.
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
|
||||
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
|
||||
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
|
||||
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -171,6 +199,10 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
let tolerance = self.prediction.tolerance;
|
||||
if !tolerance.is_finite() || tolerance <= 0.0 {
|
||||
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -198,6 +230,10 @@ pub struct Player {
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -241,12 +277,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_by_default_and_without_a_section() {
|
||||
assert!(!Config::default().enabled);
|
||||
fn on_by_default_but_only_combat_acts() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!c.runs(Check::Timer));
|
||||
assert!(!c.runs(Check::Ground));
|
||||
for &check in Check::ALL {
|
||||
assert!(c.runs(check), "{}", check.name());
|
||||
let a = c.actions(check);
|
||||
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
|
||||
assert_eq!(acts, check == Check::Combat, "{}", check.name());
|
||||
}
|
||||
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
|
||||
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,6 +298,8 @@ mod tests {
|
||||
assert_eq!(c.timer.kick, 0.0);
|
||||
assert_eq!(c.timer.tolerance_ms, 500.0);
|
||||
assert_eq!(c.timer.alert, TimerConfig::default().alert);
|
||||
let zero = "[anticheat]\n[anticheat.prediction]\ntolerance = 0\n";
|
||||
assert!(Config::from_server_toml(zero).is_err());
|
||||
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
|
||||
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
//! The movement prediction's record: each of a player's client ticks as the server heard them, so
|
||||
//! the server can play the tick again with vanilla's movement code and compare (docs/anticheat.md).
|
||||
//!
|
||||
//! A vanilla client ends every tick with ServerboundClientTickEndPacket, after the tick's input
|
||||
//! (ServerboundPlayerInputPacket, sent when the keys change) and its move (ServerboundMovePlayerPacket,
|
||||
//! when it moved or turned). Those packets arrive in order, so the packets between two tick ends are one
|
||||
//! client tick.
|
||||
//!
|
||||
//! What the server changes about the player (a push, a teleport, an effect) takes effect on the client
|
||||
//! when it handles the packet, which is some client ticks after the server sent it. At the end of each of
|
||||
//! its ticks the server sends a ClientboundPingPacket whose id is that server tick; the client answers
|
||||
//! with ServerboundPongPacket as soon as it handles it, before its next tick runs. A client tick knows
|
||||
//! the last server tick it had handled: every change of that server tick or an earlier one was in effect
|
||||
//! during the client tick, none of a later one.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Client ticks kept: two seconds at 20 a second.
|
||||
pub const KEPT: usize = 40;
|
||||
|
||||
/// Server changes kept, the oldest dropped first.
|
||||
const CHANGES_KEPT: usize = 64;
|
||||
|
||||
/// Changes near the player kept until every kept tick has seen them.
|
||||
const NEAR_KEPT: usize = 1024;
|
||||
|
||||
/// Something the server sent the player that changes how its client moves. The client applies it when
|
||||
/// it handles the packet, before its next tick.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Change {
|
||||
/// ClientboundSetEntityMotionPacket for the player itself: its new velocity, as the client
|
||||
/// decodes it.
|
||||
Velocity([f64; 3]),
|
||||
/// ClientboundExplodePacket's player knockback, added to the velocity.
|
||||
Knockback([f64; 3]),
|
||||
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
|
||||
/// attributes and effects, as they are when the replay reaches it.
|
||||
State,
|
||||
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
|
||||
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
|
||||
/// tick played before the client had them may be off.
|
||||
Abilities,
|
||||
}
|
||||
|
||||
/// A teleport the client accepted: the replay starts again from it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Restart {
|
||||
/// Where it put the player.
|
||||
pub at: [f64; 3],
|
||||
/// It kept the player's motion (an End gateway's).
|
||||
pub kept_motion: bool,
|
||||
/// Client ticks ended and changes heard before it: the ones after are the replay's.
|
||||
pub ended: u64,
|
||||
pub changed: u64,
|
||||
}
|
||||
|
||||
/// One client tick.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ClientTick {
|
||||
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
|
||||
/// shift, sprint), as last sent.
|
||||
pub input: u8,
|
||||
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
|
||||
pub sprinting: bool,
|
||||
/// The rotation the client last sent.
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
/// Where the tick's move said the player is, if it said: a move with a position.
|
||||
pub position: Option<[f64; 3]>,
|
||||
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
|
||||
pub flags: Option<u8>,
|
||||
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
|
||||
pub seen: Option<u64>,
|
||||
}
|
||||
|
||||
/// A player's last client ticks.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Record {
|
||||
/// The tick being heard.
|
||||
current: ClientTick,
|
||||
/// The ended ones, oldest first, at most [`KEPT`].
|
||||
ticks: VecDeque<ClientTick>,
|
||||
/// The last server tick the client answered for.
|
||||
seen: Option<u64>,
|
||||
/// Client ticks ended since the record began.
|
||||
ended: u64,
|
||||
/// Where an accepted teleport put the player, until the replay starts again from there.
|
||||
restart: Option<Restart>,
|
||||
/// What the server changed, with the server tick that sent it, oldest first.
|
||||
changes: VecDeque<(u64, Change)>,
|
||||
/// Changes heard since the record began.
|
||||
changed: u64,
|
||||
/// The blocks the server changed near the player (None: an entity it stopped showing the player
|
||||
/// from near it, which counts wherever the player has gone since), with the server tick that sent
|
||||
/// each, until every kept tick has seen them.
|
||||
near: VecDeque<(u64, Option<[i32; 3]>)>,
|
||||
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
|
||||
near_dropped: Option<u64>,
|
||||
}
|
||||
|
||||
impl Record {
|
||||
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
|
||||
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
|
||||
if position.is_some() {
|
||||
self.current.position = position;
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
(self.current.yaw, self.current.pitch) = (yaw, pitch);
|
||||
}
|
||||
self.current.flags = Some(flags);
|
||||
}
|
||||
|
||||
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
|
||||
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
|
||||
/// every ping, once, in order.
|
||||
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
|
||||
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
|
||||
return false;
|
||||
}
|
||||
self.seen = Some(server_tick);
|
||||
true
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
|
||||
/// next tick starts with the same keys and rotation, until the client says otherwise.
|
||||
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
|
||||
self.current.input = input;
|
||||
self.current.sprinting = sprinting;
|
||||
self.current.seen = self.seen;
|
||||
let next = ClientTick {
|
||||
yaw: self.current.yaw,
|
||||
pitch: self.current.pitch,
|
||||
..ClientTick::default()
|
||||
};
|
||||
let ended = std::mem::replace(&mut self.current, next);
|
||||
if self.ticks.len() == KEPT {
|
||||
self.ticks.pop_front();
|
||||
}
|
||||
self.ticks.push_back(ended);
|
||||
self.ended += 1;
|
||||
// What the oldest tick kept had seen, every later one had too.
|
||||
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
|
||||
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
|
||||
self.near.pop_front();
|
||||
}
|
||||
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
|
||||
}
|
||||
self.ticks.back().expect("just pushed")
|
||||
}
|
||||
|
||||
/// The ended ticks, oldest first.
|
||||
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
|
||||
self.ticks.iter()
|
||||
}
|
||||
|
||||
/// The last server tick the client answered for.
|
||||
pub fn seen(&self) -> Option<u64> {
|
||||
self.seen
|
||||
}
|
||||
|
||||
/// Client ticks ended since the record began.
|
||||
pub fn ended(&self) -> u64 {
|
||||
self.ended
|
||||
}
|
||||
|
||||
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
|
||||
/// kept.
|
||||
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
|
||||
let new = self.ended.checked_sub(played)?;
|
||||
let new = usize::try_from(new)
|
||||
.ok()
|
||||
.filter(|&n| n <= self.ticks.len())?;
|
||||
Some(self.ticks.iter().skip(self.ticks.len() - new))
|
||||
}
|
||||
|
||||
/// `change`, sent during server tick `server_tick` (before that tick's ping).
|
||||
pub fn changed(&mut self, server_tick: u64, change: Change) {
|
||||
if self.changes.len() == CHANGES_KEPT {
|
||||
self.changes.pop_front();
|
||||
}
|
||||
self.changes.push_back((server_tick, change));
|
||||
self.changed += 1;
|
||||
}
|
||||
|
||||
/// Changes heard since the record began.
|
||||
pub fn changes(&self) -> u64 {
|
||||
self.changed
|
||||
}
|
||||
|
||||
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
|
||||
pub fn changes_since(
|
||||
&self,
|
||||
applied: u64,
|
||||
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
|
||||
let new = self.changed.checked_sub(applied)?;
|
||||
let new = usize::try_from(new)
|
||||
.ok()
|
||||
.filter(|&n| n <= self.changes.len())?;
|
||||
Some(self.changes.iter().skip(self.changes.len() - new))
|
||||
}
|
||||
|
||||
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
|
||||
/// at `pos`.
|
||||
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
|
||||
self.note_near(server_tick, Some(pos));
|
||||
}
|
||||
|
||||
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
|
||||
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
|
||||
pub fn entity_gone(&mut self, server_tick: u64) {
|
||||
if self.near.back() != Some(&(server_tick, None)) {
|
||||
self.note_near(server_tick, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
|
||||
if self.near.len() == NEAR_KEPT {
|
||||
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
|
||||
}
|
||||
self.near.push_back((server_tick, pos));
|
||||
}
|
||||
|
||||
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
|
||||
/// not have had a change at a position for which `near` holds: it still had the old block, or the
|
||||
/// entity, where the server no longer has it.
|
||||
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
|
||||
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
|
||||
self.near_dropped.is_some_and(unseen)
|
||||
|| self
|
||||
.near
|
||||
.iter()
|
||||
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
|
||||
}
|
||||
|
||||
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
|
||||
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
|
||||
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
|
||||
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
|
||||
self.restart_tick();
|
||||
self.restart = Some(Restart {
|
||||
at,
|
||||
kept_motion,
|
||||
ended: self.ended,
|
||||
changed: self.changed,
|
||||
});
|
||||
}
|
||||
|
||||
/// The last teleport the client accepted, once.
|
||||
pub fn take_restart(&mut self) -> Option<Restart> {
|
||||
self.restart.take()
|
||||
}
|
||||
|
||||
/// The ticks before now say nothing about the ones after.
|
||||
pub fn clear(&mut self) {
|
||||
self.ticks.clear();
|
||||
self.restart_tick();
|
||||
}
|
||||
|
||||
fn restart_tick(&mut self) {
|
||||
self.current = ClientTick {
|
||||
yaw: self.current.yaw,
|
||||
pitch: self.current.pitch,
|
||||
..ClientTick::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
|
||||
let mut r = Record::default();
|
||||
r.changed_near(4, [1, 2, 3]);
|
||||
r.changed_near(5, [7, 2, 3]);
|
||||
let near = |p: [i32; 3]| p[0] < 5;
|
||||
assert!(r.unseen_near(None, near));
|
||||
assert!(r.unseen_near(Some(3), near));
|
||||
assert!(!r.unseen_near(Some(4), near));
|
||||
assert!(r.unseen_near(Some(4), |_| true));
|
||||
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
|
||||
r.tick_end(0, false);
|
||||
assert!(r.pong(5, 5));
|
||||
r.tick_end(0, false);
|
||||
assert!(r.unseen_near(Some(3), near));
|
||||
for _ in 0..KEPT {
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
assert!(!r.unseen_near(None, |_| true));
|
||||
// An entity gone counts anywhere.
|
||||
r.entity_gone(5);
|
||||
r.entity_gone(5);
|
||||
assert!(r.unseen_near(Some(4), |_| false));
|
||||
assert!(!r.unseen_near(Some(5), |_| false));
|
||||
// One dropped early counts for the ticks that hadn't seen it.
|
||||
for i in 0..=NEAR_KEPT as u64 {
|
||||
r.changed_near(6 + i, [100, 0, 0]);
|
||||
}
|
||||
assert!(
|
||||
r.unseen_near(Some(5), near),
|
||||
"a change dropped unseen counts"
|
||||
);
|
||||
assert!(!r.unseen_near(Some(6), near));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_is_the_packets_between_two_tick_ends() {
|
||||
let mut r = Record::default();
|
||||
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
|
||||
let t = *r.tick_end(0b1001, true);
|
||||
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
|
||||
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
|
||||
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
|
||||
// A tick without a move keeps the rotation and has no position.
|
||||
let t = *r.tick_end(0, false);
|
||||
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
|
||||
assert_eq!(r.ticks().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
|
||||
let mut r = Record::default();
|
||||
assert!(r.pong(5, 6));
|
||||
r.tick_end(0, false);
|
||||
assert!(r.pong(6, 6));
|
||||
r.tick_end(0, false);
|
||||
r.tick_end(0, false);
|
||||
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
|
||||
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
|
||||
// Never sent, answered twice, or out of order.
|
||||
assert!(!r.pong(9, 6));
|
||||
assert!(!r.pong(6, 6));
|
||||
assert!(!r.pong(4, 6));
|
||||
assert_eq!(r.seen(), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_two_seconds_are_kept() {
|
||||
let mut r = Record::default();
|
||||
for i in 0..KEPT + 5 {
|
||||
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
assert_eq!(r.ticks().len(), KEPT);
|
||||
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
|
||||
r.clear();
|
||||
assert_eq!(r.ticks().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_replay_takes_the_ticks_it_has_not_played() {
|
||||
let mut r = Record::default();
|
||||
for i in 0..3 {
|
||||
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
}
|
||||
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
|
||||
r.since(played)
|
||||
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
|
||||
}
|
||||
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
|
||||
assert_eq!(x(&r, 3), Some(vec![]));
|
||||
// Played ahead of the record, or so far behind the ticks are gone.
|
||||
assert_eq!(x(&r, 4), None);
|
||||
r.clear();
|
||||
assert_eq!(x(&r, 2), None);
|
||||
assert_eq!(x(&r, 3), Some(vec![]));
|
||||
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
|
||||
r.tick_end(0, false);
|
||||
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
|
||||
r.teleported([5.0, 6.0, 7.0], false);
|
||||
// A tick that ends before the replay starts again is still one after the teleport.
|
||||
r.tick_end(0, false);
|
||||
let restart = r.take_restart().unwrap();
|
||||
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
|
||||
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
|
||||
// The tick ended before it stays for a replay that hasn't played it; the move of the one
|
||||
// under way is gone.
|
||||
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
|
||||
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
|
||||
assert_eq!(r.take_restart(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_keep_the_server_tick_that_sent_them() {
|
||||
let mut r = Record::default();
|
||||
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
|
||||
r.changed(5, Change::State);
|
||||
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
|
||||
assert_eq!(
|
||||
all,
|
||||
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
|
||||
);
|
||||
assert_eq!(r.changes_since(1).unwrap().count(), 1);
|
||||
assert!(r.changes_since(3).is_none());
|
||||
for i in 0..CHANGES_KEPT as u64 {
|
||||
r.changed(6 + i, Change::State);
|
||||
}
|
||||
// The first two are gone.
|
||||
assert!(r.changes_since(1).is_none());
|
||||
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
mod component_predicate;
|
||||
mod special;
|
||||
pub use special::{SpecialKind, SpecialModel};
|
||||
|
||||
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
|
||||
ExtendedView,
|
||||
KeybindDown(String),
|
||||
ViewEntity,
|
||||
/// A `component` predicate, evaluated by the host.
|
||||
/// A single `component` predicate; external predicate types use the host.
|
||||
Component(Value),
|
||||
}
|
||||
impl ConditionProperty {
|
||||
@@ -651,13 +652,7 @@ impl ConditionProperty {
|
||||
"minecraft:extended_view" => Self::ExtendedView,
|
||||
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
|
||||
"minecraft:view_entity" => Self::ViewEntity,
|
||||
"minecraft:component" => {
|
||||
let predicate = required(m, "predicate")?;
|
||||
if object(predicate)?.len() != 1 {
|
||||
return Err(err("a single component predicate is expected"));
|
||||
}
|
||||
Self::Component(predicate.clone())
|
||||
}
|
||||
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
|
||||
_ => return Err(err("unknown condition property")),
|
||||
})
|
||||
}
|
||||
@@ -964,10 +959,14 @@ pub trait ItemEnv {
|
||||
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
|
||||
0.0
|
||||
}
|
||||
/// `ItemStack.getMaxStackSize` when the prototype has no component.
|
||||
/// Additional component predicate types supplied by the host.
|
||||
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
/// Membership in the level's enchantment registry tags.
|
||||
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_keybind_down(&self, _keybind: &str) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
|
||||
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
|
||||
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
|
||||
ConditionProperty::Component(predicate) => {
|
||||
self.env.component_matches(self.stack, predicate)
|
||||
component_predicate::matches(predicate, self.components, self.env)
|
||||
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Component conditions used by enchantment-dependent item models.
|
||||
use super::*;
|
||||
|
||||
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
|
||||
/// (`predicate`) and its wrapped codec's separate `value` field.
|
||||
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
let id = component_type(required(m, "predicate")?)?;
|
||||
let mut value = required(m, "value")?.clone();
|
||||
if matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
let mut entries = Vec::new();
|
||||
for entry in list(&value)? {
|
||||
let mut entry = object(entry)?.clone();
|
||||
if let Some(selector) = get(&entry, "enchantments") {
|
||||
let selector = match selector {
|
||||
Value::Array(ids) => Value::Array(
|
||||
ids.iter()
|
||||
.map(ident)
|
||||
.map(|r| r.map(Value::String))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Value::String(name) if name.starts_with('#') => {
|
||||
Value::String(format!("#{}", identifier(&name[1..])?))
|
||||
}
|
||||
_ => Value::String(ident(selector)?),
|
||||
};
|
||||
entry.insert("enchantments".into(), selector);
|
||||
}
|
||||
if let Some(levels) = get(&entry, "levels") {
|
||||
let levels = if levels.is_object() {
|
||||
let mut bounds = Map::new();
|
||||
for key in ["min", "max"] {
|
||||
if let Some(n) = get(object(levels)?, key) {
|
||||
bounds.insert(key.into(), Value::from(int(n)?));
|
||||
}
|
||||
}
|
||||
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
|
||||
if bounds
|
||||
.get("min")
|
||||
.and_then(Value::as_i64)
|
||||
.zip(bounds.get("max").and_then(Value::as_i64))
|
||||
.is_some_and(|(min, max)| min > max)
|
||||
{
|
||||
return Err(err("minimum exceeds maximum"));
|
||||
}
|
||||
Value::Object(bounds)
|
||||
} else {
|
||||
Value::from(int(levels)?)
|
||||
};
|
||||
entry.insert("levels".into(), levels);
|
||||
}
|
||||
entries.push(Value::Object(entry));
|
||||
}
|
||||
value = Value::Array(entries);
|
||||
}
|
||||
Ok(Value::Object(Map::from_iter([(id, value)])))
|
||||
}
|
||||
|
||||
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
|
||||
/// all entries must match; each entry needs one selected, present enchantment
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
if !matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let Some(have) = components.get(id).and_then(Value::as_object) else {
|
||||
return Some(false);
|
||||
};
|
||||
Some(expected.as_array()?.iter().all(|entry| {
|
||||
let Some(entry) = entry.as_object() else {
|
||||
return false;
|
||||
};
|
||||
have.iter().any(|(id, level)| {
|
||||
let level = level.as_i64().unwrap_or(0);
|
||||
let selector = get(entry, "enchantments");
|
||||
let selected = match selector {
|
||||
None => true,
|
||||
Some(Value::String(s)) => s
|
||||
.strip_prefix('#')
|
||||
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
|
||||
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
|
||||
_ => false,
|
||||
};
|
||||
selected
|
||||
&& if selector.is_some() {
|
||||
matches_enchantment(level, get(entry, "levels"))
|
||||
} else {
|
||||
level_matches(level, get(entry, "levels"))
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
|
||||
/// nonzero level even when its bounds accept zero.
|
||||
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
|
||||
level != 0 && level_matches(level, bounds)
|
||||
}
|
||||
|
||||
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
|
||||
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
|
||||
match bounds {
|
||||
None => true,
|
||||
Some(Value::Number(n)) => n.as_i64() == Some(level),
|
||||
Some(Value::Object(bounds)) => {
|
||||
get(bounds, "min")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|min| level >= min)
|
||||
&& get(bounds, "max")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|max| level <= max)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
struct Tags;
|
||||
impl ItemEnv for Tags {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
enchantment == "minecraft:density" && tag == "horizon:mace"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
|
||||
let components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_selectors_use_the_hosts_registry_tags() {
|
||||
let condition = json!({"predicate":"enchantments", "value":[
|
||||
{"enchantments":"#horizon:mace", "levels":{"min":3}}
|
||||
]});
|
||||
let predicate = decode(condition.as_object().unwrap()).unwrap();
|
||||
let mut components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -335,6 +335,177 @@ pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
}
|
||||
|
||||
/// A JOML `Matrix3f`: `m[column][row]`.
|
||||
type Mat3 = [[f32; 3]; 3];
|
||||
|
||||
/// `Matrix3f.mul`: `a * b`.
|
||||
fn mul3(a: &Mat3, b: &Mat3) -> Mat3 {
|
||||
std::array::from_fn(|c| {
|
||||
std::array::from_fn(|r| fma(a[0][r], b[c][0], fma(a[1][r], b[c][1], a[2][r] * b[c][2])))
|
||||
})
|
||||
}
|
||||
/// `Matrix3f.transpose`.
|
||||
fn transpose3(m: &Mat3) -> Mat3 {
|
||||
std::array::from_fn(|c| std::array::from_fn(|r| m[r][c]))
|
||||
}
|
||||
/// `Matrix3f.rotate(Quaternionfc)`: `m * R(q)`, without fma.
|
||||
fn rotate3(m: &Mat3, q: [f32; 4]) -> Mat3 {
|
||||
let [x, y, z, w] = q;
|
||||
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
|
||||
let (zw, xy, xz, yw, yz, xw) = (z * w, x * y, x * z, y * w, y * z, x * w);
|
||||
let (zw, xy, xz, yw, yz, xw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
|
||||
let rotation = [
|
||||
[w2 + x2 - z2 - y2, xy + zw, xz - yw],
|
||||
[xy - zw, y2 - z2 + w2 - x2, yz + xw],
|
||||
[yw + xz, yz - xw, z2 - y2 - x2 + w2],
|
||||
];
|
||||
std::array::from_fn(|c| {
|
||||
let rc = rotation[c];
|
||||
std::array::from_fn(|r| m[0][r] * rc[0] + m[1][r] * rc[1] + m[2][r] * rc[2])
|
||||
})
|
||||
}
|
||||
/// `Quaternionf.mul`: `a * b`.
|
||||
fn quaternion_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||||
let ([x, y, z, w], [qx, qy, qz, qw]) = (a, b);
|
||||
[
|
||||
fma(w, qx, fma(x, qw, fma(y, qz, -z * qy))),
|
||||
fma(w, qy, fma(-x, qz, fma(y, qw, z * qx))),
|
||||
fma(w, qz, fma(x, qy, fma(-y, qx, z * qw))),
|
||||
fma(w, qw, fma(-x, qx, fma(-y, qy, -z * qz))),
|
||||
]
|
||||
}
|
||||
|
||||
/// `GivensParameters`: the half angle's sine and cosine.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Givens(f32, f32);
|
||||
|
||||
impl Givens {
|
||||
/// `GivensParameters.fromUnnormalized`.
|
||||
fn unnormalized(sin_half: f32, cos_half: f32) -> Self {
|
||||
let inv = 1.0 / (sin_half * sin_half + cos_half * cos_half).sqrt();
|
||||
Self(inv * sin_half, inv * cos_half)
|
||||
}
|
||||
/// `MatrixUtil.PI_4`: `fromPositiveAngle(π / 4)`.
|
||||
fn quarter_pi() -> Self {
|
||||
let half = std::f32::consts::FRAC_PI_4 / 2.0;
|
||||
let sin = joml_sin(half);
|
||||
Self(sin, cos_from_sin(sin, half))
|
||||
}
|
||||
fn inverse(self) -> Self {
|
||||
Self(-self.0, self.1)
|
||||
}
|
||||
fn cos(self) -> f32 {
|
||||
self.1 * self.1 - self.0 * self.0
|
||||
}
|
||||
fn sin(self) -> f32 {
|
||||
2.0 * self.0 * self.1
|
||||
}
|
||||
/// `aroundX/Y/Z(Quaternionf)` (axis 0, 1, 2).
|
||||
fn quaternion(self, axis: usize) -> [f32; 4] {
|
||||
let mut q = [0.0, 0.0, 0.0, self.1];
|
||||
q[axis] = self.0;
|
||||
q
|
||||
}
|
||||
/// `aroundX/Y/Z(Matrix3f)` (axis 0, 1, 2).
|
||||
fn matrix(self, axis: usize) -> Mat3 {
|
||||
let (cos, sin) = (self.cos(), self.sin());
|
||||
let (a, b) = ((axis + 1) % 3, (axis + 2) % 3);
|
||||
let mut m = [[0.0; 3]; 3];
|
||||
m[axis][axis] = 1.0;
|
||||
m[a][a] = cos;
|
||||
m[b][b] = cos;
|
||||
m[a][b] = sin;
|
||||
m[b][a] = -sin;
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// `MatrixUtil.approxGivensQuat`.
|
||||
fn approx_givens(a11: f32, a12: f32, a22: f32) -> Givens {
|
||||
let g = 3.0 + 2.0 * 2.0f32.sqrt();
|
||||
let (cos_half, sin_half) = (2.0 * (a11 - a22), a12);
|
||||
if g * sin_half * sin_half < cos_half * cos_half {
|
||||
Givens::unnormalized(sin_half, cos_half)
|
||||
} else {
|
||||
Givens::quarter_pi()
|
||||
}
|
||||
}
|
||||
/// `MatrixUtil.qrGivensQuat`.
|
||||
fn qr_givens(a1: f32, a2: f32) -> Givens {
|
||||
let p = f64::from(a1).hypot(f64::from(a2)) as f32;
|
||||
let mut sin_half = if p > 1.0e-6 { a2 } else { 0.0 };
|
||||
let mut cos_half = a1.abs() + if p > 1.0e-6 { p } else { 1.0e-6 };
|
||||
if a1 < 0.0 {
|
||||
std::mem::swap(&mut sin_half, &mut cos_half);
|
||||
}
|
||||
Givens::unnormalized(sin_half, cos_half)
|
||||
}
|
||||
/// `MatrixUtil.stepJacobi`: the xy, xz then yz pairs, each by
|
||||
/// `similarityTransform` (`R^T * m * R`).
|
||||
fn step_jacobi(m: &mut Mat3, q: &mut [f32; 4]) {
|
||||
let mut rotate = |m: &mut Mat3, g: Givens, axis: usize| {
|
||||
*q = quaternion_mul(*q, g.quaternion(axis));
|
||||
let r = g.matrix(axis);
|
||||
*m = mul3(&transpose3(&r), &mul3(m, &r));
|
||||
};
|
||||
if m[0][1] * m[0][1] + m[1][0] * m[1][0] > 1.0e-6 {
|
||||
let g = approx_givens(m[0][0], 0.5 * (m[0][1] + m[1][0]), m[1][1]);
|
||||
rotate(m, g, 2);
|
||||
}
|
||||
if m[0][2] * m[0][2] + m[2][0] * m[2][0] > 1.0e-6 {
|
||||
let g = approx_givens(m[0][0], 0.5 * (m[0][2] + m[2][0]), m[2][2]).inverse();
|
||||
rotate(m, g, 1);
|
||||
}
|
||||
if m[1][2] * m[1][2] + m[2][1] * m[2][1] > 1.0e-6 {
|
||||
let g = approx_givens(m[1][1], 0.5 * (m[1][2] + m[2][1]), m[2][2]);
|
||||
rotate(m, g, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MatrixUtil.svdDecompose(Matrix4fc, ...)`, how `new Transformation(matrix)`
|
||||
/// splits a matrix: translation, left rotation, scale, right rotation. The
|
||||
/// right rotation diagonalizes `a^T * a` (`eigenvalueJacobi`, 5 steps), then
|
||||
/// QR Givens rotations about z, y and x bring `a * R(right)` to the scale.
|
||||
pub fn svd_decompose(m: &Mat4) -> ([f32; 3], [f32; 4], [f32; 3], [f32; 4]) {
|
||||
let inv = 1.0 / m.0[3][3];
|
||||
let a: Mat3 = std::array::from_fn(|c| std::array::from_fn(|r| m.0[c][r] * inv));
|
||||
let mut b = mul3(&transpose3(&a), &a);
|
||||
let mut right = [0.0, 0.0, 0.0, 1.0];
|
||||
for _ in 0..5 {
|
||||
step_jacobi(&mut b, &mut right);
|
||||
}
|
||||
right = normalize_quaternion(right);
|
||||
let (x_small, y_small) = (f64::from(b[0][0]) < 1.0e-6, f64::from(b[1][1]) < 1.0e-6);
|
||||
let mut left = [0.0, 0.0, 0.0, 1.0];
|
||||
let mut r = rotate3(&a, right);
|
||||
let mut qr = |r: &mut Mat3, g: Givens, axis: usize| {
|
||||
left = quaternion_mul(left, g.quaternion(axis));
|
||||
*r = mul3(&transpose3(&g.matrix(axis)), r);
|
||||
};
|
||||
let g = if x_small {
|
||||
qr_givens(r[1][1], -r[1][0])
|
||||
} else {
|
||||
qr_givens(r[0][0], r[0][1])
|
||||
};
|
||||
qr(&mut r, g, 2);
|
||||
let g = if x_small {
|
||||
qr_givens(r[2][2], -r[2][0])
|
||||
} else {
|
||||
qr_givens(r[0][0], r[0][2])
|
||||
};
|
||||
qr(&mut r, g.inverse(), 1);
|
||||
let g = if y_small {
|
||||
qr_givens(r[2][2], -r[2][1])
|
||||
} else {
|
||||
qr_givens(r[1][1], r[1][2])
|
||||
};
|
||||
qr(&mut r, g, 0);
|
||||
let scale = [r[0][0], r[1][1], r[2][2]];
|
||||
let translation = std::array::from_fn(|i| m.0[3][i] * inv);
|
||||
let [x, y, z, w] = right;
|
||||
(translation, left, scale, [-x, -y, -z, w])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -355,6 +526,51 @@ mod tests {
|
||||
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn svd_decomposes_like_matrix_util() {
|
||||
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
let parts = |m: [f32; 16]| {
|
||||
let (t, l, s, r) = svd_decompose(&Mat4::from_row_major(m));
|
||||
[bits(&t), bits(&l), bits(&s), bits(&r)]
|
||||
};
|
||||
// Values from MatrixUtil.svdDecompose on the 26.3 jar.
|
||||
let scaled = [
|
||||
2.0, 0.0, 0.0, 1.0, 0.0, 3.0, 0.0, -2.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
assert_eq!(
|
||||
parts(scaled),
|
||||
[
|
||||
vec![0x3f80_0000u32, 0xc000_0000, 0x3e80_0000],
|
||||
vec![0u32, 0, 0, 0x3f80_0000],
|
||||
vec![0x4000_0000u32, 0x4040_0000, 0x3f00_0000],
|
||||
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
|
||||
]
|
||||
);
|
||||
let quarter_turn = [
|
||||
0.0, -1.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
assert_eq!(
|
||||
parts(quarter_turn),
|
||||
[
|
||||
vec![0x3f00_0000u32, 0, 0],
|
||||
vec![0u32, 0, 0x3f35_04f3, 0x3f35_04f3],
|
||||
vec![0x3f7f_ffffu32, 0x3f7f_ffff, 0x3f80_0000],
|
||||
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
|
||||
]
|
||||
);
|
||||
let sheared = [
|
||||
1.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0,
|
||||
];
|
||||
assert_eq!(
|
||||
parts(sheared),
|
||||
[
|
||||
vec![0u32, 0, 0x3fc0_0000],
|
||||
vec![0x3c03_7010u32, 0x3d38_d722, 0x3ea8_ef95, 0x3f71_60d9],
|
||||
vec![0x3f23_2630u32, 0x3ec6_bdde, 0x3f81_5b1f],
|
||||
vec![0xbcc5_7e11u32, 0xbda8_1e36, 0xbee2_916d, 0x3f64_860a],
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn half_turn_has_exact_zeros_like_joml() {
|
||||
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
|
||||
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
|
||||
|
||||
+238
-17
@@ -15,10 +15,13 @@
|
||||
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
|
||||
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
|
||||
|
||||
//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after
|
||||
//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds).
|
||||
|
||||
use horizon_protocol::{
|
||||
PROTOCOL_VERSION,
|
||||
buf::{Reader, Writer},
|
||||
connection::{ConnWriter, Connection},
|
||||
connection::{ConnWriter, Connection, Counters},
|
||||
crypto::offline_uuid,
|
||||
ids,
|
||||
item::Stack,
|
||||
@@ -33,7 +36,7 @@ use serde_json::json;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc, LazyLock, Mutex,
|
||||
Arc, LazyLock, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
thread,
|
||||
@@ -60,9 +63,14 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
server's replies pile up)
|
||||
--join-every SECONDS default 1
|
||||
@@ -75,6 +83,12 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
|
||||
--ground claim the ground in every move (with --height 0 on a flat world: walking
|
||||
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
|
||||
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
|
||||
registry id, count), to test what a server takes
|
||||
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
|
||||
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
|
||||
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
|
||||
to test what a server takes
|
||||
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
|
||||
--report FILE a JSON summary at the end (- prints it after the per-second lines)
|
||||
";
|
||||
@@ -131,10 +145,18 @@ struct Options {
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
height: f64,
|
||||
ground: bool,
|
||||
/// --raw: (tick after joining, packet id, body).
|
||||
raw: Vec<(u64, i32, Vec<u8>)>,
|
||||
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
|
||||
give: Vec<(i16, i32, i32)>,
|
||||
/// --click: the block to open, then the slot, button and click type in its window.
|
||||
click: Option<([i32; 3], i16, i8, i32)>,
|
||||
name: String,
|
||||
/// The `minecraft:brand` to send, if any.
|
||||
brand: Option<String>,
|
||||
@@ -156,10 +178,15 @@ fn options() -> Result<Options, String> {
|
||||
explore_speed: 22.0,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
height: 24.0,
|
||||
ground: false,
|
||||
raw: Vec::new(),
|
||||
give: Vec::new(),
|
||||
click: None,
|
||||
name: "Bot".into(),
|
||||
report: None,
|
||||
brand: None,
|
||||
@@ -175,6 +202,14 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-swing" {
|
||||
o.no_swing = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--ground" {
|
||||
o.ground = true;
|
||||
continue;
|
||||
@@ -202,6 +237,53 @@ fn options() -> Result<Options, String> {
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
"--give" => {
|
||||
for part in value.split(',') {
|
||||
let n: Vec<&str> = part.split(':').collect();
|
||||
let parsed = match n[..] {
|
||||
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
|
||||
_ => None,
|
||||
};
|
||||
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
|
||||
o.give.push((slot, item, count));
|
||||
}
|
||||
}
|
||||
"--click" => {
|
||||
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
|
||||
let n: Vec<&str> = value.split(':').collect();
|
||||
let [at, slot, button, mode] = n[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let at: Vec<i32> = at
|
||||
.split(',')
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| bad())?;
|
||||
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
|
||||
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
|
||||
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
|
||||
}
|
||||
"--raw" => {
|
||||
for part in value.split(',') {
|
||||
let bad = || number("TICK:ID:HEX");
|
||||
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
|
||||
return Err(bad());
|
||||
};
|
||||
let body = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
hex.get(i..i + 2)
|
||||
.and_then(|b| u8::from_str_radix(b, 16).ok())
|
||||
})
|
||||
.collect::<Option<Vec<u8>>>()
|
||||
.ok_or_else(bad)?;
|
||||
o.raw.push((
|
||||
tick.parse().map_err(|_| bad())?,
|
||||
id.parse().map_err(|_| bad())?,
|
||||
body,
|
||||
));
|
||||
}
|
||||
}
|
||||
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
|
||||
"--timer" => {
|
||||
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
|
||||
@@ -258,15 +340,50 @@ struct Bot {
|
||||
teleport: Mutex<Option<[f64; 3]>>,
|
||||
error: Mutex<Option<String>>,
|
||||
joined_after_ms: AtomicU64,
|
||||
/// The connection's byte counters (wire bytes, after compression and encryption), set once it is open.
|
||||
counters: OnceLock<Arc<Counters>>,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<(Option<u128>, Option<i32>)>,
|
||||
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<Vec<(u128, Option<i32>)>>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
fn bytes_in(&self) -> u64 {
|
||||
self.counters
|
||||
.get()
|
||||
.map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64)
|
||||
}
|
||||
fn bytes_out(&self) -> u64 {
|
||||
self.counters
|
||||
.get()
|
||||
.map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty.
|
||||
fn percentile(sorted: &[u64], p: u64) -> u64 {
|
||||
if sorted.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize;
|
||||
sorted[rank - 1]
|
||||
}
|
||||
|
||||
/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max.
|
||||
fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
times.sort_unstable();
|
||||
json!({
|
||||
"count": times.len(),
|
||||
"p50_ms": percentile(×, 50),
|
||||
"p99_ms": percentile(×, 99),
|
||||
"max_ms": times.last().copied().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -593,9 +710,10 @@ fn read(
|
||||
}
|
||||
cb::ADD_ENTITY => {
|
||||
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
|
||||
let mut target = lock(&bot.target);
|
||||
if target.0 == Some(p.uuid) {
|
||||
target.1 = Some(p.id);
|
||||
for target in lock(&bot.target).iter_mut() {
|
||||
if target.0 == p.uuid {
|
||||
target.1 = Some(p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -692,8 +810,14 @@ fn run_bot(
|
||||
}
|
||||
};
|
||||
let (reader, writer) = connection.split();
|
||||
let _ = bot.counters.set(reader.counters.clone());
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
*lock(&bot.target) = o
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
@@ -716,7 +840,9 @@ fn run_bot(
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
if !o.no_loaded {
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
}
|
||||
bot.deaf.store(o.no_read, Ordering::Relaxed);
|
||||
// What the costly floods need in hand (creative: the server takes the bot's word for it).
|
||||
let tool = match behaviour {
|
||||
@@ -724,6 +850,15 @@ fn run_bot(
|
||||
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
|
||||
_ => None,
|
||||
};
|
||||
for &(slot, item, count) in &o.give {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
slot,
|
||||
item: Some(Stack::new(item, count)),
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
if tool.is_some() {
|
||||
send(
|
||||
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
|
||||
@@ -848,6 +983,43 @@ fn run_bot(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((at, slot, button, input)) = o.click {
|
||||
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
|
||||
if tick == 60 {
|
||||
sequence += 1;
|
||||
send(
|
||||
play::Serverbound::UseItemOn(play::UseItemOn {
|
||||
hand: 0,
|
||||
pos: at,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
} else if tick == 80 {
|
||||
// The first window a player opens is 1.
|
||||
send(
|
||||
play::Serverbound::ContainerClick(play::ContainerClick {
|
||||
container: 1,
|
||||
state: 0,
|
||||
slot,
|
||||
button,
|
||||
input,
|
||||
changed: Vec::new(),
|
||||
carried: None,
|
||||
})
|
||||
.encode(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (at, id, body) in &o.raw {
|
||||
if *at == tick {
|
||||
send(raw(*id, |w| w.bytes(body)));
|
||||
}
|
||||
}
|
||||
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
|
||||
for i in 0..o.flood {
|
||||
sequence += 1;
|
||||
@@ -855,13 +1027,16 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let target = lock(&bot.target).1;
|
||||
if let Some(id) = target
|
||||
&& joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
{
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
if !o.no_swing {
|
||||
send(play::Serverbound::Punch(Empty).encode());
|
||||
}
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
tick += 1;
|
||||
let ok = send(
|
||||
@@ -996,10 +1171,20 @@ fn main() {
|
||||
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
|
||||
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
|
||||
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
|
||||
"join_time": join_summary(
|
||||
bots.iter()
|
||||
.map(|b| b.joined_after_ms.load(Ordering::SeqCst))
|
||||
.filter(|&ms| ms > 0)
|
||||
.collect(),
|
||||
),
|
||||
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
|
||||
"name": format!("{}{i}", o.name),
|
||||
"behaviour": behaviour_of(&o.behaviour, i).name(),
|
||||
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
|
||||
"bytes_in": b.bytes_in(),
|
||||
"bytes_out": b.bytes_out(),
|
||||
"chunks": b.chunks.load(Ordering::Relaxed),
|
||||
"unique_chunks": b.unique.load(Ordering::Relaxed),
|
||||
"resent": b.resent.load(Ordering::Relaxed),
|
||||
@@ -1034,3 +1219,39 @@ fn main() {
|
||||
}
|
||||
std::process::exit(if errors.is_empty() { 0 } else { 1 });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn percentiles_use_nearest_rank() {
|
||||
assert_eq!(percentile(&[], 50), 0);
|
||||
assert_eq!(percentile(&[7], 99), 7);
|
||||
let v: Vec<u64> = (1..=100).collect();
|
||||
assert_eq!(percentile(&v, 50), 50);
|
||||
assert_eq!(percentile(&v, 99), 99);
|
||||
assert_eq!(percentile(&[10, 20, 30, 40], 50), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_summary_sorts_and_reports_max() {
|
||||
let s = join_summary(vec![300, 100, 200]);
|
||||
assert_eq!(s["count"], 3);
|
||||
assert_eq!(s["p50_ms"], 200);
|
||||
assert_eq!(s["p99_ms"], 300);
|
||||
assert_eq!(s["max_ms"], 300);
|
||||
assert_eq!(join_summary(vec![])["count"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_bytes_come_from_the_connection_counters() {
|
||||
let bot = Bot::default();
|
||||
assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0));
|
||||
let counters = Arc::new(Counters::default());
|
||||
counters.bytes_in.store(1234, Ordering::Relaxed);
|
||||
counters.bytes_out.store(56, Ordering::Relaxed);
|
||||
let _ = bot.counters.set(counters);
|
||||
assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56));
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,73 @@ impl Parser<'_> {
|
||||
Err("unterminated string".into())
|
||||
}
|
||||
}
|
||||
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
|
||||
/// `hoverEvent` names SNBT and older chat JSON still use.
|
||||
fn style_event_object<'a>(
|
||||
o: &'a serde_json::Map<String, Value>,
|
||||
modern: &str,
|
||||
legacy: &str,
|
||||
) -> Option<&'a serde_json::Map<String, Value>> {
|
||||
o.get(modern)
|
||||
.or_else(|| o.get(legacy))
|
||||
.and_then(Value::as_object)
|
||||
}
|
||||
|
||||
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
|
||||
fn style_flag(v: Option<&Value>) -> Option<bool> {
|
||||
match v {
|
||||
Some(Value::Bool(b)) => Some(*b),
|
||||
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn untrusted_http_url(url: &str) -> bool {
|
||||
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
|
||||
// https only, scheme case-insensitive.
|
||||
url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
}
|
||||
|
||||
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
|
||||
/// renames (`url` / `command` / `page`).
|
||||
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
let legacy = || text("value");
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("open_url") => text("url")
|
||||
.or_else(legacy)
|
||||
.filter(|u| untrusted_http_url(u))
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| e.get("value").and_then(Value::as_i64))
|
||||
.or_else(|| {
|
||||
e.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse().ok())
|
||||
})
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command")
|
||||
.or_else(legacy)
|
||||
.map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command")
|
||||
.or_else(legacy)
|
||||
.map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The style fields of a component object.
|
||||
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
let mut style = Style::default();
|
||||
@@ -196,52 +263,27 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
});
|
||||
}
|
||||
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
|
||||
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
|
||||
style.bold = flag("bold");
|
||||
style.italic = flag("italic");
|
||||
style.underlined = flag("underlined");
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.bold = style_flag(o.get("bold"));
|
||||
style.italic = style_flag(o.get("italic"));
|
||||
style.underlined = style_flag(o.get("underlined"));
|
||||
style.strikethrough = style_flag(o.get("strikethrough"));
|
||||
style.obfuscated = style_flag(o.get("obfuscated"));
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
// ClickEvent.CODEC: the actions a chat line acts on here. A component
|
||||
// from a server never opens a file.
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
|
||||
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
|
||||
Some("open_url") => text("url")
|
||||
.filter(|u| {
|
||||
u.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
})
|
||||
.map(crate::text::ClickEvent::OpenUrl),
|
||||
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
|
||||
Some("change_page") => e
|
||||
.get("page")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|p| i32::try_from(p).ok())
|
||||
.filter(|p| *p >= 1)
|
||||
.map(crate::text::ClickEvent::ChangePage),
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
|
||||
// the dialog itself; the server may send it (isAllowedFromServer).
|
||||
Some("show_dialog") => e.get("dialog").map(|dialog| {
|
||||
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
|
||||
style.click = parse_click_event(e);
|
||||
}
|
||||
// HoverEvent.ShowText, ShowItem and ShowEntity.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
|
||||
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
|
||||
match e.get("action").and_then(Value::as_str) {
|
||||
Some("show_text") => {
|
||||
if let Some(value) = e.get("value") {
|
||||
// Modern `value`, or the older `contents` / nested `text`.
|
||||
let value = e
|
||||
.get("value")
|
||||
.or_else(|| e.get("contents"))
|
||||
.or_else(|| e.get("text"));
|
||||
if let Some(value) = value {
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
}
|
||||
@@ -506,10 +548,43 @@ mod tests {
|
||||
let t = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(t.plain(), "Hi there");
|
||||
assert_eq!(t.spans[1].style.color, named_color("red"));
|
||||
// SNBT `1b` is a number in JSON; style flags still take it as true.
|
||||
assert_eq!(t.spans[1].style.bold, Some(true));
|
||||
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
|
||||
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_click_event_open_url_uses_value_field() {
|
||||
use crate::text::ClickEvent;
|
||||
let modern = serde_json::json!({
|
||||
"text": "site",
|
||||
"click_event": {"action": "open_url", "url": "https://example.com"},
|
||||
"underlined": true
|
||||
});
|
||||
let legacy = serde_json::json!({
|
||||
"text": "site",
|
||||
"clickEvent": {"action": "open_url", "value": "https://example.com"},
|
||||
"underlined": 1
|
||||
});
|
||||
for component in [modern, legacy] {
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
assert_eq!(
|
||||
text.spans[0].style.click,
|
||||
Some(ClickEvent::OpenUrl("https://example.com".into()))
|
||||
);
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
|
||||
let (v, _) = parse_snbt(snbt).unwrap();
|
||||
let text = to_text(&v, &mut |_| Text::empty());
|
||||
assert_eq!(
|
||||
text.spans[0].style.click,
|
||||
Some(ClickEvent::OpenUrl("https://example.com".into()))
|
||||
);
|
||||
assert_eq!(text.spans[0].style.underlined, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_font_is_inherited_and_kept() {
|
||||
let (v, _) =
|
||||
|
||||
@@ -57,6 +57,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
"assets/horizon/lang/zh_cn.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
|
||||
}
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
@@ -66,6 +69,15 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -279,6 +291,16 @@ mod tests {
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let zh = Language::load(&[&empty], "zh_cn");
|
||||
assert_eq!(zh.get("horizon.options.controller"), Some("手柄"));
|
||||
assert_eq!(zh.get("horizon.pages.mods"), Some("模组"));
|
||||
assert_eq!(zh.get("Preset"), Some("预设"));
|
||||
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
|
||||
// Not every bundle is complete: the en_us layer is in every map, so a
|
||||
// missing key falls back to English (`horizon.mods.not_built` is not
|
||||
// in ru_ru either).
|
||||
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
|
||||
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
|
||||
let pl = Language::load(&[&empty], "pl_pl");
|
||||
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
|
||||
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
|
||||
@@ -287,6 +309,13 @@ mod tests {
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Teclado en pantalla")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,8 @@ pub enum Category {
|
||||
Harmful,
|
||||
Neutral,
|
||||
}
|
||||
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
|
||||
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
|
||||
/// colour, category, instantaneous.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EffectDef {
|
||||
pub id: &'static str,
|
||||
@@ -84,11 +85,52 @@ pub const EFFECTS: &[EffectDef] = &[
|
||||
fx("infested", 9214860, H),
|
||||
fx("breath_of_the_nautilus", 65518, B),
|
||||
];
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let id = id.trim_start_matches("minecraft:");
|
||||
EFFECTS.iter().find(|e| e.id == id)
|
||||
/// The effects content mods declare, registered after vanilla's.
|
||||
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
|
||||
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
|
||||
/// The list is set once per process: false when it already was, and the
|
||||
/// effects stay those of the first call until the game restarts.
|
||||
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
|
||||
if MOD_EFFECTS.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let defs: Vec<EffectDef> = effects
|
||||
.into_iter()
|
||||
.map(|(id, color, category)| {
|
||||
debug_assert!(id.contains(':'), "{id} has no namespace");
|
||||
fx(Box::leak(id.into_boxed_str()), color, category)
|
||||
})
|
||||
.collect();
|
||||
MOD_EFFECTS.set(defs).is_ok()
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
/// The content mods' effects, in registry order.
|
||||
pub fn mod_effects() -> &'static [EffectDef] {
|
||||
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
/// Every registered effect: vanilla's, then the content mods'.
|
||||
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
|
||||
EFFECTS.iter().chain(mod_effects())
|
||||
}
|
||||
pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
let short = id.trim_start_matches("minecraft:");
|
||||
match EFFECTS.iter().find(|e| e.id == short) {
|
||||
Some(e) => Some(e),
|
||||
None => mod_effect(id),
|
||||
}
|
||||
}
|
||||
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
|
||||
#[cold]
|
||||
#[inline(never)]
|
||||
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
|
||||
mod_effects().iter().find(|e| e.id == id)
|
||||
}
|
||||
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
|
||||
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
|
||||
pub fn description_id(id: &str) -> String {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("effect.{namespace}.{}", path.replace('/', "."))
|
||||
}
|
||||
/// Namespaced effect ids for mob effect storage; a mod's id already is.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
@@ -230,10 +272,15 @@ pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
|
||||
}
|
||||
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
|
||||
/// their colours weighted by amplifier + 1, else the base potion colour.
|
||||
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
for (e, amp) in effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
let w = amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
@@ -248,3 +295,55 @@ pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
_ => BASE_POTION_COLOR,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn description_ids_keep_the_namespace() {
|
||||
assert_eq!(description_id("speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
}
|
||||
|
||||
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
|
||||
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
|
||||
#[test]
|
||||
fn effects_mix_their_colours_by_amplifier() {
|
||||
let c = |id: &str| effect(id).unwrap().color;
|
||||
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
|
||||
let (p, s) = (c("poison"), c("speed"));
|
||||
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
|
||||
assert_eq!(
|
||||
effects_color([("poison", 0), ("speed", 2)].into_iter()),
|
||||
mix(16) | mix(8) | mix(0)
|
||||
);
|
||||
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
|
||||
}
|
||||
|
||||
/// The only test that registers: the registry is set once per process.
|
||||
#[test]
|
||||
fn mod_effects_follow_vanilla_once_registered() {
|
||||
assert!(register_mod_effects([(
|
||||
"ruby:glow".to_owned(),
|
||||
0xFF0000,
|
||||
Category::Neutral
|
||||
)]));
|
||||
assert!(!register_mod_effects([(
|
||||
"ruby:late".to_owned(),
|
||||
1,
|
||||
Category::Harmful
|
||||
)]));
|
||||
let glow = effect("ruby:glow").unwrap();
|
||||
assert_eq!(
|
||||
(glow.color, glow.category, glow.instant),
|
||||
(0xFF0000, Category::Neutral, false)
|
||||
);
|
||||
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
|
||||
assert_eq!(namespaced(glow.id), "ruby:glow");
|
||||
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
|
||||
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
|
||||
assert_eq!(ids.len(), EFFECTS.len() + 1);
|
||||
assert_eq!(ids.last(), Some(&"ruby:glow"));
|
||||
}
|
||||
}
|
||||
@@ -3,179 +3,8 @@
|
||||
//! number formatting.
|
||||
use crate::text::{self, Style, Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `Double.toString`.
|
||||
pub fn java_double(v: f64) -> String {
|
||||
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
|
||||
}
|
||||
/// `Float.toString`.
|
||||
pub fn java_float(v: f32) -> String {
|
||||
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
|
||||
}
|
||||
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
|
||||
if v.is_nan() {
|
||||
return "NaN".into();
|
||||
}
|
||||
if v.is_infinite() {
|
||||
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
|
||||
}
|
||||
if v == 0.0 {
|
||||
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
|
||||
}
|
||||
let a = v.abs();
|
||||
if (1.0e-3..1.0e7).contains(&a) {
|
||||
if plain.contains('.') {
|
||||
plain.to_owned()
|
||||
} else {
|
||||
format!("{plain}.0")
|
||||
}
|
||||
} else {
|
||||
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
|
||||
let mantissa = if mantissa.contains('.') {
|
||||
mantissa.to_owned()
|
||||
} else {
|
||||
format!("{mantissa}.0")
|
||||
};
|
||||
format!("{mantissa}E{exponent}")
|
||||
}
|
||||
}
|
||||
|
||||
/// `SnbtGrammar.escapeControlCharacters`.
|
||||
fn escape_control(c: char) -> Option<&'static str> {
|
||||
Some(match c {
|
||||
'\u{8}' => "b",
|
||||
'\u{c}' => "f",
|
||||
'\n' => "n",
|
||||
'\r' => "r",
|
||||
'\t' => "t",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `StringTag.quoteAndEscape`.
|
||||
pub fn quote_and_escape(input: &str) -> String {
|
||||
let mut out = String::from(" ");
|
||||
let mut quote = '\0';
|
||||
for c in input.chars() {
|
||||
if c == '\\' {
|
||||
out.push_str("\\\\");
|
||||
} else if c != '"' && c != '\'' {
|
||||
match escape_control(c) {
|
||||
Some(e) => {
|
||||
out.push('\\');
|
||||
out.push_str(e);
|
||||
}
|
||||
None => out.push(c),
|
||||
}
|
||||
} else {
|
||||
if quote == '\0' {
|
||||
quote = if c == '"' { '\'' } else { '"' };
|
||||
}
|
||||
if quote == c {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
if quote == '\0' {
|
||||
quote = '"';
|
||||
}
|
||||
out.replace_range(0..1, "e.to_string());
|
||||
out.push(quote);
|
||||
out
|
||||
}
|
||||
fn unquoted_key(key: &str) -> bool {
|
||||
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
|
||||
return false;
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let first_ok = chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
|
||||
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
|
||||
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
|
||||
}
|
||||
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
|
||||
t.to_string_lossy()
|
||||
}
|
||||
|
||||
/// `Tag.toString` (StringTagVisitor).
|
||||
pub fn to_snbt(tag: &Tag) -> String {
|
||||
let mut out = String::new();
|
||||
visit(tag, &mut out);
|
||||
out
|
||||
}
|
||||
fn visit(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
Tag::End => out.push_str("END"),
|
||||
Tag::Byte(v) => out.push_str(&format!("{v}b")),
|
||||
Tag::Short(v) => out.push_str(&format!("{v}s")),
|
||||
Tag::Int(v) => out.push_str(&v.to_string()),
|
||||
Tag::Long(v) => out.push_str(&format!("{v}L")),
|
||||
Tag::Float(v) => {
|
||||
out.push_str(&java_float(f32::from_bits(*v)));
|
||||
out.push('f');
|
||||
}
|
||||
Tag::Double(v) => {
|
||||
out.push_str(&java_double(f64::from_bits(*v)));
|
||||
out.push('d');
|
||||
}
|
||||
Tag::String(s) => out.push_str("e_and_escape(&string_of(s))),
|
||||
Tag::ByteArray(a) => {
|
||||
out.push_str("[B;");
|
||||
out.push_str(
|
||||
&a.iter()
|
||||
.map(|v| format!("{}B", *v as i8))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
out.push(']');
|
||||
}
|
||||
Tag::IntArray(a) => {
|
||||
out.push_str("[I;");
|
||||
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
|
||||
out.push(']');
|
||||
}
|
||||
Tag::LongArray(a) => {
|
||||
out.push_str("[L;");
|
||||
out.push_str(
|
||||
&a.iter()
|
||||
.map(|v| format!("{v}L"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
);
|
||||
out.push(']');
|
||||
}
|
||||
Tag::List(l) => {
|
||||
out.push('[');
|
||||
for (i, t) in l.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
visit(t, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Tag::Compound(c) => {
|
||||
out.push('{');
|
||||
let mut entries: Vec<(String, &Tag)> =
|
||||
c.iter().map(|(k, v)| (string_of(k), v)).collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
for (i, (k, v)) in entries.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
if unquoted_key(k) {
|
||||
out.push_str(k);
|
||||
} else {
|
||||
out.push_str("e_and_escape(k));
|
||||
}
|
||||
out.push(':');
|
||||
visit(v, out);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
use horizon_world::snbt_text::string_of;
|
||||
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
|
||||
|
||||
// ---- the coloured form -----------------------------------------------------
|
||||
|
||||
|
||||
@@ -567,6 +567,34 @@ fn length(v: [f32; 2]) -> f32 {
|
||||
(v[0] * v[0] + v[1] * v[1]).sqrt()
|
||||
}
|
||||
|
||||
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
|
||||
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
|
||||
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
|
||||
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
|
||||
|
||||
/// Stick axes after correcting pads whose right stick gilrs still exposes on
|
||||
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
|
||||
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
|
||||
pub fn stick_axes(
|
||||
left: [f32; 2],
|
||||
right: [f32; 2],
|
||||
left_z: f32,
|
||||
vendor: Option<u16>,
|
||||
product: Option<u16>,
|
||||
sdl_mapped: bool,
|
||||
) -> ([f32; 2], [f32; 2]) {
|
||||
if !sdl_mapped
|
||||
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
|
||||
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
|
||||
{
|
||||
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
|
||||
// is the right trigger and must not drive look.
|
||||
(left, [left_z, right[0]])
|
||||
} else {
|
||||
(left, right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
|
||||
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
|
||||
/// clipped and the first movement past the deadzone is small.
|
||||
@@ -1178,6 +1206,38 @@ mod tests {
|
||||
assert_eq!(odd, PadSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ultimate2_right_stick_on_z_without_sdl() {
|
||||
let (left, right) = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
-0.5,
|
||||
Some(BITDO_ULTIMATE2_VID_PID.0),
|
||||
Some(BITDO_ULTIMATE2_VID_PID.1),
|
||||
false,
|
||||
);
|
||||
assert_eq!(left, [0.2, 0.3]);
|
||||
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
|
||||
let kept = stick_axes(
|
||||
[0.2, 0.3],
|
||||
[0.7, -0.9],
|
||||
-0.5,
|
||||
Some(BITDO_ULTIMATE2_VID_PID.0),
|
||||
Some(BITDO_ULTIMATE2_VID_PID.1),
|
||||
true,
|
||||
);
|
||||
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
|
||||
let other = stick_axes(
|
||||
[0.0, 0.0],
|
||||
[1.0, 0.0],
|
||||
0.5,
|
||||
Some(0x045e),
|
||||
Some(0x028e),
|
||||
false,
|
||||
);
|
||||
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_deadzone_rescales() {
|
||||
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
|
||||
|
||||
@@ -10,6 +10,10 @@ pub struct KeyState {
|
||||
released_by_screen_when_down: bool,
|
||||
toggle: Option<bool>,
|
||||
keyboard: bool,
|
||||
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
|
||||
/// vanilla has no option for): until it lets go, the mapping toggles as
|
||||
/// if its vanilla option were on, so a screen releases and restores it.
|
||||
pad_toggled: bool,
|
||||
}
|
||||
|
||||
impl KeyState {
|
||||
@@ -20,6 +24,7 @@ impl KeyState {
|
||||
released_by_screen_when_down: false,
|
||||
toggle: None,
|
||||
keyboard: true,
|
||||
pad_toggled: false,
|
||||
}
|
||||
}
|
||||
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
|
||||
@@ -43,16 +48,28 @@ impl KeyState {
|
||||
true
|
||||
}
|
||||
}
|
||||
/// The mapping toggles: its option is on, or a pad toggled it down.
|
||||
fn needs_toggle(&self, option: bool) -> bool {
|
||||
option || self.pad_toggled
|
||||
}
|
||||
/// `setDown`; `needs_toggle` is the mapping's option value.
|
||||
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
|
||||
if self.toggle.is_some() && needs_toggle {
|
||||
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
|
||||
if down {
|
||||
self.is_down = !self.is_down;
|
||||
// Toggled back up: the pad's toggle is over.
|
||||
self.pad_toggled &= self.is_down;
|
||||
}
|
||||
} else {
|
||||
self.is_down = down;
|
||||
}
|
||||
}
|
||||
/// A controller's toggle button: flips the mapping like `setDown(true)`
|
||||
/// with its option on, whatever the option says.
|
||||
pub fn pad_toggle(&mut self) {
|
||||
self.is_down = !self.is_down;
|
||||
self.pad_toggled = self.is_down;
|
||||
}
|
||||
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
|
||||
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
|
||||
self.keyboard && (self.toggle.is_none() || !needs_toggle)
|
||||
@@ -61,7 +78,8 @@ impl KeyState {
|
||||
pub fn release(&mut self, needs_toggle: bool) {
|
||||
if self.toggle.is_some() {
|
||||
// ToggleKeyMapping.release does not clear pending clicks.
|
||||
if needs_toggle && self.is_down || self.released_by_screen_when_down {
|
||||
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
|
||||
{
|
||||
self.released_by_screen_when_down = true;
|
||||
}
|
||||
self.reset();
|
||||
@@ -70,7 +88,8 @@ impl KeyState {
|
||||
self.set_down(false, needs_toggle);
|
||||
}
|
||||
}
|
||||
/// `ToggleKeyMapping.reset`.
|
||||
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
|
||||
/// that released it restores it.
|
||||
pub fn reset(&mut self) {
|
||||
self.is_down = false;
|
||||
}
|
||||
@@ -83,7 +102,10 @@ impl KeyState {
|
||||
let Some(restore) = self.toggle else {
|
||||
return false;
|
||||
};
|
||||
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
|
||||
let result = restore
|
||||
&& self.needs_toggle(needs_toggle)
|
||||
&& self.keyboard
|
||||
&& self.released_by_screen_when_down;
|
||||
self.released_by_screen_when_down = false;
|
||||
result
|
||||
}
|
||||
@@ -138,6 +160,15 @@ impl ToggleKeys {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
|
||||
/// a press with no screen open flips the mapping and clicks it, as a
|
||||
/// key press does with the mapping's toggle option on.
|
||||
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
|
||||
if !screen_open {
|
||||
self.0[slot].pad_toggle();
|
||||
self.0[slot].click();
|
||||
}
|
||||
}
|
||||
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
|
||||
/// screen or overlay.
|
||||
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
|
||||
@@ -167,6 +198,9 @@ impl ToggleKeys {
|
||||
pub fn respawn(&mut self) {
|
||||
for key in &mut self.0 {
|
||||
key.reset();
|
||||
// As with the vanilla option, the death screen closing after
|
||||
// this still restores a toggle it released.
|
||||
key.pad_toggled &= key.released_by_screen_when_down;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +231,57 @@ mod tests {
|
||||
keys.respawn();
|
||||
assert!(!keys.is_down(SPRINT));
|
||||
}
|
||||
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
|
||||
/// back after a screen, as the vanilla option's toggle does; a
|
||||
/// keyboard shift-click in the inventory does not drop it.
|
||||
#[test]
|
||||
fn pad_toggle_is_restored_after_a_screen() {
|
||||
let off = [false; 4];
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
keys.screen_opened(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.key(SNEAK, KeyAction::Press, true, false);
|
||||
keys.key(SNEAK, KeyAction::Release, true, false);
|
||||
keys.screen_closed(off);
|
||||
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
|
||||
// Toggled back up, it stays up through the next screen.
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.screen_opened(off);
|
||||
keys.screen_closed(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
// A held key is not restored, as before.
|
||||
keys.key(SNEAK, KeyAction::Press, false, false);
|
||||
keys.screen_opened(off);
|
||||
keys.key(SNEAK, KeyAction::Release, true, false);
|
||||
keys.screen_closed(off);
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
}
|
||||
/// After the pad's toggle is over (toggled up, or reset by a respawn
|
||||
/// with no screen to restore it), the shift key holds again.
|
||||
#[test]
|
||||
fn pad_toggle_ends_with_its_release() {
|
||||
let off = [false; 4];
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
keys.respawn();
|
||||
assert!(!keys.is_down(SNEAK));
|
||||
keys.key(SNEAK, KeyAction::Press, false, false);
|
||||
keys.key(SNEAK, KeyAction::Release, false, false);
|
||||
assert!(!keys.is_down(SNEAK), "held, not toggled");
|
||||
// The death screen releases it and closes after the respawn: the
|
||||
// toggle comes back, as with the vanilla option.
|
||||
keys.pad_toggle(SNEAK, false);
|
||||
keys.screen_opened(off);
|
||||
keys.respawn();
|
||||
keys.screen_closed(off);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
// A press with a screen open does nothing.
|
||||
keys.pad_toggle(SNEAK, true);
|
||||
assert!(keys.is_down(SNEAK));
|
||||
}
|
||||
#[test]
|
||||
fn screen_release_restores_toggled_state() {
|
||||
let mut k = KeyState::toggle(true, true);
|
||||
|
||||
@@ -673,6 +673,11 @@ mod tests {
|
||||
vsync_present_mode(&[PresentMode::Fifo], false),
|
||||
Some(PresentMode::Fifo)
|
||||
);
|
||||
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
|
||||
assert_eq!(
|
||||
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
|
||||
Some(PresentMode::Immediate)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
|
||||
|
||||
@@ -32,7 +32,7 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
glam = "0.30"
|
||||
log = "0.4"
|
||||
miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
|
||||
png = "0.18"
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -44,7 +44,7 @@ unicode-bidi = "0.3.18"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time"] }
|
||||
|
||||
@@ -192,9 +192,8 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
|
||||
/// layers, without the block or item models or the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
@@ -209,8 +208,13 @@ impl RenderAssets {
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
// A server draws no block and no item: its block models (about 24 MB
|
||||
// baked) and item model library (7 MB) stay empty.
|
||||
let parsed = if draws {
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
|
||||
@@ -39,6 +39,12 @@ struct Channel {
|
||||
looping: bool,
|
||||
at: [f32; 3],
|
||||
relative: bool,
|
||||
/// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`).
|
||||
owner: u8,
|
||||
/// Its world (`Mixer::world_id`; 0 none).
|
||||
world: u32,
|
||||
/// The sound file it started and when, to tell another player's copy of it.
|
||||
started: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
@@ -46,7 +52,37 @@ struct Mixer {
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
listener_pitch: f32,
|
||||
/// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is
|
||||
/// heard from the nearest camera, and panned half as wide while several share the speakers.
|
||||
listeners: HashMap<u8, ([f64; 3], f32, f32)>,
|
||||
/// Each camera's world by owner, as ids: the callback compares and copies integers.
|
||||
worlds: HashMap<u8, u32>,
|
||||
/// World names by id - 1 (`world_id`).
|
||||
world_names: Vec<String>,
|
||||
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_name: String,
|
||||
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_lost: bool,
|
||||
}
|
||||
/// A camera's forward, up and right vectors.
|
||||
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
|
||||
impl Mixer {
|
||||
/// The id of world `name` (1 and up), given once per name.
|
||||
fn world_id(&mut self, name: &str) -> u32 {
|
||||
let i = match self.world_names.iter().position(|w| w == name) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.world_names.push(name.to_owned());
|
||||
self.world_names.len() - 1
|
||||
}
|
||||
};
|
||||
i as u32 + 1
|
||||
}
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
@@ -69,7 +105,34 @@ pub struct MixerBackend {
|
||||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
/// Whose channels these are (`Channel::owner`).
|
||||
owner: u8,
|
||||
world: u32,
|
||||
/// How many other players share this output: alone, a sound never looks for its copy.
|
||||
shares: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
/// A splitscreen player's backend leaving: its camera and sounds go with it.
|
||||
impl Drop for MixerBackend {
|
||||
fn drop(&mut self) {
|
||||
if self.owner == 0 {
|
||||
return;
|
||||
}
|
||||
self.shares
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.listeners.remove(&self.owner);
|
||||
m.worlds.remove(&self.owner);
|
||||
let owner = self.owner;
|
||||
m.channels.retain(|_, c| c.owner != owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Splitscreen: each shared backend's channel ids start at a block of their own, never handed out
|
||||
/// again, so a player in a seat used before never reuses an id a departed one's decoder may
|
||||
/// still attach to. The game's own backend counts from 0, as before.
|
||||
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// Two players' copies of one sound: started within this of each other.
|
||||
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
@@ -167,6 +230,20 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
/// The ALSA cards (Linux) or Core Audio devices (macOS) last seen: listing
|
||||
/// the output devices probes every one, so it waits for these to change
|
||||
/// (`card_fingerprint`).
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: Option<String>,
|
||||
/// The other half of `lastSeenDevices`: every output device last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: Vec<String>,
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
}
|
||||
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
use symphonia::core::{
|
||||
@@ -210,7 +287,59 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
}
|
||||
Some((mono, rate))
|
||||
}
|
||||
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
|
||||
/// player's game picks its own variant of the same event.
|
||||
// ponytail: variants named by a trailing number, as vanilla's are; the event name if a pack breaks it.
|
||||
fn variantless(key: &str) -> &str {
|
||||
key.trim_end_matches(".ogg")
|
||||
.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
}
|
||||
impl MixerBackend {
|
||||
/// Splitscreen: another player's game started this same sound here just now (both clients
|
||||
/// heard it); this copy stays stopped, the first is heard from the nearest camera.
|
||||
///
|
||||
/// A copy is recognised only by its file (without the variant), world, place and time: the
|
||||
/// games pick variants on their own and play many sounds client-side (steps, digging), so
|
||||
/// nothing marks two copies of one event. Two different events alike in all four (two
|
||||
/// players' steps on grass, side by side, within 150 ms) are heard once. That is accepted:
|
||||
/// at that distance and timing they sound like one; a doubled sound is the louder fault.
|
||||
fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool {
|
||||
// Alone: nobody else's copy, and no lock taken for it.
|
||||
if self.shares.load(std::sync::atomic::Ordering::Relaxed) == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut m) = self.mixer.lock() else {
|
||||
return false;
|
||||
};
|
||||
// Alone (no other player's camera), or a sound by the listener (a click, music): its own.
|
||||
let Some(c) = m.channels.get(&channel).filter(|c| !c.relative) else {
|
||||
return false;
|
||||
};
|
||||
if m.listeners.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let key = format!("{namespace}:{path}");
|
||||
let key = variantless(&key);
|
||||
let at = c.at;
|
||||
let world = c.world;
|
||||
let twin = m.channels.values().any(|o| {
|
||||
o.owner != self.owner
|
||||
&& o.world == world
|
||||
&& !o.relative
|
||||
&& o.started
|
||||
.as_ref()
|
||||
.is_some_and(|(k, t)| k == key && t.elapsed() < SAME_SOUND)
|
||||
&& (0..3).map(|i| (o.at[i] - at[i]).powi(2)).sum::<f32>() < 4.0
|
||||
});
|
||||
if let Some(c) = m.channels.get_mut(&channel) {
|
||||
if twin {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.started = Some((key.to_owned(), std::time::Instant::now()));
|
||||
}
|
||||
}
|
||||
twin
|
||||
}
|
||||
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
@@ -235,6 +364,9 @@ impl Backend for MixerBackend {
|
||||
looping: false,
|
||||
at: [0.0; 3],
|
||||
relative: false,
|
||||
owner: self.owner,
|
||||
world: self.world,
|
||||
started: None,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -272,6 +404,10 @@ impl Backend for MixerBackend {
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
// A looping sound follows its entity in one client's tracking only: each keeps its own.
|
||||
if !looping && self.duplicate(channel, (namespace, &path)) {
|
||||
return;
|
||||
}
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
@@ -380,28 +516,50 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
|
||||
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
data.fill(0.0);
|
||||
let listener = mixer.listener;
|
||||
// The camera's forward and up vectors (`Camera.forwardVector`/`upVector`)
|
||||
// and OpenAL's right = forward x up.
|
||||
let (yaw, pitch) = (
|
||||
f64::from(mixer.listener_yaw.to_radians()),
|
||||
f64::from(mixer.listener_pitch.to_radians()),
|
||||
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 right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
forward[0] * up[1] - forward[1] * up[0],
|
||||
];
|
||||
(forward, up, right)
|
||||
};
|
||||
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
|
||||
let mut cameras = std::mem::take(&mut mixer.cameras);
|
||||
cameras.clear();
|
||||
cameras.extend(
|
||||
std::iter::once((
|
||||
0,
|
||||
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
|
||||
))
|
||||
.chain(
|
||||
mixer
|
||||
.listeners
|
||||
.iter()
|
||||
.map(|(&owner, &camera)| (owner, camera)),
|
||||
)
|
||||
.map(|(owner, (at, yaw, pitch))| {
|
||||
(
|
||||
at,
|
||||
basis(yaw, pitch),
|
||||
mixer.worlds.get(&owner).copied().unwrap_or_default(),
|
||||
)
|
||||
}),
|
||||
);
|
||||
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],
|
||||
forward[0] * up[1] - forward[1] * up[0],
|
||||
];
|
||||
let shared = cameras.len() > 1;
|
||||
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
|
||||
@@ -412,27 +570,48 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
let d = if c.relative {
|
||||
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
|
||||
} else {
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
f64::from(c.at[0]) - listener[0],
|
||||
f64::from(c.at[1]) - listener[1],
|
||||
f64::from(c.at[2]) - listener[2],
|
||||
at[0] - listener[0],
|
||||
at[1] - listener[1],
|
||||
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 = 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 (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 = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [left, right] = if c.relative {
|
||||
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;
|
||||
}
|
||||
.map(|pan| gain * pan);
|
||||
let [left, right] = [left, right].map(|pan| gain * pan);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
mixer.cameras = cameras;
|
||||
for (id, [left, right, gain], step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -506,19 +685,96 @@ fn open_device(
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer),
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
stream.play()?;
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
m.device_name = device.name().unwrap_or_default();
|
||||
m.device_lost = false;
|
||||
}
|
||||
Ok((stream, config))
|
||||
}
|
||||
/// Try one output device for `open`: skip it when it was already tried, log it
|
||||
/// when it opens, and keep its error when it does not.
|
||||
#[cfg(feature = "audio")]
|
||||
fn try_open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
tried: &mut Vec<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) -> Option<cpal::Stream> {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
return None;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(device, mixer) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
Some(stream)
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{name}: {e}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The name of the system's default output device.
|
||||
#[cfg(feature = "audio")]
|
||||
fn default_output_name() -> Option<String> {
|
||||
cpal::default_host()
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}
|
||||
/// A cheap fingerprint of what `output_devices` can list. On Linux, the ALSA
|
||||
/// cards: the rest are `cpal`'s fixed names. On macOS, every Core Audio
|
||||
/// device's name, inputs included: telling which of them play opens each one
|
||||
/// (about 90 ms a second with twenty devices), naming them takes 2 or 3 ms.
|
||||
/// `None` elsewhere, where the list is always looked at.
|
||||
#[cfg(feature = "audio")]
|
||||
fn card_fingerprint() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::fs::read_to_string("/proc/asound/cards").ok()
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let devices = cpal::default_host().devices().ok()?;
|
||||
Some(
|
||||
devices
|
||||
.filter_map(|d| d.name().ok())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Whether the output device list should be looked at again: only the ALSA
|
||||
/// cards can add or remove an entry, and `None` means there is no fingerprint,
|
||||
/// so the list has to be checked as before.
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
|
||||
match (seen, cards) {
|
||||
(Some(seen), Some(cards)) => seen != cards,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
@@ -528,6 +784,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
let rate = config.sample_rate().0;
|
||||
let channels = config.channels() as usize;
|
||||
let mut scratch = Vec::new();
|
||||
let on_error = mixer.clone();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
move |data: &mut [T], _| {
|
||||
@@ -541,7 +798,14 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
*out = T::from_sample(*s);
|
||||
}
|
||||
},
|
||||
|e| log::warn!("Horizon: audio stream error: {e}"),
|
||||
move |e| {
|
||||
log::warn!("Horizon: audio stream error: {e}");
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable)
|
||||
&& let Ok(mut m) = on_error.lock()
|
||||
{
|
||||
m.device_lost = true;
|
||||
}
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
Ok(stream)
|
||||
@@ -615,6 +879,17 @@ impl Audio {
|
||||
};
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.loaded = live;
|
||||
#[cfg(feature = "audio")]
|
||||
let cards = if live { card_fingerprint() } else { None };
|
||||
// Where the cards gate the list (Linux, macOS), it starts empty: opening the
|
||||
// output does not need it, and listing it probes every device; the first
|
||||
// change fills it. Elsewhere it starts with every device listed, as before.
|
||||
#[cfg(feature = "audio")]
|
||||
let devices = if live && cards.is_none() {
|
||||
output_devices()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
@@ -626,6 +901,9 @@ impl Audio {
|
||||
decoder: None,
|
||||
next: 0,
|
||||
live,
|
||||
owner: 0,
|
||||
world: 0,
|
||||
shares: Arc::default(),
|
||||
},
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
@@ -637,6 +915,14 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: cards,
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: devices,
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
@@ -701,29 +987,20 @@ impl Audio {
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut errors = Vec::new();
|
||||
// The default device first, then any other output a host lists:
|
||||
// a default that cannot open (busy, or a stale ALSA default) should
|
||||
// not leave the game silent when another device works.
|
||||
let default = host.default_output_device();
|
||||
let others = host.output_devices().into_iter().flatten();
|
||||
// The default device first: it is what plays, and listing the others
|
||||
// probes every PCM, which is slow and prints ALSA's errors for the
|
||||
// plugins this system lacks.
|
||||
let mut tried = Vec::new();
|
||||
for device in default.into_iter().chain(others) {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => errors.push(format!("{name}: {e}")),
|
||||
if let Some(device) = host.default_output_device()
|
||||
&& let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors)
|
||||
{
|
||||
return Ok(stream);
|
||||
}
|
||||
// A default that cannot open (busy, or a stale ALSA default) should not
|
||||
// leave the game silent when another device works.
|
||||
for device in host.output_devices().into_iter().flatten() {
|
||||
if let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors) {
|
||||
return Ok(stream);
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
@@ -737,15 +1014,75 @@ impl Audio {
|
||||
backend: &mut self.backend,
|
||||
}
|
||||
}
|
||||
/// World identity for this player's sources and camera. Existing channels keep their world.
|
||||
pub fn set_world(&mut self, world: &str) {
|
||||
if let Ok(mut mixer) = self.backend.mixer.lock() {
|
||||
let id = mixer.world_id(world);
|
||||
self.backend.world = id;
|
||||
mixer.worlds.insert(self.backend.owner, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// `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) {
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
m.listener_pitch = pitch;
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
m.listener_pitch = pitch;
|
||||
} else {
|
||||
m.listeners
|
||||
.insert(self.backend.owner, (position, yaw, pitch));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Splitscreen: another player's sounds on this output, heard from its camera too
|
||||
/// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go.
|
||||
pub fn share(&self, owner: u8) -> Self {
|
||||
assert_ne!(owner, 0, "shared audio owners must be nonzero");
|
||||
self.backend
|
||||
.shares
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut engine = SoundEngine::new(self.engine.registry.clone());
|
||||
engine.loaded = self.backend.live;
|
||||
Self {
|
||||
_stream: None,
|
||||
device_name: self.device_name.clone(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer: self.backend.mixer.clone(),
|
||||
root: self.backend.root.clone(),
|
||||
cache: self.backend.cache.clone(),
|
||||
decoder: None,
|
||||
next: (SHARED_CHANNELS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1)
|
||||
<< 40,
|
||||
live: self.backend.live,
|
||||
owner,
|
||||
world: self.backend.world,
|
||||
shares: self.backend.shares.clone(),
|
||||
},
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: self.master,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: self.cards_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
@@ -764,8 +1101,81 @@ impl Audio {
|
||||
}
|
||||
self.engine.listening = self.record_heard;
|
||||
}
|
||||
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
|
||||
/// default changed (with no device chosen in the options), or the chosen
|
||||
/// device came back; the output is then opened again. The devices are looked
|
||||
/// at once a second.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
// A splitscreen share plays through the first player's output: it leaves the device,
|
||||
// and the mixer's lost flag, to that output.
|
||||
if self.backend.owner != 0 {
|
||||
return false;
|
||||
}
|
||||
let (current, lost) = match self.backend.mixer.lock() {
|
||||
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
|
||||
Err(_) => return false,
|
||||
};
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.default_seen = default_output_name();
|
||||
self.cards_seen = card_fingerprint();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
self.device_ticks += 1;
|
||||
if self._stream.is_none() || self.device_ticks < 20 {
|
||||
return false;
|
||||
}
|
||||
self.device_ticks = 0;
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them); only the
|
||||
// fingerprint's devices can change the list, and it is cheap.
|
||||
if default == self.default_seen
|
||||
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.cards_seen = cards;
|
||||
let devices = output_devices();
|
||||
// Only a change in the device lists is looked at, as in vanilla: a
|
||||
// preferred device that is listed but cannot open is not retried.
|
||||
if default == self.default_seen && devices == self.devices_seen {
|
||||
return false;
|
||||
}
|
||||
self.default_seen = default.clone();
|
||||
let wanted = self.device_name.clone();
|
||||
let available = devices.contains(&wanted);
|
||||
self.devices_seen = devices;
|
||||
if wanted.is_empty() {
|
||||
let changed = default.as_deref().is_some_and(|d| d != current);
|
||||
if changed {
|
||||
log::info!("Horizon: system default audio device has changed");
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
let changed = current != wanted && available;
|
||||
if changed {
|
||||
log::info!("Horizon: preferred audio device has become available");
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
#[cfg(feature = "audio")]
|
||||
{
|
||||
if self.should_change_device() {
|
||||
// SoundEngine.reload: stop every sound, open the output again.
|
||||
self.stop_all();
|
||||
let device = self.device_name.clone();
|
||||
if let Err(e) = self.set_device(&device) {
|
||||
log::warn!("Horizon: could not reopen the audio output: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
}
|
||||
@@ -801,6 +1211,37 @@ impl Audio {
|
||||
let seed = self.random.next_long();
|
||||
self.play_seeded(event, at, volume, pitch, seed);
|
||||
}
|
||||
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
|
||||
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
|
||||
pub fn play_distance_delayed(
|
||||
&mut self,
|
||||
event: &str,
|
||||
at: [f64; 3],
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
camera: [f64; 3],
|
||||
) {
|
||||
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
|
||||
if distance_sqr <= 100.0 {
|
||||
self.play(event, Some(at), volume, pitch);
|
||||
return;
|
||||
}
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
}
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
self.sync_volumes();
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let source = self
|
||||
.source_override
|
||||
.unwrap_or_else(|| crate::settings::sound_source(&event));
|
||||
let instance =
|
||||
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine
|
||||
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
@@ -1013,6 +1454,9 @@ mod tests {
|
||||
looping,
|
||||
at,
|
||||
relative: false,
|
||||
owner: 0,
|
||||
world: 0,
|
||||
started: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1170,6 +1614,185 @@ mod tests {
|
||||
assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitscreen_sounds_are_heard_from_the_nearest_camera() {
|
||||
// P1 far away, a second player beside the source: heard at full gain, centred.
|
||||
let mut m = Mixer {
|
||||
listener: [100.0, 0.0, 0.0],
|
||||
..Mixer::default()
|
||||
};
|
||||
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
|
||||
let mut data = [0.0; 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(
|
||||
data[0] > 0.5 && (data[0] - data[1]).abs() < 1e-4,
|
||||
"{data:?}"
|
||||
);
|
||||
// Alone, P1 would not hear it: 100 blocks past its 16.
|
||||
m.listeners.clear();
|
||||
let mut data = [0.0; 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_listener_registers_even_in_the_hosts_world() {
|
||||
let mut host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
|
||||
host.set_world("minecraft:overworld");
|
||||
let mut guest = host.share(1);
|
||||
guest.set_world("minecraft:overworld");
|
||||
guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0);
|
||||
let id = guest.backend.create(false).unwrap();
|
||||
let mixer = host.backend.mixer.lock().unwrap();
|
||||
let overworld = mixer
|
||||
.world_names
|
||||
.iter()
|
||||
.position(|w| w == "minecraft:overworld");
|
||||
let overworld = overworld.map(|i| i as u32 + 1);
|
||||
assert_eq!(mixer.worlds.get(&1).copied(), overworld);
|
||||
assert_eq!(Some(mixer.channels[&id].world), overworld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cameras_in_other_dimensions_cannot_hear_a_source() {
|
||||
let mut m = Mixer {
|
||||
listener: [100.0, 0.0, 0.0],
|
||||
..Default::default()
|
||||
};
|
||||
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
|
||||
let nether = m.world_id("nether");
|
||||
m.worlds.insert(1, nether);
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
|
||||
let mut data = [0.0; 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
data, [0.0; 2],
|
||||
"nearby Nether camera must not hear Overworld source"
|
||||
);
|
||||
m.channels.get_mut(&1).unwrap().world = nether;
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(data[0] > 0.5, "same-world camera must hear source");
|
||||
let end = m.world_id("end");
|
||||
m.worlds.insert(1, end);
|
||||
data.fill(0.0);
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 2], "no listener remains in source's world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sounds_in_a_world_without_listeners_finish_while_inaudible() {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(vec![1.0; 4], None, [0.0; 3], false);
|
||||
let nether = mixer.world_id("nether");
|
||||
source.world = nether;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 4]);
|
||||
assert!(mixer.channels[&1].position > 0.0);
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(mixer.channels[&1].state, State::Stopped);
|
||||
mixer.worlds.insert(0, nether);
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
variantless("minecraft:mob/creeper/say1.ogg"),
|
||||
"minecraft:mob/creeper/say"
|
||||
);
|
||||
assert_eq!(
|
||||
variantless("minecraft:step/grass6.ogg"),
|
||||
"minecraft:step/grass"
|
||||
);
|
||||
assert_eq!(
|
||||
variantless("minecraft:random/click.ogg"),
|
||||
"minecraft:random/click"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_players_copy_of_a_sound_is_dropped() {
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
// One other player shares the output.
|
||||
let shares = Arc::new(std::sync::atomic::AtomicUsize::new(1));
|
||||
let backend = |owner| MixerBackend {
|
||||
mixer: mixer.clone(),
|
||||
root: PathBuf::new(),
|
||||
cache: Arc::default(),
|
||||
decoder: None,
|
||||
next: (u64::from(owner)) << 40,
|
||||
live: true,
|
||||
owner,
|
||||
world: 0,
|
||||
shares: shares.clone(),
|
||||
};
|
||||
let (mut p1, mut p2) = (backend(0), backend(1));
|
||||
// P2 is listening (its camera is in the mixer), so two players share the speakers.
|
||||
mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.listeners
|
||||
.insert(1, ([0.0; 3], 0.0, 0.0));
|
||||
let started = |b: &mut MixerBackend, at: [f64; 3]| {
|
||||
let id = b.create(false).unwrap();
|
||||
b.set_position(id, at);
|
||||
!b.duplicate(id, ("minecraft", "mob/creeper/say1.ogg"))
|
||||
};
|
||||
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
|
||||
// P2's client got the same sound at the same place: one copy plays.
|
||||
assert!(!started(&mut p2, [5.2, 64.0, 5.0]));
|
||||
// Elsewhere, or P1's own second one, it is a sound of its own.
|
||||
assert!(started(&mut p2, [40.0, 64.0, 5.0]));
|
||||
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
|
||||
// Equal coordinates in another dimension describe a different sound.
|
||||
p2.world = mixer.lock().unwrap().world_id("nether");
|
||||
assert!(started(&mut p2, [5.0, 64.0, 5.0]));
|
||||
// P2 leaving takes its sounds.
|
||||
drop(p2);
|
||||
assert!(
|
||||
mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.channels
|
||||
.values()
|
||||
.all(|c| c.owner == 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusing_a_seat_does_not_reuse_its_channels_or_leave_its_camera() {
|
||||
let host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
|
||||
let mut guest = host.share(1);
|
||||
guest.set_world("overworld");
|
||||
guest.set_listener([1.0, 0.0, 0.0], 0.0, 0.0);
|
||||
let old = guest.backend.create(false).unwrap();
|
||||
drop(guest);
|
||||
{
|
||||
let mixer = host.backend.mixer.lock().unwrap();
|
||||
assert!(!mixer.channels.contains_key(&old));
|
||||
assert!(!mixer.listeners.contains_key(&1));
|
||||
assert!(!mixer.worlds.contains_key(&1));
|
||||
}
|
||||
let mut replacement = host.share(1);
|
||||
let new = replacement.backend.create(false).unwrap();
|
||||
assert_ne!(old, new);
|
||||
assert!(
|
||||
replacement
|
||||
.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.channels
|
||||
.contains_key(&new)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mono_output_plays_sources_unpanned() {
|
||||
let mut m = Mixer::default();
|
||||
@@ -1179,4 +1802,15 @@ mod tests {
|
||||
mix(&mut m, &mut data, 1, 48_000);
|
||||
assert_eq!(data, [0.5; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
// has to be looked at as before.
|
||||
assert!(cards_changed(None, None));
|
||||
assert!(cards_changed(Some("0 [PCH ]"), None));
|
||||
// The same cards list the same devices; a card added or removed does not.
|
||||
assert!(!cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]")));
|
||||
assert!(cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]\n 1 [USB ]")));
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,46 @@ pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<St
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// `Minecraft`'s constructor futures for the title screen: `UserApiService.fetchProperties`
|
||||
/// (the `player` service's `getAttributes`, with the bearer token) and
|
||||
/// `SessionService.fetchProfile(uuid, true)` (`getProfileById`). A failed
|
||||
/// attributes call falls back to the offline properties, a failed profile call
|
||||
/// to no actions, as vanilla does. Blocks: call it off the main thread.
|
||||
pub fn fetch_account_status(session: &AuthSession) -> crate::ban_notice::AccountStatus {
|
||||
use crate::ban_notice::{AccountStatus, parse_profile_actions, parse_properties};
|
||||
let Ok(doc) = discovery() else {
|
||||
return AccountStatus::default();
|
||||
};
|
||||
let properties = endpoint(&doc, "player", "getAttributes")
|
||||
.and_then(|url| {
|
||||
agent()
|
||||
.get(&url)
|
||||
.set("Authorization", &format!("Bearer {}", session.access_token))
|
||||
.call()
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
.and_then(|body| parse_properties(&body))
|
||||
.unwrap_or_default();
|
||||
let actions = endpoint(&doc, "session", "getProfileById")
|
||||
.and_then(|url| {
|
||||
let url = url.replace("{profileId}", &format!("{:032x}", session.uuid));
|
||||
agent()
|
||||
.get(&format!("{url}?unsigned=true"))
|
||||
.call()
|
||||
.ok()?
|
||||
.into_string()
|
||||
.ok()
|
||||
})
|
||||
.map(|body| parse_profile_actions(&body))
|
||||
.unwrap_or_default();
|
||||
AccountStatus {
|
||||
properties,
|
||||
actions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
|
||||
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
|
||||
serde_json::json!({
|
||||
@@ -232,6 +272,25 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
|
||||
}
|
||||
}
|
||||
|
||||
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
|
||||
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
|
||||
/// Single-player and integrated servers often send no `textures` property, or
|
||||
/// one without the equipped cape; the client asks the profile endpoint once.
|
||||
pub fn own_textures(session: &AuthSession) -> Option<crate::skins::Textures> {
|
||||
let response = agent()
|
||||
.get(MINECRAFT_PROFILE)
|
||||
.set("Authorization", &format!("Bearer {}", session.access_token))
|
||||
.call()
|
||||
.ok()?;
|
||||
if response.status() != 200 {
|
||||
return None;
|
||||
}
|
||||
let body = response.into_string().ok()?;
|
||||
let profile: serde_json::Value = serde_json::from_str(&body).ok()?;
|
||||
Some(crate::skins::services_profile_textures(&profile))
|
||||
}
|
||||
|
||||
/// What the session service says about a player joining this server
|
||||
/// (MinecraftServicesSessionService.hasJoinedServer).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
|
||||
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
|
||||
//! attributes call), the forced name change and the banned skin
|
||||
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
|
||||
//! The network calls are in `auth.rs`; this file parses what they return.
|
||||
use crate::text::Text;
|
||||
|
||||
/// `CommonLinks.SUSPENSION_HELP`.
|
||||
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
|
||||
|
||||
/// authlib's `BanDetails` (the id is never shown).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BanDetails {
|
||||
/// Epoch milliseconds the ban ends; `None` is permanent.
|
||||
pub expires_ms: Option<i64>,
|
||||
pub reason: Option<String>,
|
||||
pub reason_message: Option<String>,
|
||||
}
|
||||
|
||||
/// authlib's `UserProperties`, as far as the title screen reads it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProperties {
|
||||
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
|
||||
pub servers_allowed: bool,
|
||||
/// `bannedScopes.get("MULTIPLAYER")`.
|
||||
pub multiplayer_ban: Option<BanDetails>,
|
||||
}
|
||||
impl Default for UserProperties {
|
||||
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// authlib's `ProfileResult.actions()`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProfileActions {
|
||||
pub forced_name_change: bool,
|
||||
pub using_banned_skin: bool,
|
||||
}
|
||||
|
||||
/// What the title screen learns about the account.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AccountStatus {
|
||||
pub properties: UserProperties,
|
||||
pub actions: ProfileActions,
|
||||
}
|
||||
impl AccountStatus {
|
||||
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
|
||||
pub fn allows_multiplayer(&self) -> bool {
|
||||
self.properties.servers_allowed
|
||||
&& self.properties.multiplayer_ban.is_none()
|
||||
&& !self.actions.forced_name_change
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the screens `Gui.addInitialScreens` adds.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Notice {
|
||||
/// `BanNoticeScreens.create`.
|
||||
Multiplayer(BanDetails),
|
||||
/// `BanNoticeScreens.createNameBan` with the profile's name.
|
||||
Name(String),
|
||||
/// `BanNoticeScreens.createSkinBan`.
|
||||
Skin,
|
||||
}
|
||||
|
||||
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
|
||||
/// name ban, the skin ban.
|
||||
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
|
||||
let mut notices = Vec::new();
|
||||
if let Some(ban) = &status.properties.multiplayer_ban {
|
||||
notices.push(Notice::Multiplayer(ban.clone()));
|
||||
}
|
||||
if status.actions.forced_name_change {
|
||||
notices.push(Notice::Name(profile_name.to_owned()));
|
||||
}
|
||||
if status.actions.using_banned_skin {
|
||||
notices.push(Notice::Skin);
|
||||
}
|
||||
notices
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
/// The screen's title (bold).
|
||||
pub fn title(&self) -> Text {
|
||||
let key = match self {
|
||||
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
|
||||
Self::Multiplayer(_) => "gui.banned.title.permanent",
|
||||
Self::Name(_) => "gui.banned.name.title",
|
||||
Self::Skin => "gui.banned.skin.title",
|
||||
};
|
||||
Text::translate(key, &[]).bold()
|
||||
}
|
||||
/// The screen's message; `now_ms` is `Instant.now()`.
|
||||
pub fn message(&self, now_ms: i64) -> Text {
|
||||
let link = || Text::literal(SUSPENSION_HELP);
|
||||
match self {
|
||||
Self::Multiplayer(ban) => Text::translate(
|
||||
"gui.banned.description",
|
||||
&[reason_text(ban), status_text(ban, now_ms), link()],
|
||||
),
|
||||
Self::Name(name) => Text::translate(
|
||||
"gui.banned.name.description",
|
||||
&[Text::colored(name.clone(), YELLOW), link()],
|
||||
),
|
||||
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatFormatting.YELLOW`.
|
||||
const YELLOW: u32 = 0xFFFF55;
|
||||
|
||||
/// `BanReason.byId`: the translation key suffix.
|
||||
fn ban_reason(id: i32) -> Option<&'static str> {
|
||||
Some(match id {
|
||||
2 => "false_reporting",
|
||||
5 => "hate_speech",
|
||||
16 | 25 => "hate_terrorism_notorious_figure",
|
||||
17 | 19 | 23 | 31 => "generic_violation",
|
||||
21 => "harassment_or_bullying",
|
||||
27 => "defamation_impersonation_false_information",
|
||||
28 => "drugs",
|
||||
29 => "fraud",
|
||||
30 => "spam_or_advertising",
|
||||
32 => "nudity_or_pornography",
|
||||
33 | 35 | 36 => "sexually_inappropriate",
|
||||
34 => "extreme_violence_or_gore",
|
||||
53 => "imminent_harm_to_person_or_property",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanReasonText`.
|
||||
fn reason_text(ban: &BanDetails) -> Text {
|
||||
let numeric = ban
|
||||
.reason
|
||||
.as_deref()
|
||||
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
|
||||
let Some(reason) = numeric else {
|
||||
return Text::translate("gui.banned.description.unknownreason", &[]);
|
||||
};
|
||||
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
|
||||
let id = reason.parse::<i32>().ok();
|
||||
let text = if let Some(key) = id.and_then(ban_reason) {
|
||||
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
|
||||
} else if let Some(message) = &ban.reason_message {
|
||||
Text::translate(
|
||||
"gui.banned.description.reason_id_message",
|
||||
&[Text::literal(reason), Text::literal(message.clone())],
|
||||
)
|
||||
.bold()
|
||||
} else {
|
||||
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
|
||||
};
|
||||
Text::translate("gui.banned.description.reason", &[text])
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanStatusText`.
|
||||
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
|
||||
match ban.expires_ms {
|
||||
Some(expires) => {
|
||||
let duration = Text::translate(
|
||||
"gui.banned.description.temporary.duration",
|
||||
&[duration_text(expires - now_ms)],
|
||||
)
|
||||
.bold();
|
||||
Text::translate("gui.banned.description.temporary", &[duration])
|
||||
}
|
||||
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
|
||||
/// else minutes (`Duration.toHours` and friends truncate toward zero).
|
||||
fn duration_text(millis: i64) -> Text {
|
||||
let count = |n: i64| Text::literal(n.to_string());
|
||||
let hours = millis / 3_600_000;
|
||||
if hours > 72 {
|
||||
Text::translate("gui.days", &[count(millis / 86_400_000)])
|
||||
} else if hours < 1 {
|
||||
Text::translate("gui.minutes", &[count(millis / 60_000)])
|
||||
} else {
|
||||
Text::translate("gui.hours", &[count(hours)])
|
||||
}
|
||||
}
|
||||
|
||||
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
|
||||
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
|
||||
match value {
|
||||
serde_json::Value::Number(n) => n.as_i64(),
|
||||
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|t| t.timestamp_millis())
|
||||
.or_else(|| s.parse().ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
|
||||
/// is an error, which vanilla answers with the offline properties.
|
||||
pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let servers_allowed = v
|
||||
.pointer("/privileges/multiplayerServer/enabled")
|
||||
.and_then(|e| e.as_bool())
|
||||
.unwrap_or(false);
|
||||
let multiplayer_ban = v
|
||||
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
|
||||
.filter(|b| b.is_object())
|
||||
.map(|b| {
|
||||
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
|
||||
BanDetails {
|
||||
expires_ms: b.get("expires").and_then(parse_instant),
|
||||
reason: text("reason"),
|
||||
reason_message: text("reasonMessage"),
|
||||
}
|
||||
});
|
||||
Some(UserProperties {
|
||||
servers_allowed,
|
||||
multiplayer_ban,
|
||||
})
|
||||
}
|
||||
|
||||
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
|
||||
pub fn parse_profile_actions(body: &str) -> ProfileActions {
|
||||
let mut actions = ProfileActions::default();
|
||||
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
|
||||
for action in v
|
||||
.get("profileActions")
|
||||
.and_then(|a| a.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
match action.get("type").and_then(|t| t.as_str()) {
|
||||
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
|
||||
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
/// 2026-11-01T00:00:00Z.
|
||||
const EXPIRES: i64 = 1_793_491_200_000;
|
||||
|
||||
#[test]
|
||||
fn parses_a_multiplayer_ban() {
|
||||
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
|
||||
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
|
||||
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
|
||||
let p = parse_properties(body).unwrap();
|
||||
assert!(!p.servers_allowed);
|
||||
let ban = p.multiplayer_ban.unwrap();
|
||||
assert_eq!(ban.expires_ms, Some(EXPIRES));
|
||||
assert_eq!(ban.reason.as_deref(), Some("21"));
|
||||
let numeric =
|
||||
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
|
||||
.unwrap();
|
||||
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
|
||||
assert!(parse_properties("nope").is_none());
|
||||
let clean = parse_properties(
|
||||
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profile_actions() {
|
||||
let a = parse_profile_actions(
|
||||
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
|
||||
);
|
||||
assert!(a.forced_name_change && !a.using_banned_skin);
|
||||
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
|
||||
assert!(!a.forced_name_change && a.using_banned_skin);
|
||||
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queues_in_vanilla_order() {
|
||||
let ban = BanDetails {
|
||||
expires_ms: None,
|
||||
reason: None,
|
||||
reason_message: None,
|
||||
};
|
||||
let status = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: Some(ban.clone()),
|
||||
},
|
||||
actions: ProfileActions {
|
||||
forced_name_change: true,
|
||||
using_banned_skin: true,
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
initial_notices(&status, "Steve"),
|
||||
vec![
|
||||
Notice::Multiplayer(ban),
|
||||
Notice::Name("Steve".into()),
|
||||
Notice::Skin
|
||||
]
|
||||
);
|
||||
assert!(!status.allows_multiplayer());
|
||||
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
|
||||
assert!(AccountStatus::default().allows_multiplayer());
|
||||
let no_servers = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: false,
|
||||
multiplayer_ban: None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!no_servers.allows_multiplayer());
|
||||
}
|
||||
|
||||
/// The message's component JSON: the keys and arguments, whatever language is loaded.
|
||||
fn json(notice: &Notice, now: i64) -> String {
|
||||
notice.message(now).component().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ban_text_follows_banned_screens() {
|
||||
let temp = |reason: Option<&str>, message: Option<&str>| {
|
||||
Notice::Multiplayer(BanDetails {
|
||||
expires_ms: Some(100 * HOUR),
|
||||
reason: reason.map(str::to_owned),
|
||||
reason_message: message.map(str::to_owned),
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
temp(None, None).title().translation_key(),
|
||||
Some("gui.banned.title.temporary")
|
||||
);
|
||||
let perm = Notice::Multiplayer(BanDetails {
|
||||
expires_ms: None,
|
||||
reason: Some("5".into()),
|
||||
reason_message: None,
|
||||
});
|
||||
assert_eq!(
|
||||
perm.title().translation_key(),
|
||||
Some("gui.banned.title.permanent")
|
||||
);
|
||||
let text = json(&perm, 0);
|
||||
assert!(text.contains("gui.banned.reason.hate_speech"));
|
||||
assert!(text.contains("gui.banned.description.permanent"));
|
||||
assert!(text.contains(SUSPENSION_HELP));
|
||||
// Unknown ids show their code, with the message when there is one.
|
||||
let with_message = json(&temp(Some("999"), Some("why")), 0);
|
||||
assert!(with_message.contains("gui.banned.description.reason_id_message"));
|
||||
assert!(with_message.contains("why"));
|
||||
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
|
||||
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
|
||||
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
|
||||
let duration = |now| json(&temp(None, None), now);
|
||||
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
|
||||
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
|
||||
let minutes = duration(100 * HOUR - 90_000);
|
||||
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
|
||||
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
|
||||
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
|
||||
}
|
||||
}
|
||||
@@ -789,6 +789,8 @@ pub struct Scene<'a> {
|
||||
pub glyphs: u64,
|
||||
}
|
||||
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
kinds: Vec<Kind>,
|
||||
@@ -1116,9 +1118,10 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
|
||||
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
|
||||
/// BellRenderer.submit, LecternRenderer.submit (the book) and
|
||||
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
|
||||
/// destroy stage.
|
||||
/// BellRenderer.submit, LecternRenderer.submit (the book),
|
||||
/// CopperGolemStatueBlockRenderer.submit, ConduitRenderer.submit (the shell
|
||||
/// or cage) and EnchantTableRenderer.submit (the book): the model drawn
|
||||
/// again under the destroy stage.
|
||||
pub fn render_breaking(
|
||||
&self,
|
||||
scene: &Scene,
|
||||
@@ -1136,6 +1139,8 @@ impl BlockEntityRenderer {
|
||||
| Kind::Bell
|
||||
| Kind::Lectern { .. }
|
||||
| Kind::CopperGolem { .. }
|
||||
| Kind::Conduit
|
||||
| Kind::EnchantingTable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1490,6 +1495,7 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
@@ -1524,7 +1530,13 @@ impl BlockEntityRenderer {
|
||||
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
|
||||
_ => {}
|
||||
};
|
||||
let start = out.len();
|
||||
draw_part(model, "root", m, &anim, paint, out);
|
||||
// `SkullBlockRenderer.resolveSkullRenderType`: a head with a profile takes
|
||||
// the player skin's `entityTranslucent` render type.
|
||||
if has_profile {
|
||||
crate::gfx::mark_oit(&mut out[start..]);
|
||||
}
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
@@ -1674,6 +1686,10 @@ impl BlockEntityRenderer {
|
||||
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees * (PI / 180.0)));
|
||||
draw_part(model, "root", m, &none, paint, out);
|
||||
}
|
||||
// The crumbling overlay covers the shell and the cage only.
|
||||
if crumbling {
|
||||
return;
|
||||
}
|
||||
let wind = if phase == 1 {
|
||||
"entity/conduit/wind_vertical"
|
||||
} else {
|
||||
|
||||
@@ -24,6 +24,9 @@ struct Out {
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
|
||||
return border_color(in);
|
||||
}
|
||||
fn border_color(in: Out) -> vec4<f32> {
|
||||
let color = textureSample(forcefield, forcefield_sampler, in.uv);
|
||||
if color.a == 0.0 {
|
||||
discard;
|
||||
|
||||
@@ -12,8 +12,8 @@ use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
|
||||
potion, potion_color,
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
|
||||
};
|
||||
/// `MobEffectUtil.formatDuration` at 20 TPS.
|
||||
pub fn format_duration(ticks: i32) -> String {
|
||||
@@ -80,8 +80,7 @@ pub fn effect_lines<'a>(
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
let mut line = translate(&description_id(e)).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
|
||||
@@ -21,6 +21,8 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
/// GuiMessageTag.systemSinglePlayer's indicator colour.
|
||||
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
|
||||
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
|
||||
const MODIFIED_INDICATOR: u32 = 0x60_6060;
|
||||
/// A player message's signature (MessageSignature), which names it to a
|
||||
/// server's deletion.
|
||||
pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
@@ -169,7 +171,7 @@ impl ChatOptions {
|
||||
struct Message {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
/// `GuiMessage.signature`: a signed player message's, for deletion.
|
||||
signature: Option<Box<Signature>>,
|
||||
@@ -179,7 +181,54 @@ struct Message {
|
||||
struct Line {
|
||||
added: u64,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
}
|
||||
|
||||
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
|
||||
/// over it, and for a modified message the icon after each line.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub color: u32,
|
||||
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
|
||||
pub icon: bool,
|
||||
pub text: Text,
|
||||
}
|
||||
impl Tag {
|
||||
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
|
||||
pub fn system_single_player(color: u32) -> Self {
|
||||
Self {
|
||||
color,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.system_single_player", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatNotSecure`.
|
||||
pub fn not_secure() -> Self {
|
||||
Self {
|
||||
color: SYSTEM_INDICATOR,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.not_secure", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatError`: red (`CHAT_ERROR`, 16733525), the tooltip `chat.tag.error`.
|
||||
pub fn chat_error() -> Self {
|
||||
Self {
|
||||
color: 0xFF_5555,
|
||||
icon: false,
|
||||
text: Text::translate("chat.tag.error", &[]),
|
||||
}
|
||||
}
|
||||
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
|
||||
pub fn modified(original_content: &str) -> Self {
|
||||
Self {
|
||||
color: MODIFIED_INDICATOR,
|
||||
icon: true,
|
||||
text: Text::empty()
|
||||
.append(Text::translate("chat.tag.modified", &[]))
|
||||
.append(Text::literal("\n"))
|
||||
.append(Text::literal(original_content).with_color(text::GRAY)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
@@ -236,7 +285,8 @@ impl Chat {
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
|
||||
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
|
||||
self.add_message(&atlas.font, content, tag, source, ticks, focused);
|
||||
}
|
||||
/// `add` of a player message with its signature, which a server's
|
||||
/// deletion names it by (`GuiMessage.signature`).
|
||||
@@ -245,11 +295,13 @@ impl Chat {
|
||||
atlas: &HudAtlas,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
tag: Option<Tag>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
|
||||
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn add_signed_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
@@ -257,13 +309,25 @@ impl Chat {
|
||||
signature: Option<Signature>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.add_player_message(font, content, signature, None, ticks, focused);
|
||||
}
|
||||
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
|
||||
fn add_player_message(
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
signature: Option<Signature>,
|
||||
tag: Option<Tag>,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
) {
|
||||
self.push_message(
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator: None,
|
||||
tag: tag.map(std::sync::Arc::new),
|
||||
source: ChatSource::Player,
|
||||
signature: signature.map(Box::new),
|
||||
},
|
||||
@@ -274,7 +338,7 @@ impl Chat {
|
||||
&mut self,
|
||||
font: &crate::font::Font,
|
||||
content: Text,
|
||||
indicator: Option<u32>,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
ticks: u64,
|
||||
focused: bool,
|
||||
@@ -284,7 +348,7 @@ impl Chat {
|
||||
Message {
|
||||
added: ticks,
|
||||
content,
|
||||
indicator,
|
||||
tag,
|
||||
source,
|
||||
signature: None,
|
||||
},
|
||||
@@ -307,7 +371,13 @@ impl Chat {
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
|
||||
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
|
||||
9 + 4 + 2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let width = self.max_width() - icon;
|
||||
let content = self.displayed(&message.content);
|
||||
for line in font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
@@ -319,7 +389,7 @@ impl Chat {
|
||||
Line {
|
||||
added: message.added,
|
||||
content: line,
|
||||
indicator: message.indicator,
|
||||
tag: message.tag.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -416,7 +486,9 @@ impl Chat {
|
||||
*message = Message {
|
||||
added: message.added,
|
||||
content: Self::deleted_marker(),
|
||||
indicator: Some(SYSTEM_INDICATOR),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
source: ChatSource::Server,
|
||||
signature: None,
|
||||
};
|
||||
@@ -540,8 +612,8 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(indicator) = line.indicator {
|
||||
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -702,12 +774,91 @@ impl Chat {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Whether the cursor is on a line's indicator bar while chat is focused
|
||||
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
|
||||
/// message tag's text.
|
||||
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
|
||||
self.line_at(height, x, y)
|
||||
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
|
||||
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
|
||||
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
|
||||
/// the tag under it (its bar, or its icon) and the icons to draw, those
|
||||
/// under the cursor and those of the message the cursor is over.
|
||||
pub fn focused_tags(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> (Option<Text>, Vec<GuiDraw>) {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
|
||||
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
|
||||
mouse_x >= f64::from(left)
|
||||
&& mouse_x < f64::from(right)
|
||||
&& mouse_y >= f64::from(top)
|
||||
&& mouse_y < f64::from(bottom)
|
||||
};
|
||||
let visible = self
|
||||
.lines
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let mut tooltip = None;
|
||||
let mut icons = Vec::new();
|
||||
let mut hovered_message = false;
|
||||
for i in (0..visible).rev() {
|
||||
let index = i + self.scroll;
|
||||
let line = &self.lines[index];
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
let top = bottom - entry_height;
|
||||
let width = font.width_text(&line.content).ceil() as i32;
|
||||
hovered_message |= inside(0, top, width, bottom);
|
||||
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
|
||||
let end_of_entry = index == 0
|
||||
|| match (&line.tag, &self.lines[index - 1].tag) {
|
||||
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
};
|
||||
let force_icon = end_of_entry && hovered_message;
|
||||
if end_of_entry {
|
||||
hovered_message = false;
|
||||
}
|
||||
let Some(tag) = &line.tag else { continue };
|
||||
if inside(-4, top, -2, bottom) {
|
||||
tooltip = Some(tag.text.clone());
|
||||
}
|
||||
if tag.icon {
|
||||
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
|
||||
let icon_top = text_bottom - 9 - 1;
|
||||
let over = inside(left, icon_top, left + 9, text_bottom);
|
||||
if over {
|
||||
tooltip = Some(tag.text.clone());
|
||||
}
|
||||
if force_icon || over {
|
||||
icons.push(GuiDraw::Sprite {
|
||||
sprite: "icon/chat_modified".into(),
|
||||
x: left,
|
||||
y: icon_top,
|
||||
w: 9,
|
||||
h: 9,
|
||||
region: None,
|
||||
color: hud_layout::WHITE,
|
||||
blend: hud_layout::Blend::Textured,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if icons.is_empty() {
|
||||
return (tooltip, icons);
|
||||
}
|
||||
let mut draws = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
draws.extend(icons);
|
||||
draws.push(GuiDraw::Pop);
|
||||
(tooltip, draws)
|
||||
}
|
||||
/// Plain text of the newest messages (for tests and the control socket).
|
||||
pub fn recent_lines(&self, n: usize) -> Vec<String> {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
Line {
|
||||
added: 0,
|
||||
content,
|
||||
indicator: None,
|
||||
tag: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -557,7 +557,7 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
|
||||
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
|
||||
assert!(chat.deletions.is_empty());
|
||||
let line = &chat.lines[1];
|
||||
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.tag.as_ref().map(|t| t.color), Some(SYSTEM_INDICATOR));
|
||||
assert_eq!(line.added, 100, "the marker keeps the message's time");
|
||||
assert!(
|
||||
line.content
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
//! Resident chunk membership, with a token for consumers that cache its keys.
|
||||
use crate::{chunk::Chunk, fast_hash::FxHashMap};
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
};
|
||||
use std::{
|
||||
collections::hash_map::{Iter, ValuesMut},
|
||||
collections::{
|
||||
VecDeque,
|
||||
hash_map::{Iter, ValuesMut},
|
||||
},
|
||||
ops::Deref,
|
||||
sync::{
|
||||
Arc,
|
||||
@@ -16,18 +22,46 @@ fn next_revision() -> u64 {
|
||||
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The membership changes one map keeps for the consumers that read them
|
||||
/// every tick (`TickChunkMembership.synchronize`); older ones are dropped.
|
||||
const MAX_RECENT_DELTAS: usize = 256;
|
||||
|
||||
/// One membership change of one `ChunkMap`, stamped with the revision it
|
||||
/// brought the map to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ChunkMembershipDelta {
|
||||
Added(Key),
|
||||
Removed(Key),
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// Read access behaves like a map. Structural writes are explicit so edits to
|
||||
/// blocks or light don't invalidate consumers of the loaded chunk key set.
|
||||
/// Tokens are process-wide: replacing the entire map also invalidates caches.
|
||||
pub struct ChunkMap {
|
||||
columns: Columns,
|
||||
revision: u64,
|
||||
/// The oldest revision `recent_deltas` still answers for: the revision of
|
||||
/// the change it last dropped, or the revision that cleared them. A
|
||||
/// consumer holding an older one rebuilds, it does not replay.
|
||||
min_valid_revision: u64,
|
||||
/// This map's recent membership changes, in revision order.
|
||||
/// Revisions are process-wide: the numbers missing in between belong to
|
||||
/// the other maps (a server thread's, another dimension's), which say
|
||||
/// nothing about this one's keys.
|
||||
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
|
||||
/// The resident chunks generation has not placed its animals in yet.
|
||||
fresh: FxHashSet<Key>,
|
||||
}
|
||||
impl Default for ChunkMap {
|
||||
fn default() -> Self {
|
||||
let revision = next_revision();
|
||||
Self {
|
||||
columns: Columns::default(),
|
||||
revision: next_revision(),
|
||||
revision,
|
||||
min_valid_revision: revision,
|
||||
recent_deltas: VecDeque::new(),
|
||||
fresh: FxHashSet::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,26 +69,107 @@ impl ChunkMap {
|
||||
pub fn membership_revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
fn record_delta(&mut self, delta: ChunkMembershipDelta) {
|
||||
if self.recent_deltas.len() >= MAX_RECENT_DELTAS
|
||||
&& let Some((evicted, _)) = self.recent_deltas.pop_front()
|
||||
{
|
||||
self.min_valid_revision = evicted;
|
||||
}
|
||||
self.recent_deltas.push_back((self.revision, delta));
|
||||
}
|
||||
/// This map's membership changes since `last_revision`, one of its own
|
||||
/// earlier `membership_revision`s, or `None` when they can no longer be
|
||||
/// answered: the consumer's revision predates what the ring kept, the map
|
||||
/// was cleared since (a reset rebuilds everything), or the revision is not
|
||||
/// this map's, which its own revisions give away: every one of them in the
|
||||
/// answered range is the opening revision or a change the ring still
|
||||
/// holds, so a revision another map took in between is refused, not
|
||||
/// replayed.
|
||||
pub fn deltas_since(
|
||||
&self,
|
||||
last_revision: u64,
|
||||
) -> Option<impl Iterator<Item = (u64, ChunkMembershipDelta)> + '_> {
|
||||
if last_revision == 0
|
||||
|| last_revision < self.min_valid_revision
|
||||
|| last_revision > self.revision
|
||||
|| self.recent_deltas.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pos = self
|
||||
.recent_deltas
|
||||
.partition_point(|(rev, _)| *rev <= last_revision);
|
||||
// A revision this map never took: another map's, drawing on the same
|
||||
// process-wide counter. Only the opening revision and the ones the
|
||||
// ring still holds are this map's.
|
||||
let is_own_revision = last_revision == self.min_valid_revision
|
||||
|| (pos > 0 && self.recent_deltas[pos - 1].0 == last_revision);
|
||||
if !is_own_revision {
|
||||
return None;
|
||||
}
|
||||
let slice = self.recent_deltas.range(pos..);
|
||||
if slice
|
||||
.clone()
|
||||
.any(|(_, d)| *d == ChunkMembershipDelta::Reset)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(slice.copied())
|
||||
}
|
||||
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
|
||||
if chunk.fresh {
|
||||
self.fresh.insert(key);
|
||||
} else {
|
||||
self.fresh.remove(&key);
|
||||
}
|
||||
let previous = self.columns.insert(key, chunk);
|
||||
if previous.is_none() {
|
||||
self.revision = next_revision();
|
||||
self.record_delta(ChunkMembershipDelta::Added(key));
|
||||
}
|
||||
previous
|
||||
}
|
||||
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
|
||||
self.fresh.remove(key);
|
||||
let previous = self.columns.remove(key);
|
||||
if previous.is_some() {
|
||||
self.revision = next_revision();
|
||||
self.record_delta(ChunkMembershipDelta::Removed(*key));
|
||||
}
|
||||
previous
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
if !self.columns.is_empty() {
|
||||
self.columns.clear();
|
||||
self.fresh.clear();
|
||||
self.revision = next_revision();
|
||||
self.recent_deltas.clear();
|
||||
self.recent_deltas
|
||||
.push_back((self.revision, ChunkMembershipDelta::Reset));
|
||||
self.min_valid_revision = self.revision;
|
||||
}
|
||||
}
|
||||
pub fn has_fresh(&self) -> bool {
|
||||
!self.fresh.is_empty()
|
||||
}
|
||||
/// The fresh keys in the chunk map's own iteration order, so the spawns
|
||||
/// they drive run in the order a scan of the columns gave.
|
||||
pub fn fresh_keys_ordered(&self) -> Vec<Key> {
|
||||
if self.fresh.is_empty() {
|
||||
Vec::new()
|
||||
} else if self.fresh.len() == 1 {
|
||||
self.fresh.iter().copied().collect()
|
||||
} else {
|
||||
self.columns
|
||||
.keys()
|
||||
.filter(|k| self.fresh.contains(k))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
pub fn remove_fresh(&mut self, key: &Key) {
|
||||
self.fresh.remove(key);
|
||||
}
|
||||
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
|
||||
self.columns.get_mut(key)
|
||||
}
|
||||
@@ -125,4 +240,146 @@ mod tests {
|
||||
map = ChunkMap::default();
|
||||
assert_ne!(cleared, map.membership_revision());
|
||||
}
|
||||
|
||||
/// The deltas' kinds, checking the ring answers in revision order.
|
||||
fn kinds(deltas: Vec<(u64, ChunkMembershipDelta)>) -> Vec<ChunkMembershipDelta> {
|
||||
let mut last = 0;
|
||||
for (rev, _) in &deltas {
|
||||
assert!(*rev > last, "the ring answers in revision order");
|
||||
last = *rev;
|
||||
}
|
||||
deltas.into_iter().map(|(_, d)| d).collect()
|
||||
}
|
||||
|
||||
/// Revision numbers are process-wide: the ones another map takes in
|
||||
/// between, and a payload-only rewrite that takes none at all, say nothing
|
||||
/// about this map's keys.
|
||||
#[test]
|
||||
fn deltas_since_ignores_revisions_other_maps_take() {
|
||||
let mut map = ChunkMap::default();
|
||||
let mut other = ChunkMap::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
map.insert((0, 0), chunk.clone());
|
||||
let seen = map.membership_revision();
|
||||
for key in [(1, 0), (2, 0), (3, 0)] {
|
||||
other.insert(key, chunk.clone());
|
||||
}
|
||||
map.insert((0, 0), Arc::new(Chunk::empty(0, 0))); // Payload only.
|
||||
map.insert((4, 0), chunk.clone());
|
||||
map.remove(&(0, 0));
|
||||
assert_eq!(
|
||||
kinds(map.deltas_since(seen).unwrap().collect()),
|
||||
[
|
||||
ChunkMembershipDelta::Added((4, 0)),
|
||||
ChunkMembershipDelta::Removed((0, 0)),
|
||||
]
|
||||
);
|
||||
// The consumer's own revision answers, with nothing new since.
|
||||
assert!(
|
||||
map.deltas_since(map.membership_revision())
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
// A revision another map took in between: refused, not replayed.
|
||||
assert!(map.deltas_since(other.membership_revision()).is_none());
|
||||
}
|
||||
|
||||
/// A map built ahead of the live one, its revisions interleaved with it,
|
||||
/// answering for a revision it never took: refused, so the consumer
|
||||
/// rebuilds rather than replay another map's changes.
|
||||
#[test]
|
||||
fn deltas_since_none_for_interleaved_revision_from_another_map() {
|
||||
let mut map = ChunkMap::default();
|
||||
let mut other = ChunkMap::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
map.insert((0, 0), chunk.clone());
|
||||
let seen = map.membership_revision();
|
||||
other.insert((1, 0), chunk.clone());
|
||||
let foreign = other.membership_revision();
|
||||
map.insert((2, 0), chunk.clone());
|
||||
assert!(map.deltas_since(foreign).is_none());
|
||||
assert_eq!(
|
||||
kinds(map.deltas_since(seen).unwrap().collect()),
|
||||
[ChunkMembershipDelta::Added((2, 0))]
|
||||
);
|
||||
}
|
||||
|
||||
/// The ring answers for the changes it kept, and only those.
|
||||
#[test]
|
||||
fn deltas_since_none_for_a_revision_the_ring_dropped() {
|
||||
let mut map = ChunkMap::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let created = map.membership_revision();
|
||||
let mut revisions = Vec::new();
|
||||
let changes = MAX_RECENT_DELTAS as i32 + 1;
|
||||
for i in 0..changes {
|
||||
map.insert((i, 0), chunk.clone());
|
||||
revisions.push(map.membership_revision());
|
||||
}
|
||||
let (first, kept) = revisions.split_first().unwrap();
|
||||
// The oldest change fell out with the consumer's revision: rebuild.
|
||||
assert!(map.deltas_since(created).is_none());
|
||||
assert_eq!(map.deltas_since(*first).unwrap().count(), kept.len());
|
||||
}
|
||||
|
||||
/// A reset rebuilds whatever the consumer last saw, and the ring answers
|
||||
/// for what follows it.
|
||||
#[test]
|
||||
fn deltas_since_none_across_a_clear() {
|
||||
let mut map = ChunkMap::default();
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
map.insert((0, 0), chunk.clone());
|
||||
let before = map.membership_revision();
|
||||
map.clear();
|
||||
assert!(map.deltas_since(before).is_none());
|
||||
let cleared = map.membership_revision();
|
||||
map.insert((1, 0), chunk);
|
||||
assert_eq!(
|
||||
kinds(map.deltas_since(cleared).unwrap().collect()),
|
||||
[ChunkMembershipDelta::Added((1, 0))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_chunks_tracking() {
|
||||
let mut map = ChunkMap::default();
|
||||
assert!(!map.has_fresh());
|
||||
let mut chunk = Chunk::empty(1, 2);
|
||||
chunk.fresh = true;
|
||||
map.insert((1, 2), Arc::new(chunk));
|
||||
assert!(map.has_fresh());
|
||||
assert_eq!(map.fresh_keys_ordered(), vec![(1, 2)]);
|
||||
|
||||
map.remove_fresh(&(1, 2));
|
||||
assert!(!map.has_fresh());
|
||||
|
||||
let mut chunk2 = Chunk::empty(3, 4);
|
||||
chunk2.fresh = true;
|
||||
map.insert((3, 4), Arc::new(chunk2));
|
||||
assert!(map.has_fresh());
|
||||
|
||||
map.remove(&(3, 4));
|
||||
assert!(!map.has_fresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_keys_ordered_matches_chunk_map_iteration_order() {
|
||||
let mut map = ChunkMap::default();
|
||||
let keys = [(-5, 10), (12, -3), (0, 0), (7, 8), (-1, -1), (4, 4)];
|
||||
for (i, &key) in keys.iter().enumerate() {
|
||||
let mut chunk = Chunk::empty(key.0, key.1);
|
||||
chunk.fresh = i % 2 == 0;
|
||||
map.insert(key, Arc::new(chunk));
|
||||
}
|
||||
|
||||
let ordered = map.fresh_keys_ordered();
|
||||
let expected: Vec<Key> = map
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|k| map.fresh.contains(k))
|
||||
.collect();
|
||||
assert_eq!(ordered, expected);
|
||||
assert_eq!(ordered.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,28 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
|
||||
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
|
||||
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
|
||||
/// `HopperMenu` (five slots). `None` for any other entity type.
|
||||
pub fn vehicle_menu(entity_type: &str) -> Option<(&'static str, usize)> {
|
||||
match entity_type {
|
||||
"minecraft:chest_minecart" => Some(("minecraft:generic_9x3", 27)),
|
||||
"minecraft:hopper_minecart" => Some(("minecraft:hopper", HOPPER_SLOTS)),
|
||||
n if n.ends_with("_chest_boat") || n.ends_with("_chest_raft") => {
|
||||
Some(("minecraft:generic_9x3", 27))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `MenuProvider.getDisplayName` for a container vehicle: `Entity.getDisplayName`
|
||||
/// is its custom name, else `getName`, the entity type's name key. Horizon's
|
||||
/// vehicles keep no custom name, so this is the type's name key.
|
||||
pub fn vehicle_menu_name(entity_type: &str) -> String {
|
||||
format!("entity.{}", entity_type.replacen(':', ".", 1))
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
|
||||
@@ -223,6 +223,35 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
|
||||
let file = world.join("datapacks.json");
|
||||
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
|
||||
}
|
||||
/// Re-Create World's packs from a saved world: a new folder holding a copy of
|
||||
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
|
||||
/// world has none), and the selection its `datapacks.json` makes, lowest
|
||||
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
|
||||
/// The built-in pack is selected unless the world turned it off.
|
||||
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
|
||||
let packs = discover(world);
|
||||
let mut selected = enabled_ids(world, &packs);
|
||||
let vanilla_off = std::fs::read(world.join("datapacks.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.and_then(|v| v["disabled"].as_array().cloned())
|
||||
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
|
||||
if !vanilla_off {
|
||||
selected.insert(0, "vanilla".into());
|
||||
}
|
||||
if packs.is_empty() {
|
||||
return (None, selected);
|
||||
}
|
||||
let dir = temp_folder().ok().and_then(|dir| {
|
||||
if copy(&world.join("datapacks"), &dir).is_ok() {
|
||||
Some(dir)
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
None
|
||||
}
|
||||
});
|
||||
(dir, selected)
|
||||
}
|
||||
/// A file, or a folder and everything in it.
|
||||
fn copy(from: &Path, to: &Path) -> Result<(), String> {
|
||||
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
|
||||
@@ -424,7 +453,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -432,14 +461,14 @@ pub struct DataStore {
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` or `tags/<sub>/`, where the id
|
||||
/// keeps the sub path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" => kind,
|
||||
"recipe" | "loot_table" | "advancement" | "dialog" => kind,
|
||||
"tags" => {
|
||||
let (sub, name) = rest.rsplit_once('/')?;
|
||||
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
|
||||
@@ -468,6 +497,7 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
@@ -836,6 +866,10 @@ mod tests {
|
||||
"data/mine/predicate/p.json",
|
||||
"{\"condition\":\"minecraft:random_chance\",\"chance\":1}",
|
||||
);
|
||||
write(
|
||||
"data/mine/dialog/config/points.json",
|
||||
"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}",
|
||||
);
|
||||
let packs = discover(&dir);
|
||||
assert_eq!(packs.len(), 1);
|
||||
assert_eq!(packs[0].id, "file/mypack");
|
||||
@@ -848,6 +882,11 @@ mod tests {
|
||||
["mine:a", "mine:sub/b"]
|
||||
);
|
||||
assert!(store.predicates.contains_key("mine:p"));
|
||||
// Dialogs reach the synchronized registry through the content files.
|
||||
assert_eq!(
|
||||
store.files["dialog/mine:config/points"],
|
||||
[b"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}".to_vec()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ use horizon_client_core::potions;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectInstance {
|
||||
/// Effect id without namespace (`speed`, `poison`, ...).
|
||||
/// Effect id: vanilla's without namespace (`speed`, `poison`, ...), a
|
||||
/// content mod's `<ns>:<path>`.
|
||||
pub id: &'static str,
|
||||
/// Remaining ticks; -1 is infinite.
|
||||
pub duration: i32,
|
||||
@@ -219,7 +220,7 @@ impl PlayerEffects {
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
format!("minecraft:{}", e.id),
|
||||
potions::namespaced(e.id).to_owned(),
|
||||
e.duration,
|
||||
e.amplifier,
|
||||
e.ambient,
|
||||
@@ -525,6 +526,30 @@ mod tests {
|
||||
assert_eq!(fx.get("speed").unwrap().duration, 49);
|
||||
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
|
||||
}
|
||||
/// The only test here that registers mod effects (once per process).
|
||||
#[test]
|
||||
fn a_mod_effect_saves_under_its_namespace() {
|
||||
potions::register_mod_effects([(
|
||||
"ruby:glow".to_owned(),
|
||||
0xFF0000,
|
||||
potions::Category::Neutral,
|
||||
)]);
|
||||
let mut fx = PlayerEffects::default();
|
||||
fx.add(EffectInstance::new(
|
||||
potions::effect("ruby:glow").unwrap().id,
|
||||
600,
|
||||
1,
|
||||
));
|
||||
let saved = fx.saved();
|
||||
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
|
||||
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
|
||||
// Without its mod the saved effect is dropped, as an unknown id is.
|
||||
assert!(
|
||||
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
|
||||
.list
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn raid_omen_acts_on_its_last_tick() {
|
||||
let mut fx = PlayerEffects::default();
|
||||
|
||||
@@ -23,6 +23,7 @@ struct Globals {
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -131,7 +132,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = overlay >> 16u;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
}
|
||||
// Retained bolt-local geometry, translated using the same f64 camera
|
||||
@@ -224,6 +225,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_color(in, front);
|
||||
}
|
||||
fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
if in.overlay == 11u || in.overlay == 12u {
|
||||
@@ -271,6 +275,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
|
||||
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
|
||||
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
|
||||
return shadow_color(in);
|
||||
}
|
||||
fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
color *= in.light_color;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
@@ -306,21 +313,33 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
|
||||
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
|
||||
// geometry that sets none (the End sky, the End flash): it stays brightest.
|
||||
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
|
||||
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
|
||||
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
|
||||
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
|
||||
// as breaking::stage_light packs them.
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
fn energy_swirl_base(in: Out) -> vec4<f32> {
|
||||
let bytes = round(in.color * 255.0);
|
||||
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
|
||||
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
|
||||
if color.a < 0.1 { discard; }
|
||||
return vec4(color.rgb * (128.0 / 255.0), color.a);
|
||||
}
|
||||
fn energy_swirl_fog(in: Out) -> f32 {
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
return f * globals.fog_color.a;
|
||||
}
|
||||
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
|
||||
}
|
||||
|
||||
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
|
||||
@@ -449,6 +468,9 @@ struct WeatherTime { values: vec4<f32> }
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
|
||||
return weather_color(in);
|
||||
}
|
||||
fn weather_color(in: Out) -> vec4<f32> {
|
||||
// Repeat within this sprite, never across the atlas. The atlas uses nearest
|
||||
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
|
||||
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
|
||||
|
||||
@@ -40,9 +40,9 @@ mod entity_outline;
|
||||
mod weather;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
|
||||
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
WATER_MASK_OVERLAY,
|
||||
WATER_MASK_OVERLAY, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
@@ -77,6 +77,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
@@ -84,6 +85,8 @@ mod native_pack;
|
||||
mod native_post;
|
||||
mod native_sky;
|
||||
mod occlusion;
|
||||
mod oit;
|
||||
mod oit_features;
|
||||
mod pack_atlas;
|
||||
pub mod pack_draw;
|
||||
mod pack_entities;
|
||||
@@ -224,6 +227,8 @@ struct Globals {
|
||||
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
|
||||
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
|
||||
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
|
||||
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
|
||||
filtering: [f32; 4],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
struct SectionGpu {
|
||||
@@ -542,6 +547,10 @@ pub struct Renderer {
|
||||
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`, for a shader pack's terrain.
|
||||
terrain_sprites: [f64; 2],
|
||||
sprite_sampler: wgpu::Sampler,
|
||||
/// `Options.textureFiltering`: 0 none, 1 RGSS, 2 anisotropic.
|
||||
texture_filtering: u32,
|
||||
/// The anisotropic sampler's clamp (`Options.maxAnisotropyValue`); 1 unless anisotropic.
|
||||
max_anisotropy: u16,
|
||||
lightmap_view: wgpu::TextureView,
|
||||
lightmap_sampler: wgpu::Sampler,
|
||||
animations: animate::SpriteAnimator,
|
||||
@@ -672,6 +681,10 @@ pub struct Renderer {
|
||||
pub gamma: f32,
|
||||
/// Optional full-light lightmap (Horizon Extras).
|
||||
pub fullbright: bool,
|
||||
/// The Improved transparency option (`GameRenderer.useImprovedTransparency`).
|
||||
pub improved_transparency: bool,
|
||||
/// Its passes and targets, made when first drawn with.
|
||||
oit: Option<oit::Oit>,
|
||||
/// Advance animated block textures (Horizon setting; vanilla always does).
|
||||
pub animated_textures: bool,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
@@ -723,6 +736,36 @@ fn sprite_views(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The terrain sprites' sampler for a Texture Filtering mode. None keeps the nearest sampler;
|
||||
/// RGSS and anisotropic take LevelRenderer.chunkLayerSampler's linear one (clamped to the edge),
|
||||
/// with `anisotropy` samples when it is above 1 (wgpu then needs a linear mipmap filter too).
|
||||
fn sprite_sampler_descriptor(filtering: u32, anisotropy: u16) -> wgpu::SamplerDescriptor<'static> {
|
||||
if filtering == 0 {
|
||||
return wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites (filtered)"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
anisotropy_clamp: anisotropy,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `Options.maxAnisotropyValue` for a `maxAnisotropyBit`: `1 << bit`, at most wgpu's 16.
|
||||
fn sampler_anisotropy(bit: i32) -> u16 {
|
||||
1u16 << bit.clamp(0, 4)
|
||||
}
|
||||
/// terrain.wgsl's group 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn terrain_group(
|
||||
@@ -1119,6 +1162,7 @@ async fn open_backend(
|
||||
// stand-in neither clamps nor drops NaN.
|
||||
let features = timing::GpuTimer::features(&adapter)
|
||||
| cull::Cull::features(&adapter)
|
||||
| oit::features(&adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
|
||||
@@ -1207,6 +1251,19 @@ impl Renderer {
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Bytes of the section vertex arena's buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
@@ -1701,15 +1758,7 @@ impl Renderer {
|
||||
..Default::default()
|
||||
});
|
||||
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
|
||||
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Block sprites"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let sprite_sampler = device.create_sampler(&sprite_sampler_descriptor(0, 1));
|
||||
let lightmap = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Lightmap"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -2728,7 +2777,7 @@ impl Renderer {
|
||||
}
|
||||
cull
|
||||
});
|
||||
let depth = Self::depth(&device, &config, cull.as_ref());
|
||||
let depth = Self::depth(&device, &config);
|
||||
let world_post = world_post::Post::new(&device, config.format.remove_srgb_suffix());
|
||||
let uploads = upload::Uploads::new(&device);
|
||||
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
|
||||
@@ -2840,6 +2889,8 @@ impl Renderer {
|
||||
terrain_layout: bindings,
|
||||
terrain_sprites: sprite_constants.map(|(_, v)| v),
|
||||
sprite_sampler,
|
||||
texture_filtering: 0,
|
||||
max_anisotropy: 1,
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
animations,
|
||||
@@ -2914,6 +2965,8 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
gamma: 0.5,
|
||||
improved_transparency: false,
|
||||
oit: None,
|
||||
fullbright: false,
|
||||
animated_textures: true,
|
||||
present_modes,
|
||||
@@ -2998,13 +3051,8 @@ impl Renderer {
|
||||
self.surface = Some(surface);
|
||||
Ok(())
|
||||
}
|
||||
/// The world's depth, which occlusion culling's test may read.
|
||||
fn depth(
|
||||
device: &wgpu::Device,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
cull: Option<&cull::Cull>,
|
||||
) -> wgpu::TextureView {
|
||||
let read = cull.is_some_and(|c| c.depth_test() == cull::DepthTest::InShader);
|
||||
/// The world's depth, which occlusion culling's test and post effects may read.
|
||||
fn depth(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::TextureView {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon depth"),
|
||||
@@ -3017,11 +3065,11 @@ impl Renderer {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: if read {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING
|
||||
} else {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
},
|
||||
// TEXTURE_BINDING: a post effect may read the depth buffer.
|
||||
// COPY_SRC: improved transparency copies it.
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
@@ -3085,7 +3133,7 @@ impl Renderer {
|
||||
} else {
|
||||
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
|
||||
}
|
||||
self.depth = Self::depth(&self.device, &self.config, self.cull.as_ref());
|
||||
self.depth = Self::depth(&self.device, &self.config);
|
||||
}
|
||||
/// Whether sections are drawn as one index list per frame (`flat`).
|
||||
pub fn flat_draws(&self) -> bool {
|
||||
@@ -3184,7 +3232,7 @@ impl Renderer {
|
||||
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
|
||||
);
|
||||
if let Some(section) = section {
|
||||
self.sections.insert(key, section);
|
||||
self.sections.insert_with_bounds(key, section, mesh.bounds);
|
||||
}
|
||||
}
|
||||
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
|
||||
@@ -3516,7 +3564,13 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
horizon_hdr::set_metadata(
|
||||
device,
|
||||
surface,
|
||||
hdr.paper_white,
|
||||
hdr.declared_peak(),
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3529,9 +3583,29 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture (`crate::hdr_calibration`) while it runs, else None.
|
||||
pub fn set_hdr_calibration(&mut self, calibration: Option<HdrCalibration>) {
|
||||
let Some(hdr) = &mut self.hdr else {
|
||||
return;
|
||||
};
|
||||
if hdr.calibration == calibration {
|
||||
return;
|
||||
}
|
||||
let declared = hdr.declared_peak();
|
||||
hdr.calibration = calibration;
|
||||
if hdr.declared_peak() != declared {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
horizon_hdr::set_metadata(
|
||||
&self.device,
|
||||
surface,
|
||||
hdr.paper_white,
|
||||
hdr.declared_peak(),
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3583,7 +3657,8 @@ impl Renderer {
|
||||
// Only HDR output asks the system about the display, so with HDR off the
|
||||
// game never waits on the compositor for it.
|
||||
self.display = if output.is_some() {
|
||||
let (watch, now) = DisplayWatch::new(&self.window);
|
||||
let (watch, now) =
|
||||
DisplayWatch::new(&self.window, self.surface.as_ref(), &self.adapter);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
@@ -3591,7 +3666,8 @@ impl Renderer {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
@@ -3622,7 +3698,7 @@ impl Renderer {
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed() else {
|
||||
let Some(now) = self.display.changed(self.surface.as_ref(), &self.adapter) else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
@@ -3712,9 +3788,13 @@ impl Renderer {
|
||||
/// stages' labels; on an error the pack that ran keeps running.
|
||||
pub fn set_native_shader_pack(
|
||||
&mut self,
|
||||
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
|
||||
pack: Option<(
|
||||
horizon_shader_api::Report,
|
||||
Vec<(String, f64)>,
|
||||
Vec<(u32, u32)>,
|
||||
)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let Some((report, constants)) = pack else {
|
||||
let Some((report, constants, sprite_tags)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -3723,6 +3803,7 @@ impl Renderer {
|
||||
flat: self.flat.as_ref().map(|f| &f.draw_layout),
|
||||
sprites: self.terrain_sprites,
|
||||
format: self.config.format.remove_srgb_suffix(),
|
||||
sprite_tags: &sprite_tags,
|
||||
};
|
||||
let (pack, labels) =
|
||||
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
|
||||
@@ -3805,6 +3886,44 @@ impl Renderer {
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
self.pack_post.as_ref().map(|p| p.0)
|
||||
}
|
||||
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
|
||||
/// anisotropic; `bit`: `maxAnisotropyBit`). None is the nearest sampler terrain always used;
|
||||
/// the others take LevelRenderer's chunk layer sampler (linear, clamped, `bit` anisotropy
|
||||
/// when anisotropic) and terrain.wgsl's sampleNearest / sampleRGSS.
|
||||
pub fn set_texture_filtering(&mut self, filtering: i32, bit: i32) {
|
||||
let filtering = filtering.clamp(0, 2) as u32;
|
||||
let anisotropy = if filtering == 2 {
|
||||
sampler_anisotropy(bit)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if filtering == self.texture_filtering && anisotropy == self.max_anisotropy {
|
||||
return;
|
||||
}
|
||||
self.texture_filtering = filtering;
|
||||
self.max_anisotropy = anisotropy;
|
||||
self.sprite_sampler = self
|
||||
.device
|
||||
.create_sampler(&sprite_sampler_descriptor(filtering, anisotropy));
|
||||
self.rebuild_terrain_group();
|
||||
}
|
||||
fn rebuild_terrain_group(&mut self) {
|
||||
let views = sprite_views(
|
||||
&self.sprite_textures,
|
||||
&self.sprite_unused,
|
||||
Some(self.mipmaps + 1),
|
||||
);
|
||||
self.group = terrain_group(
|
||||
&self.device,
|
||||
"Horizon terrain resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&views,
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
}
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
@@ -3818,17 +3937,7 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
self.mipmaps = levels;
|
||||
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1));
|
||||
self.group = terrain_group(
|
||||
&self.device,
|
||||
"Horizon terrain resources",
|
||||
&self.terrain_layout,
|
||||
&self.globals,
|
||||
&views,
|
||||
&self.sprite_sampler,
|
||||
&self.lightmap_view,
|
||||
&self.lightmap_sampler,
|
||||
);
|
||||
self.rebuild_terrain_group();
|
||||
}
|
||||
/// The views `render` draws `frame` from.
|
||||
pub fn views(&self, frame: &Frame) -> ViewSet {
|
||||
@@ -4148,6 +4257,12 @@ impl Renderer {
|
||||
lights
|
||||
},
|
||||
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
|
||||
filtering: [
|
||||
self.texture_filtering as f32,
|
||||
self.sprite_textures[0].width() as f32,
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -4700,6 +4815,13 @@ impl Renderer {
|
||||
);
|
||||
entities = &self.pack_entity_rest;
|
||||
}
|
||||
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
|
||||
// path.
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.is_none()
|
||||
&& oit::supported(&self.device);
|
||||
let [
|
||||
entity_quads,
|
||||
text_quads,
|
||||
@@ -4710,9 +4832,14 @@ impl Renderer {
|
||||
shadow_quads,
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
] = self
|
||||
.entity_batch
|
||||
.update(entities, frame.additive, frame.hand, frame.see_through);
|
||||
oit_entity_quads,
|
||||
] = self.entity_batch.update(
|
||||
entities,
|
||||
frame.additive,
|
||||
frame.hand,
|
||||
frame.see_through,
|
||||
oit_on,
|
||||
);
|
||||
let weather_quads = frame.weather.len() as u32 / 4;
|
||||
let world_quads = entity_quads
|
||||
+ text_quads
|
||||
@@ -4722,7 +4849,11 @@ impl Renderer {
|
||||
+ swirl_quads
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads;
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
let cloud_quads = if let Some(input) = &frame.clouds {
|
||||
let (rebuilt, uniform) =
|
||||
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
|
||||
@@ -4763,7 +4894,7 @@ impl Renderer {
|
||||
);
|
||||
let all_entities = &self.entity_batch.combined;
|
||||
let hand_quads = (frame.hand.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32;
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let entity_vertices = all_entities.len() as u64;
|
||||
if entity_vertices > self.entity_capacity {
|
||||
@@ -4972,7 +5103,7 @@ impl Renderer {
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
|
||||
let keep_depth = pack_reads_world || (taa && self.world_post.taa) || oit_on;
|
||||
// The pack's program reads the vertices this frame's draws bind.
|
||||
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
|
||||
// The shadow camera's lists, drawn by the pack's shadow programs.
|
||||
@@ -5025,6 +5156,46 @@ impl Renderer {
|
||||
if culling && let Some(cull) = &mut self.cull {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
if oit_on {
|
||||
let size = self
|
||||
.world_post
|
||||
.size()
|
||||
.filter(|_| scaled.is_some())
|
||||
.unwrap_or([self.config.width, self.config.height]);
|
||||
let (device, layout, sprites) =
|
||||
(&self.device, &self.terrain_layout, self.terrain_sprites);
|
||||
let clouds = [0, 1].map(|i| self.cloud_pipeline.get_bind_group_layout(i));
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
let flat = self
|
||||
.flat
|
||||
.as_ref()
|
||||
.map(|f| f.translucent.get_bind_group_layout(1));
|
||||
let features = [
|
||||
self.entity_pipeline.get_bind_group_layout(0),
|
||||
self.weather.pipeline().get_bind_group_layout(1),
|
||||
self.border_pipeline.get_bind_group_layout(0),
|
||||
];
|
||||
self.oit
|
||||
.get_or_insert_with(|| {
|
||||
oit::Oit::new(
|
||||
device,
|
||||
layout,
|
||||
flat.as_ref(),
|
||||
[&features[0], &features[1], &features[2]],
|
||||
[&clouds[0], &clouds[1]],
|
||||
sprites,
|
||||
format,
|
||||
)
|
||||
})
|
||||
.prepare(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
size,
|
||||
&views.primary().projection,
|
||||
[water_mask_quads > 0, cloud_quads > 0],
|
||||
);
|
||||
}
|
||||
let oit = self.oit.as_ref().filter(|_| oit_on);
|
||||
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
|
||||
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
|
||||
wgpu::Color::BLACK
|
||||
@@ -5262,29 +5433,29 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 {
|
||||
if text_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.text_polygon_offset_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 {
|
||||
if translucent_item_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.translucent_item_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_item_quads * 6,
|
||||
((world_quads - translucent_item_quads) * 4) as i32,
|
||||
((base_quads - translucent_item_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !pack_draws_weather {
|
||||
if !pack_draws_weather && !oit_on {
|
||||
self.weather.draw(&mut pass, &self.entity_group);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if crumbling_quads > 0 {
|
||||
if crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5295,13 +5466,13 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if shadow_quads > 0 {
|
||||
if shadow_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.shadow_blob_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..shadow_quads * 6,
|
||||
((world_quads
|
||||
((base_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads)
|
||||
@@ -5312,7 +5483,7 @@ impl Renderer {
|
||||
}
|
||||
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
|
||||
// before translucent terrain, so no water shows inside the boat.
|
||||
if water_mask_quads > 0 {
|
||||
if water_mask_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.water_mask_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5324,13 +5495,13 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.lightning.bolts.is_empty() {
|
||||
if !frame.lightning.bolts.is_empty() && !oit_on {
|
||||
pass.set_pipeline(&self.lightning_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
self.lightning.draw(&mut pass, &frame.lightning);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if swirl_quads > 0 {
|
||||
if swirl_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.energy_swirl_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5346,7 +5517,7 @@ impl Renderer {
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if additive_quads > 0 {
|
||||
if additive_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.additive_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5369,6 +5540,168 @@ impl Renderer {
|
||||
match self.flat.as_ref().filter(|_| flat_frame) {
|
||||
// The pack draws translucent terrain after its deferred programs.
|
||||
_ if pack_draws_water => {}
|
||||
// LevelRenderer.executeOit in place of the classic blend; what
|
||||
// follows draws over its composite in a pass of its own.
|
||||
flat if let Some(oit) = oit => {
|
||||
drop(pass);
|
||||
// LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.
|
||||
let mask = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
pass.set_pipeline(&self.water_mask_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..water_mask_quads * 6,
|
||||
((entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads)
|
||||
* 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
};
|
||||
let clouds =
|
||||
|pass: &mut wgpu::RenderPass<'_>, pipeline: &wgpu::RenderPipeline| {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
pass.set_index_buffer(
|
||||
self.indices.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
|
||||
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
|
||||
};
|
||||
// "Features, World Border, Weather": what follows the opaque
|
||||
// world in the classic pass, one stage at a time.
|
||||
let features = |pass: &mut wgpu::RenderPass<'_>, stage: oit::Stage| {
|
||||
let f = oit.features();
|
||||
let s = stage as usize;
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
// Quads of a material, `first` quads into the buffer.
|
||||
let quads = |pass: &mut wgpu::RenderPass<'_>,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
count: u32,
|
||||
first: u32| {
|
||||
if count > 0 {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
};
|
||||
quads(pass, &f.text[s], text_quads, entity_quads);
|
||||
quads(
|
||||
pass,
|
||||
&f.crumbling[s],
|
||||
crumbling_quads,
|
||||
entity_quads + text_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.crumbling[s],
|
||||
translucent_crumbling_quads,
|
||||
entity_quads + text_quads + crumbling_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.swirl[s],
|
||||
swirl_quads,
|
||||
entity_quads
|
||||
+ text_quads
|
||||
+ crumbling_quads
|
||||
+ translucent_crumbling_quads
|
||||
+ water_mask_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.shadow[s],
|
||||
shadow_quads,
|
||||
base_quads
|
||||
- translucent_item_quads
|
||||
- translucent_particle_quads
|
||||
- shadow_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.entity[s],
|
||||
translucent_particle_quads,
|
||||
base_quads - translucent_item_quads - translucent_particle_quads,
|
||||
);
|
||||
quads(
|
||||
pass,
|
||||
&f.entity[s],
|
||||
translucent_item_quads,
|
||||
base_quads - translucent_item_quads,
|
||||
);
|
||||
// Translucent, emissive, eyes and breeze wind models:
|
||||
// the same fragment, their light and shading are in
|
||||
// the vertices.
|
||||
quads(pass, &f.entity[s], oit_entity_quads, base_quads);
|
||||
if !frame.lightning.bolts.is_empty() {
|
||||
pass.set_pipeline(&f.lightning[s]);
|
||||
self.lightning.draw(pass, &frame.lightning);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
}
|
||||
quads(pass, &f.additive[s], additive_quads, world_quads);
|
||||
self.weather
|
||||
.draw_with(pass, &f.weather[s], &self.entity_group);
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
|
||||
pass.set_pipeline(&f.border[s]);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
|
||||
for &side in &b.sides {
|
||||
let first = side as u32 * 6;
|
||||
pass.draw_indexed(first..first + 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
};
|
||||
let extras = oit::Extras {
|
||||
water_mask: (water_mask_quads > 0).then_some(&mask as _),
|
||||
clouds: (cloud_quads > 0).then_some(&clouds as _),
|
||||
features: Some(&features),
|
||||
};
|
||||
oit.run(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
&|pass, stage| {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
// Flat section draws take the flat pipelines.
|
||||
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(oit.terrain(stage));
|
||||
pass.set_index_buffer(
|
||||
self.indices.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
self.draw_sections(
|
||||
pass,
|
||||
&self.indirect,
|
||||
0,
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
},
|
||||
&extras,
|
||||
);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, true);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// HDR output's mark on the eyes drawn in the stages, as the
|
||||
// classic pass marks them after the entities.
|
||||
if oit_entity_quads > 0 && self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..oit_entity_quads * 6, (base_quads * 4) as i32, 0..1);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
flat.draw(&mut pass, flat.translucent_calls(), |p| {
|
||||
@@ -5394,15 +5727,14 @@ impl Renderer {
|
||||
// translucent particle layer draws after translucent terrain —
|
||||
// the quads write depth, so before it they would hide the glass
|
||||
// or water behind them.
|
||||
if translucent_particle_quads > 0 {
|
||||
if translucent_particle_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
|
||||
as i32,
|
||||
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -5414,7 +5746,7 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
|
||||
pass.set_pipeline(&self.border_pipeline);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -5425,7 +5757,7 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_crumbling_quads > 0 {
|
||||
if translucent_crumbling_quads > 0 && !oit_on {
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -5435,7 +5767,7 @@ impl Renderer {
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
if cloud_quads > 0 && !pack_draws_clouds {
|
||||
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
|
||||
pass.set_pipeline(&self.cloud_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
@@ -5633,6 +5965,8 @@ impl Renderer {
|
||||
&target_texture,
|
||||
&view_target,
|
||||
[self.config.width, self.config.height],
|
||||
// The scaled world pass drew its own depth buffer.
|
||||
scaled.is_none().then_some(&self.depth),
|
||||
&frame.post_effects,
|
||||
);
|
||||
}
|
||||
@@ -6153,6 +6487,46 @@ mod tests {
|
||||
assert!(!drawable_size(984, 65496, 32768));
|
||||
assert!(!drawable_size(984, u32::MAX, 32768));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_luminances_become_auto_values() {
|
||||
use super::os_reported;
|
||||
use crate::graphics::Reported;
|
||||
let info = |max, white| wgpu::DisplayHdrInfo {
|
||||
luminance: Some(wgpu::DisplayLuminance {
|
||||
max_nits: max,
|
||||
sdr_white_nits: white,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
// Windows' SDR content brightness slider runs 80..480 nits.
|
||||
assert_eq!(
|
||||
os_reported(&info(Some(998.4), Some(240.0))),
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
os_reported(&info(Some(120.0), Some(1000.0))),
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
assert_eq!(
|
||||
os_reported(&info(Some(380.0), Some(456.0))),
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
assert_eq!(os_reported(&info(Some(f32::NAN), Some(80.0))), None);
|
||||
assert_eq!(os_reported(&wgpu::DisplayHdrInfo::default()), None);
|
||||
}
|
||||
#[test]
|
||||
fn surface_is_8_bit_and_not_srgb() {
|
||||
use super::surface_format;
|
||||
@@ -6225,9 +6599,9 @@ mod tests {
|
||||
.flat_map(|(layer, id)| quad(layer, id))
|
||||
.collect();
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
@@ -6299,8 +6673,31 @@ mod tests {
|
||||
&mut files,
|
||||
);
|
||||
assert!(files.len() >= 10, "{files:?}");
|
||||
// Improved transparency's files are appended to terrain.wgsl and entity.wgsl.
|
||||
let mut sources: Vec<(std::path::PathBuf, String)> = Vec::new();
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if name == "oit.wgsl" {
|
||||
// The shared maths: it needs the file it is appended to.
|
||||
continue;
|
||||
} else if name == "oit_world.wgsl" {
|
||||
let base = include_str!("terrain.wgsl").to_owned() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "oit_features.wgsl" {
|
||||
let base = super::entity_shader() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "oit_border.wgsl" {
|
||||
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else if name == "oit_clouds.wgsl" {
|
||||
let base = super::entity_shader() + include_str!("oit.wgsl");
|
||||
sources.push((path, base + &text));
|
||||
} else {
|
||||
sources.push((path, text));
|
||||
}
|
||||
}
|
||||
for (path, text) in sources {
|
||||
// entity.wgsl comes after the overlay's constants (entity_shader).
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.or_else(|_| {
|
||||
@@ -6419,6 +6816,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn texture_filtering_samplers() {
|
||||
use super::{sampler_anisotropy, sprite_sampler_descriptor};
|
||||
// None is the sampler terrain always used.
|
||||
let none = sprite_sampler_descriptor(0, 1);
|
||||
assert_eq!(none.mag_filter, wgpu::FilterMode::Nearest);
|
||||
assert_eq!(none.min_filter, wgpu::FilterMode::Nearest);
|
||||
assert_eq!(none.address_mode_u, wgpu::AddressMode::Repeat);
|
||||
assert_eq!(none.anisotropy_clamp, 1);
|
||||
// LevelRenderer.chunkLayerSampler: linear both ways, clamped.
|
||||
for (mode, aniso) in [(1, 1), (2, 8)] {
|
||||
let d = sprite_sampler_descriptor(mode, aniso);
|
||||
assert_eq!(d.mag_filter, wgpu::FilterMode::Linear);
|
||||
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
|
||||
assert_eq!(d.mipmap_filter, wgpu::MipmapFilterMode::Linear);
|
||||
assert_eq!(d.address_mode_v, wgpu::AddressMode::ClampToEdge);
|
||||
assert_eq!(d.anisotropy_clamp, aniso);
|
||||
}
|
||||
assert_eq!(
|
||||
[1, 2, 3].map(sampler_anisotropy),
|
||||
[2, 4, 8],
|
||||
"Options.maxAnisotropyValue is 1 << bit"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn shader_globals_match_the_rust_layout() {
|
||||
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
|
||||
let fields = |source: &str| -> Vec<String> {
|
||||
@@ -6635,32 +7056,65 @@ mod sprite_upload_tests {
|
||||
}
|
||||
|
||||
/// What the system says about the window's display, kept up to date: on Wayland, the
|
||||
/// compositor's preferred image description of the surface.
|
||||
/// compositor's preferred image description of the surface; elsewhere what wgpu reads
|
||||
/// from the OS (on Windows, DXGI's luminances and the SDR white level).
|
||||
#[derive(Default)]
|
||||
struct DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: Option<horizon_hdr::wayland::Watch>,
|
||||
/// Without Wayland's event, what wgpu last read and when to read it again: the OS
|
||||
/// sends no event for a moved window or a changed SDR brightness slider.
|
||||
polled: Option<(Option<crate::graphics::Reported>, std::time::Instant)>,
|
||||
}
|
||||
/// How often [`DisplayWatch`] reads the OS's values again where no event says they changed.
|
||||
const DISPLAY_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
impl DisplayWatch {
|
||||
/// Starts watching, with what the system reports now.
|
||||
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
|
||||
fn new(
|
||||
window: &Window,
|
||||
surface: Option<&wgpu::Surface>,
|
||||
adapter: &wgpu::Adapter,
|
||||
) -> (Self, Option<crate::graphics::Reported>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
|
||||
let watch = Self {
|
||||
wayland: Some(wayland),
|
||||
polled: None,
|
||||
};
|
||||
return (watch, now.map(reported));
|
||||
}
|
||||
let _ = window;
|
||||
(Self::default(), None)
|
||||
let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter)));
|
||||
log_os_reported(now);
|
||||
let watch = Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: None,
|
||||
polled: Some((now, std::time::Instant::now() + DISPLAY_POLL)),
|
||||
};
|
||||
(watch, now)
|
||||
}
|
||||
/// What the system reports, if that changed since the last call.
|
||||
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
|
||||
fn changed(
|
||||
&mut self,
|
||||
surface: Option<&wgpu::Surface>,
|
||||
adapter: &wgpu::Adapter,
|
||||
) -> Option<Option<crate::graphics::Reported>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(wayland) = &mut self.wayland {
|
||||
return wayland.changed().map(|now| now.map(reported));
|
||||
}
|
||||
None
|
||||
let (last, next) = self.polled.as_mut()?;
|
||||
let time = std::time::Instant::now();
|
||||
if time < *next {
|
||||
return None;
|
||||
}
|
||||
*next = time + DISPLAY_POLL;
|
||||
let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter)));
|
||||
(now != *last).then(|| {
|
||||
log_os_reported(now);
|
||||
*last = now;
|
||||
now
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6680,8 +7134,6 @@ static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(Display
|
||||
pending: None,
|
||||
});
|
||||
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
// Only Wayland's HDR reports call it so far.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn log_display(line: String) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
@@ -6711,6 +7163,33 @@ fn flush_display_log() {
|
||||
(log.last, log.at) = (line, Some(now));
|
||||
}
|
||||
}
|
||||
fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
log_display(match reported {
|
||||
Some(r) => format!(
|
||||
"Horizon renderer: the system reports SDR white at {} nits and a peak of {} nits",
|
||||
r.paper_white, r.peak
|
||||
),
|
||||
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.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
let peak = luminance.max_nits.filter(|&n| usable(n))?;
|
||||
let white = luminance.sdr_white_nits.filter(|&n| usable(n))?;
|
||||
let paper_white = (white.round() as u32).clamp(80, 480);
|
||||
Some(crate::graphics::Reported {
|
||||
paper_white,
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
|
||||
/// What a compositor's luminances mean in HDR output's scale.
|
||||
///
|
||||
|
||||
@@ -491,10 +491,6 @@ impl Cull {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn depth_test(&self) -> DepthTest {
|
||||
self.depth_test
|
||||
}
|
||||
|
||||
/// In the world pass after phase 1 (`Early`): draw the tested boxes
|
||||
/// against its depth. Leaves the pass's pipeline and group 0 changed.
|
||||
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
|
||||
@@ -9,7 +9,24 @@ use super::Vertex;
|
||||
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
|
||||
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
|
||||
/// and translucent item quads (32, drawn without depth writes).
|
||||
const MATERIALS: usize = 9;
|
||||
///
|
||||
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
const MATERIALS: usize = 10;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
/// `breezeWind`). Only the improved transparency path reads it.
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
for vertex in quads {
|
||||
vertex.layer |= OIT_ENTITY;
|
||||
}
|
||||
}
|
||||
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
|
||||
pub const WATER_MASK_OVERLAY: u32 = 13;
|
||||
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
|
||||
@@ -30,7 +47,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order).
|
||||
fn material(layer: u32) -> usize {
|
||||
fn material(layer: u32, oit: bool) -> usize {
|
||||
if oit && layer & OIT_ENTITY != 0 {
|
||||
return 9;
|
||||
}
|
||||
match layer {
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
@@ -51,6 +71,10 @@ pub(super) struct EntityBatch {
|
||||
// frame, keeping their storage.
|
||||
runs: [Vec<(usize, usize)>; MATERIALS],
|
||||
pub(super) combined: Vec<Vertex>,
|
||||
/// Additive quads tagged `OIT_ENTITY` that the last `update` moved into the
|
||||
/// last material (improved transparency on): the dragon's eyes, which share
|
||||
/// the additive buffer with its rays.
|
||||
pub(super) moved_additive: u32,
|
||||
}
|
||||
|
||||
impl EntityBatch {
|
||||
@@ -60,6 +84,7 @@ impl EntityBatch {
|
||||
additive: &[Vertex],
|
||||
hand: &[Vertex],
|
||||
see_through: &[Vertex],
|
||||
oit: bool,
|
||||
) -> [u32; MATERIALS] {
|
||||
let quads = entities.as_chunks::<4>().0;
|
||||
for runs in &mut self.runs {
|
||||
@@ -68,10 +93,10 @@ impl EntityBatch {
|
||||
let mut counts = [0usize; MATERIALS];
|
||||
let mut start = 0;
|
||||
while start < quads.len() {
|
||||
let kind = material(quads[start][0].layer);
|
||||
let kind = material(quads[start][0].layer, oit);
|
||||
let end = quads[start + 1..]
|
||||
.iter()
|
||||
.position(|quad| material(quad[0].layer) != kind)
|
||||
.position(|quad| material(quad[0].layer, oit) != kind)
|
||||
.map_or(quads.len(), |n| start + 1 + n);
|
||||
self.runs[kind].push((start, end - start));
|
||||
counts[kind] += end - start;
|
||||
@@ -87,7 +112,32 @@ impl EntityBatch {
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(additive);
|
||||
// `RenderTypes.eyes` is blended like the other entities, not added: with
|
||||
// improved transparency on, tagged additive quads join the last
|
||||
// material, drawn without the additive overlay and the baked shading.
|
||||
self.moved_additive = 0;
|
||||
if oit {
|
||||
for quad in additive.as_chunks::<4>().0 {
|
||||
if quad[0].layer & OIT_ENTITY != 0 {
|
||||
self.combined.extend(quad.iter().map(|v| Vertex {
|
||||
layer: v.layer & !0xffff,
|
||||
normal: [0; 4],
|
||||
..*v
|
||||
}));
|
||||
counts[9] += 1;
|
||||
self.moved_additive += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
for quad in additive.as_chunks::<4>().0 {
|
||||
if quad[0].layer & OIT_ENTITY == 0 {
|
||||
self.combined.extend_from_slice(quad);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.combined.extend_from_slice(hand);
|
||||
self.combined.extend_from_slice(see_through);
|
||||
counts.map(|count| count as u32)
|
||||
@@ -156,13 +206,78 @@ mod tests {
|
||||
hand: &[Vertex],
|
||||
) {
|
||||
let (expected, counts) = baseline(entities, additive, hand);
|
||||
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
|
||||
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&batch.combined),
|
||||
bytemuck::cast_slice::<_, u8>(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oit_entity_quads_are_a_last_material_only_when_on() {
|
||||
let quad = |layer: u32, x: f32| {
|
||||
(0..4).map(move |_| Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
layer,
|
||||
..geometry(1, false)[0]
|
||||
})
|
||||
};
|
||||
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
|
||||
let entities: Vec<Vertex> = [
|
||||
(0, 0.0),
|
||||
(OIT_ENTITY | 3 << 16, 1.0),
|
||||
(1, 2.0),
|
||||
(SHADOW_OVERLAY, 3.0),
|
||||
(OIT_ENTITY | 1, 4.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_additive_quads_join_the_last_material_only_when_on() {
|
||||
let quad = |layer: u32, x: f32| {
|
||||
(0..4).map(move |_| Vertex {
|
||||
pos: [x, 0.0, 0.0],
|
||||
layer,
|
||||
normal: [1, 2, 3, 4],
|
||||
..geometry(1, false)[0]
|
||||
})
|
||||
};
|
||||
// The dragon's eyes (tagged, overlay 2) and a ray (plain).
|
||||
let additive: Vec<Vertex> = [(2 | OIT_ENTITY | 3 << 16, 1.0), (2, 2.0)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let entities: Vec<Vertex> = quad(0, 0.0).collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: untouched.
|
||||
let counts = batch.update(&entities, &additive, &[], &[], false);
|
||||
assert_eq!((counts[9], batch.moved_additive), (0, 0));
|
||||
assert_eq!(batch.combined.len(), 12);
|
||||
assert_eq!(batch.combined[4].layer, 2 | OIT_ENTITY | 3 << 16);
|
||||
// On: the eyes are an entity quad without the additive overlay, the ray stays.
|
||||
let counts = batch.update(&entities, &additive, &[], &[], true);
|
||||
assert_eq!((counts[0], counts[9], batch.moved_additive), (1, 1, 1));
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0]);
|
||||
assert_eq!(batch.combined[4].layer, OIT_ENTITY | 3 << 16);
|
||||
assert_eq!(batch.combined[4].normal, [0; 4]);
|
||||
assert_eq!(batch.combined[8].layer, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_material_order_tails_and_reuse() {
|
||||
let mut batch = EntityBatch::default();
|
||||
@@ -227,7 +342,7 @@ mod tests {
|
||||
equivalent(&mut batch, &entities, &additive, &hand);
|
||||
for _ in 0..50 {
|
||||
black_box(baseline(black_box(&entities), &additive, &hand));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
|
||||
black_box(&batch.combined);
|
||||
}
|
||||
let mut before = Vec::new();
|
||||
@@ -240,7 +355,7 @@ mod tests {
|
||||
before.push(start.elapsed().as_nanos() / 100);
|
||||
let start = Instant::now();
|
||||
for _ in 0..100 {
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
|
||||
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
|
||||
black_box(&batch.combined);
|
||||
}
|
||||
after.push(start.elapsed().as_nanos() / 100);
|
||||
|
||||
@@ -91,9 +91,22 @@ pub const TIERS: [Tier; 4] = [
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
|
||||
/// above the controls instead of the frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Calibration {
|
||||
/// 1 black level, 2 peak brightness, 3 paper white.
|
||||
pub step: u32,
|
||||
/// The slider's value, in nits.
|
||||
pub nits: f32,
|
||||
/// Where the controls start, as a share of the height.
|
||||
pub band: f32,
|
||||
}
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Horizon's logo as a 1-bit mask, for calibration's test pictures.
|
||||
logo: wgpu::TextureView,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
@@ -120,10 +133,17 @@ pub struct Hdr {
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
@@ -143,6 +163,7 @@ impl Hdr {
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
@@ -232,12 +253,13 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
size: 96,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
@@ -254,6 +276,7 @@ impl Hdr {
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +321,10 @@ impl Hdr {
|
||||
binding: 2,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
@@ -310,6 +337,16 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
pub fn declared_peak(&self) -> f32 {
|
||||
match self.calibration {
|
||||
Some(c) if c.step == 2 => 10_000.0,
|
||||
_ => self.peak,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
@@ -430,6 +467,10 @@ impl Hdr {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
@@ -442,9 +483,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let mut params = [0.0; 20];
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 24];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -459,6 +501,9 @@ impl Hdr {
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
params[20] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[21] = c.map_or(0.0, |c| c.nits);
|
||||
params[22] = c.map_or(0.0, |c| c.band);
|
||||
params
|
||||
}
|
||||
|
||||
@@ -489,6 +534,47 @@ 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.
|
||||
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 mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p[3] > 128 && p[..3].iter().all(|&c| c > 200) {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
device
|
||||
.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR calibration logo"),
|
||||
size: wgpu::Extent3d {
|
||||
width: w,
|
||||
height: h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&mask,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
|
||||
@@ -66,7 +66,7 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent)).color;
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
@@ -91,6 +91,9 @@ pub struct TerrainShape<'a> {
|
||||
pub sprites: [f64; 2],
|
||||
/// The world target's colour format.
|
||||
pub format: wgpu::TextureFormat,
|
||||
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
|
||||
/// (`Manifest::sprite_tags`).
|
||||
pub sprite_tags: &'a [(u32, u32)],
|
||||
}
|
||||
|
||||
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
|
||||
@@ -110,17 +113,34 @@ fn vanilla_terrain() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`.
|
||||
fn shader_source(source: &str) -> String {
|
||||
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
|
||||
fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
|
||||
let mut tags = tags.to_vec();
|
||||
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
|
||||
for group in tags.chunk_by(|a, b| a.1 == b.1) {
|
||||
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
|
||||
out += &format!(
|
||||
" case {}: {{ return {}u; }}\n",
|
||||
layers.join(", "),
|
||||
group[0].1
|
||||
);
|
||||
}
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
|
||||
vanilla_terrain()
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
sprite_tags(tags)
|
||||
)
|
||||
}
|
||||
|
||||
/// The material's naga module, its errors in the stage's file.
|
||||
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source);
|
||||
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -134,7 +154,7 @@ impl NativeMaterial {
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<Self, String> {
|
||||
let module = module(stage)?;
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
@@ -299,13 +319,15 @@ mod tests {
|
||||
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
|
||||
return Surface(apply_fog(sprite(lit)));\n}\n";
|
||||
let stage = stage(own);
|
||||
let module = module(&stage).unwrap();
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
|
||||
let entries: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct NativeFrame {
|
||||
pub group: wgpu::BindGroup,
|
||||
start: std::time::Instant,
|
||||
frames: u32,
|
||||
/// `frame.wetness`, and when it was last written.
|
||||
wetness: (f32, Option<std::time::Instant>),
|
||||
}
|
||||
|
||||
/// The uniform `frame` at binding 0, read by `visibility`.
|
||||
@@ -80,13 +82,18 @@ impl NativeFrame {
|
||||
group,
|
||||
start: std::time::Instant::now(),
|
||||
frames: 0,
|
||||
wetness: (0.0, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes this frame's inputs, for a world of `size`.
|
||||
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
let inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, dt), Some(now));
|
||||
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
inputs.wetness = self.wetness.0;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
}
|
||||
@@ -357,6 +364,13 @@ impl NativePost {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
|
||||
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
|
||||
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
|
||||
let half_life = if rain > wetness { 30.0 } else { 10.0 };
|
||||
rain + (wetness - rain) * 0.5f32.powf(dt / half_life)
|
||||
}
|
||||
|
||||
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
@@ -416,6 +430,10 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
|
||||
},
|
||||
frame_index: index,
|
||||
hide_gui: u32::from(pack.hide_gui),
|
||||
precipitation: pack.biome_precipitation.clamp(0, 2) as u32,
|
||||
temperature: pack.temperature,
|
||||
downfall: pack.rainfall,
|
||||
wetness: pack.rain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +442,13 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_shader_api::{MANIFEST, Pack, check};
|
||||
|
||||
#[test]
|
||||
fn wetness_rises_slower_than_it_dries() {
|
||||
assert_eq!(wetness(0.0, 1.0, 30.0), 0.5);
|
||||
assert_eq!(wetness(1.0, 0.0, 10.0), 0.5);
|
||||
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -128,6 +128,9 @@ struct Graph {
|
||||
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
|
||||
/// the camera's.
|
||||
entered: Vec<u8>,
|
||||
/// Whether any section in the searched top layer was reached. Sections
|
||||
/// above a clamped box use this conservative sky-band answer.
|
||||
sky_reached: bool,
|
||||
/// Per section corner, during a full search: `SEES`, or how far the line
|
||||
/// from it towards the camera is known to stay in reached sections (the
|
||||
/// first sample that was not). Sections are only ever added during a
|
||||
@@ -156,8 +159,22 @@ impl Graph {
|
||||
)
|
||||
}
|
||||
fn contains(&self, key: SectionKey) -> bool {
|
||||
self.index(key)
|
||||
.is_some_and(|i| self.directions[i] & REACHED != 0)
|
||||
if let Some(i) = self.index(key) {
|
||||
self.directions[i] & REACHED != 0
|
||||
} else if (0..=self.sections).contains(&key.1)
|
||||
&& key.1 >= self.y0 + self.height
|
||||
&& (key.0 - self.from.0).abs() <= self.radius
|
||||
&& (key.2 - self.from.2).abs() <= self.radius
|
||||
{
|
||||
// Everything above the highest non-air section is open sky. A
|
||||
// full search can reach that layer from a neighbouring column,
|
||||
// so checking only this column's top cell can hide an upper
|
||||
// section that is visible through the open layer. Once any top
|
||||
// cell is reached, conservatively keep the whole sky band visible.
|
||||
self.sky_reached
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
|
||||
fn search(
|
||||
@@ -174,13 +191,37 @@ impl Graph {
|
||||
self.min_y = columns.min_y;
|
||||
self.sections = columns.sections;
|
||||
self.y0 = -1;
|
||||
self.height = columns.sections + 2;
|
||||
if from.1 >= columns.sections {
|
||||
self.height = columns.sections + 2;
|
||||
} else {
|
||||
let mut max_non_air_y = 0;
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let Some(column) = columns.map.get(&(from.0 + dx, from.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
for (y, &v) in column.iter().enumerate().rev() {
|
||||
let y = y as i32;
|
||||
if y <= max_non_air_y {
|
||||
break;
|
||||
}
|
||||
if v != ALL_VISIBLE {
|
||||
max_non_air_y = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let top_y = max_non_air_y.max(from.1);
|
||||
self.height = (top_y + 3).max(3).min(columns.sections + 2);
|
||||
}
|
||||
let side = (2 * radius + 1) as usize;
|
||||
let size = side * side * self.height as usize;
|
||||
self.directions.clear();
|
||||
self.directions.resize(size, 0);
|
||||
self.entered.clear();
|
||||
self.entered.resize(size, 0);
|
||||
self.sky_reached = false;
|
||||
self.rays.clear();
|
||||
self.rays
|
||||
.resize((side + 1) * (side + 1) * (self.height + 1) as usize, 0);
|
||||
@@ -194,7 +235,8 @@ impl Graph {
|
||||
let base = self
|
||||
.index((from.0 + dx, 0, from.2 + dz))
|
||||
.expect("column inside the searched box");
|
||||
vis[base..base + column.len()].copy_from_slice(column);
|
||||
let len = (column.len() as i32).min(self.height - 1) as usize;
|
||||
vis[base..base + len].copy_from_slice(&column[..len]);
|
||||
}
|
||||
}
|
||||
queue.clear();
|
||||
@@ -279,6 +321,9 @@ impl Graph {
|
||||
while let Some(index) = queue.pop_front() {
|
||||
let i = index as usize;
|
||||
let key = self.key(i);
|
||||
if key.1 == self.y0 + self.height - 1 {
|
||||
self.sky_reached = true;
|
||||
}
|
||||
let directions = self.directions[i];
|
||||
let entered = self.entered[i];
|
||||
let exits = if entered == 0 {
|
||||
@@ -1082,6 +1127,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A clamped search may conservatively show sections above its searched
|
||||
/// box, but it must never hide one that the full-height search reaches.
|
||||
#[test]
|
||||
fn vertical_clamping_only_adds_overdraw() {
|
||||
let mut compared = 0;
|
||||
let mut overdraw = 0;
|
||||
let mut clamped = 0;
|
||||
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
|
||||
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
random_world(&mut o, seed, count, spread);
|
||||
let camera = at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9);
|
||||
o.settle(camera, radius);
|
||||
let graph = o.graph.as_ref().expect("searched");
|
||||
clamped += usize::from(graph.height < o.columns.sections + 2);
|
||||
let full = reference(&o.columns, camera, radius, true);
|
||||
let from = o.columns.section_of(camera);
|
||||
let side = 2 * radius + 1;
|
||||
let height = o.columns.sections + 2;
|
||||
let full_contains = |key: SectionKey| {
|
||||
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 + 1);
|
||||
if dx.abs() > radius || dz.abs() > radius || !(0..height).contains(&dy) {
|
||||
return false;
|
||||
}
|
||||
full[(((dz + radius) * side + dx + radius) * height + dy) as usize]
|
||||
};
|
||||
for x in from.0 - radius..=from.0 + radius {
|
||||
for z in from.2 - radius..=from.2 + radius {
|
||||
for y in -1..=o.columns.sections {
|
||||
let key = (x, y, z);
|
||||
let expected = full_contains(key);
|
||||
let actual = o.contains(key);
|
||||
compared += 1;
|
||||
assert!(
|
||||
!expected || actual,
|
||||
"clamped search hid {key:?} for seed {seed:#x}, spread {spread}, radius {radius}; from={from:?}, y0={}, height={}, expected={expected}, actual={actual}",
|
||||
graph.y0,
|
||||
graph.height
|
||||
);
|
||||
overdraw += usize::from(actual && !expected);
|
||||
if actual && !expected {
|
||||
assert!(
|
||||
y >= graph.y0 + graph.height,
|
||||
"overdraw must be above the clamped box: {key:?}, y0={}, height={}",
|
||||
graph.y0,
|
||||
graph.height
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(clamped > 0, "the comparison exercised a clamped search");
|
||||
assert!(compared > 50_000, "compared {compared} sections");
|
||||
assert!(overdraw > 0, "the conservative top-cell rule was exercised");
|
||||
}
|
||||
|
||||
/// A line checked again from the sample it failed at, after more
|
||||
/// sections were reached, ends where checking it from its corner does.
|
||||
#[test]
|
||||
@@ -1226,4 +1329,26 @@ mod tests {
|
||||
assert_eq!(graph.entered, local.entered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_clamping_bounds_height_and_reaches_open_sky() {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
o.set((0, 5, 0), 0); // Wall at section 5
|
||||
o.settle(at((0, 2, 0)), 4);
|
||||
let graph = o.graph.as_ref().expect("searched");
|
||||
assert_eq!(graph.height, 5 + 3);
|
||||
assert!(o.contains((0, 2, 0)), "camera section is reached");
|
||||
assert!(o.contains((1, 20, 1)), "open air column is reached");
|
||||
|
||||
// An enclosed cave cannot reach the sky
|
||||
let mut cave = Occlusion::new(0, 24);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
cave.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
cave.settle(at((0, 1, 0)), 4);
|
||||
assert!(
|
||||
!cave.contains((0, 10, 0)),
|
||||
"enclosed cave does not see the sky"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,968 @@
|
||||
//! Improved transparency: vanilla 26.3's order-independent transparency
|
||||
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
|
||||
//! against the opaque world's depth, without writing it, into targets the
|
||||
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
|
||||
//! composites the sum. With the option off none of this exists and the world
|
||||
//! draws as before.
|
||||
|
||||
use super::oit_features::Features;
|
||||
use glam::Mat4;
|
||||
|
||||
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
|
||||
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
|
||||
r: -(f32::MAX as f64),
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 0.0,
|
||||
};
|
||||
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
|
||||
const GROUP: u32 = 2;
|
||||
|
||||
/// `OitStage`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
DepthBounds,
|
||||
Transmittance,
|
||||
Accumulate,
|
||||
}
|
||||
|
||||
/// What the device must support: max blending on 32-bit float targets.
|
||||
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
|
||||
}
|
||||
|
||||
pub fn supported(device: &wgpu::Device) -> bool {
|
||||
device
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_BLENDABLE)
|
||||
}
|
||||
|
||||
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
|
||||
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
|
||||
let blend = |operation| wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation,
|
||||
};
|
||||
let target = |format, operation| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(operation),
|
||||
alpha: blend(operation),
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
};
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
match stage {
|
||||
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
|
||||
Stage::Transmittance => vec![
|
||||
target(COEFFICIENT_FORMAT, add),
|
||||
target(COEFFICIENT_FORMAT, add),
|
||||
],
|
||||
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
|
||||
/// blocks along the view axis (`oit_linear_depth`).
|
||||
fn projection_terms(projection: &Mat4) -> [f32; 2] {
|
||||
[projection.z_axis.z, projection.w_axis.z]
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
bounds: wgpu::Texture,
|
||||
culled: wgpu::Texture,
|
||||
views: Views,
|
||||
/// The uniform alone, with the culled bounds, with those and the
|
||||
/// coefficients, for the cull with the unculled bounds, and the composite.
|
||||
alone: wgpu::BindGroup,
|
||||
sampled: wgpu::BindGroup,
|
||||
accumulate: wgpu::BindGroup,
|
||||
cull: wgpu::BindGroup,
|
||||
composite: wgpu::BindGroup,
|
||||
/// The depth the terrain's stages test against while a water mask is
|
||||
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
|
||||
terrain_depth: Option<Depth>,
|
||||
cloud_depth: Option<Depth>,
|
||||
}
|
||||
|
||||
struct Depth {
|
||||
texture: wgpu::Texture,
|
||||
view: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl Depth {
|
||||
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent(size),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
Self {
|
||||
view: texture.create_view(&Default::default()),
|
||||
texture,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
|
||||
wgpu::Extent3d {
|
||||
width: size[0].max(1),
|
||||
height: size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the translucent layer's other drawers add to a frame: each draws with
|
||||
/// a pipeline per stage where it has one.
|
||||
#[derive(Default)]
|
||||
pub struct Extras<'a> {
|
||||
/// `executeOitWaterMask`: the boats' water patches, depth only.
|
||||
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
|
||||
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
|
||||
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
|
||||
/// "Features, World Border, Weather": draws what the entity, weather and
|
||||
/// border shaders put after the opaque world, with `Oit::features`'s
|
||||
/// pipelines of the stage.
|
||||
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
|
||||
}
|
||||
|
||||
struct Views {
|
||||
bounds: wgpu::TextureView,
|
||||
culled: wgpu::TextureView,
|
||||
coefficients: [wgpu::TextureView; 2],
|
||||
accumulated: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct Oit {
|
||||
uniform: wgpu::Buffer,
|
||||
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
|
||||
/// coefficients; and with the accumulation too.
|
||||
layouts: [wgpu::BindGroupLayout; 4],
|
||||
/// One per `Stage`.
|
||||
terrain: [wgpu::RenderPipeline; 3],
|
||||
/// The terrain's flat section draws (`Flat`), where the device has them.
|
||||
flat: Option<[wgpu::RenderPipeline; 3]>,
|
||||
features: Features,
|
||||
clouds: [wgpu::RenderPipeline; 3],
|
||||
cull: wgpu::RenderPipeline,
|
||||
composite: wgpu::RenderPipeline,
|
||||
targets: Option<Targets>,
|
||||
}
|
||||
|
||||
impl Oit {
|
||||
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
|
||||
/// `flat` the flat draws' second group, `features` the entity, weather
|
||||
/// and world border layouts,
|
||||
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
|
||||
/// constants, `format` the world target's.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
terrain: &wgpu::BindGroupLayout,
|
||||
flat: Option<&wgpu::BindGroupLayout>,
|
||||
features: [&wgpu::BindGroupLayout; 3],
|
||||
clouds: [&wgpu::BindGroupLayout; 2],
|
||||
sprites: [f64; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::TERRAIN_ATTRIBUTES,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance,
|
||||
}),
|
||||
];
|
||||
let buffers = &buffers[..];
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../terrain.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let texture = |binding| {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
};
|
||||
let params = entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
);
|
||||
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries,
|
||||
})
|
||||
};
|
||||
let layouts = [
|
||||
layout("Improved transparency", &[params]),
|
||||
layout("Improved transparency bounds", &[params, texture(1)]),
|
||||
layout(
|
||||
"Improved transparency coefficients",
|
||||
&[params, texture(1), texture(2), texture(3)],
|
||||
),
|
||||
layout(
|
||||
"Improved transparency composite",
|
||||
&[params, texture(1), texture(2), texture(3), texture(4)],
|
||||
),
|
||||
];
|
||||
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
bind_group_layouts: &[first, None, Some(&layouts[group])],
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
let depth = |write| {
|
||||
Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
})
|
||||
};
|
||||
let pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
targets: &[Option<wgpu::ColorTargetState>],
|
||||
depth_stencil| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let terrain_pipeline = |label, fragment, stage| {
|
||||
pipeline(
|
||||
label,
|
||||
&pipeline_layout(Some(terrain), stage as usize),
|
||||
"vs_terrain",
|
||||
fragment,
|
||||
buffers,
|
||||
&stage_targets(stage),
|
||||
depth(false),
|
||||
)
|
||||
};
|
||||
let terrain_pipelines = [
|
||||
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
|
||||
terrain_pipeline(
|
||||
"OIT transmittance",
|
||||
"fs_oit_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
|
||||
];
|
||||
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
|
||||
// the draw table at group 1.
|
||||
let flat_pipelines = flat.map(|draw| {
|
||||
let flat_buffers = &buffers[..1];
|
||||
[
|
||||
(
|
||||
"OIT depth bounds (flat)",
|
||||
"fs_oit_bounds",
|
||||
Stage::DepthBounds,
|
||||
),
|
||||
(
|
||||
"OIT transmittance (flat)",
|
||||
"fs_oit_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
(
|
||||
"OIT accumulate (flat)",
|
||||
"fs_oit_accumulate",
|
||||
Stage::Accumulate,
|
||||
),
|
||||
]
|
||||
.map(|(label, fragment, stage)| {
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency flat terrain"),
|
||||
bind_group_layouts: &[
|
||||
Some(terrain),
|
||||
Some(draw),
|
||||
Some(&layouts[stage as usize]),
|
||||
],
|
||||
..Default::default()
|
||||
});
|
||||
pipeline(
|
||||
label,
|
||||
&layout,
|
||||
"vs_terrain_flat",
|
||||
fragment,
|
||||
flat_buffers,
|
||||
&stage_targets(stage),
|
||||
depth(false),
|
||||
)
|
||||
})
|
||||
});
|
||||
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
|
||||
pipeline(
|
||||
label,
|
||||
&pipeline_layout(None, group),
|
||||
"vs_sky",
|
||||
fragment,
|
||||
&[],
|
||||
targets,
|
||||
depth_stencil,
|
||||
)
|
||||
};
|
||||
let replace = |format| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
};
|
||||
let cull = screen(
|
||||
"OIT cull depth bounds",
|
||||
"fs_oit_cull",
|
||||
1,
|
||||
&[replace(BOUNDS_FORMAT)],
|
||||
depth(true),
|
||||
);
|
||||
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
|
||||
// the nearest see-through depth written.
|
||||
let premultiplied = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
|
||||
operation: add,
|
||||
};
|
||||
let composite = screen(
|
||||
"OIT composite",
|
||||
"fs_oit_composite",
|
||||
3,
|
||||
&[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: premultiplied,
|
||||
alpha: premultiplied,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
);
|
||||
// The clouds: entity.wgsl's vertex shader and vertex format, with the
|
||||
// stages' fragments.
|
||||
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency clouds"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &cloud_attributes,
|
||||
})];
|
||||
let cloud_pipeline = |label, fragment, stage: Stage| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency clouds"),
|
||||
bind_group_layouts: &[
|
||||
Some(clouds[0]),
|
||||
Some(clouds[1]),
|
||||
Some(&layouts[stage as usize]),
|
||||
],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some("vs_cloud"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &cloud_buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
|
||||
// so the later stages keep the nearest face of each cloud.
|
||||
depth_stencil: depth(stage == Stage::DepthBounds),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: Default::default(),
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let cloud_pipelines = [
|
||||
cloud_pipeline(
|
||||
"OIT cloud depth bounds",
|
||||
"fs_oit_cloud_bounds",
|
||||
Stage::DepthBounds,
|
||||
),
|
||||
cloud_pipeline(
|
||||
"OIT cloud transmittance",
|
||||
"fs_oit_cloud_transmittance",
|
||||
Stage::Transmittance,
|
||||
),
|
||||
cloud_pipeline(
|
||||
"OIT cloud accumulate",
|
||||
"fs_oit_cloud_accumulate",
|
||||
Stage::Accumulate,
|
||||
),
|
||||
];
|
||||
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
|
||||
Self {
|
||||
uniform,
|
||||
layouts,
|
||||
terrain: terrain_pipelines,
|
||||
flat: flat_pipelines,
|
||||
features,
|
||||
clouds: cloud_pipelines,
|
||||
cull,
|
||||
composite,
|
||||
targets: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipeline the terrain's translucent layer draws with in `stage`.
|
||||
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
|
||||
&self.terrain[stage as usize]
|
||||
}
|
||||
|
||||
/// The pipeline the terrain's translucent layer draws with in `stage`
|
||||
/// as flat section draws, where they run.
|
||||
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
|
||||
self.flat.as_ref().map(|f| &f[stage as usize])
|
||||
}
|
||||
|
||||
pub fn features(&self) -> &Features {
|
||||
&self.features
|
||||
}
|
||||
|
||||
/// Sizes the targets to the world's and writes this frame's projection.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
projection: &Mat4,
|
||||
wants: [bool; 2],
|
||||
) {
|
||||
queue.write_buffer(
|
||||
&self.uniform,
|
||||
0,
|
||||
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
|
||||
);
|
||||
let [mask, clouds] = wants;
|
||||
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
|
||||
if mask && t.terrain_depth.is_none() {
|
||||
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
|
||||
}
|
||||
if clouds && t.cloud_depth.is_none() {
|
||||
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let make = |label, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: extent(size),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let attachment =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let bounds = make(
|
||||
"OIT depth bounds",
|
||||
BOUNDS_FORMAT,
|
||||
attachment | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let culled = make(
|
||||
"OIT culled depth bounds",
|
||||
BOUNDS_FORMAT,
|
||||
attachment | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
let coefficients =
|
||||
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
|
||||
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
|
||||
let views = Views {
|
||||
bounds: bounds.create_view(&Default::default()),
|
||||
culled: culled.create_view(&Default::default()),
|
||||
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
|
||||
accumulated: accumulated.create_view(&Default::default()),
|
||||
};
|
||||
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
}];
|
||||
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
}));
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Improved transparency"),
|
||||
layout,
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
let [c0, c1] = &views.coefficients;
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
alone: group(&self.layouts[0], &[]),
|
||||
sampled: group(&self.layouts[1], &[&views.culled]),
|
||||
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
|
||||
cull: group(&self.layouts[1], &[&views.bounds]),
|
||||
composite: group(
|
||||
&self.layouts[3],
|
||||
&[&views.bounds, c0, c1, &views.accumulated],
|
||||
),
|
||||
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
|
||||
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
|
||||
bounds,
|
||||
culled,
|
||||
views,
|
||||
});
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
|
||||
/// layer into each stage's pass with that stage's pipeline, `extras` the
|
||||
/// water masks and clouds, then the composite goes over `color`. `depth`
|
||||
/// is the opaque world's: the stages only read it; the cull and the
|
||||
/// composite write it.
|
||||
pub fn run(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
|
||||
extras: &Extras<'_>,
|
||||
) {
|
||||
let Some(t) = &self.targets else { return };
|
||||
let attachment = |view, load| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
let load = wgpu::LoadOp::Load;
|
||||
let read_only = |view| {
|
||||
Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: None,
|
||||
stencil_ops: None,
|
||||
})
|
||||
};
|
||||
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
|
||||
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
|
||||
if let Some((mask, _)) = water_mask {
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
|
||||
let [c0, c1] = &t.views.coefficients;
|
||||
let culled = &t.views.culled;
|
||||
for (stage, label, group, first, then) in [
|
||||
(
|
||||
Stage::DepthBounds,
|
||||
"OIT depth bounds",
|
||||
&t.alone,
|
||||
vec![attachment(
|
||||
&t.views.bounds,
|
||||
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
|
||||
)],
|
||||
// The clouds' bounds join the culled ones.
|
||||
vec![attachment(culled, load)],
|
||||
),
|
||||
(
|
||||
Stage::Transmittance,
|
||||
"OIT transmittance",
|
||||
&t.sampled,
|
||||
vec![attachment(c0, clear), attachment(c1, clear)],
|
||||
vec![attachment(c0, load), attachment(c1, load)],
|
||||
),
|
||||
(
|
||||
Stage::Accumulate,
|
||||
"OIT accumulate",
|
||||
&t.accumulate,
|
||||
vec![attachment(&t.views.accumulated, clear)],
|
||||
vec![attachment(&t.views.accumulated, load)],
|
||||
),
|
||||
] {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, stage);
|
||||
}
|
||||
if let Some(features) = extras.features {
|
||||
// Over the world's own depth, even where a water mask patched
|
||||
// the terrain's: the masks only hide water.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: &[attachment(
|
||||
match stage {
|
||||
Stage::DepthBounds => &t.views.bounds,
|
||||
Stage::Transmittance => c0,
|
||||
Stage::Accumulate => &t.views.accumulated,
|
||||
},
|
||||
load,
|
||||
)]
|
||||
.into_iter()
|
||||
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
|
||||
.collect::<Vec<_>>(),
|
||||
depth_stencil_attachment: read_only(depth),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
features(&mut pass, stage);
|
||||
}
|
||||
if stage == Stage::DepthBounds {
|
||||
self.execute_depth_bounds_cull(encoder, t, depth);
|
||||
}
|
||||
if let Some((clouds, cloud_depth)) = cloud_depth {
|
||||
self.render_clouds(
|
||||
encoder,
|
||||
t,
|
||||
stage,
|
||||
depth,
|
||||
(label, group, &then),
|
||||
(clouds, cloud_depth),
|
||||
);
|
||||
}
|
||||
if stage == Stage::DepthBounds
|
||||
&& let Some((mask, _)) = water_mask
|
||||
{
|
||||
self.execute_water_mask(encoder, t, color, depth, mask);
|
||||
}
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT composite"),
|
||||
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.composite);
|
||||
pass.set_bind_group(GROUP, &t.composite, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
|
||||
/// the world's depth (after the cull) and draws into it, so the later
|
||||
/// stages, which only read it, keep each pixel's nearest cloud face.
|
||||
fn render_clouds(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
stage: Stage,
|
||||
depth: &wgpu::TextureView,
|
||||
(label, group, targets): (
|
||||
&str,
|
||||
&wgpu::BindGroup,
|
||||
&[Option<wgpu::RenderPassColorAttachment<'_>>],
|
||||
),
|
||||
(draw, cloud_depth): (
|
||||
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
|
||||
&Depth,
|
||||
),
|
||||
) {
|
||||
if stage == Stage::DepthBounds {
|
||||
encoder.copy_texture_to_texture(
|
||||
depth.texture().as_image_copy(),
|
||||
cloud_depth.texture.as_image_copy(),
|
||||
extent(t.size),
|
||||
);
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
color_attachments: targets,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &cloud_depth.view,
|
||||
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, &self.clouds[stage as usize]);
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
|
||||
/// again, with the boats' water patches on top.
|
||||
fn execute_water_mask(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
color: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
|
||||
) {
|
||||
let Some(terrain) = &t.terrain_depth else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
depth.texture().as_image_copy(),
|
||||
terrain.texture.as_image_copy(),
|
||||
extent(t.size),
|
||||
);
|
||||
// The mask pipeline writes depth only but has a colour target.
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT water mask"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &terrain.view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
mask(&mut pass);
|
||||
}
|
||||
|
||||
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
|
||||
/// the nearest fully opaque see-through fragment is in front of the world,
|
||||
/// the far bound stops there and its depth replaces the world's.
|
||||
fn execute_depth_bounds_cull(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
t: &Targets,
|
||||
depth: &wgpu::TextureView,
|
||||
) {
|
||||
encoder.copy_texture_to_texture(
|
||||
t.bounds.as_image_copy(),
|
||||
t.culled.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: t.size[0].max(1),
|
||||
height: t.size[1].max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("OIT cull depth bounds"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &t.views.culled,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.cull);
|
||||
pass.set_bind_group(GROUP, &t.cull, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn validate(source: &str, entries: &[&str]) {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
for entry in entries {
|
||||
assert!(
|
||||
module.entry_points.iter().any(|e| e.name == *entry),
|
||||
"{entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terrain_shader_validates() {
|
||||
validate(
|
||||
&(include_str!("../terrain.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl")),
|
||||
&[
|
||||
"fs_oit_bounds",
|
||||
"fs_oit_transmittance",
|
||||
"fs_oit_accumulate",
|
||||
"fs_oit_cull",
|
||||
"fs_oit_composite",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cloud_shader_validates() {
|
||||
validate(
|
||||
&(super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl")),
|
||||
&[
|
||||
"fs_oit_cloud_bounds",
|
||||
"fs_oit_cloud_transmittance",
|
||||
"fs_oit_cloud_accumulate",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feature_shaders_validate() {
|
||||
let stages = |name: &str| {
|
||||
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
|
||||
};
|
||||
let entity = ["entity", "shadow", "weather", "swirl"]
|
||||
.map(stages)
|
||||
.concat();
|
||||
validate(
|
||||
&(super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl")),
|
||||
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
let border = stages("border");
|
||||
validate(
|
||||
&(include_str!("../border.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_border.wgsl")),
|
||||
&border.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_depth_gives_the_distance_along_the_view_axis() {
|
||||
// The world's reversed-Z projection: near plane at depth 1.
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let [x, y] = projection_terms(&projection);
|
||||
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
|
||||
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
|
||||
let device = clip.z / clip.w;
|
||||
let linear = y / (device + x);
|
||||
assert!(
|
||||
(linear - distance).abs() < distance * 2e-3,
|
||||
"{distance}: {linear}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Improved transparency for what is drawn after the opaque world besides the
|
||||
//! terrain and the clouds: `LevelRenderer.executeOit`'s "Features, World
|
||||
//! Border, Weather" pass. Each kind keeps its classic vertex shader, vertex
|
||||
//! format and depth state (minus the depth writes, as every `OIT_*` pipeline),
|
||||
//! with the three stages' fragments (`oit_features.wgsl`, `oit_border.wgsl`).
|
||||
|
||||
use super::oit::{Stage, stage_targets};
|
||||
|
||||
/// One pipeline per stage.
|
||||
type Set = [wgpu::RenderPipeline; 3];
|
||||
|
||||
pub struct Features {
|
||||
/// `OIT_ENTITY`/`OIT_ITEM`: translucent items and particles.
|
||||
pub entity: Set,
|
||||
/// `OIT_TEXT_POLYGON_OFFSET`.
|
||||
pub text: Set,
|
||||
/// `OIT_CRUMBLING`.
|
||||
pub crumbling: Set,
|
||||
/// `OIT_ENTITY_SHADOW`.
|
||||
pub shadow: Set,
|
||||
/// `OIT_ENERGY_SWIRL`.
|
||||
pub swirl: Set,
|
||||
/// `OIT_LIGHTNING`: the retained bolts.
|
||||
pub lightning: Set,
|
||||
/// `OIT_DRAGON_RAYS`: the additive geometry after the bolts.
|
||||
pub additive: Set,
|
||||
/// `OIT_WEATHER`.
|
||||
pub weather: Set,
|
||||
/// `OIT_WORLD_BORDER`.
|
||||
pub border: Set,
|
||||
}
|
||||
|
||||
/// What a kind differs in.
|
||||
struct Kind<'a> {
|
||||
label: &'a str,
|
||||
/// The entry points' common prefix: `fs_oit_<name>_<stage>`.
|
||||
name: &'a str,
|
||||
vertex: &'a str,
|
||||
cull: Option<wgpu::Face>,
|
||||
bias: wgpu::DepthBiasState,
|
||||
}
|
||||
|
||||
const PLAIN: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
|
||||
impl Features {
|
||||
/// `layouts`: `Oit`'s stage groups (at group 2); `entity`, `weather` and
|
||||
/// `border` the bind group layouts of the classic pipelines.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
layouts: &[wgpu::BindGroupLayout; 4],
|
||||
entity: &wgpu::BindGroupLayout,
|
||||
weather: &wgpu::BindGroupLayout,
|
||||
border: &wgpu::BindGroupLayout,
|
||||
) -> Self {
|
||||
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency features"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency world border"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../border.wgsl").to_owned()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_border.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let weather_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
|
||||
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
|
||||
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
|
||||
let entity_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
})];
|
||||
let weather_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::weather::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &weather_attributes,
|
||||
})];
|
||||
let border_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<crate::border_render::BorderVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &border_attributes,
|
||||
})];
|
||||
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let lightning_buffers = [
|
||||
entity_buffers[0].clone(),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &lightning_attributes,
|
||||
}),
|
||||
];
|
||||
let set = |module: &wgpu::ShaderModule,
|
||||
groups: [Option<&wgpu::BindGroupLayout>; 2],
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
kind: Kind<'_>| {
|
||||
[Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate].map(|stage| {
|
||||
let suffix = ["bounds", "transmittance", "accumulate"][stage as usize];
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Improved transparency features"),
|
||||
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&format!("OIT {} {suffix}", kind.label)),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module,
|
||||
entry_point: Some(kind.vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: kind.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: kind.bias,
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
|
||||
compilation_options: Default::default(),
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
};
|
||||
let entities = [Some(entity), None];
|
||||
let kind = |label, name, cull, bias| Kind {
|
||||
label,
|
||||
name,
|
||||
vertex: "vs_entity",
|
||||
cull,
|
||||
bias,
|
||||
};
|
||||
// The classic pipelines' biases: text and cracks (10, 1), shadows
|
||||
// (128 ulps and a slope, over the block tops they lie on).
|
||||
let offset = wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
let shadow_bias = wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
};
|
||||
Self {
|
||||
entity: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("entities", "entity", None, PLAIN),
|
||||
),
|
||||
text: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("text", "entity", None, offset),
|
||||
),
|
||||
crumbling: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("breaking cracks", "entity", Some(wgpu::Face::Back), offset),
|
||||
),
|
||||
shadow: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("entity shadows", "shadow", None, shadow_bias),
|
||||
),
|
||||
swirl: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("energy swirls", "swirl", None, PLAIN),
|
||||
),
|
||||
lightning: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&lightning_buffers,
|
||||
Kind {
|
||||
label: "lightning",
|
||||
name: "lightning",
|
||||
vertex: "vs_lightning",
|
||||
cull: None,
|
||||
bias: PLAIN,
|
||||
},
|
||||
),
|
||||
additive: set(
|
||||
&entity_module,
|
||||
entities,
|
||||
&entity_buffers,
|
||||
kind("additive entities", "lightning", None, PLAIN),
|
||||
),
|
||||
weather: set(
|
||||
&entity_module,
|
||||
[Some(entity), Some(weather)],
|
||||
&weather_buffers,
|
||||
Kind {
|
||||
label: "weather",
|
||||
name: "weather",
|
||||
vertex: "vs_weather",
|
||||
cull: None,
|
||||
bias: PLAIN,
|
||||
},
|
||||
),
|
||||
// `OIT_WORLD_BORDER`: a polygon offset of 3 towards the camera.
|
||||
border: set(
|
||||
&border_module,
|
||||
[Some(border), None],
|
||||
&border_buffers,
|
||||
Kind {
|
||||
label: "world border",
|
||||
name: "border",
|
||||
vertex: "vs_border",
|
||||
cull: None,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 3,
|
||||
slope_scale: 3.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +349,10 @@ impl PackEntities {
|
||||
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
|
||||
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
|
||||
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
|
||||
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
|
||||
}
|
||||
self.uniforms.extend_from_slice(&bytes);
|
||||
@@ -408,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
|
||||
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
|
||||
/// shader draws in its own way; entity.wgsl).
|
||||
fn plain(run: &[Vertex]) -> bool {
|
||||
run.iter()
|
||||
.step_by(4)
|
||||
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
|
||||
run.iter().step_by(4).all(|v| {
|
||||
!matches!(
|
||||
v.layer & !super::entity_batch::OIT_ENTITY,
|
||||
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The programs of entity runs, in the order they draw.
|
||||
|
||||
@@ -203,9 +203,9 @@ fn body(angle: f32, tilt: f32) -> Vec3 {
|
||||
}
|
||||
|
||||
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
|
||||
/// positive angle moves the noon sun toward +Z (south).
|
||||
/// positive angle moves the noon sun toward -Z (north), as Iris does.
|
||||
pub fn tilt_matrix(tilt: f32) -> Mat4 {
|
||||
Mat4::from_rotation_x(tilt.to_radians())
|
||||
Mat4::from_rotation_x(-tilt.to_radians())
|
||||
}
|
||||
|
||||
/// The world direction of the shadow light (7.1): the sun by day, the moon by night.
|
||||
@@ -232,38 +232,39 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
|
||||
depth * gl * view
|
||||
}
|
||||
|
||||
/// Distance from the snapped point to the shadow camera, toward the light: the middle of the
|
||||
/// default near and far planes (OQ-6).
|
||||
const SHADOW_EYE: f64 = 128.0;
|
||||
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
|
||||
/// not of `shadowModelView` (Iris's matrices, measured).
|
||||
const SHADOW_EYE: f32 = 100.0;
|
||||
|
||||
/// The shadow camera (7.1; OQ-6, impl-plan.md): its view (player space to shadow view space)
|
||||
/// and GL projection. The camera looks from the light, world +Z up (+Y when the light is near
|
||||
/// Z); the point it centres moves in steps of `shadowIntervalSize` along the shadow view's
|
||||
/// own axes, and it sits `SHADOW_EYE` blocks toward the light from it. Orthographic over
|
||||
/// `shadowDistance` each side, or perspective with `shadowMapFov`.
|
||||
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
|
||||
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
|
||||
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
|
||||
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
|
||||
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
|
||||
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
|
||||
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
|
||||
/// with `shadowMapFov`.
|
||||
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
|
||||
let light = light.as_dvec3();
|
||||
let up = if light.z.abs() > 0.99 {
|
||||
DVec3::Y
|
||||
} else {
|
||||
DVec3::Z
|
||||
};
|
||||
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
|
||||
let at = rotation.transform_vector3(camera);
|
||||
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
|
||||
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
|
||||
let zenith = untilted.x.atan2(untilted.y);
|
||||
let rotation = DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)
|
||||
* DMat4::from_rotation_z(zenith)
|
||||
* DMat4::from_rotation_x(tilt);
|
||||
let step = f64::from(shadow.interval);
|
||||
let snapped = if step > 0.0 {
|
||||
(at / step).floor() * step
|
||||
let origin = if step > 0.0 {
|
||||
DVec3::splat(step / 2.0) - camera % step
|
||||
} else {
|
||||
at
|
||||
DVec3::ZERO
|
||||
};
|
||||
let offset = at - snapped - DVec3::new(0.0, 0.0, SHADOW_EYE);
|
||||
let view = (DMat4::from_translation(offset) * rotation).as_mat4();
|
||||
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
|
||||
let (near, far) = (shadow.near, shadow.far);
|
||||
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
|
||||
let projection = match shadow.fov {
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far),
|
||||
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
|
||||
None => {
|
||||
let d = shadow.distance;
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, near, far)
|
||||
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
|
||||
}
|
||||
};
|
||||
(view, projection)
|
||||
@@ -360,7 +361,8 @@ impl FrameUniforms {
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
"aspectRatio" => Value::f(&[aspect]),
|
||||
"near" => Value::f(&[0.05]),
|
||||
"far" => Value::f(&[frame.distance]),
|
||||
// The chunk render distance, not the distant terrain's reach.
|
||||
"far" => Value::f(&[frame.detail_distance]),
|
||||
"rainStrength" => Value::f(&[inputs.rain]),
|
||||
"wetness" => Value::f(&[self.wetness]),
|
||||
"thunderStrength" => Value::f(&[inputs.thunder]),
|
||||
@@ -486,8 +488,8 @@ mod tests {
|
||||
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
|
||||
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(body(90.0, 0.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
// sunPathRotation tilts noon toward +Z and leaves sunset on the horizon.
|
||||
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, 50.0), 1e-3));
|
||||
// sunPathRotation tilts noon toward -Z and leaves sunset on the horizon.
|
||||
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, -50.0), 1e-3));
|
||||
assert!(body(90.0, 30.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
}
|
||||
|
||||
@@ -500,10 +502,13 @@ mod tests {
|
||||
let at = |p: Vec3| view.transform_point3(p);
|
||||
// Straight down: a block below the player is farther from the light.
|
||||
assert!(at(Vec3::new(0.0, -1.0, 0.0)).z < at(Vec3::ZERO).z);
|
||||
// The player is less than a step from the centre, 128 blocks from the light.
|
||||
let centre = at(Vec3::ZERO);
|
||||
assert!(centre.x.abs() <= 2.0 && centre.y.abs() <= 2.0, "{centre}");
|
||||
assert!((centre.z + 128.0).abs() < 1e-3, "{centre}");
|
||||
// The view's origin is interval / 2 - camera % interval from the player on each axis,
|
||||
// the remainder with the camera's sign: up to a step and a half on negative axes.
|
||||
let origin = view.inverse().w_axis.truncate();
|
||||
assert!(
|
||||
origin.abs_diff_eq(Vec3::new(0.7, 1.0, 1.7), 1e-3),
|
||||
"{origin}"
|
||||
);
|
||||
// Moving within a step keeps the world where it was in the map.
|
||||
camera.x = 1000.6;
|
||||
let (moved, _) = shadow_camera(Vec3::Y, camera, &shadow);
|
||||
@@ -515,7 +520,38 @@ mod tests {
|
||||
.abs_diff_eq(at(world), 1e-3)
|
||||
);
|
||||
// shadowDistance blocks reach the map's edge.
|
||||
let edge = projection.project_point3(Vec3::new(160.0, 0.0, -128.0));
|
||||
let edge = projection.project_point3(Vec3::new(160.0, 0.0, 0.0));
|
||||
assert!((edge.x.abs() - 1.0).abs() < 1e-4, "{edge}");
|
||||
}
|
||||
|
||||
/// Iris's matrices at 3000 ticks with `sunPathRotation = 30` and the camera at
|
||||
/// (0.5, -58.38, 0.5), read from its uniforms by a probe pack.
|
||||
#[test]
|
||||
fn the_shadow_camera_matches_iris() {
|
||||
let shadow = Shadow {
|
||||
sun_path_rotation: 30.0,
|
||||
..Shadow::default()
|
||||
};
|
||||
let light = Vec3::new(0.56782, 0.71287, -0.41158).normalize();
|
||||
let (view, projection) = shadow_camera(light, DVec3::new(0.5, -58.38, 0.5), &shadow);
|
||||
let iris_view = Mat4::from_cols_array_2d(&[
|
||||
[0.82315, 0.0, 0.56782, 0.0],
|
||||
[-0.49175, -0.5, 0.71287, 0.0],
|
||||
[0.28391, -0.86603, -0.41158, 0.0],
|
||||
[0.12508, 1.12301, -1.06188, 1.0],
|
||||
]);
|
||||
assert!(view.abs_diff_eq(iris_view, 2e-4), "{view}");
|
||||
assert!(
|
||||
(projection.z_axis.z + 0.0078110).abs() < 1e-6,
|
||||
"{projection}"
|
||||
);
|
||||
assert!((projection.w_axis.z + 0.21851).abs() < 1e-4, "{projection}");
|
||||
// Elsewhere the origin keeps to the steps: Iris put it at (-0.5, 1.38, 2.5) here.
|
||||
let (view, _) = shadow_camera(light, DVec3::new(3.5, -58.38, -1.5), &shadow);
|
||||
let origin = view.inverse().w_axis.truncate();
|
||||
assert!(
|
||||
origin.abs_diff_eq(Vec3::new(-0.5, 1.38, 2.5), 1e-3),
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub struct Resident<T> {
|
||||
/// The minimum Y of section index 0 (the level's column).
|
||||
min_y: i32,
|
||||
items: Vec<(SectionKey, T)>,
|
||||
bounds: Vec<Option<[Vec3; 2]>>,
|
||||
slots: FxHashMap<SectionKey, u32>,
|
||||
columns: Vec<Column>,
|
||||
column_index: FxHashMap<(i32, i32), u32>,
|
||||
@@ -40,6 +41,7 @@ impl<T> Default for Resident<T> {
|
||||
Self {
|
||||
min_y: crate::chunk::MIN_Y,
|
||||
items: Vec::new(),
|
||||
bounds: Vec::new(),
|
||||
slots: FxHashMap::default(),
|
||||
columns: Vec::new(),
|
||||
column_index: FxHashMap::default(),
|
||||
@@ -78,18 +80,26 @@ impl<T> Resident<T> {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.items.clear();
|
||||
self.bounds.clear();
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
}
|
||||
/// Adds or replaces `key`'s value.
|
||||
/// Adds or replaces `key`'s value with default 16x16x16 bounds.
|
||||
#[cfg(test)]
|
||||
pub fn insert(&mut self, key: SectionKey, value: T) {
|
||||
self.insert_with_bounds(key, value, None);
|
||||
}
|
||||
/// Adds or replaces `key`'s value with optional tight geometry bounds.
|
||||
pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) {
|
||||
if let Some(&slot) = self.slots.get(&key) {
|
||||
self.items[slot as usize].1 = value;
|
||||
self.bounds[slot as usize] = bounds;
|
||||
return;
|
||||
}
|
||||
let slot = self.items.len() as u32;
|
||||
self.items.push((key, value));
|
||||
self.bounds.push(bounds);
|
||||
self.slots.insert(key, slot);
|
||||
let column = match self.column_index.get(&(key.0, key.2)) {
|
||||
Some(&c) => &mut self.columns[c as usize],
|
||||
@@ -129,6 +139,7 @@ impl<T> Resident<T> {
|
||||
} else {
|
||||
column.bounds();
|
||||
}
|
||||
let _ = self.bounds.swap_remove(slot as usize);
|
||||
let (_, value) = self.items.swap_remove(slot as usize);
|
||||
// The last item now sits in `slot`: point its key and column at it.
|
||||
if let Some(&(moved, _)) = self.items.get(slot as usize) {
|
||||
@@ -175,31 +186,57 @@ fn camera_column(camera: DVec3) -> (i32, i32) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The world AABB (min, max) of a section from its origin and optional tight bounds.
|
||||
#[inline]
|
||||
fn section_box(min: Vec3, bounds: Option<[Vec3; 2]>) -> (Vec3, Vec3) {
|
||||
match bounds {
|
||||
Some([bmin, bmax]) => (min + bmin, min + bmax),
|
||||
None => (min, min + Vec3::splat(16.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the section at `key` is drawn: its column within the render
|
||||
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
|
||||
/// partly inside the frustum. `None` when it is not, else its squared centre
|
||||
/// distance and origin. `Resident::visible` walks columns instead.
|
||||
#[cfg(test)]
|
||||
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
|
||||
in_view_with_bounds(key, camera, radius, frustum, None)
|
||||
}
|
||||
|
||||
/// `in_view` with optional tight bounds.
|
||||
#[cfg(test)]
|
||||
fn in_view_with_bounds(
|
||||
key: SectionKey,
|
||||
camera: DVec3,
|
||||
radius: i32,
|
||||
frustum: &Frustum,
|
||||
bounds: Option<[Vec3; 2]>,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
|
||||
return None;
|
||||
}
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
|
||||
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false, bounds)
|
||||
}
|
||||
|
||||
/// `in_view` without the distance test; `inside`: the section's column is
|
||||
/// entirely inside the frustum, so the section is too.
|
||||
#[cfg(test)]
|
||||
fn in_frustum(
|
||||
key: SectionKey,
|
||||
min_y: i32,
|
||||
camera: DVec3,
|
||||
frustum: &Frustum,
|
||||
inside: bool,
|
||||
bounds: Option<[Vec3; 2]>,
|
||||
) -> Option<(f64, Vec3)> {
|
||||
let origin = origin(key, min_y, camera);
|
||||
let min = origin.as_vec3();
|
||||
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
|
||||
return None;
|
||||
if !inside {
|
||||
let (bmin, bmax) = section_box(min, bounds);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
|
||||
}
|
||||
@@ -239,17 +276,50 @@ impl<T> Resident<T> {
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
|
||||
let ox = f64::from(column.x * 16) - camera.x;
|
||||
let oz = f64::from(column.z * 16) - camera.z;
|
||||
let min_x = ox as f32;
|
||||
let min_z = oz as f32;
|
||||
let dx8 = ox + 8.0;
|
||||
let dz8 = oz + 8.0;
|
||||
let xz_sq = dx8 * dx8 + dz8 * dz8;
|
||||
|
||||
if inside {
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let dy8 = oy + 8.0;
|
||||
let distance = xz_sq + dy8 * dy8;
|
||||
out.push(Visible {
|
||||
distance: d.to_bits(),
|
||||
distance: distance.to_bits(),
|
||||
key,
|
||||
slot,
|
||||
origin: o.to_array(),
|
||||
origin: min.to_array(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for &(y, slot) in &column.sections {
|
||||
let key = (column.x, y, column.z);
|
||||
if near && !reachable(key) {
|
||||
continue;
|
||||
}
|
||||
let oy = f64::from(self.min_y + y * 16) - camera.y;
|
||||
let min = Vec3::new(min_x, oy as f32, min_z);
|
||||
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
|
||||
if !frustum.intersects(bmin, bmax) {
|
||||
continue;
|
||||
}
|
||||
let dy8 = oy + 8.0;
|
||||
let distance = xz_sq + dy8 * dy8;
|
||||
out.push(Visible {
|
||||
distance: distance.to_bits(),
|
||||
key,
|
||||
slot,
|
||||
origin: min.to_array(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -271,7 +341,15 @@ mod tests {
|
||||
for y in 0..24 {
|
||||
// Leave holes so columns have uneven bounds.
|
||||
if (x * 7 + y * 3 + z * 5).rem_euclid(4) != 0 {
|
||||
r.insert((x, y, z), ());
|
||||
// Mix of sections with fallback None and randomized tight bounds.
|
||||
let bounds = if (x + y + z).rem_euclid(2) == 0 {
|
||||
let y_min = ((x + z).rem_euclid(4)) as f32;
|
||||
let y_max = y_min + 2.0 + ((y.rem_euclid(5)) as f32);
|
||||
Some([Vec3::new(0.0, y_min, 0.0), Vec3::new(16.0, y_max, 16.0)])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
r.insert_with_bounds((x, y, z), (), bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,12 +375,21 @@ mod tests {
|
||||
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|&(k, _)| {
|
||||
.enumerate()
|
||||
.filter_map(|(slot, &(k, _))| {
|
||||
let near = in_view_distance(camera_column(camera), radius, k.0, k.2);
|
||||
if near {
|
||||
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
|
||||
in_view_with_bounds(k, camera, radius, &frustum, r.bounds[slot])
|
||||
.filter(|_| reachable(k))
|
||||
} else if beyond(k.0, k.2) {
|
||||
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
|
||||
in_frustum(
|
||||
k,
|
||||
crate::chunk::MIN_Y,
|
||||
camera,
|
||||
&frustum,
|
||||
false,
|
||||
r.bounds[slot],
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -471,4 +558,152 @@ mod tests {
|
||||
list.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_mesh_bounds_culls_section_outside_frustum() {
|
||||
let mut r = Resident::default();
|
||||
let key = (0, 0, 0);
|
||||
// Section geometry only occupies local Y in [0.0, 2.0]
|
||||
let tight = Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, 2.0, 16.0)]);
|
||||
r.insert_with_bounds(key, (), tight);
|
||||
|
||||
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 15), -2.0);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
let view = crate::camera::view_matrix(0.0, -60.0);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
|
||||
// Tight bounds culls the section whose geometry is entirely outside the frustum
|
||||
let mut out = Vec::new();
|
||||
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"tight bounds should cull section whose geometry is below frustum"
|
||||
);
|
||||
|
||||
// With fallback 16x16x16 bounds, the section intersects the frustum and is visible
|
||||
r.insert_with_bounds(key, (), None);
|
||||
out.clear();
|
||||
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
1,
|
||||
"full 16x16x16 bounds intersect frustum and are accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn tight_bounds_benchmark() {
|
||||
use std::time::Instant;
|
||||
|
||||
let mut r_fallback = Resident::default();
|
||||
let mut r_tight = Resident::default();
|
||||
|
||||
let radius = 16;
|
||||
for x in -radius..=radius {
|
||||
for z in -radius..=radius {
|
||||
// Section 0..4: underground rock
|
||||
for y in 0..4 {
|
||||
r_fallback.insert((x, y, z), ());
|
||||
r_tight.insert_with_bounds((x, y, z), (), None);
|
||||
}
|
||||
// Section 4: surface terrain with grass / hills (e.g. Y in [0, 4] or [0, 6])
|
||||
let height = 2.0 + (((x * 3 + z * 5).rem_euclid(5)) as f32) * 1.5;
|
||||
let surface_bounds =
|
||||
Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, height, 16.0)]);
|
||||
r_fallback.insert((x, 4, z), ());
|
||||
r_tight.insert_with_bounds((x, 4, z), (), surface_bounds);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n=== Tight Bounds Culling Benchmark ===");
|
||||
println!("Total Resident Sections: {}", r_tight.len());
|
||||
|
||||
let test_views = [
|
||||
("Looking level across terrain (pitch 0°)", 0.0, 0.0),
|
||||
("Looking slightly up (pitch -15°)", 45.0, -15.0),
|
||||
("Looking up towards sky (pitch -35°)", 90.0, -35.0),
|
||||
("Looking down at ground (pitch 25°)", 180.0, 25.0),
|
||||
("Diagonal view (yaw 135°, pitch -10°)", 135.0, -10.0),
|
||||
];
|
||||
|
||||
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 4 * 16 + 10), 8.0);
|
||||
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
|
||||
|
||||
for (desc, yaw, pitch) in test_views {
|
||||
let view = crate::camera::view_matrix(yaw, pitch);
|
||||
let frustum = Frustum {
|
||||
planes: super::super::frustum(projection * view),
|
||||
};
|
||||
|
||||
let mut out_fallback = Vec::new();
|
||||
let mut out_tight = Vec::new();
|
||||
|
||||
r_fallback.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_fallback,
|
||||
);
|
||||
r_tight.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_tight,
|
||||
);
|
||||
|
||||
let culled_extra = out_fallback.len().saturating_sub(out_tight.len());
|
||||
let pct_saved = if !out_fallback.is_empty() {
|
||||
(culled_extra as f64 / out_fallback.len() as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Timing over 1,000 passes
|
||||
let iters = 1_000;
|
||||
let t0 = Instant::now();
|
||||
for _ in 0..iters {
|
||||
out_fallback.clear();
|
||||
r_fallback.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_fallback,
|
||||
);
|
||||
}
|
||||
let dur_fallback = t0.elapsed();
|
||||
|
||||
let t1 = Instant::now();
|
||||
for _ in 0..iters {
|
||||
out_tight.clear();
|
||||
r_tight.visible(
|
||||
camera,
|
||||
radius,
|
||||
&frustum,
|
||||
|_| true,
|
||||
|_, _| false,
|
||||
&mut out_tight,
|
||||
);
|
||||
}
|
||||
let dur_tight = t1.elapsed();
|
||||
|
||||
println!(
|
||||
"{desc}\n Fallback 16x16: {} sections | {:?}\n Tight Bounds: {} sections | {:?}\n Reduction: -{} sections ({:.1}% culled)\n",
|
||||
out_fallback.len(),
|
||||
dur_fallback / iters,
|
||||
out_tight.len(),
|
||||
dur_tight / iters,
|
||||
culled_extra,
|
||||
pct_saved
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,9 @@ mod tests {
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let mut best = 0.0f32;
|
||||
let mut k0 = d.dot(n(TRIANGLES));
|
||||
for i in 0..TRIANGLES {
|
||||
for (i, triangle) in normals.iter().take(TRIANGLES).enumerate() {
|
||||
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
|
||||
let w = normals[i][3];
|
||||
let w = triangle[3];
|
||||
if k0 * w <= 0.0 && k1 * w >= 0.0 {
|
||||
let apex = d.dot(n(i));
|
||||
let s = apex + k1 - k0;
|
||||
@@ -75,6 +75,115 @@ mod tests {
|
||||
best
|
||||
}
|
||||
|
||||
// Projecting the fan onto the YZ plane leaves uniformly spaced rim
|
||||
// angles after undoing its Y squash. Test both ray directions because
|
||||
// the projection plane goes through the camera.
|
||||
fn sector_weight(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let normals = normals(alpha, sun);
|
||||
if d.y * d.y + d.z * d.z < 1e-10 {
|
||||
return weight(&normals, d);
|
||||
}
|
||||
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
|
||||
let apex = d.dot(n(0));
|
||||
if apex * normals[0][3] < -1e-4 {
|
||||
return 0.0;
|
||||
}
|
||||
let y = d.dot(n(16));
|
||||
let x = -d.dot(n(20));
|
||||
let ax = x.abs();
|
||||
let ay = y.abs();
|
||||
let t = std::f32::consts::SQRT_2 - 1.0;
|
||||
let q = if ay > ax {
|
||||
if ax < ay * t { 3 } else { 2 }
|
||||
} else if ay > ax * t {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let sector = if x >= 0.0 {
|
||||
if y >= 0.0 { q } else { 15 - q }
|
||||
} else if y >= 0.0 {
|
||||
7 - q
|
||||
} else {
|
||||
8 + q
|
||||
};
|
||||
let mut best = 0.0f32;
|
||||
let k0 = d.dot(n(16 + sector as usize));
|
||||
let k1 = d.dot(n(16 + ((sector + 1) % 16) as usize));
|
||||
let offsets: &[i32] = if k0.abs().min(k1.abs()) < ax.max(ay) * 0.001 {
|
||||
&[0, 8, -1, 1, 7, 9]
|
||||
} else {
|
||||
&[0, 8]
|
||||
};
|
||||
for &offset in offsets {
|
||||
let i = (sector + offset).rem_euclid(16) as usize;
|
||||
let k0 = d.dot(n(16 + i));
|
||||
let k1 = d.dot(n(16 + (i + 1) % 16));
|
||||
let w = normals[i][3];
|
||||
let apex = d.dot(n(i));
|
||||
let sum = apex + k1 - k0;
|
||||
if sum.abs() >= 1e-6
|
||||
&& sum * w > 0.0
|
||||
&& apex * sum >= 0.0
|
||||
&& k1 * sum >= 0.0
|
||||
&& k0 * sum <= 0.0
|
||||
{
|
||||
best = best.max(apex / sum);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
#[test]
|
||||
fn angular_sector_matches_random_and_exact_rim_directions() {
|
||||
let mut state = 19u32;
|
||||
let mut random = || {
|
||||
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
(state >> 8) as f32 / 16777216.0
|
||||
};
|
||||
for _ in 0..1000 {
|
||||
let alpha = 0.0011 + random() * 0.9989;
|
||||
let z = if random() > 0.5 { 0.3 } else { -0.2 };
|
||||
let sun = Vec3::new(0.0, (1.0f32 - z * z).sqrt(), z);
|
||||
let normals = normals(alpha, sun);
|
||||
let (_, rim) = vertices(alpha, sun);
|
||||
let random_ray = Vec3::new(
|
||||
random() * 2.0 - 1.0,
|
||||
random() * 2.0 - 1.0,
|
||||
random() * 2.0 - 1.0,
|
||||
)
|
||||
.normalize();
|
||||
for ray in rim.into_iter().chain([random_ray]) {
|
||||
let d = ray.normalize();
|
||||
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"{alpha} {sun} {d}: {got} != {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angular_sector_matches_all_triangle_search() {
|
||||
for alpha in [0.0011f32, 0.05, 0.6, 1.0] {
|
||||
for z in [-0.2f32, 0.3] {
|
||||
let sun = Vec3::new(0.0, (1.0 - z * z).sqrt(), z);
|
||||
let normals = normals(alpha, sun);
|
||||
for yaw in 0..360 {
|
||||
for pitch in -89..90 {
|
||||
let (y, p) = ((yaw as f32).to_radians(), (pitch as f32).to_radians());
|
||||
let d = Vec3::new(y.cos() * p.cos(), p.sin(), y.sin() * p.cos());
|
||||
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
|
||||
assert!(
|
||||
(got - want).abs() < 1e-4,
|
||||
"alpha={alpha} z={z} d={d} {got} != {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
|
||||
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
|
||||
let (apex, rim) = vertices(alpha, sun);
|
||||
|
||||
@@ -124,6 +124,26 @@ impl Weather {
|
||||
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
|
||||
}
|
||||
/// The classic pipeline, for its layouts.
|
||||
pub fn pipeline(&self) -> &wgpu::RenderPipeline {
|
||||
&self.pipeline
|
||||
}
|
||||
/// `draw` with another pipeline of the same layouts and vertex format.
|
||||
pub fn draw_with(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
entities: &wgpu::BindGroup,
|
||||
) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
}
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, entities, &[]);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
|
||||
}
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
|
||||
if self.count == 0 {
|
||||
return;
|
||||
|
||||
@@ -344,7 +344,7 @@ impl Post {
|
||||
"Scaled world depth",
|
||||
size,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
sampled,
|
||||
sampled | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
|
||||
@@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
@@ -332,7 +334,7 @@ pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
|
||||
}
|
||||
/// A LOD distance as far terrain uses it on this adapter.
|
||||
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
|
||||
let distance = distance.min(1024);
|
||||
let distance = distance.min(crate::lod::MAX_CHUNKS);
|
||||
if lod_capped(info) {
|
||||
distance.min(INTEGRATED_LOD_DISTANCE)
|
||||
} else {
|
||||
@@ -391,6 +393,8 @@ pub struct Graphics {
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -429,6 +433,7 @@ impl Default for Graphics {
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -467,7 +472,9 @@ impl Graphics {
|
||||
let number = |d: u32| value.parse().unwrap_or(d);
|
||||
match key.trim() {
|
||||
"preset" => g.preset = Preset::parse(value),
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"lodDistance" => {
|
||||
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
|
||||
}
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
@@ -494,7 +501,7 @@ impl Graphics {
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
@@ -507,6 +514,7 @@ impl Graphics {
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -524,7 +532,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -556,11 +564,20 @@ impl Graphics {
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
)
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
HDR_KEY => self.hdr_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
@@ -691,6 +708,7 @@ mod tests {
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
@@ -701,7 +719,7 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
@@ -758,12 +776,15 @@ mod tests {
|
||||
#[test]
|
||||
fn lod_distance_is_opt_in_and_bounded() {
|
||||
assert_eq!(Graphics::default().lod_distance, 0);
|
||||
for distance in [0, 64, 128, 256, 512, 1024] {
|
||||
for distance in [0, 64, 128, 256, 512, 1024, 65_536, crate::lod::MAX_CHUNKS] {
|
||||
let g = Graphics::parse(&format!("lodDistance:{distance}\n"));
|
||||
assert_eq!(g.lod_distance, distance);
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
}
|
||||
assert_eq!(Graphics::parse("lodDistance:10000\n").lod_distance, 1024);
|
||||
assert_eq!(
|
||||
Graphics::parse("lodDistance:99999999\n").lod_distance,
|
||||
crate::lod::MAX_CHUNKS
|
||||
);
|
||||
assert_eq!(Graphics::parse("lodDistance:-1\n").lod_distance, 0);
|
||||
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! lay out rows and handle input; a menu theme replaces this module.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -156,10 +157,11 @@ impl Style {
|
||||
cost: Cost,
|
||||
text: &str,
|
||||
) {
|
||||
let tag = Text::literal(cost.label());
|
||||
let tag = Text::literal(lang::get(cost.label()));
|
||||
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
|
||||
let indent = atlas.font.width_text(&tag) + 4.0;
|
||||
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
|
||||
let text = lang::get(text);
|
||||
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
|
||||
for (i, line) in lines.iter().take(2).enumerate() {
|
||||
let lx = if i == 0 { x + indent } else { x };
|
||||
batch.text_styled(
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings::{Opt, Settings},
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -43,6 +44,10 @@ pub enum H {
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
/// Starts HDR calibration (`crate::hdr_calibration`).
|
||||
HdrCalibrate,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
@@ -133,6 +138,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrCalibrate,
|
||||
None,
|
||||
"Sets the three below full screen, one test picture at a time.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
@@ -153,6 +163,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrKey,
|
||||
None,
|
||||
"A key that opens this screen from the game. Not bound at first.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
@@ -331,6 +346,28 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
}
|
||||
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// up to the whole world.
|
||||
const LOD_STEPS: [u32; 18] = [
|
||||
0,
|
||||
64,
|
||||
128,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
2048,
|
||||
4096,
|
||||
8192,
|
||||
16384,
|
||||
32768,
|
||||
65536,
|
||||
131072,
|
||||
262144,
|
||||
524288,
|
||||
1048576,
|
||||
2097152,
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
|
||||
/// Upscaling is only there while TAA is, which does it.
|
||||
@@ -354,11 +391,54 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "On" } else { "Off" }
|
||||
/// 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,
|
||||
}
|
||||
fn setup(s: &Settings) -> Setup {
|
||||
setup_of(&s.horizon, crate::graphics::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
|
||||
}
|
||||
}
|
||||
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
|
||||
fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
match (item, setup(s)) {
|
||||
(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::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) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
fn on_off(on: bool) -> String {
|
||||
lang::get(if on { "On" } else { "Off" })
|
||||
}
|
||||
/// A control's caption and its value, both through the language table: Horizon's captions are
|
||||
/// English sentences, and some of the values ("Off", "12 chunks") are words too.
|
||||
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
|
||||
Text::literal(format!("{caption}: {value}"))
|
||||
let value = value.to_string();
|
||||
Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value)))
|
||||
}
|
||||
|
||||
impl H {
|
||||
@@ -373,13 +453,17 @@ impl H {
|
||||
.unwrap_or(UPLOAD_STEPS.len() - 1);
|
||||
Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32)
|
||||
}
|
||||
H::LodDistance => {
|
||||
let i = LOD_STEPS
|
||||
.iter()
|
||||
.position(|&d| d >= g.lod_distance)
|
||||
.unwrap_or(LOD_STEPS.len() - 1);
|
||||
Some(i as f32 / (LOD_STEPS.len() - 1) as f32)
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
@@ -396,19 +480,21 @@ impl H {
|
||||
H::Preset => labelled("Preset", g.preset.label()),
|
||||
H::LodDistance => match g.lod_distance {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", format!("{n} chunks")),
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", format!("{n} per frame")),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
},
|
||||
H::UploadBudget => match g.upload_budget_kib {
|
||||
0 => labelled("Upload Budget", "Unlimited"),
|
||||
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
|
||||
k => labelled("Upload Budget", format!("{k} KiB/frame")),
|
||||
k if k >= 1024 => {
|
||||
labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)]))
|
||||
}
|
||||
k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])),
|
||||
},
|
||||
H::Sky => labelled("Sky", on_off(g.sky)),
|
||||
H::Fog => labelled("Fog", on_off(g.fog)),
|
||||
@@ -423,7 +509,10 @@ impl H {
|
||||
},
|
||||
),
|
||||
H::Workers => match g.workers {
|
||||
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
|
||||
0 => labelled(
|
||||
"Worker Threads",
|
||||
lang::tr("Auto (%s)", &[&g.worker_threads()]),
|
||||
),
|
||||
n => labelled("Worker Threads", n),
|
||||
},
|
||||
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
|
||||
@@ -437,23 +526,37 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
|
||||
),
|
||||
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
lang::tr(
|
||||
"%s nits",
|
||||
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
|
||||
),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -474,14 +577,12 @@ impl H {
|
||||
g.upload_budget_kib =
|
||||
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
|
||||
}
|
||||
H::LodDistance => {
|
||||
g.lod_distance = LOD_STEPS[(v * (LOD_STEPS.len() - 1) as f32).round() as usize]
|
||||
}
|
||||
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::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
@@ -507,9 +608,6 @@ impl H {
|
||||
}
|
||||
let g = &mut s.horizon;
|
||||
match self {
|
||||
H::LodDistance => {
|
||||
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
|
||||
}
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
@@ -579,7 +677,11 @@ impl Item {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
H::HdrCalibrate
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
@@ -618,7 +720,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
|
||||
"Full Detail Distance",
|
||||
format!("{} chunks", s.render_distance),
|
||||
lang::tr("%s chunks", &[&s.render_distance]),
|
||||
),
|
||||
Item::Vanilla(o) => o.label(s),
|
||||
Item::Horizon(h) => h.label(s),
|
||||
@@ -686,6 +788,10 @@ pub enum Action {
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
/// Wait for a key or mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
/// Start HDR calibration.
|
||||
Calibrate,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -708,6 +814,8 @@ pub struct GraphicsUi {
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
/// The key binding the options screen is waiting on (`Action::BindKey`).
|
||||
pub binding: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
@@ -818,7 +926,7 @@ impl GraphicsUi {
|
||||
let x = STYLE.row_x(w);
|
||||
for (y, name) in headers {
|
||||
if shown(y, STYLE.header_height) {
|
||||
STYLE.header(batch, atlas, &Text::literal(name), x, y);
|
||||
STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y);
|
||||
}
|
||||
}
|
||||
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
|
||||
@@ -840,11 +948,17 @@ impl GraphicsUi {
|
||||
if !shown(wd.y, 20.0) {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let mut label = item.label(s);
|
||||
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
|
||||
// KeyBindsScreen: "> key <" while it waits.
|
||||
label = Text::literal("Shortcut: > ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <");
|
||||
}
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -872,7 +986,7 @@ impl GraphicsUi {
|
||||
wd.x + wd.w + STYLE.gap,
|
||||
wd.y + 1.0,
|
||||
cost,
|
||||
text,
|
||||
description(item, s, text),
|
||||
);
|
||||
}
|
||||
Target::Button(action) => {
|
||||
@@ -885,7 +999,7 @@ impl GraphicsUi {
|
||||
batch,
|
||||
atlas,
|
||||
(wd.x, wd.y, wd.w),
|
||||
&Text::literal(label),
|
||||
&Text::literal(lang::get(label)),
|
||||
hover,
|
||||
true,
|
||||
);
|
||||
@@ -907,7 +1021,7 @@ impl GraphicsUi {
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
STYLE.title(batch, atlas, &Text::literal(lang::get(title)));
|
||||
if let Some(lines) = tooltip {
|
||||
ui::tooltip(batch, atlas, &lines, cursor);
|
||||
}
|
||||
@@ -965,6 +1079,20 @@ impl GraphicsUi {
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
|
||||
s.horizon.hdr_peak = 0;
|
||||
s.horizon.hdr_paper_white = 0;
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) => {
|
||||
vec![Action::ButtonClick, Action::Calibrate]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrKey)) => {
|
||||
vec![
|
||||
Action::ButtonClick,
|
||||
Action::BindKey(crate::graphics::HDR_KEY),
|
||||
]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -1216,9 +1344,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_advanced_setting_has_a_description() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for row in rows(level) {
|
||||
if let Row::Setting(s) = row {
|
||||
assert!(!s.text.is_empty(), "{:?}", s.item);
|
||||
@@ -1229,11 +1367,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_distance_cycles_in_both_levels_without_changing_near_distance() {
|
||||
fn lod_distance_slides_in_both_levels_without_changing_near_distance() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::LodDistance);
|
||||
let near = s.render_distance;
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: Off");
|
||||
assert_eq!(item.slider(&s), Some(0.0));
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let items: Vec<_> = rows(level)
|
||||
.into_iter()
|
||||
@@ -1248,14 +1387,29 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(items[render + 1], item);
|
||||
}
|
||||
for distance in [64, 128, 256, 512, 1024, 0] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
// Every stop is reachable and reads back as the same position.
|
||||
for (i, &distance) in LOD_STEPS.iter().enumerate() {
|
||||
let at = i as f32 / (LOD_STEPS.len() - 1) as f32;
|
||||
item.set_slider(&mut s, at);
|
||||
assert_eq!(s.horizon.lod_distance, distance);
|
||||
assert_eq!(item.slider(&s), Some(at));
|
||||
assert_eq!(s.render_distance, near);
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
// The ends are off and the whole world, which the far terrain plans.
|
||||
item.set_slider(&mut s, 0.0);
|
||||
assert_eq!(s.horizon.lod_distance, 0);
|
||||
item.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.horizon.lod_distance, crate::lod::MAX_CHUNKS);
|
||||
assert_eq!(
|
||||
item.label(&s).plain(),
|
||||
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
|
||||
);
|
||||
item.set_slider(&mut s, 5.0 / 17.0);
|
||||
assert_eq!(s.horizon.lod_distance, 1024);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
|
||||
// A distance between stops (from the file) sits on the next one.
|
||||
s.horizon.lod_distance = 3000;
|
||||
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
|
||||
@@ -26,8 +26,14 @@ struct Params {
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -81,6 +87,48 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
// Whether `p` falls on the logo drawn `size` pixels wide around `centre`.
|
||||
fn on_logo(p: vec2<f32>, centre: vec2<f32>, size: f32) -> bool {
|
||||
let uv = (p - centre) / size + 0.5;
|
||||
if any(uv < vec2(0.0)) || any(uv >= vec2(1.0)) {
|
||||
return false;
|
||||
}
|
||||
let texels = vec2<f32>(textureDimensions(logo));
|
||||
return textureLoad(logo, vec2<i32>(uv * texels), 0).r > 0.5;
|
||||
}
|
||||
|
||||
// A calibration step's test picture at `p`, in nits, the way display calibration
|
||||
// tools draw them: nothing in it is bright over a large area, so the display
|
||||
// doesn't dim itself while it is judged.
|
||||
fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
let top = size.y * params.calibration.z;
|
||||
let centre = vec2(size.x, top) / 2.0;
|
||||
let v = params.calibration.y;
|
||||
switch u32(params.calibration.x) {
|
||||
// Black level: the logo at the slider on 0. Lowered until it disappears,
|
||||
// it sits on the darkest the display shows.
|
||||
case 1u: {
|
||||
return select(0.0, v, on_logo(p, centre, top * 0.4));
|
||||
}
|
||||
// Peak: a tenth of the screen at 10 000 nits, which the display clips at
|
||||
// its brightest, the logo inside at the slider. Raised until it
|
||||
// disappears, it sits on that brightest.
|
||||
case 2u: {
|
||||
let side = sqrt(0.1 * size.x * size.y);
|
||||
if all(abs(p - centre) < vec2(side / 2.0)) {
|
||||
return select(10000.0, v, on_logo(p, centre, side * 0.7));
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
// Paper white: a white card at the slider, on a dim grey, to match a
|
||||
// sheet of paper held beside the screen.
|
||||
default: {
|
||||
let card = vec2(top * 0.9, top * 0.6);
|
||||
return select(3.0, v, all(abs(p - centre) < card / 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
@@ -93,6 +141,16 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
|
||||
//! display calibration tools do it. HDR output draws each step's test picture straight in
|
||||
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
|
||||
//! shapes the game's picture, so what the player sees is the display alone:
|
||||
//!
|
||||
//! - Black Level: Horizon's logo at the slider's brightness on 0 nits; lowered until it
|
||||
//! disappears, it sits on the darkest the display shows.
|
||||
//! - Peak Brightness: a patch of a tenth of the screen at 10 000 nits (the display clips
|
||||
//! it at its brightest), the logo inside at the slider; raised until it disappears, it
|
||||
//! sits on that brightest. Small, so the display doesn't dim a bright full screen; the
|
||||
//! HDR metadata declares PQ's 10 000 nits meanwhile, so a compositor that clips at the
|
||||
//! declared peak (KWin) lets the patch through.
|
||||
//! - Paper White: a white card at the slider, matched to a sheet of white paper held
|
||||
//! beside the screen in the light the player plays in: white is where the eyes have
|
||||
//! adapted to, lower in a dark room than by day (as ITU-R BT.2408's reference white).
|
||||
//!
|
||||
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
Black,
|
||||
Peak,
|
||||
PaperWhite,
|
||||
}
|
||||
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
|
||||
|
||||
/// What the options screen does after an input here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Nothing,
|
||||
Click,
|
||||
Changed,
|
||||
Save,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct Calibration {
|
||||
step: usize,
|
||||
/// Nits, in the order of `STEPS`.
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
auto: [Option<f32>; 2],
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
/// Height of the band of controls at the bottom, in GUI pixels.
|
||||
const BAND: f32 = 84.0;
|
||||
|
||||
impl Step {
|
||||
/// Range and step of the slider, in nits: the settings' own.
|
||||
fn range(self) -> (f32, f32, f32) {
|
||||
match self {
|
||||
Step::Black => (0.0, 1.0, 0.01),
|
||||
Step::Peak => (200.0, 2000.0, 50.0),
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Black Level",
|
||||
Step::Peak => "Peak Brightness",
|
||||
Step::PaperWhite => "Paper White",
|
||||
}
|
||||
}
|
||||
fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Lower it until the logo just disappears into the black.",
|
||||
Step::Peak => "Raise it until the logo just disappears into the bright square.",
|
||||
Step::PaperWhite => {
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `hdr::Calibration::step`'s number.
|
||||
pub fn number(self) -> u32 {
|
||||
match self {
|
||||
Step::Black => 1,
|
||||
Step::Peak => 2,
|
||||
Step::PaperWhite => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
top: f32,
|
||||
slider: (f32, f32, f32),
|
||||
cancel: (f32, f32, f32),
|
||||
back: (f32, f32, f32),
|
||||
next: (f32, f32, f32),
|
||||
}
|
||||
fn layout(w: f32, h: f32) -> Layout {
|
||||
let top = (h - BAND).floor();
|
||||
let cx = (w / 2.0).floor();
|
||||
let by = top + 58.0;
|
||||
Layout {
|
||||
top,
|
||||
slider: (cx - 100.0, top + 32.0, 200.0),
|
||||
cancel: (cx - 152.0, by, 98.0),
|
||||
back: (cx - 49.0, by, 98.0),
|
||||
next: (cx + 54.0, by, 98.0),
|
||||
}
|
||||
}
|
||||
fn over(cursor: (f32, f32), (x, y, w): (f32, f32, f32)) -> bool {
|
||||
ui::hovered(cursor, x, y, w, 20.0)
|
||||
}
|
||||
|
||||
impl Calibration {
|
||||
pub fn new(s: &Settings) -> Self {
|
||||
let g = &s.horizon;
|
||||
Self {
|
||||
step: 0,
|
||||
values: [
|
||||
g.hdr_black_level as f32 / 100.0,
|
||||
g.peak_nits() as f32,
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
dragging: false,
|
||||
}
|
||||
}
|
||||
fn current(&self) -> Step {
|
||||
STEPS[self.step]
|
||||
}
|
||||
fn set(&mut self, v: f32) {
|
||||
let (min, max, step) = self.current().range();
|
||||
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
Text::literal(match self.current() {
|
||||
Step::Black => format!("Black Level: {v:.2} nits"),
|
||||
step => format!("{}: {v:.0} nits", step.name()),
|
||||
})
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
|
||||
(
|
||||
self.current(),
|
||||
self.values[self.step],
|
||||
layout(0.0, h).top / h.max(1.0),
|
||||
)
|
||||
}
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let l = layout(w, h);
|
||||
// Above the band HDR output draws the test picture over whatever is here.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
let step = self.current();
|
||||
let title = Text::literal(format!(
|
||||
"HDR Calibration {}/{}: {}",
|
||||
self.step + 1,
|
||||
STEPS.len(),
|
||||
step.name()
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(step.hint());
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&hint,
|
||||
0.0,
|
||||
l.top + 18.0,
|
||||
w,
|
||||
rgba(160, 160, 160, 255),
|
||||
);
|
||||
let (min, max, _) = step.range();
|
||||
let (x, y, sw) = l.slider;
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
sw,
|
||||
&self.label(),
|
||||
(self.values[self.step] - min) / (max - min),
|
||||
over(cursor, l.slider) || self.dragging,
|
||||
true,
|
||||
self.dragging,
|
||||
);
|
||||
let last = self.step + 1 == STEPS.len();
|
||||
for (rect, label, active) in [
|
||||
(l.cancel, "Cancel", true),
|
||||
(l.back, "Back", self.step > 0),
|
||||
(l.next, if last { "Save" } else { "Next" }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
rect.0,
|
||||
rect.1,
|
||||
rect.2,
|
||||
20.0,
|
||||
&Text::literal(label),
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A press, or a drag with `drag`, at `cursor`.
|
||||
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
|
||||
let l = layout(w, h);
|
||||
let (x, _, sw) = l.slider;
|
||||
if drag {
|
||||
if !self.dragging {
|
||||
return Outcome::Nothing;
|
||||
}
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.slider) {
|
||||
self.dragging = true;
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.cancel) {
|
||||
return Outcome::Cancel;
|
||||
}
|
||||
if over(cursor, l.back) && self.step > 0 {
|
||||
self.step -= 1;
|
||||
return Outcome::Click;
|
||||
}
|
||||
if over(cursor, l.next) {
|
||||
return self.next();
|
||||
}
|
||||
Outcome::Nothing
|
||||
}
|
||||
fn next(&mut self) -> Outcome {
|
||||
if self.step + 1 == STEPS.len() {
|
||||
return Outcome::Save;
|
||||
}
|
||||
self.step += 1;
|
||||
Outcome::Click
|
||||
}
|
||||
/// The slider let go: true if it was held (it clicks on release, as sliders do).
|
||||
pub fn release(&mut self) -> bool {
|
||||
std::mem::take(&mut self.dragging)
|
||||
}
|
||||
/// Left and Right step the slider, Enter goes on, Escape cancels.
|
||||
pub fn key(&mut self, key: KeyCode) -> Outcome {
|
||||
let (_, _, step) = self.current().range();
|
||||
match key {
|
||||
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
|
||||
self.set(self.values[self.step] - step);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::ArrowRight | KeyCode::ArrowUp => {
|
||||
self.set(self.values[self.step] + step);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
|
||||
KeyCode::Escape => Outcome::Cancel,
|
||||
_ => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
/// Writes what the player landed on into Horizon's settings.
|
||||
pub fn save(&self, s: &mut Settings) {
|
||||
let g = &mut s.horizon;
|
||||
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
|
||||
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
|
||||
Some(auto) if auto == self.values[i] => 0,
|
||||
_ => self.values[i].round() as u32,
|
||||
});
|
||||
g.hdr_peak = peak;
|
||||
g.hdr_paper_white = paper_white;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calibration_saves_only_on_save() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr_black_level = 5;
|
||||
let before = s.horizon.clone();
|
||||
let mut c = Calibration::new(&s);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
|
||||
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
|
||||
assert_eq!(s.horizon, before);
|
||||
// Black, then Peak (one step up), then Paper White, then Save.
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 3);
|
||||
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
|
||||
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod pack_text;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
/// default resources' texture of that name, whatever the resolution of the
|
||||
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
|
||||
@@ -78,6 +80,9 @@ pub struct HudAtlas {
|
||||
pub glint: (Vec<u8>, u32, u32),
|
||||
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
|
||||
pub glint_armor: (Vec<u8>, u32, u32),
|
||||
/// A resource pack's own `core/text` shader for the GUI's text, translated;
|
||||
/// none draws the text natively.
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -330,7 +335,7 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
Self::pack(
|
||||
let mut atlas = Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
@@ -347,7 +352,9 @@ impl HudAtlas {
|
||||
"map",
|
||||
],
|
||||
true,
|
||||
)
|
||||
)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
|
||||
@@ -408,6 +415,61 @@ impl HudAtlas {
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
/// The boot screen's atlas, before any pack loads: the theme's run-time
|
||||
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
|
||||
/// transparent black. Nothing from a pack: the boot screen draws with
|
||||
/// the theme's bundled faces, so the font here has no sheets to read.
|
||||
pub fn boot() -> Self {
|
||||
let (width, height) = (1024, 1024);
|
||||
let text_rows = 512;
|
||||
let glyph_top = text_rows + 4;
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
// The white sprite the fills and the font's underline pass sample.
|
||||
for row in 0..4 {
|
||||
let at = ((text_rows + row) * width * 4) as usize;
|
||||
pixels[at..at + 16].fill(255);
|
||||
}
|
||||
let rect = |u0: u32, v0: u32, u1: u32, v1: u32, w: u32, h: u32| Rect {
|
||||
u0: u0 as f32 / width as f32,
|
||||
v0: v0 as f32 / height as f32,
|
||||
u1: u1 as f32 / width as f32,
|
||||
v1: v1 as f32 / height as f32,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
let mut sprites = HashMap::new();
|
||||
sprites.insert(
|
||||
"horizon/white".into(),
|
||||
rect(0, text_rows, 4, glyph_top, 4, 4),
|
||||
);
|
||||
sprites.insert(
|
||||
"horizon/ui-text".into(),
|
||||
rect(0, 0, width, text_rows, width, text_rows),
|
||||
);
|
||||
let white = sprites["horizon/white"];
|
||||
Self {
|
||||
font: crate::font::Font::load(
|
||||
Path::new("horizon-boot"),
|
||||
&sprites,
|
||||
&pixels,
|
||||
(width, height),
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
translucent: HashSet::new(),
|
||||
flags: HashMap::new(),
|
||||
object_skins: HashMap::new(),
|
||||
sprite_animations: Vec::new(),
|
||||
glint: (vec![0; 4], 1, 1),
|
||||
glint_armor: (vec![0; 4], 1, 1),
|
||||
white,
|
||||
text_shader: None,
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
/// width from 4096 up that keeps the atlas, glyph rows included, within
|
||||
/// `max` texels a side; and the first row free for glyphs. High-resolution
|
||||
@@ -669,6 +731,7 @@ impl HudAtlas {
|
||||
sprite_animations,
|
||||
glint,
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
@@ -694,6 +757,9 @@ pub struct HudVertex {
|
||||
/// The `GLINT_TEXTURING` UV into the repeating glint texture, unused
|
||||
/// unless `uv.x >= 2`.
|
||||
pub glint_uv: [f32; 2],
|
||||
/// 1 for the GUI's text (`RenderPipelines.GUI_TEXT`), which a resource
|
||||
/// pack's text shader draws when it has one.
|
||||
pub text: u32,
|
||||
}
|
||||
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
|
||||
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
|
||||
@@ -750,6 +816,9 @@ pub struct HudBatch {
|
||||
pub end_portal: EndPortalFill,
|
||||
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
|
||||
pub glint_alpha: f32,
|
||||
/// A resource pack text shader's `GameTime`: the day's fraction
|
||||
/// (`gameTime % 24000` and the partial tick, over 24000).
|
||||
pub game_time: f32,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
/// Player object profiles encountered while painting text this frame.
|
||||
@@ -804,6 +873,7 @@ impl HudBatch {
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
game_time: 0.0,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
}
|
||||
@@ -881,6 +951,7 @@ impl HudBatch {
|
||||
color,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -900,6 +971,7 @@ impl HudBatch {
|
||||
color,
|
||||
depth: 0.0,
|
||||
glint_uv: glint_uv[i],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -911,6 +983,7 @@ impl HudBatch {
|
||||
color,
|
||||
depth: p[i][2],
|
||||
glint_uv: [0.0, 0.0],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1051,6 +1124,10 @@ impl HudBatch {
|
||||
[uv(cx0, cy0), uv(cx1, cy0), uv(cx1, cy1), uv(cx0, cy1)],
|
||||
tl.color,
|
||||
);
|
||||
let end = self.vertices.len();
|
||||
for v in &mut self.vertices[end - 6..] {
|
||||
v.text = tl.text;
|
||||
}
|
||||
}
|
||||
moved.push(self.vertices.len() as u32);
|
||||
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
|
||||
@@ -1110,6 +1187,7 @@ impl HudBatch {
|
||||
color: c[i],
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1338,6 +1416,7 @@ impl HudBatch {
|
||||
}
|
||||
}
|
||||
};
|
||||
let glyph = self.vertices.len();
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
@@ -1355,6 +1434,11 @@ impl HudBatch {
|
||||
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
if !world {
|
||||
for v in &mut self.vertices[glyph..] {
|
||||
v.text = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
@@ -1490,6 +1574,10 @@ pub struct HudRenderer {
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
format: wgpu::TextureFormat,
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
}
|
||||
impl HudRenderer {
|
||||
pub fn new(
|
||||
@@ -1716,8 +1804,21 @@ impl HudRenderer {
|
||||
uniform,
|
||||
buffer,
|
||||
capacity,
|
||||
format,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
})
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
pub fn set_text_shader(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shader: Option<&horizon_shaders::CoreProgram>,
|
||||
) {
|
||||
self.pack_text = shader.and_then(|shader| {
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
|
||||
});
|
||||
}
|
||||
pub fn animate_sprites(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
@@ -1834,6 +1935,9 @@ impl HudRenderer {
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
if let Some(pack_text) = &self.pack_text {
|
||||
pack_text.prepare(uploads, encoder, batch, size);
|
||||
}
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
pub fn draw(
|
||||
@@ -1915,15 +2019,31 @@ impl HudRenderer {
|
||||
pass.set_scissor_rect(x, y, w, h);
|
||||
}
|
||||
for (range, blend) in &runs[at..end] {
|
||||
pass.set_pipeline(match blend {
|
||||
let pipeline = match blend {
|
||||
Blend::Alpha => &self.pipeline,
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
});
|
||||
pass.draw(range.clone(), 0..1);
|
||||
};
|
||||
match &self.pack_text {
|
||||
Some(pack_text) if *blend == Blend::Alpha => {
|
||||
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
|
||||
if text {
|
||||
pack_text.draw(&mut pass, part);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
} else {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw(part, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
@@ -2649,6 +2769,7 @@ mod tests {
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
game_time: 0.0,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! A resource pack's `core/text` shader on the GUI's text (`RenderPipelines.GUI_TEXT`, the shader
|
||||
//! with `IS_GUI`): servers' HUDs that their pack's vertex shader moves to the screen's corners,
|
||||
//! and text effects in its fragment shader. Without one the text draws natively.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
|
||||
use super::{HudBatch, HudVertex};
|
||||
|
||||
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
|
||||
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
|
||||
/// left; vanilla's quads go top left, bottom left, bottom right, top right, which is what
|
||||
/// `gl_VertexID % 4` tells a pack's shader. The draw passes its first vertex as its instance.
|
||||
const GLUE: Glue = Glue {
|
||||
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
|
||||
prelude: "layout(location = 0) in vec2 hz_pos;\n\
|
||||
layout(location = 1) in vec2 hz_uv;\n\
|
||||
layout(location = 2) in vec4 hz_color;\n\
|
||||
const int hz_corner[6] = int[6](0, 3, 2, 0, 2, 1);\n\
|
||||
int hz_vertex_id() {\n\
|
||||
int i = int(gl_VertexIndex) - int(gl_InstanceIndex);\n\
|
||||
return i / 6 * 4 + hz_corner[i % 6];\n\
|
||||
}",
|
||||
setup: "Position = vec3(hz_pos, 0.0); Color = hz_color; UV0 = hz_uv;",
|
||||
// GL's clip depth runs from -w to w, wgpu's from 0 to w; GUI text has no depth test.
|
||||
finish: "gl_Position.z = 0.5 * gl_Position.w;",
|
||||
vertex_id: "hz_vertex_id()",
|
||||
};
|
||||
|
||||
const VERTEX: &str = "assets/minecraft/shaders/core/text.vsh";
|
||||
const FRAGMENT: &str = "assets/minecraft/shaders/core/text.fsh";
|
||||
|
||||
/// The packs' text shader over `defaults` (the game's own resources, which have vanilla's and
|
||||
/// its includes), when a pack replaces either stage.
|
||||
pub(super) fn load(root: &Path, defaults: &Path) -> Option<Arc<CoreProgram>> {
|
||||
if root == defaults {
|
||||
return None;
|
||||
}
|
||||
let read = |base: &Path, path: &str| std::fs::read_to_string(base.join(path)).ok();
|
||||
let own =
|
||||
|path: &str| read(root, path).filter(|text| read(defaults, path).as_ref() != Some(text));
|
||||
let (own_vertex, own_fragment) = (own(VERTEX), own(FRAGMENT));
|
||||
if own_vertex.is_none() && own_fragment.is_none() {
|
||||
return None;
|
||||
}
|
||||
let vertex = own_vertex.or_else(|| read(defaults, VERTEX))?;
|
||||
let fragment = own_fragment.or_else(|| read(defaults, FRAGMENT))?;
|
||||
let include = |id: &str| {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
let path = format!("assets/{namespace}/{path}");
|
||||
read(root, &path).or_else(|| read(defaults, &path))
|
||||
};
|
||||
match horizon_shaders::translate_core(&vertex, &fragment, &["IS_GUI"], &include, &GLUE) {
|
||||
Ok(program) => {
|
||||
log::info!("the resource pack's text shader draws the GUI's text");
|
||||
Some(Arc::new(program))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("the resource pack's text shader is not used: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
|
||||
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
|
||||
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
/// The pipeline and its resources.
|
||||
pub(super) struct PackText {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl PackText {
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
atlas: &wgpu::TextureView,
|
||||
program: &CoreProgram,
|
||||
) -> Option<Self> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = |label, module: &naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
|
||||
})
|
||||
};
|
||||
let vertex = module("Resource pack text (vertex)", &program.vertex);
|
||||
let fragment = module("Resource pack text (fragment)", &program.fragment);
|
||||
let visibility = wgpu::ShaderStages::VERTEX_FRAGMENT;
|
||||
let mut entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
for (_, binding) in horizon_shaders::SAMPLERS {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: binding + 1,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
entries: &entries,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Resource pack text uniforms"),
|
||||
size: 1024,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
// The font textures' nearest filtering; every sampler a pack names reads the atlas.
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
..Default::default()
|
||||
});
|
||||
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
|
||||
.map(|i| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &uniform,
|
||||
offset: BLOCK_OFFSETS[i],
|
||||
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
for (_, binding) in horizon_shaders::SAMPLERS {
|
||||
resources.push(wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(atlas),
|
||||
});
|
||||
resources.push(wgpu::BindGroupEntry {
|
||||
binding: binding + 1,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
});
|
||||
}
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
layout: &layout,
|
||||
entries: &resources,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<HudVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// BlendFunction.TRANSLUCENT.
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
log::warn!("the resource pack's text shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pipeline,
|
||||
group,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame's blocks, as `GuiRenderer` sets them for the GUI.
|
||||
pub(super) fn prepare(
|
||||
&self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
|
||||
}
|
||||
|
||||
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
|
||||
/// pipeline and bind group again after.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(range.clone(), range.start..range.start + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The std140 blocks: `Globals` (`GlobalSettingsUniform.update`: the camera at the origin,
|
||||
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
|
||||
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
|
||||
/// moving 11000 back, colour modulator white) and an empty `Fog`.
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
let globals = 0;
|
||||
data[globals + 3] = batch.glint_alpha;
|
||||
data[globals + 7] = batch.game_time;
|
||||
data[globals + 8] = size[0] as f32;
|
||||
data[globals + 9] = size[1] as f32;
|
||||
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
|
||||
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
|
||||
let (near, far) = (1000.0f32, 11000.0f32);
|
||||
let projection = 64;
|
||||
data[projection] = 2.0 / w;
|
||||
data[projection + 5] = -2.0 / h;
|
||||
data[projection + 10] = 2.0 / (near - far);
|
||||
data[projection + 12] = -1.0;
|
||||
data[projection + 13] = 1.0;
|
||||
data[projection + 14] = (far + near) / (near - far);
|
||||
data[projection + 15] = 1.0;
|
||||
let transforms = 128;
|
||||
for (matrix, z) in [(transforms, -11000.0), (transforms + 16, 0.0)] {
|
||||
data[matrix] = 1.0;
|
||||
data[matrix + 5] = 1.0;
|
||||
data[matrix + 10] = 1.0;
|
||||
data[matrix + 14] = z;
|
||||
data[matrix + 15] = 1.0;
|
||||
}
|
||||
data[transforms + 32..transforms + 36].fill(1.0);
|
||||
bytemuck::cast_slice(&data).to_vec()
|
||||
}
|
||||
|
||||
/// `range` split into runs of GUI text and of the rest, in order.
|
||||
pub(super) fn text_runs(
|
||||
vertices: &[HudVertex],
|
||||
range: std::ops::Range<u32>,
|
||||
) -> Vec<(std::ops::Range<u32>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<u32>, bool)> = Vec::new();
|
||||
for i in range {
|
||||
let text = vertices.get(i as usize).is_some_and(|v| v.text != 0);
|
||||
match runs.last_mut() {
|
||||
Some((run, t)) if *t == text => run.end = i + 1,
|
||||
_ => runs.push((i..i + 1, text)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
|
||||
let mut batch = HudBatch::new([1920, 1080]);
|
||||
batch.scale = 4.0;
|
||||
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
|
||||
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
|
||||
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
|
||||
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
|
||||
};
|
||||
// ProjMat * ModelViewMat * (x, y, 0, 1) for the GUI's corners: 480 x 270 GUI pixels.
|
||||
for ([x, y], [cx, cy]) in [([0.0, 0.0], [-1.0, 1.0]), ([480.0, 270.0], [1.0, -1.0])] {
|
||||
let clip = apply(64, apply(128, [x, y, 0.0, 1.0]));
|
||||
assert!(
|
||||
(clip[0] - cx).abs() < 1e-5 && (clip[1] - cy).abs() < 1e-5,
|
||||
"{clip:?}"
|
||||
);
|
||||
// At GL's far plane, as vanilla's GUI is.
|
||||
assert!((clip[2] - 1.0).abs() < 1e-4, "{clip:?}");
|
||||
}
|
||||
// A pack finds the GUI scale as ScreenSize.x * ProjMat[0][0] / 2.
|
||||
assert!((data[8] * data[64] / 2.0 - 4.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_splits_from_the_rest() {
|
||||
let vertex = |text| HudVertex {
|
||||
pos: [0.0; 2],
|
||||
uv: [0.0; 2],
|
||||
color: 0,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0; 2],
|
||||
text,
|
||||
};
|
||||
let vertices: Vec<HudVertex> = [0, 0, 1, 1, 1, 0].into_iter().map(vertex).collect();
|
||||
assert_eq!(
|
||||
text_runs(&vertices, 1..6),
|
||||
[(1..2, false), (2..5, true), (5..6, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
/// A `MobEffectInstance` as `extractEffects` sees it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EffectIcon {
|
||||
/// The effect id without namespace (`speed`): the sprite is `mob_effect/<id>`.
|
||||
/// The effect id: vanilla's without namespace (`speed`), a mod's `<ns>:<path>`.
|
||||
pub id: String,
|
||||
/// `MobEffect.isBeneficial`.
|
||||
pub beneficial: bool,
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn panels(
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (effect, icon))| {
|
||||
let mut name = translate(&format!("effect.minecraft.{}", effect.id))
|
||||
let mut name = translate(&horizon_client_core::potions::description_id(effect.id))
|
||||
.unwrap_or_else(|| effect.id.to_owned());
|
||||
if (1..=9).contains(&effect.amplifier) {
|
||||
let level = effect.amplifier + 1;
|
||||
|
||||
@@ -321,10 +321,25 @@ pub fn smooth(smoothed: f32, progress: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// `ARGB.opaque` as the batch's RGBA.
|
||||
fn opaque(rgb: u32) -> u32 {
|
||||
pub const fn opaque(rgb: u32) -> u32 {
|
||||
0xFF00_0000 | (rgb & 0xFF) << 16 | (rgb & 0xFF00) | (rgb >> 16 & 0xFF)
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR` (RGB: 239, 50, 61).
|
||||
pub const LOGO_BACKGROUND_COLOR: u32 = opaque(0xEF323D);
|
||||
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR_DARK` (black).
|
||||
pub const LOGO_BACKGROUND_COLOR_DARK: u32 = opaque(0x000000);
|
||||
|
||||
/// `LoadingOverlay.BRAND_BACKGROUND`: black when `darkMojangStudiosBackground` is set,
|
||||
/// Mojang red otherwise.
|
||||
pub fn brand_background(dark: bool) -> u32 {
|
||||
if dark {
|
||||
LOGO_BACKGROUND_COLOR_DARK
|
||||
} else {
|
||||
LOGO_BACKGROUND_COLOR
|
||||
}
|
||||
}
|
||||
|
||||
/// `LevelLoadingScreen.extractRenderState` over its background: the text,
|
||||
/// the progress bar and the chunk map, `status(x, z)` for x and z in
|
||||
/// `0..=2 * STATUS_RADIUS` from the map's north-west corner.
|
||||
@@ -370,6 +385,27 @@ pub fn draw(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
|
||||
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
|
||||
/// progress line and bar.
|
||||
pub fn draw_loading_overlay(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
line: &str,
|
||||
progress: f32,
|
||||
dark: bool,
|
||||
) {
|
||||
batch.fill(
|
||||
atlas,
|
||||
0.0,
|
||||
0.0,
|
||||
batch.width,
|
||||
batch.height,
|
||||
brand_background(dark),
|
||||
);
|
||||
draw_progress(batch, atlas, line, progress);
|
||||
}
|
||||
|
||||
/// The startup loading screen: the stage's line and a progress bar, centred
|
||||
/// like `LevelLoadingScreen`'s text and bar.
|
||||
pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progress: f32) {
|
||||
@@ -546,6 +582,14 @@ mod tests {
|
||||
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brand_background_matches_dark_mojang_studios_setting() {
|
||||
assert_eq!(brand_background(true), LOGO_BACKGROUND_COLOR_DARK);
|
||||
assert_eq!(brand_background(false), LOGO_BACKGROUND_COLOR);
|
||||
assert_eq!(LOGO_BACKGROUND_COLOR_DARK, 0xFF00_0000);
|
||||
assert_eq!(LOGO_BACKGROUND_COLOR, crate::hud::rgba(239, 50, 61, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_fixer_cancel_sits_below_the_centre() {
|
||||
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
pub mod auth;
|
||||
pub mod ban_notice;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_entities;
|
||||
@@ -34,6 +35,7 @@ pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod hdr_calibration;
|
||||
pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
@@ -62,7 +64,9 @@ pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod post_chain;
|
||||
pub mod post_glsl;
|
||||
pub mod potent_sulfur;
|
||||
pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -240,7 +241,8 @@ impl LightTables {
|
||||
/// `edge_occluded` for two packed cells.
|
||||
#[inline]
|
||||
fn packed_edge_occluded(&self, top: u16, bottom: u16) -> bool {
|
||||
bottom & 0xF != 0 || self.packed_blocked(top, bottom, Direction::Down as usize)
|
||||
bottom & 0xF != 0
|
||||
|| ((top | bottom) != 0 && self.packed_blocked(top, bottom, Direction::Down as usize))
|
||||
}
|
||||
pub fn emission(&self, state: u32) -> u8 {
|
||||
self.cells[state as usize].emission
|
||||
@@ -266,12 +268,64 @@ const OFFSETS: [(i32, i32, i32); 6] = [
|
||||
(1, 0, 0),
|
||||
];
|
||||
|
||||
struct LightScratch {
|
||||
states: Vec<u16>,
|
||||
sky: Vec<u8>,
|
||||
block: Vec<u8>,
|
||||
queue: Vec<u32>,
|
||||
sky_queue: Vec<u32>,
|
||||
present: Vec<bool>,
|
||||
lowest: Vec<i32>,
|
||||
ids: Box<[u32; 4096]>,
|
||||
}
|
||||
|
||||
impl LightScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
states: Vec::new(),
|
||||
sky: Vec::new(),
|
||||
block: Vec::new(),
|
||||
queue: Vec::new(),
|
||||
sky_queue: Vec::new(),
|
||||
present: vec![false; W * W],
|
||||
lowest: vec![0; W * W],
|
||||
ids: Box::new([0u32; 4096]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear queues and shrink them if capacity exceeds the retention cap.
|
||||
fn clear_queues(&mut self) {
|
||||
const MAX_QUEUE_CAPACITY: usize = 65_536;
|
||||
self.queue.clear();
|
||||
if self.queue.capacity() > MAX_QUEUE_CAPACITY {
|
||||
self.queue.shrink_to(MAX_QUEUE_CAPACITY);
|
||||
}
|
||||
self.sky_queue.clear();
|
||||
if self.sky_queue.capacity() > MAX_QUEUE_CAPACITY {
|
||||
self.sky_queue.shrink_to(MAX_QUEUE_CAPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
|
||||
}
|
||||
|
||||
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
|
||||
/// `sky` is false in dimensions without skylight (the Nether).
|
||||
pub fn light_chunk(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
SCRATCH.with_borrow_mut(|scratch| light_chunk_with_scratch(region, tables, sky_light, scratch))
|
||||
}
|
||||
|
||||
fn light_chunk_with_scratch(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
scratch: &mut LightScratch,
|
||||
) -> Vec<Light> {
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
// Highest occupied section in the region bounds the work.
|
||||
@@ -287,142 +341,194 @@ pub fn light_chunk(
|
||||
// One extra section above the highest occupied one: block light rises
|
||||
// up to 14 blocks into it (sky light there is always 15).
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
|
||||
// then never touches the large `LightTables::cells` table.
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
let total = W * W * h;
|
||||
|
||||
scratch.states.resize(total, 0);
|
||||
scratch.states.fill(0);
|
||||
scratch.sky.resize(total, 0);
|
||||
scratch.sky.fill(0);
|
||||
scratch.block.resize(total, 0);
|
||||
scratch.block.fill(0);
|
||||
scratch.clear_queues();
|
||||
|
||||
let states = &mut scratch.states[..total];
|
||||
let sky = &mut scratch.sky[..total];
|
||||
let block = &mut scratch.block[..total];
|
||||
let queue = &mut scratch.queue;
|
||||
let sky_queue = &mut scratch.sky_queue;
|
||||
let ids = &mut scratch.ids;
|
||||
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
let z_base = dz * 16;
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
let x_base = dx * 16;
|
||||
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push_back(p as u32);
|
||||
section.unpack(ids);
|
||||
let y_base = si * 16;
|
||||
for ly in 0..16 {
|
||||
let y = y_base + ly;
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let z = z_base + lz;
|
||||
let p_row = y_offset + z * W + x_base;
|
||||
let i_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let id = ids[i_row + lx];
|
||||
if id == 0 {
|
||||
continue;
|
||||
}
|
||||
let p = p_row + lx;
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Columns of missing neighbour chunks neither hold nor pass light.
|
||||
let mut present = vec![false; W * W];
|
||||
scratch.present.resize(W * W, false);
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
scratch.present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
let present = &scratch.present[..W * W];
|
||||
|
||||
scratch.lowest.resize(W * W, h as i32);
|
||||
let lowest = &mut scratch.lowest[..W * W];
|
||||
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
let col = z * W + x;
|
||||
if !present[col] || !sky_light {
|
||||
lowest[col] = h as i32;
|
||||
continue;
|
||||
}
|
||||
// The top layer (above the occupied range) is always open sky.
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0
|
||||
&& !tables.packed_edge_occluded(
|
||||
states[at(x, y as usize, z)],
|
||||
states[at(x, y as usize - 1, z)],
|
||||
)
|
||||
{
|
||||
let mut p = (h - 1) * (W * W) + col;
|
||||
sky[p] = 15;
|
||||
while y > 0 {
|
||||
let next_p = p - (W * W);
|
||||
if tables.packed_edge_occluded(states[p], states[next_p]) {
|
||||
break;
|
||||
}
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
p = next_p;
|
||||
sky[p] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y;
|
||||
lowest[col] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let own = lowest[z * W + x];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
|
||||
if sky_light {
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
let col = z * W + x;
|
||||
if !present[col] {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Seed propagation from the source cells that border darker cells.
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h - 1);
|
||||
for y in from..=to {
|
||||
if sky[at(x, y, z)] == 15 {
|
||||
sky_queue.push_back(at(x, y, z) as u32);
|
||||
let own = lowest[col];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
}
|
||||
}
|
||||
// Seed propagation from the source cells that border darker cells.
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h.saturating_sub(1));
|
||||
if from <= to {
|
||||
let mut p = from * (W * W) + col;
|
||||
for _ in from..=to {
|
||||
if sky[p] == 15 {
|
||||
sky_queue.push(p as u32);
|
||||
}
|
||||
p += W * W;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
|
||||
while let Some(p) = queue.pop_front() {
|
||||
let p = p as usize;
|
||||
|
||||
let block_masks = &tables.block_masks;
|
||||
let types = tables.types;
|
||||
|
||||
let spread = |light: &mut [u8], queue: &mut Vec<u32>| {
|
||||
let mut head = 0;
|
||||
while head < queue.len() {
|
||||
let p = queue[head] as usize;
|
||||
head += 1;
|
||||
let level = light[p];
|
||||
if level <= 1 {
|
||||
continue;
|
||||
}
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let min_threshold = level - 1;
|
||||
let y = p / (W * W);
|
||||
let col = p % (W * W);
|
||||
let z = col / W;
|
||||
let x = col % W;
|
||||
let from = states[p];
|
||||
let col = z * W + x;
|
||||
let from_row = (from >> 4) as usize * types;
|
||||
|
||||
// Neighbours by linear offset; `present` only depends on the
|
||||
// column, and the axis bounds rule out wrapping.
|
||||
macro_rules! neighbour {
|
||||
($d:expr, $q:expr, $ncol:expr) => {{
|
||||
if present[$ncol] {
|
||||
let q = $q;
|
||||
macro_rules! step_neighbour {
|
||||
($d:expr, $q:expr) => {{
|
||||
let q = $q;
|
||||
if light[q] < min_threshold {
|
||||
let to = states[q];
|
||||
let next = level.saturating_sub((to & 0xF).max(1) as u8);
|
||||
if next > light[q] && !tables.packed_blocked(from, to, $d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
let damp = (to & 0xF).max(1) as u8;
|
||||
if level > light[q] + damp {
|
||||
if block_masks[from_row + (to >> 4) as usize] & (1 << $d) == 0 {
|
||||
light[q] = level - damp;
|
||||
queue.push(q as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
if y > 0 {
|
||||
neighbour!(0, p - W * W, col);
|
||||
step_neighbour!(0, p - W * W);
|
||||
}
|
||||
if y + 1 < h {
|
||||
neighbour!(1, p + W * W, col);
|
||||
step_neighbour!(1, p + W * W);
|
||||
}
|
||||
if z > 0 {
|
||||
neighbour!(2, p - W, col - W);
|
||||
if z > 0 && present[col - W] {
|
||||
step_neighbour!(2, p - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
neighbour!(3, p + W, col + W);
|
||||
if z + 1 < W && present[col + W] {
|
||||
step_neighbour!(3, p + W);
|
||||
}
|
||||
if x > 0 {
|
||||
neighbour!(4, p - 1, col - 1);
|
||||
if x > 0 && present[col - 1] {
|
||||
step_neighbour!(4, p - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
neighbour!(5, p + 1, col + 1);
|
||||
if x + 1 < W && present[col + 1] {
|
||||
step_neighbour!(5, p + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
spread(sky, sky_queue);
|
||||
spread(block, queue);
|
||||
let result = pack_lighting(sections, h, sky, block, sky_light);
|
||||
scratch.clear_queues();
|
||||
result
|
||||
}
|
||||
|
||||
/// Extract just the centre column's light (`sections` of it); never copy
|
||||
@@ -434,7 +540,6 @@ fn pack_lighting(
|
||||
block: &[u8],
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
@@ -447,10 +552,13 @@ fn pack_lighting(
|
||||
if y >= h {
|
||||
continue;
|
||||
}
|
||||
let y_offset = y * (W * W);
|
||||
for lz in 0..16 {
|
||||
let p_row = y_offset + (16 + lz) * W + 16;
|
||||
let out_row = (ly << 8) | (lz << 4);
|
||||
for lx in 0..16 {
|
||||
let p = at(16 + lx, y, 16 + lz);
|
||||
data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p];
|
||||
let p = p_row + lx;
|
||||
data[out_row + lx] = sky[p] << 4 | block[p];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1832,4 +1940,336 @@ mod tests {
|
||||
changed.sections().count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn benchmark_light_chunk() {
|
||||
use std::time::Instant;
|
||||
let t = tables();
|
||||
let layout = ColumnLayout::VANILLA;
|
||||
let mut chunks = Vec::new();
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
let mut c = Chunk::with_layout(cx, cz, layout);
|
||||
for y in layout.min_y()..64 {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
for x in 2..14 {
|
||||
for z in 2..14 {
|
||||
c.set(x, 64, z, SLAB);
|
||||
}
|
||||
}
|
||||
c.set(8, 65, 8, TORCH);
|
||||
c.set(4, 30, 4, TORCH);
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
let region = [
|
||||
[Some(&chunks[0]), Some(&chunks[1]), Some(&chunks[2])],
|
||||
[Some(&chunks[3]), Some(&chunks[4]), Some(&chunks[5])],
|
||||
[Some(&chunks[6]), Some(&chunks[7]), Some(&chunks[8])],
|
||||
];
|
||||
|
||||
for _ in 0..5 {
|
||||
let _ = light_chunk(®ion, &t, true);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let iters = 200;
|
||||
for _ in 0..iters {
|
||||
let _ = std::hint::black_box(light_chunk(std::hint::black_box(®ion), &t, true));
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
println!(
|
||||
"benchmark_light_chunk: {:?} total, {:.2} µs/chunk",
|
||||
elapsed,
|
||||
elapsed.as_micros() as f64 / iters as f64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_queues_shrink_above_capacity_cap() {
|
||||
let mut scratch = LightScratch::new();
|
||||
scratch.queue.resize(100_000, 0);
|
||||
scratch.sky_queue.resize(100_000, 0);
|
||||
assert!(scratch.queue.capacity() >= 100_000);
|
||||
assert!(scratch.sky_queue.capacity() >= 100_000);
|
||||
scratch.clear_queues();
|
||||
assert_eq!(scratch.queue.len(), 0);
|
||||
assert_eq!(scratch.sky_queue.len(), 0);
|
||||
assert!(scratch.queue.capacity() < 100_000);
|
||||
assert!(scratch.sky_queue.capacity() < 100_000);
|
||||
}
|
||||
|
||||
/// The baseline flood-fill lighting algorithm before the scratch and wavefront optimizations.
|
||||
fn old_light_chunk(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
tables: &LightTables,
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let sections = region[1][1].expect("centre chunk").sections.len();
|
||||
let mut top_section = 0;
|
||||
for chunk in region.iter().flatten().flatten() {
|
||||
for (i, s) in chunk.sections.iter().enumerate().rev() {
|
||||
if !s.is_uniform(0) {
|
||||
top_section = top_section.max(i + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let h = ((top_section + 1) * 16).min(sections * 16);
|
||||
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
|
||||
let mut states = vec![0u16; W * W * h];
|
||||
let mut sky = vec![0u8; W * W * h];
|
||||
let mut block = vec![0u8; W * W * h];
|
||||
let mut queue = VecDeque::new();
|
||||
let mut sky_queue = VecDeque::new();
|
||||
let mut ids = Box::new([0u32; 4096]);
|
||||
for (dz, row) in region.iter().enumerate() {
|
||||
for (dx, chunk) in row.iter().enumerate() {
|
||||
let Some(chunk) = chunk else { continue };
|
||||
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
|
||||
if section.is_uniform(0) {
|
||||
continue;
|
||||
}
|
||||
section.unpack(&mut ids);
|
||||
for (i, &id) in ids.iter().enumerate() {
|
||||
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
|
||||
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
|
||||
let info = tables.info[id as usize];
|
||||
states[p] = info as u16;
|
||||
let emission = info >> 16;
|
||||
if emission > 0 {
|
||||
block[p] = emission as u8;
|
||||
queue.push_back(p as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut present = vec![false; W * W];
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
present[z * W + x] = region[z / 16][x / 16].is_some();
|
||||
}
|
||||
}
|
||||
let mut lowest = vec![h as i32; W * W];
|
||||
let old_packed_edge_occluded = |top: u16, bottom: u16| -> bool {
|
||||
bottom & 0xF != 0 || tables.packed_blocked(top, bottom, Direction::Down as usize)
|
||||
};
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let mut y = h as i32 - 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
while y > 0
|
||||
&& !old_packed_edge_occluded(
|
||||
states[at(x, y as usize, z)],
|
||||
states[at(x, y as usize - 1, z)],
|
||||
)
|
||||
{
|
||||
y -= 1;
|
||||
sky[at(x, y as usize, z)] = 15;
|
||||
}
|
||||
lowest[z * W + x] = y;
|
||||
}
|
||||
}
|
||||
for z in 0..W {
|
||||
for x in 0..W {
|
||||
if !present[z * W + x] || !sky_light {
|
||||
continue;
|
||||
}
|
||||
let own = lowest[z * W + x];
|
||||
let mut deepest = own;
|
||||
for (nx, nz) in [
|
||||
(x.wrapping_sub(1), z),
|
||||
(x + 1, z),
|
||||
(x, z.wrapping_sub(1)),
|
||||
(x, z + 1),
|
||||
] {
|
||||
if nx < W && nz < W && present[nz * W + nx] {
|
||||
deepest = deepest.max(lowest[nz * W + nx]);
|
||||
}
|
||||
}
|
||||
let from = own.max(0) as usize;
|
||||
let to = (deepest as usize).min(h.saturating_sub(1));
|
||||
for y in from..=to {
|
||||
if sky[at(x, y, z)] == 15 {
|
||||
sky_queue.push_back(at(x, y, z) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
|
||||
while let Some(p) = queue.pop_front() {
|
||||
let p = p as usize;
|
||||
let level = light[p];
|
||||
if level <= 1 {
|
||||
continue;
|
||||
}
|
||||
let x = p % W;
|
||||
let z = (p / W) % W;
|
||||
let y = p / (W * W);
|
||||
let from = states[p];
|
||||
let col = z * W + x;
|
||||
macro_rules! neighbour {
|
||||
($d:expr, $q:expr, $ncol:expr) => {{
|
||||
if present[$ncol] {
|
||||
let q = $q;
|
||||
let to = states[q];
|
||||
let next = level.saturating_sub((to & 0xF).max(1) as u8);
|
||||
if next > light[q] && !tables.packed_blocked(from, to, $d) {
|
||||
light[q] = next;
|
||||
queue.push_back(q as u32);
|
||||
}
|
||||
}
|
||||
}};
|
||||
}
|
||||
if y > 0 {
|
||||
neighbour!(0, p - W * W, col);
|
||||
}
|
||||
if y + 1 < h {
|
||||
neighbour!(1, p + W * W, col);
|
||||
}
|
||||
if z > 0 {
|
||||
neighbour!(2, p - W, col - W);
|
||||
}
|
||||
if z + 1 < W {
|
||||
neighbour!(3, p + W, col + W);
|
||||
}
|
||||
if x > 0 {
|
||||
neighbour!(4, p - 1, col - 1);
|
||||
}
|
||||
if x + 1 < W {
|
||||
neighbour!(5, p + 1, col + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
spread(&mut sky, &mut sky_queue);
|
||||
spread(&mut block, &mut queue);
|
||||
pack_lighting(sections, h, &sky, &block, sky_light)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_chunk_matches_old_algorithm() {
|
||||
let t = tables();
|
||||
let layout = ColumnLayout::VANILLA;
|
||||
|
||||
let mut rng = 0x1234_5678_u64;
|
||||
let mut next = |max: usize| -> usize {
|
||||
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
((rng >> 32) as usize) % max
|
||||
};
|
||||
|
||||
let cases = [
|
||||
// (sky_light, missing_pattern)
|
||||
// 0 = full 3x3
|
||||
// 1 = missing corners
|
||||
// 2 = missing edges
|
||||
// 3 = random missing neighbours
|
||||
// 4 = only centre and single neighbour
|
||||
(true, 0),
|
||||
(false, 0),
|
||||
(true, 1),
|
||||
(false, 1),
|
||||
(true, 2),
|
||||
(false, 2),
|
||||
(true, 3),
|
||||
(false, 3),
|
||||
(true, 4),
|
||||
(false, 4),
|
||||
];
|
||||
|
||||
for (round, &(sky_light, pattern)) in cases.iter().enumerate() {
|
||||
let mut chunks = Vec::new();
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
let mut c = Chunk::with_layout(cx, cz, layout);
|
||||
let base_y = -64 + next(64) as i32;
|
||||
for y in layout.min_y()..base_y {
|
||||
for i in 0..256 {
|
||||
c.set(i & 15, y, i >> 4, STONE);
|
||||
}
|
||||
}
|
||||
let features = 10 + next(20);
|
||||
for _ in 0..features {
|
||||
let fx = next(16) as i32;
|
||||
let fz = next(16) as i32;
|
||||
let fy = base_y + next(32) as i32;
|
||||
let block_type = match next(4) {
|
||||
0 => AIR,
|
||||
1 => STONE,
|
||||
2 => TORCH,
|
||||
_ => SLAB,
|
||||
};
|
||||
c.set(fx, fy, fz, block_type);
|
||||
}
|
||||
let roof_y = base_y + 10 + next(10) as i32;
|
||||
for rx in 4..12 {
|
||||
for rz in 4..12 {
|
||||
if next(3) != 0 {
|
||||
c.set(rx, roof_y, rz, SLAB);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.set(8, roof_y - 1, 8, TORCH);
|
||||
c.set(next(16) as i32, roof_y + 2, next(16) as i32, TORCH);
|
||||
c.set(0, base_y + 1, 0, TORCH);
|
||||
c.set(15, base_y + 1, 15, TORCH);
|
||||
|
||||
chunks.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
let mut region: [[Option<&Chunk>; 3]; 3] = [[None; 3]; 3];
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let is_centre = dz == 1 && dx == 1;
|
||||
let chunk_ref = &chunks[dz * 3 + dx];
|
||||
let include = if is_centre {
|
||||
true
|
||||
} else {
|
||||
match pattern {
|
||||
0 => true,
|
||||
1 => (dz + dx) % 2 == 1,
|
||||
2 => (dz + dx) % 2 == 0,
|
||||
3 => next(10) < 6,
|
||||
4 => dz == 1 && dx == 2,
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
if include {
|
||||
region[dz][dx] = Some(chunk_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old = old_light_chunk(®ion, &t, sky_light);
|
||||
let new = light_chunk(®ion, &t, sky_light);
|
||||
|
||||
assert_eq!(
|
||||
old.len(),
|
||||
new.len(),
|
||||
"round {round}: section count mismatch"
|
||||
);
|
||||
for si in 0..old.len() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
new[si].get(i),
|
||||
old[si].get(i),
|
||||
"mismatch at round {round} (sky_light={sky_light}, pattern={pattern}), section {si}, index {i} (lx={}, ly={}, lz={}): new={:#04x}, old={:#04x}",
|
||||
i & 15,
|
||||
i >> 8,
|
||||
(i >> 4) & 15,
|
||||
new[si].get(i),
|
||||
old[si].get(i)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,17 @@ fn sampling_threads(cores: usize) -> usize {
|
||||
/// terrain starts one sample job at most this often and no detail: it would
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
|
||||
pub(crate) const MAX_LEVEL: u8 = 10;
|
||||
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
|
||||
/// around (a level-22 tile is 67 million blocks wide: the whole world).
|
||||
pub(crate) const MAX_LEVEL: u8 = 22;
|
||||
/// Tiles this far from the camera (1024 chunks, as far as far terrain went
|
||||
/// before it covered the world) are planned with `WORLD_SPLIT`.
|
||||
const WORLD_START: i32 = 1024 * 16;
|
||||
/// How many tile widths away from the camera a tile of the world-wide terrain
|
||||
/// splits (`Plan::visit`: cells then span about 1/48 of the distance).
|
||||
const WORLD_SPLIT: i32 = 3;
|
||||
/// The farthest `Plan` reaches, in chunks.
|
||||
pub const MAX_CHUNKS: u32 = 1 << 22;
|
||||
/// The coarsest tile level whose chunks are generated in full (trees,
|
||||
/// structures) rather than sampled: cells of up to 4 blocks, out to about
|
||||
/// 1024 blocks at Full far detail.
|
||||
@@ -417,7 +426,7 @@ impl Plan {
|
||||
elevation: i32,
|
||||
reduced: bool,
|
||||
) -> Self {
|
||||
let range = distance.min(1024) as i32 * 16;
|
||||
let range = distance.min(MAX_CHUNKS) as i32 * 16;
|
||||
let mut level = 0;
|
||||
while (16 << level) < range {
|
||||
level += 1;
|
||||
@@ -454,7 +463,16 @@ impl Plan {
|
||||
// Keep distant cells small on screen; finest tiles retain individual
|
||||
// blocks. Reduced keeps them twice as large (a quarter of the cells).
|
||||
let split = if previous.split.contains(&key) { 10 } else { 8 };
|
||||
let threshold = key.size() * if reduced { split / 2 } else { split };
|
||||
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
|
||||
// the whole world is 22 levels, not all of them worth full cells.
|
||||
let threshold = key.size()
|
||||
* if key.distance(camera) >= WORLD_START {
|
||||
WORLD_SPLIT
|
||||
} else if reduced {
|
||||
split / 2
|
||||
} else {
|
||||
split
|
||||
};
|
||||
if key.level > 0
|
||||
&& (key.distance(camera) < detail * 16 + 16
|
||||
|| key.distance(camera).max(self.elevation) < threshold)
|
||||
@@ -1744,6 +1762,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn world_wide_plans_stay_small_and_cover_every_level() {
|
||||
// About 10 000 nodes at a corner of the world, 9 000 at its centre.
|
||||
const WORLD_PLAN_NODES: usize = 16_384;
|
||||
for (camera, elevation) in [
|
||||
([0, 0], 0),
|
||||
([115, 135], 49_000_000),
|
||||
([-30_000_000, 29_999_000], 90),
|
||||
] {
|
||||
let plan =
|
||||
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
|
||||
let reduced =
|
||||
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
|
||||
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
|
||||
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
|
||||
assert!(
|
||||
plan.nodes.len() < WORLD_PLAN_NODES,
|
||||
"{camera:?} y={elevation}: {} nodes",
|
||||
plan.nodes.len()
|
||||
);
|
||||
assert!(
|
||||
reduced.nodes.len() <= plan.nodes.len(),
|
||||
"{camera:?} y={elevation}: reduced has {} nodes, full {}",
|
||||
reduced.nodes.len(),
|
||||
plan.nodes.len()
|
||||
);
|
||||
// Every level from the finest planned one up to the roots has tiles.
|
||||
let finest = plan.nodes.iter().map(|k| k.level).min().unwrap();
|
||||
for level in finest..=MAX_LEVEL {
|
||||
assert!(
|
||||
plan.nodes.iter().any(|k| k.level == level),
|
||||
"{camera:?} y={elevation}: no level-{level} tile"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
|
||||
let ground = Plan::new([0, 0], 12, 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
|
||||
|
||||
@@ -130,6 +130,12 @@ impl Mesher {
|
||||
let tint = match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"spruce_leaves" => Tint::Constant(0x61_9961),
|
||||
"birch_leaves" => Tint::Constant(0x80_A755),
|
||||
// Cherry (and other pre-coloured) leaves: the texture is
|
||||
// already pink; biome foliage tint turns a grove yellow (#4688).
|
||||
"cherry_leaves"
|
||||
| "azalea_leaves"
|
||||
| "flowering_azalea_leaves"
|
||||
| "pale_oak_leaves" => Tint::None,
|
||||
"leaf_litter" => Tint::Dry,
|
||||
_ if leaves => Tint::Foliage,
|
||||
_ => Tint::Grass,
|
||||
@@ -298,6 +304,34 @@ fn mesh_columns_with_neighbors(
|
||||
});
|
||||
mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material)
|
||||
}
|
||||
/// From this tile level up a cell is 64 blocks wide or more: the strata
|
||||
/// inside the ground cannot be told apart, so a column is its surface.
|
||||
const HEIGHTMAP_LEVEL: u8 = 6;
|
||||
/// `column` as a heightmap: its top solid span reaching down to the bottom of
|
||||
/// the world (so the walls toward lower neighbours are one face of the
|
||||
/// surface's material, merged along the cliff), and the fluid over it.
|
||||
fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
let Some((top, below)) = column.spans.split_last() else {
|
||||
return column.clone();
|
||||
};
|
||||
let ground = |span: &Span| Span {
|
||||
min: crate::chunk::MIN_Y as i16,
|
||||
..*span
|
||||
};
|
||||
let mut spans = Vec::with_capacity(2);
|
||||
match below.last() {
|
||||
Some(under) if fluid(top) => {
|
||||
spans.push(ground(under));
|
||||
spans.push(*top);
|
||||
}
|
||||
_ if fluid(top) => spans.push(*top),
|
||||
_ => spans.push(ground(top)),
|
||||
}
|
||||
Column {
|
||||
surface: column.surface,
|
||||
spans,
|
||||
}
|
||||
}
|
||||
fn mesh_columns_with_edges(
|
||||
key: TileKey,
|
||||
columns: &[Column],
|
||||
@@ -308,6 +342,28 @@ fn mesh_columns_with_edges(
|
||||
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert_eq!(columns.len(), CELLS * CELLS);
|
||||
let heightmap_columns;
|
||||
let heightmap_neighbors;
|
||||
let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL {
|
||||
let [ox, _, oz] = key.origin();
|
||||
let mut fluid = |span: &Span| material(span, Direction::Up, [ox, oz]).fluid != 0;
|
||||
heightmap_columns = columns
|
||||
.iter()
|
||||
.map(|column| heightmap(column, &mut fluid))
|
||||
.collect::<Vec<_>>();
|
||||
heightmap_neighbors = neighbors.each_ref().map(|side| {
|
||||
side.iter()
|
||||
.map(|edge| EdgeColumn {
|
||||
start: edge.start,
|
||||
end: edge.end,
|
||||
column: heightmap(&edge.column, &mut fluid),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
(&heightmap_columns[..], &heightmap_neighbors)
|
||||
} else {
|
||||
(columns, neighbors)
|
||||
};
|
||||
let mut count = CELLS * CELLS;
|
||||
let edge_offsets = std::array::from_fn::<_, 4, _>(|side| {
|
||||
let start = count;
|
||||
@@ -330,7 +386,10 @@ fn mesh_columns_with_edges(
|
||||
step = step.min(other.size());
|
||||
}
|
||||
}
|
||||
step
|
||||
// The cells of the coarsest tiles are thousands of blocks wide: a
|
||||
// few steps each keep the work bounded, the cut around finer terrain
|
||||
// coarse for as long as such a tile stands in for its children.
|
||||
step.max(stride / MAX_STEPS)
|
||||
};
|
||||
let filled = fill_buried_gaps(
|
||||
columns,
|
||||
@@ -938,20 +997,35 @@ fn merge(rects: &mut Vec<Rect>) {
|
||||
/// The widest face `emit` writes, in blocks: its UVs, counted from the face's
|
||||
/// corner, must stay within `TerrainVertex::tiled`'s range.
|
||||
const MAX_FACE: f32 = 1024.0;
|
||||
/// A face that would be more than this many `MAX_FACE` pieces is one quad
|
||||
/// whose UVs are scaled to fit: a cell of the coarsest tiles is thousands to
|
||||
/// millions of blocks wide, and the texture repeats within it are far below a
|
||||
/// pixel.
|
||||
const MAX_PIECES: f32 = 4.0;
|
||||
/// Steps along a cell of a tile cut around finer terrain, at most.
|
||||
const MAX_STEPS: i32 = 32;
|
||||
fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
let pieces = ((rect.u1 - rect.u0) / MAX_FACE).ceil() * ((rect.v1 - rect.v0) / MAX_FACE).ceil();
|
||||
if pieces > MAX_PIECES {
|
||||
let extent = (rect.u1 - rect.u0).max(rect.v1 - rect.v0);
|
||||
emit_quad(mesh, key, rect, (MAX_FACE / extent).min(1.0));
|
||||
return;
|
||||
}
|
||||
let mut u0 = rect.u0;
|
||||
while u0 < rect.u1 {
|
||||
let u1 = rect.u1.min(u0 + MAX_FACE);
|
||||
let mut v0 = rect.v0;
|
||||
while v0 < rect.v1 {
|
||||
let v1 = rect.v1.min(v0 + MAX_FACE);
|
||||
emit_quad(mesh, key, Rect { u0, v0, u1, v1 });
|
||||
emit_quad(mesh, key, Rect { u0, v0, u1, v1 }, 1.0);
|
||||
v0 = v1;
|
||||
}
|
||||
u0 = u1;
|
||||
}
|
||||
}
|
||||
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face
|
||||
/// too wide for them.
|
||||
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
|
||||
let Rect { u0, v0, u1, v1 } = rect;
|
||||
let p = f32::from_bits(key.plane);
|
||||
let mut points = match key.direction {
|
||||
@@ -988,7 +1062,10 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
out.extend(points.into_iter().zip(uvs).map(|(pos, uv)| {
|
||||
TerrainVertex::tiled(
|
||||
pos,
|
||||
[uv[0] - corner[0], uv[1] - corner[1]],
|
||||
[
|
||||
(uv[0] - corner[0]) * uv_scale,
|
||||
(uv[1] - corner[1]) * uv_scale,
|
||||
],
|
||||
key.layer,
|
||||
key.color,
|
||||
key.light,
|
||||
@@ -1087,6 +1164,84 @@ mod tests {
|
||||
}
|
||||
assert_eq!(area, 2500.0 * 15.5);
|
||||
}
|
||||
#[test]
|
||||
fn far_columns_are_heightmaps_of_their_surface_and_water() {
|
||||
let fluid = |s: &Span| s.state.0 == 2;
|
||||
let strata = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)],
|
||||
};
|
||||
let flat = heightmap(&strata, fluid);
|
||||
assert_eq!(flat.spans, [span(-64, 64, 4)]);
|
||||
let sea = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 50, 1), span(50, 62, 2)],
|
||||
};
|
||||
let flat = heightmap(&sea, fluid);
|
||||
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
|
||||
assert!(heightmap(&columns()[0], fluid).spans.is_empty());
|
||||
// A rolling landscape of strata: far tiles need a fraction of the quads.
|
||||
let rolling: Vec<Column> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
let top = 64 + (i * 7 % 5) as i16;
|
||||
Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(0, top - 4, 1),
|
||||
span(top - 4, top - 1, 3),
|
||||
span(top - 1, top, 4),
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let quads = |level| {
|
||||
let key = TileKey { x: 0, z: 0, level };
|
||||
let mesh = mesh_columns(key, &rolling, &FxHashSet::default(), None, material);
|
||||
mesh.solid.len() / 4
|
||||
};
|
||||
let (voxel, height) = (quads(HEIGHTMAP_LEVEL - 1), quads(HEIGHTMAP_LEVEL));
|
||||
assert!(height * 3 < voxel * 2, "{height} quads against {voxel}");
|
||||
}
|
||||
#[test]
|
||||
fn world_sized_faces_are_one_quad_with_uvs_that_fit() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let key = FaceKey {
|
||||
direction: Direction::Up,
|
||||
plane: 64.0f32.to_bits(),
|
||||
overlay: 0,
|
||||
layer: 1,
|
||||
light: 0,
|
||||
color: 0,
|
||||
translucent: false,
|
||||
};
|
||||
let size = 4_194_304.0;
|
||||
emit(
|
||||
&mut mesh,
|
||||
key,
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: size,
|
||||
v1: size / 2.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.solid.len(), 4);
|
||||
let corners: Vec<[f32; 3]> = mesh.solid.iter().map(|v| v.pos).collect();
|
||||
assert!(
|
||||
corners.iter().any(|p| p[0] == size && p[2] == size / 2.0),
|
||||
"{corners:?}"
|
||||
);
|
||||
let top = mesh
|
||||
.solid
|
||||
.iter()
|
||||
.map(tiled_uv)
|
||||
.fold(0.0f32, |m, uv| m.max(uv[0]).max(uv[1]));
|
||||
assert!(top <= MAX_FACE + 1.0, "UV reaches {top}");
|
||||
assert!(
|
||||
top > MAX_FACE / 4.0,
|
||||
"the texture still repeats across the face"
|
||||
);
|
||||
}
|
||||
fn normal(q: &[TerrainVertex; 4]) -> glam::Vec3 {
|
||||
let p = |i: usize| glam::Vec3::from(q[i].pos);
|
||||
(p(1) - p(0)).cross(p(2) - p(0))
|
||||
|
||||
@@ -139,11 +139,50 @@ mod vertex_tests {
|
||||
assert_eq!(hdr_tier("red_candle", 3), LOW);
|
||||
assert_eq!(hdr_tier("glow_lichen", 7), 0);
|
||||
assert_eq!(hdr_tier("stone", 0), 0);
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
|
||||
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
|
||||
assert_eq!(hdr_tier("sea_pickle", 15), 0);
|
||||
// Unlisted ones by their light level.
|
||||
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
|
||||
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_mesh_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compute_bounds_empty_solid_and_translucent() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
assert_eq!(mesh.compute_bounds(), None);
|
||||
|
||||
let v1 = TerrainVertex::new([1.0, 2.0, 3.0], [0.0, 0.0], 0, 0, 0);
|
||||
let v2 = TerrainVertex::new([4.0, 5.0, 6.0], [0.0, 0.0], 0, 0, 0);
|
||||
mesh.solid.push(v1);
|
||||
mesh.solid.push(v2);
|
||||
|
||||
let pad = glam::Vec3::splat(SectionMesh::BOUNDS_PAD);
|
||||
assert_eq!(
|
||||
mesh.compute_bounds(),
|
||||
Some([
|
||||
glam::Vec3::new(1.0, 2.0, 3.0) - pad,
|
||||
glam::Vec3::new(4.0, 5.0, 6.0) + pad,
|
||||
])
|
||||
);
|
||||
|
||||
let v3 = TerrainVertex::new([0.5, 7.0, 8.0], [0.0, 0.0], 0, 0, 0);
|
||||
mesh.translucent.push(v3);
|
||||
assert_eq!(
|
||||
mesh.compute_bounds(),
|
||||
Some([
|
||||
glam::Vec3::new(0.5, 2.0, 3.0) - pad,
|
||||
glam::Vec3::new(4.0, 7.0, 8.0) + pad,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
@@ -158,6 +197,8 @@ pub struct SectionMesh {
|
||||
/// each frame by `block_entity_render` like vanilla's per-section
|
||||
/// `SectionCompiler.Results.blockEntities`.
|
||||
pub block_entities: Vec<([i32; 3], u32)>,
|
||||
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
|
||||
pub bounds: Option<[glam::Vec3; 2]>,
|
||||
}
|
||||
impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
@@ -167,19 +208,66 @@ impl Default for SectionMesh {
|
||||
translucent: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
bounds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectionMesh {
|
||||
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
|
||||
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
self.sides = Sides::default();
|
||||
self.visibility = ALL_VISIBLE;
|
||||
self.bounds = None;
|
||||
}
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
}
|
||||
/// Sorts the solid quads by the side they face, for the renderer to
|
||||
/// leave out those facing away from the eye (`sides`).
|
||||
pub fn sort_solid(&mut self) {
|
||||
self.sides = sides::sort(&mut self.solid);
|
||||
self.bounds = self.compute_bounds();
|
||||
}
|
||||
|
||||
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
|
||||
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
|
||||
if self.solid.is_empty() && self.translucent.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut min = glam::Vec3::splat(f32::INFINITY);
|
||||
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
|
||||
for v in &self.solid {
|
||||
let p = glam::Vec3::from(v.pos);
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
for v in &self.translucent {
|
||||
let p = glam::Vec3::from(v.pos);
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
let pad = glam::Vec3::splat(Self::BOUNDS_PAD);
|
||||
Some([min - pad, max + pad])
|
||||
}
|
||||
}
|
||||
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
|
||||
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
|
||||
#[cfg(test)]
|
||||
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
visibility_reusing(pad, info, &mut Vec::new())
|
||||
}
|
||||
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
|
||||
// The all-visible shortcut can leave a partially filled stack behind.
|
||||
stack.clear();
|
||||
// Opaque cells are already visited: resolve each state's opacity once,
|
||||
// then reuse this map for the flood fill and the uniform-section shortcuts.
|
||||
let mut seen = [false; 4096];
|
||||
@@ -201,7 +289,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
|
||||
return 0;
|
||||
}
|
||||
let mut result = 0u64;
|
||||
let mut stack = Vec::new();
|
||||
for start in 0..4096usize {
|
||||
if seen[start] {
|
||||
continue;
|
||||
@@ -458,7 +545,12 @@ impl MeshTables {
|
||||
});
|
||||
let mut face = [0u8; 6];
|
||||
let mut cover = [0f32; 6];
|
||||
for d in Direction::ALL {
|
||||
// A server's visibility knows no state (`FaceVisibility::empty`):
|
||||
// it meshes no fluid, so these stay unset.
|
||||
for d in Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|_| !visibility.is_empty())
|
||||
{
|
||||
let (face_id, shape) = visibility
|
||||
.occlusion_face(BlockStateId(id as u32), d)
|
||||
.ok_or("missing occlusion face")?;
|
||||
@@ -631,21 +723,30 @@ fn pi(x: i32, y: i32, z: i32) -> usize {
|
||||
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
|
||||
}
|
||||
/// Blocks and light of one section plus a `B`-block border.
|
||||
#[derive(Default)]
|
||||
struct Padded {
|
||||
blocks: Vec<u32>,
|
||||
light: Vec<u8>,
|
||||
}
|
||||
impl Padded {
|
||||
#[cfg(test)]
|
||||
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
|
||||
let mut blocks = vec![0u32; P * P * P];
|
||||
let mut light = vec![0u8; P * P * P];
|
||||
let mut pad = Self::default();
|
||||
pad.fill(region, sy, &mut Box::new([0; 4096]));
|
||||
pad
|
||||
}
|
||||
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
|
||||
self.blocks.resize(P * P * P, 0);
|
||||
self.blocks.fill(0);
|
||||
self.light.resize(P * P * P, 0);
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
let mut own = Box::new([0u32; 4096]);
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(&mut own);
|
||||
section.unpack(own);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
@@ -677,9 +778,18 @@ impl Padded {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { blocks, light }
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 88 KiB per worker.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
|
||||
@@ -702,14 +812,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 {
|
||||
match name {
|
||||
_ if level == 0 => 0,
|
||||
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
|
||||
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
|
||||
| "redstone_lamp" => BRIGHT,
|
||||
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
|
||||
| "sea_lantern" => INCANDESCENT,
|
||||
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
|
||||
// Glowing at every charge, not only the brighter ones.
|
||||
"respawn_anchor" => INCANDESCENT,
|
||||
"redstone_torch"
|
||||
| "redstone_wall_torch"
|
||||
| "magma_block"
|
||||
| "ochre_froglight"
|
||||
| "verdant_froglight"
|
||||
| "pearlescent_froglight"
|
||||
| "shroomlight" => LOW,
|
||||
// Its flame is a particle.
|
||||
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
|
||||
// One tier at every oxidation stage, waxed or not.
|
||||
_ if name.ends_with("copper_bulb") => LOW,
|
||||
// A faint glow in the dark, as in SDR.
|
||||
"glow_lichen" => 0,
|
||||
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
|
||||
_ if level >= 10 => INCANDESCENT,
|
||||
_ => LOW,
|
||||
}
|
||||
@@ -849,14 +970,30 @@ pub struct Mesher<'a> {
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
|
||||
let mut out = SectionMesh::default();
|
||||
self.mesh_reusing(
|
||||
region,
|
||||
sy,
|
||||
SectionMesh::default(),
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mut out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
out.clear();
|
||||
let center = region[1][1].expect("centre chunk");
|
||||
let section = ¢er.sections[sy as usize];
|
||||
if section.is_uniform(0) {
|
||||
return out;
|
||||
}
|
||||
let pad = Padded::new(region, sy);
|
||||
out.visibility = visibility(&pad, &self.tables.info);
|
||||
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
@@ -891,7 +1028,7 @@ impl Mesher<'_> {
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let nether = self.nether_shading;
|
||||
fluid::tesselate(self.tables, nether, &pad, water, &mut out, [x, y, z]);
|
||||
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
|
||||
}
|
||||
if !state.model {
|
||||
continue;
|
||||
@@ -941,7 +1078,7 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
self.face(
|
||||
&pad,
|
||||
pad,
|
||||
&mut tints,
|
||||
&mut out,
|
||||
[x, y, z],
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
|
||||
let mut pad = Padded::default();
|
||||
let mut own = Box::new([u32::MAX; 4096]);
|
||||
for min_y in [MIN_Y, -256, 0, 256] {
|
||||
let layout = crate::chunk::ColumnLayout::new(min_y, 32).unwrap();
|
||||
let base_y = min_y + 16;
|
||||
let mut center = Chunk::with_layout(0, 0, layout);
|
||||
center.set(0, base_y, 0, 7);
|
||||
center.set(15, base_y + 15, 15, 9);
|
||||
let mut neighbor = Chunk::with_layout(1, 0, layout);
|
||||
neighbor.set(0, base_y + 2, 3, 11);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
region[1][2] = Some(&neighbor);
|
||||
pad.fill(®ion, 1, &mut own);
|
||||
let buffers = (pad.blocks.as_ptr(), pad.light.as_ptr());
|
||||
for current in [region, [[None; 3]; 3], region] {
|
||||
pad.fill(¤t, 1, &mut own);
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let chunk = current[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
// A missing neighbour continues the centre's edge column.
|
||||
let (block, light) = match (chunk, current[1][1]) {
|
||||
(Some(c), _) => (
|
||||
c.get(x & 15, base_y + y, z & 15),
|
||||
c.light(x & 15, base_y + y, z & 15),
|
||||
),
|
||||
(None, Some(c)) => {
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
(c.get(ex, base_y + y, ez), c.light(ex, base_y + y, ez))
|
||||
}
|
||||
(None, None) => (0, 0xF0),
|
||||
};
|
||||
assert_eq!(pad.blocks[pi(x, y, z)], block);
|
||||
assert_eq!(pad.light[pi(x, y, z)], light);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(buffers, (pad.blocks.as_ptr(), pad.light.as_ptr()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let names = [
|
||||
"minecraft:stone",
|
||||
"minecraft:glass",
|
||||
"minecraft:grass_block",
|
||||
"minecraft:oak_leaves",
|
||||
"minecraft:water",
|
||||
"minecraft:chest",
|
||||
];
|
||||
let mut dense = Chunk::empty(0, 0);
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
dense.set(
|
||||
x,
|
||||
MIN_Y + 4,
|
||||
z,
|
||||
state(names[((x + z) as usize) % names.len()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut sparse = Chunk::empty(-3, 5);
|
||||
sparse.set(0, MIN_Y + 15, 0, state("minecraft:short_grass"));
|
||||
sparse.set(15, MIN_Y + 1, 15, state("minecraft:lava"));
|
||||
let empty = Chunk::empty(0, 0);
|
||||
let mut neighbor = Chunk::empty(1, 0);
|
||||
neighbor.set(0, MIN_Y + 4, 0, state("minecraft:stone"));
|
||||
let mut custom = Chunk::with_layout(2, -4, crate::chunk::ColumnLayout::new(-256, 64).unwrap());
|
||||
custom.set(1, -252, 2, state("minecraft:chest"));
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
// The same scratch and output buffers survive a resource reload. They must
|
||||
// contain no references into the previous RenderAssets or MeshTables.
|
||||
for reload in 0..2 {
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility,
|
||||
zoom: BiomeZoom::new(42),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
(&empty, None),
|
||||
(&dense, Some(&neighbor)),
|
||||
(&sparse, None),
|
||||
(&empty, None),
|
||||
(&dense, None),
|
||||
(&custom, None),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(center);
|
||||
region[1][2] = east;
|
||||
let mut fresh = mesher.mesh(®ion, 0);
|
||||
recycled = mesher.mesh_reusing(®ion, 0, recycled, &mut scratch);
|
||||
if reload == 0 && case == 0 {
|
||||
assert!(scratch.own.is_none());
|
||||
assert_eq!(scratch.padded.blocks.capacity(), 0);
|
||||
assert_eq!(scratch.padded.light.capacity(), 0);
|
||||
assert_eq!(scratch.visibility.capacity(), 0);
|
||||
}
|
||||
fresh.sort_solid();
|
||||
recycled.sort_solid();
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.solid),
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.solid)
|
||||
);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.translucent),
|
||||
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.translucent)
|
||||
);
|
||||
assert_eq!(fresh.block_entities, recycled.block_entities);
|
||||
assert_eq!(fresh.visibility, recycled.visibility);
|
||||
assert_eq!(fresh.sides, recycled.sides);
|
||||
assert_eq!(fresh.bounds, recycled.bounds);
|
||||
if std::ptr::eq(center, &dense) {
|
||||
assert!(!fresh.solid.is_empty());
|
||||
assert!(!fresh.translucent.is_empty());
|
||||
assert!(!fresh.block_entities.is_empty());
|
||||
}
|
||||
if std::ptr::eq(center, &custom) {
|
||||
assert_eq!(
|
||||
fresh.block_entities,
|
||||
[([33, -252, -62], state("minecraft:chest"))]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An isolated scratch-stage comparison, not a whole-mesher or FPS benchmark.
|
||||
/// Output each sample so the caller can retain the observations and spread.
|
||||
#[test]
|
||||
#[ignore = "opt-in scratch microbenchmark; run with --nocapture --test-threads=1"]
|
||||
fn benchmark_padded_reuse() {
|
||||
const ITERATIONS: usize = 4096;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
chunk.set(x, MIN_Y + y, z, 1 + ((x + y + z) % 7) as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let mut pad = Padded::new(®ion, 0);
|
||||
let mut own = Box::new([0; 4096]);
|
||||
let pointers = (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr());
|
||||
let bytes = pad.blocks.capacity() * 4 + pad.light.capacity() + 4096 * 4;
|
||||
// Warm both code paths and the process allocator before recording samples.
|
||||
for _ in 0..1024 {
|
||||
std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0));
|
||||
pad.fill(std::hint::black_box(®ion), 0, &mut own);
|
||||
std::hint::black_box(&pad);
|
||||
}
|
||||
for sample in 0..9 {
|
||||
// Alternate execution order to expose, rather than hide, order effects.
|
||||
for reused in [sample % 2 == 0, sample % 2 != 0] {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..ITERATIONS {
|
||||
if reused {
|
||||
pad.fill(std::hint::black_box(®ion), 0, &mut own);
|
||||
std::hint::black_box(&pad);
|
||||
} else {
|
||||
std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0));
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{{\"sample\":{sample},\"reused\":{reused},\"iterations\":{ITERATIONS},\"elapsed_ms\":{:.6},\"scratch_bytes\":{bytes}}}",
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,21 @@ fn visibility_matches_reference() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
let mut stack = vec![usize::MAX];
|
||||
for _ in 0..3 {
|
||||
for (name, pad) in cases() {
|
||||
assert_eq!(
|
||||
visibility_reusing(&pad, &info, &mut stack),
|
||||
reference(&pad, &info),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_preserves_every_face_pair() {
|
||||
let info = [info(false), info(true), info(false), info(true)];
|
||||
|
||||
@@ -12,6 +12,11 @@ use std::{
|
||||
pub const FILE: &str = "horizon-mods.txt";
|
||||
/// The folder holding `.hmod` files, beside `options.txt`.
|
||||
pub const DIR: &str = "horizon-mods";
|
||||
/// Where the launcher installs native mods (`<id>.hnmod`), beside [`DIR`]. They run only in a
|
||||
/// game the launcher built with them.
|
||||
pub const NATIVE_DIR: &str = "horizon-native-mods";
|
||||
/// Why a native mod installed in [`NATIVE_DIR`] doesn't run in this game.
|
||||
pub const NOT_BUILT: &str = "Installed, not built into this game: build the game with it in the launcher (Settings > Native mods), then play the instance that build is in.";
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModList {
|
||||
|
||||
@@ -579,6 +579,9 @@ impl MultiLineEdit {
|
||||
if show_cursor && !insert_cursor && self.within(cursor_y + shift, cursor_y + shift + LINE) {
|
||||
batch.text(atlas, "_", cursor_x, cursor_y, cursor_color, true);
|
||||
}
|
||||
if self.focused {
|
||||
crate::preedit::report_caret(cursor_x, cursor_y, 10.0);
|
||||
}
|
||||
if self.has_selection() {
|
||||
let (sel_begin, sel_end) = self.selected();
|
||||
let draw_x = inner_left;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Improved transparency (the options screen's order-independent transparency),
|
||||
// appended to terrain.wgsl. Vanilla 26.3 draws its translucent layer three
|
||||
// times: once to find each pixel's nearest and furthest see-through depth (the
|
||||
// depth bounds), once to add each fragment's absorbance into a small wavelet
|
||||
// transform over that range (the transmittance), and once to weight every
|
||||
// fragment's colour by what the transform says lies in front of it (the
|
||||
// accumulation). A last full-screen pass puts the sum over the opaque world.
|
||||
//
|
||||
// Eight coefficients in two RGBA16F targets: [0] is the mean absorbance, [1]
|
||||
// the widest wavelet, [2..4] the next level, [4..8] the finest, one per depth
|
||||
// bin pair.
|
||||
struct OitParams {
|
||||
// x, y: the projection's [2][2] and [3][2], which turn a device depth into
|
||||
// a distance along the view axis: y / (depth + x).
|
||||
projection: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> oit: OitParams;
|
||||
@group(2) @binding(1) var oit_bounds: texture_2d<f32>;
|
||||
@group(2) @binding(2) var oit_coeff0: texture_2d<f32>;
|
||||
@group(2) @binding(3) var oit_coeff1: texture_2d<f32>;
|
||||
@group(2) @binding(4) var oit_accumulated: texture_2d<f32>;
|
||||
|
||||
const OIT_FULLY_OPAQUE_ALPHA: f32 = 0.99;
|
||||
const OIT_FULLY_OPAQUE_TRANSMITTANCE: f32 = 0.02;
|
||||
const OIT_BINS: i32 = 8;
|
||||
const OIT_LOW_PRECISION_BINS: f32 = 2.0;
|
||||
const OIT_HIGH_PRECISION_RANGE: f32 = 10.0;
|
||||
const OIT_ALPHA_CUTOUT: f32 = 0.1;
|
||||
|
||||
fn oit_linear_depth(device_depth: f32) -> f32 {
|
||||
return oit.projection.y / (device_depth + oit.projection.x);
|
||||
}
|
||||
fn oit_absorbance(transmittance: f32) -> f32 {
|
||||
return clamp(-log(max(transmittance, 0.0001)), 0.0, 4.0);
|
||||
}
|
||||
fn oit_transmittance(absorbance: f32) -> f32 {
|
||||
return clamp(exp(-absorbance), 0.0001, 1.0);
|
||||
}
|
||||
|
||||
// Where a fragment sits in its pixel's depth range, 0 to 1. The last
|
||||
// OIT_LOW_PRECISION_BINS bins stretch over whatever is past the first ten
|
||||
// blocks, so a thin pane near the eye keeps its resolution in a deep range.
|
||||
fn oit_normalize_depth(device_depth: f32, pixel: vec2<f32>) -> f32 {
|
||||
let bounds = textureLoad(oit_bounds, vec2<i32>(pixel), 0);
|
||||
let nearest = -bounds.r;
|
||||
let furthest = bounds.g;
|
||||
let linear = oit_linear_depth(device_depth);
|
||||
let range = max(furthest - nearest, 0.0001);
|
||||
let within = clamp(linear - nearest, 0.0, range);
|
||||
let fine_share = (f32(OIT_BINS) - OIT_LOW_PRECISION_BINS) / f32(OIT_BINS);
|
||||
let fine_range = fine_share * range;
|
||||
if fine_range <= OIT_HIGH_PRECISION_RANGE {
|
||||
return within / range;
|
||||
}
|
||||
if within <= OIT_HIGH_PRECISION_RANGE {
|
||||
return within / OIT_HIGH_PRECISION_RANGE * fine_share;
|
||||
}
|
||||
return fine_share + (within - OIT_HIGH_PRECISION_RANGE)
|
||||
/ (range - OIT_HIGH_PRECISION_RANGE) * (1.0 - fine_share);
|
||||
}
|
||||
|
||||
struct OitCoefficients {
|
||||
@location(0) first: vec4<f32>,
|
||||
@location(1) second: vec4<f32>,
|
||||
}
|
||||
// One fragment's contribution to the transform (summed by additive blending).
|
||||
fn oit_add_transmittance(transmittance: f32, frag_depth: f32, pixel: vec2<f32>) -> OitCoefficients {
|
||||
var c: array<f32, 8>;
|
||||
let absorbance = oit_absorbance(transmittance);
|
||||
let original = oit_normalize_depth(frag_depth, pixel);
|
||||
let depth = original * f32(OIT_BINS - 1) / f32(OIT_BINS);
|
||||
c[0] = absorbance * (1.0 - depth);
|
||||
// The wavelet tree's leaves start at index 8.
|
||||
var tree = i32(floor(original * f32(OIT_BINS - 1))) + OIT_BINS;
|
||||
for (var level = 0; level <= 2; level++) {
|
||||
let power = 2 - level;
|
||||
let parent = tree >> 1u;
|
||||
let index_at_parent = f32(parent & ((1 << u32(power)) - 1));
|
||||
let right = tree & 1;
|
||||
let side = 1 - (right << 1u);
|
||||
let width = exp2(-f32(power));
|
||||
let height = exp2(f32(power) * 0.5);
|
||||
let offset = depth - width * index_at_parent;
|
||||
c[parent] = (f32(right) * width + f32(side) * offset) * height * absorbance;
|
||||
tree = parent;
|
||||
}
|
||||
return OitCoefficients(vec4(c[0], c[1], c[2], c[3]), vec4(c[4], c[5], c[6], c[7]));
|
||||
}
|
||||
|
||||
// The absorbance in front of `original` (a normalized depth), without the
|
||||
// fragment's own contribution `current` that the transform already holds.
|
||||
fn oit_sample_absorbance(coefficients: array<f32, 8>, original: f32, current: f32) -> f32 {
|
||||
var c = coefficients;
|
||||
var average = c[0];
|
||||
if average == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let in_bins = original * f32(OIT_BINS - 1);
|
||||
let depth = in_bins / f32(OIT_BINS);
|
||||
average -= current * (1.0 - depth);
|
||||
// B: the bin the fragment is in; A: the one before it.
|
||||
var tree_b = clamp(i32(floor(in_bins)), 0, OIT_BINS - 1);
|
||||
let sample_a_too = tree_b >= 1;
|
||||
var tree_a = select(tree_b, tree_b - 1, sample_a_too);
|
||||
tree_b += OIT_BINS;
|
||||
tree_a += OIT_BINS;
|
||||
var sample_b = average;
|
||||
var sample_a = select(0.0, average, sample_a_too);
|
||||
for (var level = 0; level <= 2; level++) {
|
||||
let power = 2 - level;
|
||||
let width = exp2(-f32(power));
|
||||
let height = exp2(f32(power) * 0.5);
|
||||
let parent_b = tree_b >> 1u;
|
||||
let right_b = tree_b & 1;
|
||||
let side_b = 1 - (right_b << 1u);
|
||||
let index_at_parent = f32(parent_b & ((1 << u32(power)) - 1));
|
||||
let offset = depth - width * index_at_parent;
|
||||
let own = (f32(right_b) * width + f32(side_b) * offset) * height * current;
|
||||
let difference_b = c[parent_b] - own;
|
||||
sample_b -= height * difference_b * f32(side_b);
|
||||
tree_b = parent_b;
|
||||
if sample_a_too {
|
||||
let parent_a = tree_a >> 1u;
|
||||
let difference_a = select(c[parent_a], difference_b, parent_a == parent_b);
|
||||
let side_a = 1 - ((tree_a & 1) << 1u);
|
||||
sample_a -= height * difference_a * f32(side_a);
|
||||
tree_a = parent_a;
|
||||
}
|
||||
}
|
||||
let blend = select(fract(in_bins), 1.0, in_bins >= f32(OIT_BINS));
|
||||
return mix(sample_a, sample_b, blend);
|
||||
}
|
||||
fn oit_sample_transmittance(pixel: vec2<i32>, depth: f32, current: f32) -> f32 {
|
||||
let a = textureLoad(oit_coeff0, pixel, 0);
|
||||
let b = textureLoad(oit_coeff1, pixel, 0);
|
||||
let c = array<f32, 8>(a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w);
|
||||
return oit_transmittance(oit_sample_absorbance(c, depth, oit_absorbance(current)));
|
||||
}
|
||||
|
||||
// The three stages' ends for a pipeline whose fragment colour is computed as
|
||||
// it is without improved transparency (vanilla's `executeAlphaOnlyPhase` and
|
||||
// `sampleColorForAccumulation`). `color` is the classic result, fogged:
|
||||
// weighting a fogged colour equals fogging the weighted one, since the fog
|
||||
// is a mix and vanilla premultiplies the fog colour by the same alpha.
|
||||
fn oit_bounds_fragment(alpha: f32, clip: vec4<f32>) -> vec4<f32> {
|
||||
if alpha < 0.01 { discard; }
|
||||
let linear = oit_linear_depth(clip.z);
|
||||
let opaque = select(0.0, clip.z, alpha > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, clip.z, opaque);
|
||||
}
|
||||
fn oit_transmittance_fragment(alpha: f32, clip: vec4<f32>) -> OitCoefficients {
|
||||
return oit_add_transmittance(1.0 - alpha, clip.z, clip.xy);
|
||||
}
|
||||
fn oit_accumulate_fragment(color: vec4<f32>, clip: vec4<f32>) -> vec4<f32> {
|
||||
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
|
||||
oit_normalize_depth(clip.z, clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
}
|
||||
// `OIT_ADDITIVE`: no coverage and nothing in the transmittance. The colour
|
||||
// is not fogged yet: the fog colour is premultiplied by the alpha, which is
|
||||
// zero, so the fog fades it out instead of tinting it.
|
||||
fn oit_accumulate_additive(color: vec4<f32>, fog: f32, clip: vec4<f32>) -> vec4<f32> {
|
||||
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
|
||||
oit_normalize_depth(clip.z, clip.xy), 1.0);
|
||||
return vec4(color.rgb * color.a * (1.0 - fog), 0.0) * seen;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Improved transparency's stages for the world border (`OIT_WORLD_BORDER`:
|
||||
// `world_border.fsh` with `OIT_ADDITIVE`), appended to border.wgsl after
|
||||
// oit.wgsl.
|
||||
@fragment fn fs_oit_border_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(border_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_border_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_border_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_additive(border_color(in), 0.0, in.clip);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Improved transparency's cloud stages (`clouds.fsh` under OIT_*), appended to
|
||||
// entity.wgsl after oit.wgsl. The clouds' vertex shader and draw are the
|
||||
// classic ones; only the fragment colour goes through the three stages.
|
||||
|
||||
// The cloud's colour; it fades out towards the cloud fog end everywhere but in
|
||||
// the depth bounds stage, which sees the unfaded alpha.
|
||||
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
|
||||
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
|
||||
return vec4(color.rgb, color.a * select(1.0, faded, fade));
|
||||
}
|
||||
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, false);
|
||||
if color.a < 0.01 { discard; }
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Improved transparency's stages for what the entity shader draws after the
|
||||
// opaque world (`LevelRenderer.executeOit`'s "Features, World Border,
|
||||
// Weather" pass), appended to entity.wgsl after oit.wgsl. The colour of each
|
||||
// is the classic fragment's, so the shaders cannot drift apart.
|
||||
|
||||
// Items, particles, text, breaking cracks (`RenderPipelines.OIT_ENTITY` and
|
||||
// the others on entity.fsh).
|
||||
@fragment fn fs_oit_entity_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_color(in, front), in.clip);
|
||||
}
|
||||
// `OIT_ENTITY_SHADOW`.
|
||||
@fragment fn fs_oit_shadow_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(shadow_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_shadow_transmittance(in: Out) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(shadow_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_shadow_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(shadow_color(in), in.clip);
|
||||
}
|
||||
// `OIT_WEATHER` (the particle shader).
|
||||
@fragment fn fs_oit_weather_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(weather_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_weather_transmittance(in: Out) -> OitCoefficients {
|
||||
return oit_transmittance_fragment(weather_color(in).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_weather_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(weather_color(in), in.clip);
|
||||
}
|
||||
// `OIT_ENERGY_SWIRL`: entity.fsh with `OIT_ADDITIVE`, which caps the alpha.
|
||||
@fragment fn fs_oit_swirl_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(min(0.99, energy_swirl_base(in).a), in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_swirl_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_swirl_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = energy_swirl_base(in);
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), energy_swirl_fog(in), in.clip);
|
||||
}
|
||||
// `OIT_LIGHTNING` and `OIT_DRAGON_RAYS`: additive (`OIT_ADDITIVE`), the
|
||||
// classic fragment's colour (fs_celestial) with the alpha capped.
|
||||
@fragment fn fs_oit_lightning_bounds(in: Out) -> @location(0) vec4<f32> {
|
||||
return oit_bounds_fragment(min(0.99, celestial_color(in).a), in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_lightning_transmittance(in: Out) -> OitCoefficients {
|
||||
discard;
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Improved transparency's terrain stages and its screen passes (oit.wgsl
|
||||
// has the shared maths), appended to terrain.wgsl after oit.wgsl.
|
||||
// The terrain's translucent layer, once per stage.
|
||||
fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < OIT_ALPHA_CUTOUT { discard; }
|
||||
return color;
|
||||
}
|
||||
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
|
||||
// depth), combined by max blending.
|
||||
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = oit_terrain_color(in);
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
// Stage 2.
|
||||
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
|
||||
let color = oit_terrain_color(in);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
// Stage 3: premultiplied colour, weighted by the transmittance in front.
|
||||
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let color = oit_terrain_color(in);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
let weighted = vec4(color.rgb * color.a, color.a) * seen;
|
||||
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
// The fog colour is premultiplied by the same weight.
|
||||
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
struct OitFragment {
|
||||
@location(0) color: vec4<f32>,
|
||||
@builtin(frag_depth) depth: f32,
|
||||
}
|
||||
// After the bounds stage: a fully opaque fragment hides what is behind it, so
|
||||
// the far bound stops there and its depth goes into the depth buffer.
|
||||
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
|
||||
let opaque = oit_linear_depth(b.a);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
|
||||
}
|
||||
// The sum over the world, renormalized to the coverage the transform gives.
|
||||
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let p = vec2<i32>(pos.xy);
|
||||
let accumulated = textureLoad(oit_accumulated, p, 0);
|
||||
let seen = oit_sample_transmittance(p, 100000.0, 1.0);
|
||||
let coverage = 1.0 - select(seen, 0.0, seen < OIT_FULLY_OPAQUE_TRANSMITTANCE);
|
||||
// Additive surfaces add light but no coverage.
|
||||
if coverage < 0.00001 && dot(accumulated.rgb, vec3(1.0)) < 0.00001 { discard; }
|
||||
let scale = select(1.0, coverage / accumulated.a, accumulated.a > 0.00001);
|
||||
return OitFragment(vec4(accumulated.rgb * scale, coverage), textureLoad(oit_bounds, p, 0).b);
|
||||
}
|
||||
@@ -128,7 +128,6 @@ impl Opt {
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::RotateWithMinecarts
|
||||
// Saved, but no Horizon system reads them yet: no Realms, no
|
||||
// friends list, no directional audio, no now-playing toast.
|
||||
@@ -141,9 +140,6 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the terrain sampler has no RGSS or anisotropic filtering yet.
|
||||
| Opt::TextureFiltering
|
||||
| Opt::MaxAnisotropy
|
||||
// Saved, but the mouse doesn't read it yet.
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
@@ -876,6 +872,7 @@ impl Opt {
|
||||
| Opt::HideLightningFlashes
|
||||
| Opt::HideSplashTexts
|
||||
| Opt::HighContrastBlockOutlines
|
||||
| Opt::MonochromeLogo
|
||||
| Opt::HideMatchedNames
|
||||
| Opt::OnlySecureChat
|
||||
| Opt::SaveChatDrafts
|
||||
@@ -1181,6 +1178,8 @@ pub enum Page {
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// HDR calibration, full screen (`calibration`).
|
||||
HdrCalibration,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -1352,10 +1351,17 @@ pub struct OptionsUi {
|
||||
pub resource_error: Option<String>,
|
||||
/// When the pack screen's search box took the focus (its cursor blink).
|
||||
pack_focus: Instant,
|
||||
/// The selection carried over from a pack screen made again over a changed pack list
|
||||
/// (`update_pack_infos`) differs from the settings'.
|
||||
pack_reselected: bool,
|
||||
pub in_world: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
}
|
||||
@@ -1387,9 +1393,12 @@ impl Default for OptionsUi {
|
||||
data_pack_stamp: (Vec::new(), Instant::now()),
|
||||
resource_error: None,
|
||||
pack_focus: Instant::now(),
|
||||
pack_reselected: false,
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
agent_status: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1418,6 +1427,10 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
if !opt.available() {
|
||||
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
|
||||
}
|
||||
// VideoSettingsScreen.tick: Max Anisotropy only works with Anisotropic filtering.
|
||||
if opt == Opt::MaxAnisotropy && s.file.int("textureFiltering") != 2 {
|
||||
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
|
||||
}
|
||||
match opt.kind(s) {
|
||||
Kind::Slider(v) => Widget {
|
||||
x,
|
||||
@@ -1472,6 +1485,23 @@ impl OptionsUi {
|
||||
self.settings_search.focused
|
||||
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
|
||||
}
|
||||
/// Whether the resource pack screen is open (not Create World's data packs on the same
|
||||
/// page).
|
||||
pub fn resource_packs_open(&self) -> bool {
|
||||
self.page() == Page::Packs && self.data_pack_dir.is_none()
|
||||
}
|
||||
/// The packs in `resourcepacks/` changed while the pack screen is open
|
||||
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
|
||||
/// `infos`, keeping the selection made on it so far.
|
||||
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
|
||||
if let Some(p) = self.pack_screen.take()
|
||||
&& p.changed
|
||||
{
|
||||
self.pack_selection = p.commit();
|
||||
self.pack_reselected = true;
|
||||
}
|
||||
self.pack_infos = infos;
|
||||
}
|
||||
pub fn page(&self) -> Page {
|
||||
*self.stack.last().unwrap_or(&Page::Main)
|
||||
}
|
||||
@@ -1580,7 +1610,9 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
@@ -1966,7 +1998,9 @@ impl OptionsUi {
|
||||
.map(|(i, installed)| {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
if installed.problem.as_deref() == Some(crate::mod_list::NOT_BUILT) {
|
||||
label = label.append(tr("horizon.mods.not_built"));
|
||||
} else if installed.problem.is_some() {
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
@@ -2587,7 +2621,14 @@ impl OptionsUi {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &self.calibration
|
||||
{
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.binding = self.selected_key;
|
||||
self.graphics
|
||||
.draw(level, s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
@@ -2845,8 +2886,26 @@ impl OptionsUi {
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
let outcome = c.click((x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
use crate::graphics_ui::Action;
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
winit::event::MouseButton::Right
|
||||
} else {
|
||||
winit::event::MouseButton::Left
|
||||
};
|
||||
return self.bind_mouse(s, button);
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.graphics
|
||||
@@ -2858,6 +2917,11 @@ impl OptionsUi {
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::BindKey(name) => self.selected_key = Some(name),
|
||||
Action::Calibrate => {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
self.push(Page::HdrCalibration);
|
||||
}
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -3084,7 +3148,8 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -3226,7 +3291,7 @@ impl OptionsUi {
|
||||
} else {
|
||||
"resourcePack.title"
|
||||
};
|
||||
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
|
||||
let mut screen = crate::pack_screen::PackScreen::new(
|
||||
&m,
|
||||
crate::text::Text::translate(title, &[]),
|
||||
w,
|
||||
@@ -3234,12 +3299,45 @@ impl OptionsUi {
|
||||
self.in_world,
|
||||
self.pack_infos.clone(),
|
||||
&self.pack_selection,
|
||||
));
|
||||
);
|
||||
screen.changed = std::mem::take(&mut self.pack_reselected);
|
||||
self.pack_screen = Some(screen);
|
||||
}
|
||||
}
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
}
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
|
||||
/// set before; both go back to Graphics: HDR.
|
||||
fn calibration_outcome(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
outcome: crate::hdr_calibration::Outcome,
|
||||
) -> Vec<Event> {
|
||||
use crate::hdr_calibration::Outcome;
|
||||
match outcome {
|
||||
Outcome::Nothing | Outcome::Changed => Vec::new(),
|
||||
Outcome::Click => vec![Event::ButtonClick],
|
||||
Outcome::Save | Outcome::Cancel => {
|
||||
if let Some(c) = self.calibration.take()
|
||||
&& outcome == Outcome::Save
|
||||
{
|
||||
c.save(s);
|
||||
}
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture, while its page is open: the step, its nits and
|
||||
/// where the controls start (a share of the height `h`).
|
||||
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
|
||||
self.calibration
|
||||
.as_ref()
|
||||
.filter(|_| self.page() == Page::HdrCalibration)
|
||||
.map(|c| c.picture(h))
|
||||
}
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
let page = self.stack.pop().unwrap_or(Page::Main);
|
||||
@@ -3348,7 +3446,7 @@ impl OptionsUi {
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
vec![Event::Changed]
|
||||
@@ -3371,6 +3469,12 @@ impl OptionsUi {
|
||||
text: Option<&str>,
|
||||
) -> Vec<Event> {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
let outcome = c.key(key);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.resource_error.is_some() {
|
||||
if matches!(key, K::Escape | K::Enter | K::NumpadEnter) {
|
||||
self.resource_error = None;
|
||||
@@ -3465,10 +3569,12 @@ mod tests {
|
||||
assert_eq!(loaded.file.double(opt.file_key().unwrap()), expected);
|
||||
}
|
||||
}
|
||||
assert!(Opt::MonochromeLogo.available());
|
||||
for opt in [
|
||||
Opt::TextBackground,
|
||||
Opt::HideLightningFlashes,
|
||||
Opt::HideSplashTexts,
|
||||
Opt::MonochromeLogo,
|
||||
] {
|
||||
let key = opt.file_key().unwrap();
|
||||
let before = s.file.bool(key);
|
||||
@@ -4024,9 +4130,21 @@ mod tests {
|
||||
assert_eq!(s.file.int("maxAnisotropyBit"), bit, "{preset}");
|
||||
assert_eq!(s.file.int("textureFiltering"), filtering, "{preset}");
|
||||
}
|
||||
// Nothing samples the terrain atlas with them yet, so the buttons stay greyed out.
|
||||
assert!(!Opt::TextureFiltering.available());
|
||||
assert!(!Opt::MaxAnisotropy.available());
|
||||
assert!(Opt::TextureFiltering.available());
|
||||
assert!(Opt::MaxAnisotropy.available());
|
||||
// The anisotropy slider only works with Anisotropic filtering (VideoSettingsScreen.tick).
|
||||
let grey = |s: &Settings| {
|
||||
matches!(
|
||||
option_widget(s, Opt::MaxAnisotropy, 0.0, 0.0, 150.0).kind,
|
||||
WKind::Button { active: false, .. }
|
||||
)
|
||||
};
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(1));
|
||||
assert!(grey(&s));
|
||||
s.file
|
||||
.set("textureFiltering", crate::options_file::V::Int(2));
|
||||
assert!(!grey(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4928,7 +5046,7 @@ mod tests {
|
||||
MouseButton::Back,
|
||||
MouseButton::Forward,
|
||||
] {
|
||||
for name in [SORT_KEY, "key.jump"] {
|
||||
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut s = Settings::default();
|
||||
ui.selected_key = Some(name);
|
||||
@@ -4937,6 +5055,7 @@ mod tests {
|
||||
let value = keybinds::mouse_name(button);
|
||||
match name {
|
||||
SORT_KEY => assert_eq!(s.extras.sort_key, value),
|
||||
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
|
||||
_ => assert_eq!(s.keys.get(name), value),
|
||||
}
|
||||
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
|
||||
|
||||
@@ -414,6 +414,13 @@ fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
|
||||
.flatten()
|
||||
.filter(|e| overlay_applies(e))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
stays_inside(d)
|
||||
&& !d.contains('/')
|
||||
&& d.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
|
||||
@@ -539,7 +546,7 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
|
||||
let zip = Zip::open(z)?;
|
||||
for root in &roots {
|
||||
let prefix = format!("{root}assets/");
|
||||
for name in zip.names() {
|
||||
for name in zip.names().filter(|name| stays_inside(name)) {
|
||||
if let Some(rest) = name.strip_prefix(&prefix) {
|
||||
out.insert(format!("assets/{rest}"), (None, name.to_owned()));
|
||||
}
|
||||
@@ -549,6 +556,15 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
/// Whether a zip entry's name, joined to the overlay folder, stays in it: `/`-separated parts
|
||||
/// (zip.rs turns `\` into `/`), none empty, `.` or `..`, and no `:` (a drive or a stream on
|
||||
/// Windows). A pack, a server's too, may name entries anything.
|
||||
fn stays_inside(name: &str) -> bool {
|
||||
!name.contains([':', '\0'])
|
||||
&& name
|
||||
.split('/')
|
||||
.all(|part| !part.is_empty() && part != "." && part != "..")
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
// Relative sources are relative to the process, not the overlay directory.
|
||||
@@ -566,31 +582,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
|
||||
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
|
||||
}
|
||||
}
|
||||
/// A directory junction, which Windows creates without privileges (a directory symlink needs
|
||||
/// developer mode). The mirror falls back to copying the tree when it fails.
|
||||
/// A directory junction (`horizon_threads::junction`); the mirror falls back to copying the
|
||||
/// tree when it fails.
|
||||
#[cfg(windows)]
|
||||
fn junction(target: &Path, at: &Path) -> io::Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
|
||||
// unquoted path would split or change the command. A Windows path cannot hold `"`.
|
||||
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
|
||||
let mut quoted = std::ffi::OsString::from("\"");
|
||||
quoted.push(std::path::absolute(path)?);
|
||||
quoted.push("\"");
|
||||
Ok(quoted)
|
||||
};
|
||||
let status = std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.raw_arg(quoted(at)?)
|
||||
.raw_arg(quoted(target)?)
|
||||
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()?;
|
||||
status
|
||||
.success()
|
||||
.then_some(())
|
||||
.ok_or_else(|| io::Error::other("mklink /J failed"))
|
||||
horizon_threads::junction(target, at)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn junction(_: &Path, _: &Path) -> io::Result<()> {
|
||||
@@ -1422,6 +1418,56 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
|
||||
fs::write(
|
||||
vanilla.join("assets/minecraft/textures/block/stone.png"),
|
||||
b"vanilla",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(base.join("packs")).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(base.join("packs/evil.zip")).unwrap());
|
||||
for (name, data) in [
|
||||
(
|
||||
"pack.mcmeta",
|
||||
&br#"{"pack":{"description":"d","pack_format":97}}"#[..],
|
||||
),
|
||||
("assets/minecraft/textures/block/stone.png", b"pack"),
|
||||
("assets/../../escaped.txt", b"out"),
|
||||
("assets/minecraft/../../../escaped_lang.json", b"{}"),
|
||||
("assets\\..\\..\\escaped_backslash.txt", b"out"),
|
||||
("assets/./minecraft/dot.txt", b"in"),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
assert_eq!(packs.len(), 1);
|
||||
let out = base.join("work/overlay");
|
||||
build_overlay(&vanilla, &packs, &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join("assets/minecraft/textures/block/stone.png")).unwrap(),
|
||||
b"pack"
|
||||
);
|
||||
for escaped in [
|
||||
"escaped.txt",
|
||||
"escaped_lang.json",
|
||||
"escaped_backslash.txt",
|
||||
"work/escaped.txt",
|
||||
"work/escaped_lang.json",
|
||||
"work/escaped_backslash.txt",
|
||||
] {
|
||||
assert!(!base.join(escaped).exists(), "{escaped} was written");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn built_in_and_folder_packs_give_their_pack_png() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
|
||||
@@ -1469,6 +1515,15 @@ mod tests {
|
||||
{"min_format":97,"max_format":[97,5],"directory":"also"},
|
||||
{"formats":50,"directory":"no"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["new", "also"]);
|
||||
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
{"formats":97,"directory":".."},
|
||||
{"formats":97,"directory":"../outside"},
|
||||
{"formats":97,"directory":"/etc"},
|
||||
{"formats":97,"directory":"a/b"},
|
||||
{"formats":97,"directory":"C:"},
|
||||
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
|
||||
@@ -9,7 +9,9 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Bump when overlay/merge semantics change. Markers are disposable cache data.
|
||||
// Version 4 mirrored every untouched directory file by file on Windows.
|
||||
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
|
||||
const VERSION: u32 = 5;
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
const VERSION: u32 = 6;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
@@ -18,13 +20,16 @@ pub(super) struct Dependencies {
|
||||
failed: bool,
|
||||
#[serde(skip)]
|
||||
tracking: bool,
|
||||
/// The vanilla root (absolute): its files are stamped without reading them.
|
||||
#[serde(skip)]
|
||||
vanilla: PathBuf,
|
||||
}
|
||||
impl Dependencies {
|
||||
fn record(&mut self, path: &Path, directory: bool) {
|
||||
if !self.tracking {
|
||||
return;
|
||||
}
|
||||
match dependency_signature(path, directory) {
|
||||
match dependency_signature(path, directory, &self.vanilla) {
|
||||
Ok(signature) => self.entries.push((path.into(), directory, signature)),
|
||||
Err(_) => self.failed = true,
|
||||
}
|
||||
@@ -38,7 +43,7 @@ impl Dependencies {
|
||||
fn valid(&self) -> bool {
|
||||
!self.failed
|
||||
&& self.entries.iter().all(|(path, directory, signature)| {
|
||||
dependency_signature(path, *directory).is_ok_and(|s| s == *signature)
|
||||
dependency_signature(path, *directory, &self.vanilla).is_ok_and(|s| s == *signature)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -72,13 +77,16 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
|
||||
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
|
||||
let meta = fs::metadata(path)?;
|
||||
hash.update(meta.len().to_le_bytes());
|
||||
field(hash, format!("{:?}", meta.modified()?).as_bytes());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// The change time stands in for the bytes here.
|
||||
let _ = read;
|
||||
hash.update(meta.dev().to_le_bytes());
|
||||
hash.update(meta.ino().to_le_bytes());
|
||||
hash.update(meta.ctime().to_le_bytes());
|
||||
@@ -87,8 +95,11 @@ fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Portable metadata lacks a change timestamp. Preserve content-based
|
||||
// validation for materialized files on those platforms.
|
||||
if meta.is_file() {
|
||||
// validation for the packs' materialized files on those platforms;
|
||||
// vanilla's (the launcher's, tens of thousands) keep to the size and
|
||||
// the write and creation times: reading each costs minutes on Windows,
|
||||
// where every open is scanned by the antivirus.
|
||||
if read && meta.is_file() {
|
||||
return content(hash, path);
|
||||
}
|
||||
if let Ok(created) = meta.created() {
|
||||
@@ -104,17 +115,18 @@ fn entries(path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
fn dependency_signature(path: &Path, directory: bool) -> io::Result<String> {
|
||||
fn dependency_signature(path: &Path, directory: bool, vanilla: &Path) -> io::Result<String> {
|
||||
let mut hash = Sha256::new();
|
||||
let read = !(path.starts_with(vanilla) || absolute(path).is_ok_and(|p| p.starts_with(vanilla)));
|
||||
match fs::metadata(path) {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => field(&mut hash, b"missing"),
|
||||
Err(e) => return Err(e),
|
||||
Ok(meta) if directory && meta.is_dir() => {
|
||||
field(&mut hash, b"directory");
|
||||
stamp(&mut hash, path)?;
|
||||
stamp(&mut hash, path, read)?;
|
||||
}
|
||||
Ok(_) if directory => field(&mut hash, b"not-directory"),
|
||||
Ok(_) => stamp(&mut hash, path)?,
|
||||
Ok(_) => stamp(&mut hash, path, read)?,
|
||||
}
|
||||
Ok(finish(hash))
|
||||
}
|
||||
@@ -190,7 +202,7 @@ fn tree_inner(
|
||||
if pack.is_none() {
|
||||
return content(hash, path);
|
||||
}
|
||||
stamp(hash, path)?;
|
||||
stamp(hash, path, true)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -283,11 +295,14 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
|
||||
let signature = pack_signature(packs);
|
||||
if let Ok(signature) = &signature
|
||||
&& let Ok(bytes) = fs::read(&marker_path)
|
||||
&& let Ok(marker) = serde_json::from_slice::<Marker>(&bytes)
|
||||
&& let Ok(mut marker) = serde_json::from_slice::<Marker>(&bytes)
|
||||
&& marker.version == VERSION
|
||||
&& marker.vanilla == vanilla_key
|
||||
&& marker.packs == *signature
|
||||
&& marker.dependencies.valid()
|
||||
&& {
|
||||
marker.dependencies.vanilla = vanilla_key.clone();
|
||||
marker.dependencies.valid()
|
||||
}
|
||||
&& out.is_dir()
|
||||
{
|
||||
return Ok(());
|
||||
@@ -300,6 +315,7 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
|
||||
}
|
||||
let mut dependencies = Dependencies {
|
||||
tracking: signature.is_ok(),
|
||||
vanilla: vanilla_key.clone(),
|
||||
..Dependencies::default()
|
||||
};
|
||||
build_overlay_uncached(vanilla, packs, out, &mut dependencies)?;
|
||||
@@ -642,15 +658,16 @@ mod tests {
|
||||
f.write("pack.zip", &archive(&files(b"bbb"), false));
|
||||
f.rebuild_matches(std::slice::from_ref(&pack));
|
||||
assert_eq!(fs::read(f.0.join("cached").join(name)).unwrap(), b"bbb");
|
||||
// The ZIP opens and enumerates, then its unsupported method fails after
|
||||
// mirror has created directories. No reusable marker survives.
|
||||
// A central directory whose second name runs past the archive fails
|
||||
// the build (an unreadable entry alone only costs that file). No
|
||||
// reusable marker survives.
|
||||
let mut broken = archive(&files(b"ccc"), false);
|
||||
let central = broken
|
||||
.windows(4)
|
||||
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
|
||||
.unwrap();
|
||||
let second = central + 46 + "pack.mcmeta".len();
|
||||
broken[second + 10..second + 12].copy_from_slice(&99u16.to_le_bytes());
|
||||
broken[second + 28..second + 30].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
f.write("pack.zip", &broken);
|
||||
let out = f.0.join("cached");
|
||||
assert!(
|
||||
|
||||
@@ -1064,8 +1064,9 @@ pub struct ParticleEngine {
|
||||
pub particle_level: u8,
|
||||
/// FireworkParticles.Starter instances.
|
||||
starters: Vec<Starter>,
|
||||
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
|
||||
/// Local sounds requested by particles (dripstone drips, firework bursts):
|
||||
/// (event, position, volume, pitch, distanceDelay).
|
||||
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
|
||||
/// How many of each type were made, for `horizon-ctl status`.
|
||||
made: std::collections::BTreeMap<&'static str, u64>,
|
||||
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
|
||||
@@ -3059,7 +3060,7 @@ impl ParticleEngine {
|
||||
(false, false) => "minecraft:entity.firework_rocket.blast",
|
||||
};
|
||||
let pitch = 0.95 + self.random.next_float() * 0.1;
|
||||
self.sounds.push((sound, s.at, 20.0, pitch));
|
||||
self.sounds.push((sound, s.at, 20.0, pitch, true));
|
||||
}
|
||||
if s.life % 2 == 0 && ((s.life / 2) as usize) < s.explosions.len() {
|
||||
let e = s.explosions[(s.life / 2) as usize].clone();
|
||||
@@ -3110,7 +3111,7 @@ impl ParticleEngine {
|
||||
"minecraft:entity.firework_rocket.twinkle"
|
||||
};
|
||||
let pitch = 0.9 + self.random.next_float() * 0.15;
|
||||
self.sounds.push((sound, s.at, 20.0, pitch));
|
||||
self.sounds.push((sound, s.at, 20.0, pitch, true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -3305,7 +3306,7 @@ impl ParticleEngine {
|
||||
}
|
||||
if let Some(sound) = sound {
|
||||
let volume = p.random.next_float() * 0.7 + 0.3;
|
||||
self.sounds.push((sound, p.position, volume, 1.0));
|
||||
self.sounds.push((sound, p.position, volume, 1.0, false));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,9 @@
|
||||
// The built-in stand-ins for the vanilla post shaders a `post_effect` chain can
|
||||
// name (`core/screenquad`, `post/rotscale`, `post/invert`, `post/blit`,
|
||||
// `post/bits`, `post/color_convolve`, `post/box_blur`, `post/spiderclip`).
|
||||
// Coordinates stay in the chain's own convention (v up from the bottom edge) and
|
||||
// are flipped only where a texture is read.
|
||||
// Targets keep GL's rows (row 0 is the bottom edge): the passes draw with clip y
|
||||
// negated (`vs_quad_gl`, `vs_rotscale`) and read their textures as they are;
|
||||
// `vs_quad` with `fs_blit` is the flip between the frame and a chain's `main`.
|
||||
|
||||
// OutSize, InSize | BlurSize, menu blur radius.
|
||||
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
|
||||
@@ -27,10 +28,10 @@ fn fcf(i: u32) -> f32 {
|
||||
return fs_cfg[i / 4u][i % 4u];
|
||||
}
|
||||
fn read_in(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(in_tex, in_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
return textureSampleLevel(in_tex, in_smp, uv, 0.0);
|
||||
}
|
||||
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
|
||||
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
|
||||
return textureSampleLevel(blur_tex, blur_smp, uv, 0.0);
|
||||
}
|
||||
|
||||
fn corner(i: u32) -> vec2<f32> {
|
||||
@@ -46,10 +47,19 @@ fn corner(i: u32) -> vec2<f32> {
|
||||
return out;
|
||||
}
|
||||
|
||||
@vertex fn vs_quad_gl(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
out.scaled = p;
|
||||
return out;
|
||||
}
|
||||
|
||||
@vertex fn vs_rotscale(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = corner(i);
|
||||
var out: Out;
|
||||
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
out.uv = p;
|
||||
let radians = vcf(4u) * 0.0174532925;
|
||||
let c = cos(radians);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Vanilla's post-processing GLSL (`#version 330` with `layout(std140)` uniform blocks
|
||||
//! and `uniform sampler2D`s, Vulkan-style `gl_VertexIndex`) turned into naga modules at
|
||||
//! run time, from whichever resource root is active, the way Horizon's shader packs turn
|
||||
//! their GLSL into naga (`horizon-shaders`' translator, which handles the compatibility
|
||||
//! dialect; the post shaders need only the block and sampler binding rewrite below).
|
||||
//!
|
||||
//! `ShaderManager`/`GlslPreprocessor` read `assets/<ns>/shaders/<path>.{vsh,fsh}` and
|
||||
//! resolve `#include <ns:path>` to `assets/<ns>/shaders/include/<path>`.
|
||||
//!
|
||||
//! The rewrite gives every uniform block a binding of bind group 0, shared by both
|
||||
//! stages, and splits each `uniform sampler2D NameSampler;` into a texture and a sampler
|
||||
//! object at the next two bindings, `NameSampler` becoming the combined
|
||||
//! `sampler2D(texture, sampler)` again where it is used. The vertex stage's `main` is
|
||||
//! wrapped so that clip y is negated: targets keep GL's rows (row 0 is the bottom edge),
|
||||
//! so `texCoord` reads the texel it would in vanilla.
|
||||
use naga::ShaderStage;
|
||||
use naga::front::glsl::{Frontend, Options};
|
||||
use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
use std::path::Path;
|
||||
|
||||
/// One program, ready for wgpu.
|
||||
pub struct Program {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// Every uniform block either stage declares, with its binding.
|
||||
pub blocks: Vec<(String, u32)>,
|
||||
/// Every sampler (`NameSampler`): the texture's binding; the sampler object is the next.
|
||||
pub samplers: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
fn is_ident(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_'
|
||||
}
|
||||
|
||||
/// The source with its comments blanked (same length, so offsets stay valid).
|
||||
fn blank_comments(src: &str) -> String {
|
||||
let b = src.as_bytes();
|
||||
let mut out = b.to_vec();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
|
||||
while i < b.len() && b[i] != b'\n' {
|
||||
out[i] = b' ';
|
||||
i += 1;
|
||||
}
|
||||
} else if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
|
||||
while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
|
||||
if b[i] != b'\n' {
|
||||
out[i] = b' ';
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
for slot in out.iter_mut().take((i + 2).min(b.len())).skip(i) {
|
||||
*slot = b' ';
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `GlslPreprocessor`'s `#include <ns:path>`.
|
||||
fn read_source(root: &Path, id: &str, ext: &str) -> Result<String, String> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = root
|
||||
.join("assets")
|
||||
.join(ns)
|
||||
.join("shaders")
|
||||
.join(format!("{path}.{ext}"));
|
||||
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
|
||||
}
|
||||
|
||||
fn include(root: &Path, src: &str, depth: u32) -> Result<String, String> {
|
||||
if depth > 8 {
|
||||
return Err("#include nested too deep".into());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for line in src.lines() {
|
||||
let t = line.trim_start();
|
||||
if let Some(rest) = t.strip_prefix("#include") {
|
||||
let name = rest
|
||||
.trim()
|
||||
.trim_start_matches(['<', '"'])
|
||||
.trim_end_matches(['>', '"'])
|
||||
.trim();
|
||||
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
let file = root
|
||||
.join("assets")
|
||||
.join(ns)
|
||||
.join("shaders")
|
||||
.join("include")
|
||||
.join(path);
|
||||
let text = std::fs::read_to_string(&file)
|
||||
.map_err(|e| format!("#include <{name}>: {}: {e}", file.display()))?;
|
||||
out.push_str(&include(root, &text, depth + 1)?);
|
||||
} else if t.starts_with("#version") || t.starts_with("#extension") {
|
||||
// Re-added below, for the translator.
|
||||
out.push('\n');
|
||||
continue;
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every `uniform` declaration of a source: (start of the declaration including a
|
||||
/// `layout(...)` before it, end of the type or of the whole `;`, type, name, is a sampler).
|
||||
fn declarations(src: &str) -> Vec<(usize, usize, String, String, bool)> {
|
||||
let plain = blank_comments(src);
|
||||
let b = plain.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
while let Some(found) = plain[at..].find("uniform") {
|
||||
let start = at + found;
|
||||
at = start + 7;
|
||||
if (start > 0 && is_ident(b[start - 1])) || b.get(start + 7).is_some_and(|c| is_ident(*c)) {
|
||||
continue;
|
||||
}
|
||||
let word = |from: usize| -> Option<(usize, usize)> {
|
||||
let mut s = from;
|
||||
while s < b.len() && b[s].is_ascii_whitespace() {
|
||||
s += 1;
|
||||
}
|
||||
let mut e = s;
|
||||
while e < b.len() && is_ident(b[e]) {
|
||||
e += 1;
|
||||
}
|
||||
(e > s).then_some((s, e))
|
||||
};
|
||||
let Some((ts, te)) = word(start + 7) else {
|
||||
continue;
|
||||
};
|
||||
let ty = plain[ts..te].to_owned();
|
||||
// The `layout(...)` right before `uniform`, if any.
|
||||
let before = plain[..start].trim_end();
|
||||
let decl_start = before
|
||||
.ends_with(')')
|
||||
.then(|| before.rfind("layout"))
|
||||
.flatten()
|
||||
.filter(|l| {
|
||||
before[*l + 6..].trim_start().starts_with('(') && !before[*l..].contains(';')
|
||||
})
|
||||
.unwrap_or(start);
|
||||
if ty.starts_with("sampler") {
|
||||
let Some((ns, ne)) = word(te) else { continue };
|
||||
let end = plain[ne..].find(';').map_or(ne, |p| ne + p + 1);
|
||||
out.push((decl_start, end, ty, plain[ns..ne].to_owned(), true));
|
||||
} else {
|
||||
let mut s = te;
|
||||
while s < b.len() && b[s].is_ascii_whitespace() {
|
||||
s += 1;
|
||||
}
|
||||
if b.get(s) == Some(&b'{') {
|
||||
out.push((decl_start, te, String::new(), ty, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The source with each declaration given its binding from the shared tables.
|
||||
fn rewrite(src: &str, blocks: &[(String, u32)], samplers: &[(String, u32)]) -> String {
|
||||
let mut out = String::new();
|
||||
let mut pos = 0;
|
||||
for (start, end, ty, name, sampler) in declarations(src) {
|
||||
out.push_str(&src[pos..start]);
|
||||
pos = end;
|
||||
if sampler {
|
||||
let tex = samplers.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
|
||||
let kind = if ty == "sampler2D" { "texture2D" } else { &ty };
|
||||
out.push_str(&format!(
|
||||
"layout(set = 0, binding = {tex}) uniform {kind} {name}_hz_tex; \
|
||||
layout(set = 0, binding = {}) uniform sampler {name}_hz_smp;\n\
|
||||
#define {name} sampler2D({name}_hz_tex, {name}_hz_smp)\n",
|
||||
tex + 1
|
||||
));
|
||||
} else {
|
||||
let binding = blocks.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
|
||||
out.push_str(&format!(
|
||||
"layout(std140, set = 0, binding = {binding}) uniform {name}"
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push_str(&src[pos..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Replaces the word `main` of `void main(` with `hz_main`.
|
||||
fn wrap_main(src: &str) -> Result<String, String> {
|
||||
let plain = blank_comments(src);
|
||||
let mut at = 0;
|
||||
while let Some(found) = plain[at..].find("main") {
|
||||
let p = at + found;
|
||||
at = p + 4;
|
||||
let b = plain.as_bytes();
|
||||
if (p > 0 && is_ident(b[p - 1])) || b.get(p + 4).is_some_and(|c| is_ident(*c)) {
|
||||
continue;
|
||||
}
|
||||
if plain[..p].trim_end().ends_with("void") && plain[p + 4..].trim_start().starts_with('(') {
|
||||
let mut out = String::with_capacity(src.len() + 80);
|
||||
out.push_str(&src[..p]);
|
||||
out.push_str("hz_main");
|
||||
out.push_str(&src[p + 4..]);
|
||||
out.push_str("\nvoid main() { hz_main(); gl_Position.y = -gl_Position.y; }\n");
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
Err("no main".into())
|
||||
}
|
||||
|
||||
fn parse(stage: ShaderStage, text: &str) -> Result<naga::Module, String> {
|
||||
let module = Frontend::default()
|
||||
.parse(&Options::from(stage), text)
|
||||
.map_err(|e| {
|
||||
e.errors
|
||||
.iter()
|
||||
.map(|e| e.kind.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})?;
|
||||
Validator::new(ValidationFlags::all(), Capabilities::empty())
|
||||
.validate(&module)
|
||||
.map_err(|e| format!("{:?}", e.into_inner()))?;
|
||||
Ok(module)
|
||||
}
|
||||
|
||||
/// Translates the vertex and fragment shader a pass names (`minecraft:post/invert`).
|
||||
pub fn translate(root: &Path, vertex: &str, fragment: &str) -> Result<Program, String> {
|
||||
let vs = include(root, &read_source(root, vertex, "vsh")?, 0)?;
|
||||
let fs = include(root, &read_source(root, fragment, "fsh")?, 0)?;
|
||||
// Blocks and samplers are shared by name across the stages, so both agree on a binding.
|
||||
let mut blocks: Vec<(String, u32)> = Vec::new();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for src in [&vs, &fs] {
|
||||
for (_, _, _, name, sampler) in declarations(src) {
|
||||
if sampler {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
} else if !blocks.iter().any(|(n, _)| *n == name) {
|
||||
blocks.push((name, blocks.len() as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = blocks.len() as u32;
|
||||
let samplers: Vec<(String, u32)> = names.into_iter().zip((first..).step_by(2)).collect();
|
||||
let vs = wrap_main(&rewrite(&vs, &blocks, &samplers))?;
|
||||
let fs = rewrite(&fs, &blocks, &samplers);
|
||||
let vertex_module = parse(ShaderStage::Vertex, &format!("#version 450\n{vs}"))
|
||||
.map_err(|e| format!("{vertex}: {e}"))?;
|
||||
let fragment_module = parse(ShaderStage::Fragment, &format!("#version 450\n{fs}"))
|
||||
.map_err(|e| format!("{fragment}: {e}"))?;
|
||||
Ok(Program {
|
||||
vertex: vertex_module,
|
||||
fragment: fragment_module,
|
||||
blocks,
|
||||
samplers,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn root() -> Option<std::path::PathBuf> {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)?
|
||||
.join(".local/horizon-client/26.3");
|
||||
dir.join("assets/minecraft/shaders/post/blit.fsh")
|
||||
.exists()
|
||||
.then_some(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_post_shaders_translate() {
|
||||
let Some(root) = root() else { return };
|
||||
let vs = ["minecraft:core/screenquad", "minecraft:post/rotscale"];
|
||||
let fs = [
|
||||
"bits",
|
||||
"blit",
|
||||
"box_blur",
|
||||
"color_convolve",
|
||||
"entity_outline_box_blur",
|
||||
"entity_sobel",
|
||||
"invert",
|
||||
"spiderclip",
|
||||
];
|
||||
for f in fs {
|
||||
for v in vs {
|
||||
let name = format!("minecraft:post/{f}");
|
||||
let p = translate(&root, v, &name);
|
||||
// rotscale's outputs only match spiderclip, but each stage translates alone.
|
||||
if let Err(e) = &p {
|
||||
panic!("{v} + {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_and_block_bindings() {
|
||||
let src = "layout(std140) uniform A {\n float x;\n};\nuniform sampler2D InSampler; // c\nvoid main(){}\n";
|
||||
let d = declarations(src);
|
||||
assert_eq!(d.len(), 2);
|
||||
let out = rewrite(src, &[("A".into(), 0)], &[("InSampler".into(), 1)]);
|
||||
assert!(out.contains("layout(std140, set = 0, binding = 0) uniform A {"));
|
||||
assert!(out.contains("binding = 1) uniform texture2D InSampler_hz_tex;"));
|
||||
assert!(out.contains("binding = 2) uniform sampler InSampler_hz_smp;"));
|
||||
assert!(wrap_main("void main() {}").unwrap().contains("hz_main"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Input method composition text (`PreeditEvent`, `IMEPreeditOverlay`,
|
||||
//! `TextInputManager`): the text an input method is still composing, shown
|
||||
//! under the focused text field's caret, and the area the system's candidate
|
||||
//! window is placed at.
|
||||
//!
|
||||
//! Vanilla hands each `PreeditEvent` to the focused widget, which keeps an
|
||||
//! overlay and reports its caret every frame. Horizon draws its fields from
|
||||
//! many places, so the event is kept here and every focused field calls
|
||||
//! [`report_caret`] where it draws its caret; the game draws the overlay last
|
||||
//! with [`draw_overlay`] and applies [`take_text_input_area`] to the window.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::{Style, Text},
|
||||
ui::nine_slice,
|
||||
};
|
||||
use std::{sync::Mutex, time::Instant};
|
||||
|
||||
/// `PreeditEvent`: the composition, its caret and the blocks of text the
|
||||
/// input method set apart (the one being converted is `focused_block`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PreeditEvent {
|
||||
pub full_text: String,
|
||||
/// Byte offset of the caret in `full_text` (vanilla's UTF-16 offset).
|
||||
pub caret_position: usize,
|
||||
pub blocks: Vec<String>,
|
||||
pub focused_block: usize,
|
||||
}
|
||||
|
||||
impl PreeditEvent {
|
||||
/// `PreeditEvent.fromSdlTextEditing`: `selection_start` and
|
||||
/// `selection_length` are in code points; a negative start puts the caret
|
||||
/// at the end. Empty text is no composition.
|
||||
pub fn from_sdl_text_editing(
|
||||
text: Option<&str>,
|
||||
selection_start: i32,
|
||||
selection_length: i32,
|
||||
) -> Option<Self> {
|
||||
let text = text.filter(|t| !t.is_empty())?;
|
||||
let total = text.chars().count();
|
||||
let caret_codepoint = if selection_start < 0 {
|
||||
total
|
||||
} else {
|
||||
(selection_start as usize).min(total)
|
||||
};
|
||||
let caret_char = offset_by_codepoints(text, caret_codepoint);
|
||||
let single = |caret| Self {
|
||||
full_text: text.to_owned(),
|
||||
caret_position: caret,
|
||||
blocks: vec![text.to_owned()],
|
||||
focused_block: 0,
|
||||
};
|
||||
if selection_length <= 0 {
|
||||
return Some(single(caret_char));
|
||||
}
|
||||
let end_codepoint = (caret_codepoint + selection_length as usize).min(total);
|
||||
let end_char = offset_by_codepoints(text, end_codepoint);
|
||||
if end_char <= caret_char {
|
||||
return Some(single(caret_char));
|
||||
}
|
||||
let mut blocks = Vec::new();
|
||||
let mut focused_block = 0;
|
||||
if caret_char > 0 {
|
||||
blocks.push(text[..caret_char].to_owned());
|
||||
focused_block = 1;
|
||||
}
|
||||
blocks.push(text[caret_char..end_char].to_owned());
|
||||
if end_char < text.len() {
|
||||
blocks.push(text[end_char..].to_owned());
|
||||
}
|
||||
Some(Self {
|
||||
full_text: text.to_owned(),
|
||||
caret_position: caret_char,
|
||||
blocks,
|
||||
focused_block,
|
||||
})
|
||||
}
|
||||
|
||||
/// winit's `Ime::Preedit`: `cursor` is the byte range of the part being
|
||||
/// converted, `None` when the input method hides its cursor. SDL's
|
||||
/// `TEXTEDITING` says the same in code points.
|
||||
pub fn from_winit(text: &str, cursor: Option<(usize, usize)>) -> Option<Self> {
|
||||
let (start, length) = match cursor {
|
||||
Some((from, to)) => {
|
||||
let from = from.min(text.len());
|
||||
let to = to.clamp(from, text.len());
|
||||
let start = text.get(..from).map_or(-1, |s| s.chars().count() as i32);
|
||||
let length = text.get(from..to).map_or(0, |s| s.chars().count() as i32);
|
||||
(start, length)
|
||||
}
|
||||
None => (-1, 0),
|
||||
};
|
||||
Self::from_sdl_text_editing(Some(text), start, length)
|
||||
}
|
||||
|
||||
/// `PreeditEvent.toFormattedText`.
|
||||
pub fn to_formatted_text(&self, focused_style: &Style) -> Text {
|
||||
if self.blocks.len() == 1 {
|
||||
return Text::literal(self.blocks[0].clone()).with_style(focused_style);
|
||||
}
|
||||
let mut result = Text::empty();
|
||||
for (i, block) in self.blocks.iter().enumerate() {
|
||||
let mut part = Text::literal(block.clone());
|
||||
if i == self.focused_block {
|
||||
part = part.with_style(focused_style);
|
||||
}
|
||||
result.push(part);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// `String.offsetByCodePoints(0, n)`: the byte offset after `n` characters.
|
||||
fn offset_by_codepoints(text: &str, n: usize) -> usize {
|
||||
text.char_indices().nth(n).map_or(text.len(), |(i, _)| i)
|
||||
}
|
||||
|
||||
/// The caret a field reported this frame, in GUI pixels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Caret {
|
||||
x: f32,
|
||||
y: f32,
|
||||
input_height: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
|
||||
preedit: Option<(PreeditEvent, Instant)>,
|
||||
caret: Option<Caret>,
|
||||
area: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
preedit: None,
|
||||
caret: None,
|
||||
area: None,
|
||||
});
|
||||
|
||||
fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.textEditing`: the input method's composition changed
|
||||
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
|
||||
pub fn set_preedit(event: Option<PreeditEvent>) {
|
||||
let mut s = state();
|
||||
s.preedit = event.map(|e| (e, Instant::now()));
|
||||
}
|
||||
|
||||
/// Whether text is being composed.
|
||||
pub fn composing() -> bool {
|
||||
state().preedit.is_some()
|
||||
}
|
||||
|
||||
/// What the focused field's `extractRenderState` ends with: its caret is at
|
||||
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
|
||||
/// Without a composition that is the area for the candidate window; with one
|
||||
/// it is where the overlay goes ([`draw_overlay`]).
|
||||
pub fn report_caret(x: f32, y: f32, input_height: f32) {
|
||||
state().caret = Some(Caret { x, y, input_height });
|
||||
}
|
||||
|
||||
/// `IMEPreeditOverlay.extractRenderState` for the caret reported this frame,
|
||||
/// drawn on top of everything in `batch`. Without a composition the caret is
|
||||
/// the text input area (`TextInputManager.setTextInputArea`).
|
||||
pub fn draw_overlay(batch: &mut HudBatch, atlas: &HudAtlas) {
|
||||
let mut s = state();
|
||||
let Some(caret) = s.caret.take() else {
|
||||
return;
|
||||
};
|
||||
let Some((event, init)) = s.preedit.as_ref() else {
|
||||
s.area = Some([
|
||||
caret.x,
|
||||
caret.y,
|
||||
caret.x + 1.0,
|
||||
caret.y + caret.input_height,
|
||||
]);
|
||||
return;
|
||||
};
|
||||
let font = &atlas.font;
|
||||
let focused = Style {
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let black = 0xFF00_0000;
|
||||
let text = event.to_formatted_text(&focused).with_color(black);
|
||||
let width = font.width_text(&text);
|
||||
let caret_pos = font.width(&event.full_text[..event.caret_position]);
|
||||
let blink_visible = (init.elapsed().as_millis() / 300).is_multiple_of(2);
|
||||
let line = 9.0;
|
||||
let (gui_w, gui_h) = (batch.width, batch.height);
|
||||
let mut left = caret.x;
|
||||
let mut right = left + width;
|
||||
if right > gui_w {
|
||||
left = gui_w - width;
|
||||
right = left + width;
|
||||
}
|
||||
let input_bottom = caret.y + caret.input_height;
|
||||
let mut bottom = input_bottom + 4.0 + line;
|
||||
if bottom > gui_h {
|
||||
bottom = caret.y - 4.0 - line;
|
||||
}
|
||||
let top = bottom - line;
|
||||
s.area = Some([left, top, right, bottom]);
|
||||
drop(s);
|
||||
nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/widget/preedit",
|
||||
left - 5.0,
|
||||
top - 5.0,
|
||||
right - left + 10.0,
|
||||
bottom - top + 10.0,
|
||||
u32::MAX,
|
||||
);
|
||||
batch.text_styled(atlas, &text, left, top, black, false);
|
||||
if blink_visible {
|
||||
batch.fill(atlas, left + caret_pos, top - 1.0, 1.0, 11.0, black);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text input area (`x0, y0, x1, y1` in GUI pixels) set since the last
|
||||
/// call, for `Window.set_ime_cursor_area`.
|
||||
pub fn take_text_input_area() -> Option<[f32; 4]> {
|
||||
state().area.take()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_text_is_no_composition() {
|
||||
assert_eq!(PreeditEvent::from_sdl_text_editing(None, 0, 0), None);
|
||||
assert_eq!(PreeditEvent::from_sdl_text_editing(Some(""), 0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_start_puts_the_caret_at_the_end() {
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("abc"), -1, 0).unwrap();
|
||||
assert_eq!(
|
||||
(e.caret_position, e.blocks, e.focused_block),
|
||||
(3, vec!["abc".into()], 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caret_counts_code_points() {
|
||||
// Two code points before the caret, the second outside the BMP.
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("a\u{1F600}b"), 2, 0).unwrap();
|
||||
assert_eq!(e.caret_position, "a\u{1F600}".len());
|
||||
let past = PreeditEvent::from_sdl_text_editing(Some("ab"), 9, 0).unwrap();
|
||||
assert_eq!(past.caret_position, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_splits_the_text_into_blocks() {
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
|
||||
assert_eq!(e.blocks, ["ab", "cd", "ef"]);
|
||||
assert_eq!((e.focused_block, e.caret_position), (1, 2));
|
||||
let first = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 0, 3).unwrap();
|
||||
assert_eq!(first.blocks, ["abc", "def"]);
|
||||
assert_eq!(first.focused_block, 0);
|
||||
let last = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 3, 99).unwrap();
|
||||
assert_eq!(last.blocks, ["abc", "def"]);
|
||||
assert_eq!(last.focused_block, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winit_byte_ranges_become_code_points() {
|
||||
let text = "\u{3042}\u{3044}\u{3046}";
|
||||
// Converting the middle character (bytes 3..6).
|
||||
let e = PreeditEvent::from_winit(text, Some((3, 6))).unwrap();
|
||||
assert_eq!(e.blocks, ["\u{3042}", "\u{3044}", "\u{3046}"]);
|
||||
assert_eq!(e.focused_block, 1);
|
||||
// A hidden cursor sits at the end.
|
||||
let hidden = PreeditEvent::from_winit(text, None).unwrap();
|
||||
assert_eq!(hidden.caret_position, text.len());
|
||||
assert_eq!(hidden.blocks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_focused_block_is_underlined() {
|
||||
let focused = Style {
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
|
||||
let styled: Vec<(char, bool)> = e
|
||||
.to_formatted_text(&focused)
|
||||
.chars()
|
||||
.map(|(c, s)| (c, s.underlined == Some(true)))
|
||||
.collect();
|
||||
let underlined: String = styled.iter().filter(|x| x.1).map(|x| x.0).collect();
|
||||
assert_eq!(underlined, "cd");
|
||||
assert_eq!(styled.len(), 6);
|
||||
let one = PreeditEvent::from_sdl_text_editing(Some("ab"), 0, 0).unwrap();
|
||||
assert!(
|
||||
one.to_formatted_text(&focused)
|
||||
.chars()
|
||||
.all(|(_, s)| s.underlined == Some(true))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! outline, nether portal swirl). Positions are clip-space quads; textures
|
||||
//! come from the shared 2D atlas and may repeat.
|
||||
use crate::{
|
||||
effects::EffectInstance,
|
||||
hud::{Rect, TextureFlags},
|
||||
hud_layout::GuiDraw,
|
||||
};
|
||||
@@ -271,6 +272,28 @@ impl ScreenEffects {
|
||||
}
|
||||
}
|
||||
|
||||
/// The nausea effect's blend-out advance: over its last `NAUSEA_BLEND_OUT`
|
||||
/// ticks it has no effect (`MobEffects.NAUSEA`, `endsWithin(60)`).
|
||||
const NAUSEA_BLEND_OUT: i32 = 60;
|
||||
|
||||
/// `MobEffectInstance.BlendState.hasEffect`: still past the blend-out point,
|
||||
/// so the effect counts as on (`!endsWithin(60)` for `MobEffects.NAUSEA`).
|
||||
pub fn nausea_has_effect(e: &EffectInstance) -> bool {
|
||||
!e.ends_within(NAUSEA_BLEND_OUT)
|
||||
}
|
||||
|
||||
/// A new nausea instance's `BlendState`: from nothing, or, when the server
|
||||
/// asked for no blending, at once (`MobEffectInstance.skipBlending`'s
|
||||
/// `BlendState.setImmediate`).
|
||||
pub fn nausea_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32) {
|
||||
let factor = if blend || !nausea_has_effect(e) {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(factor, factor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! dimension type allows.
|
||||
|
||||
/// Section indexes of one column, 0 being the lowest.
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct SectionMask([u64; 4]);
|
||||
|
||||
impl SectionMask {
|
||||
|
||||
@@ -497,10 +497,13 @@ impl Settings {
|
||||
/// `Options.createInformation`: what the server learns about this player.
|
||||
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
|
||||
ClientInformation {
|
||||
language: self.language.clone(),
|
||||
chat_visibility: self.chat_visibility as i32,
|
||||
chat_colors: self.chat_colors,
|
||||
model_customisation: self.file.model_customisation(),
|
||||
main_hand: i32::from(!self.main_hand_left),
|
||||
allows_listing: self.file.bool("allowServerListing"),
|
||||
particle_status: i32::from(self.particles),
|
||||
..ClientInformation::new(view_distance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,50 @@ pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
|
||||
let (value, signature) = parse_profile(profile).textures?;
|
||||
unpack(&value, signature, &TEXTURE_URIS).skin
|
||||
}
|
||||
|
||||
/// The textures of a Minecraft services `/minecraft/profile` answer: the
|
||||
/// `ACTIVE` skin and cape (`MinecraftProfileResponse`). Unsigned, like the
|
||||
/// local player's own textures, and only URLs under the allowed domains count.
|
||||
pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
|
||||
let active = |key: &str| {
|
||||
profile
|
||||
.get(key)?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|t| t.get("state").and_then(|s| s.as_str()) == Some("ACTIVE"))
|
||||
};
|
||||
let skin_entry = active("skins");
|
||||
let skin = skin_entry
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
let slim = skin_entry
|
||||
.and_then(|t| t.get("variant"))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("slim"));
|
||||
let cape = active("capes")
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
Textures {
|
||||
skin,
|
||||
slim,
|
||||
cape,
|
||||
elytra: None,
|
||||
signed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `profile_skin_url` on a block entity's `profile` NBT (`SkullBlockEntity`):
|
||||
/// the same `properties` list, held as a compound. A name-only profile (a
|
||||
/// string tag) has no properties and no skin URL.
|
||||
@@ -844,6 +888,12 @@ impl SkinSlots {
|
||||
kept: Default::default(),
|
||||
}
|
||||
}
|
||||
/// The atlas was replaced (a resource pack reload): every slot is blank,
|
||||
/// so no skin counts as stored and each uploads again.
|
||||
pub fn forget(&mut self) {
|
||||
self.slots.clear();
|
||||
self.kept.clear();
|
||||
}
|
||||
/// Whether `location` is in a slot.
|
||||
pub fn contains(&self, location: &str) -> bool {
|
||||
self.slots.iter().any(|(l, _)| l == location)
|
||||
@@ -910,6 +960,23 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
|
||||
/// table holds nothing and hands out slots from the start again, even for
|
||||
/// skins drawn in the last frame.
|
||||
#[test]
|
||||
fn a_new_atlas_forgets_every_skin_slot() {
|
||||
use super::SkinSlots;
|
||||
let mut slots = SkinSlots::new(2);
|
||||
assert_eq!(slots.place("skins/a".into()), Some((0, None)));
|
||||
assert_eq!(slots.place("skins/b".into()), Some((1, None)));
|
||||
assert_eq!(slots.place("skins/c".into()), None);
|
||||
slots.forget();
|
||||
assert!(!slots.contains("skins/a"));
|
||||
assert!(!slots.contains("skins/b"));
|
||||
assert_eq!(slots.place("skins/c".into()), Some((0, None)));
|
||||
assert_eq!(slots.place("skins/a".into()), Some((1, None)));
|
||||
}
|
||||
|
||||
/// The launcher's offline skin: a 64x64 or legacy 64x32 PNG laid out as
|
||||
/// a downloaded skin, slim only for the `slim` model; anything else is
|
||||
/// refused and the player keeps their default skin.
|
||||
@@ -1010,6 +1077,36 @@ mod tests {
|
||||
assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_profile_answers_give_the_active_skin_and_cape() {
|
||||
let profile = serde_json::json!({
|
||||
"skins": [
|
||||
{"state": "INACTIVE", "url": "https://textures.minecraft.net/texture/old"},
|
||||
{"state": "ACTIVE", "url": "https://textures.minecraft.net/texture/skin1", "variant": "CLASSIC"}
|
||||
],
|
||||
"capes": [{"state": "ACTIVE", "url": "http://textures.minecraft.net/texture/cape1"}]
|
||||
});
|
||||
let t = super::services_profile_textures(&profile);
|
||||
assert_eq!(
|
||||
t.skin.as_deref(),
|
||||
Some("https://textures.minecraft.net/texture/skin1")
|
||||
);
|
||||
assert_eq!(
|
||||
t.cape.as_deref(),
|
||||
Some("http://textures.minecraft.net/texture/cape1")
|
||||
);
|
||||
assert!(!t.signed);
|
||||
let elsewhere = serde_json::json!({"skins": [{"state": "ACTIVE", "url": "https://evil.example/texture/x"}]});
|
||||
assert_eq!(
|
||||
super::services_profile_textures(&elsewhere),
|
||||
super::Textures::default()
|
||||
);
|
||||
assert_eq!(
|
||||
super::services_profile_textures(&serde_json::json!({})),
|
||||
super::Textures::default()
|
||||
);
|
||||
}
|
||||
|
||||
/// `getByName` and `getProfileById` answer from the services the
|
||||
/// discovery document names; a profile resolves through both.
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::biome::BiomeZoom;
|
||||
@@ -234,10 +234,39 @@ struct Shared {
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
/// Only workers use this pool; frames never wait for its lock. Retaining
|
||||
/// cleared buffers avoids racing the renderer for the process heap.
|
||||
meshes: Mutex<MeshPool>,
|
||||
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
|
||||
/// job since: the last of them gives the freed memory back to the OS.
|
||||
released: AtomicUsize,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct MeshPool {
|
||||
meshes: Vec<SectionMesh>,
|
||||
bytes: usize,
|
||||
}
|
||||
impl MeshPool {
|
||||
const BYTES: usize = 16 << 20;
|
||||
const COUNT: usize = 128;
|
||||
fn take(&mut self) -> SectionMesh {
|
||||
let mesh = self.meshes.pop().unwrap_or_default();
|
||||
self.bytes -= mesh.capacity_bytes();
|
||||
mesh
|
||||
}
|
||||
/// A rejected mesh is dropped by the caller, after releasing the lock.
|
||||
fn put(&mut self, mut mesh: SectionMesh) -> Option<SectionMesh> {
|
||||
let bytes = mesh.capacity_bytes();
|
||||
if bytes == 0 || self.meshes.len() == Self::COUNT || bytes > Self::BYTES - self.bytes {
|
||||
return Some(mesh);
|
||||
}
|
||||
mesh.clear();
|
||||
self.bytes += bytes;
|
||||
self.meshes.push(mesh);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
/// half way through a send, and a worker descheduled there on a busy machine
|
||||
@@ -536,6 +565,7 @@ impl Streaming {
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
meshes: Mutex::new(MeshPool::default()),
|
||||
released: AtomicUsize::new(0),
|
||||
});
|
||||
let workers = threads.max(1);
|
||||
@@ -556,6 +586,7 @@ impl Streaming {
|
||||
// the worker across mesh jobs, like vanilla's per-thread cache:
|
||||
// cloning it per job copied every state and face shape.
|
||||
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
|
||||
let mut mesh_scratch = MeshScratch::default();
|
||||
loop {
|
||||
let job = {
|
||||
let mut queue = shared.queue.lock().unwrap();
|
||||
@@ -580,10 +611,12 @@ impl Streaming {
|
||||
if wait.timed_out() && queue.is_empty() {
|
||||
// A server nobody is on, a client standing still:
|
||||
// free the generator's buffers (and the mesher's
|
||||
// face cache) until the next job needs them.
|
||||
// face cache and scratch) until the next job
|
||||
// needs them.
|
||||
drop(queue);
|
||||
generator.idle(&mut worker);
|
||||
faces = None;
|
||||
mesh_scratch = MeshScratch::default();
|
||||
released = true;
|
||||
// The allocator keeps freed pages until memory
|
||||
// runs short: once every worker has freed its own,
|
||||
@@ -591,6 +624,12 @@ impl Streaming {
|
||||
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
|
||||
== workers
|
||||
{
|
||||
// No worker needs retained mesh capacity now.
|
||||
// Drop it outside the pool lock before asking
|
||||
// the allocator to return freed pages.
|
||||
let meshes =
|
||||
std::mem::take(&mut *shared.meshes.lock().unwrap());
|
||||
drop(meshes);
|
||||
horizon_threads::release_free_memory();
|
||||
}
|
||||
queue = shared.queue.lock().unwrap();
|
||||
@@ -620,7 +659,10 @@ impl Streaming {
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
drop(meshes);
|
||||
for (_, mesh) in meshes {
|
||||
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
||||
drop(rejected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
@@ -648,7 +690,13 @@ impl Streaming {
|
||||
biomes: &context.biomes,
|
||||
visibility,
|
||||
};
|
||||
let mut mesh = mesher.mesh(&borrow(®ion), sy);
|
||||
let recycled = shared.meshes.lock().unwrap().take();
|
||||
let mut mesh = mesher.mesh_reusing(
|
||||
&borrow(®ion),
|
||||
sy,
|
||||
recycled,
|
||||
&mut mesh_scratch,
|
||||
);
|
||||
mesh.sort_solid();
|
||||
faces = Some((context.tables.clone(), mesher.visibility));
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
@@ -958,6 +1006,21 @@ impl Streaming {
|
||||
}
|
||||
Some(Box::new(region))
|
||||
}
|
||||
/// Whether `light_region` would find all nine columns: the same walk
|
||||
/// without building the snapshot, so the scheduler can rank the ready
|
||||
/// columns before any region is cloned. A proto neighbour whose light
|
||||
/// view turns out empty is skipped when its region is built, and asked
|
||||
/// for again, as `light_region` returning `None` always was.
|
||||
fn light_region_ready(&self, world: &World, x: i32, z: i32) -> bool {
|
||||
light_region_ready(
|
||||
world,
|
||||
|x, z| self.in_range(x, z, 1),
|
||||
&self.decorated,
|
||||
&self.protos,
|
||||
x,
|
||||
z,
|
||||
)
|
||||
}
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
@@ -1357,13 +1420,29 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
self.schedule_generation(world, workers);
|
||||
// Lighting once all neighbours exist.
|
||||
let light: Vec<_> = world.dirty_light.iter().copied().collect();
|
||||
for (x, z) in light {
|
||||
// Lighting once all neighbours exist, nearest first and only as
|
||||
// much of it as the workers can chew. Vanilla's light engine also
|
||||
// hands a bounded queue to its workers and reschedules what did
|
||||
// not fit by distance; submitted whole, the light jobs of every
|
||||
// unlit column at once — each carrying a 3x3 region snapshot —
|
||||
// grew with the render distance and outlived the columns they
|
||||
// were queued for (#3629). What stays in `dirty_light` is asked
|
||||
// for again, from the current centre, as capacity frees.
|
||||
let mut light: Vec<(i64, i32, i32)> = Vec::new();
|
||||
let mut gone: Vec<(i32, i32)> = Vec::new();
|
||||
for &(x, z) in world.dirty_light.iter() {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
world.dirty_light.remove(&(x, z));
|
||||
continue;
|
||||
gone.push((x, z));
|
||||
} else if self.light_region_ready(world, x, z) {
|
||||
light.push((self.distance(x, z), x, z));
|
||||
}
|
||||
}
|
||||
for key in gone {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1409,6 +1488,7 @@ impl Streaming {
|
||||
}
|
||||
// Column by column: the checks below hold for all of a column's
|
||||
// sections, and columns waiting for light cost one look per frame.
|
||||
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
|
||||
let columns: Vec<_> = world.dirty_sections.columns().collect();
|
||||
for ((x, z), sections) in columns {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
@@ -1437,6 +1517,18 @@ impl Streaming {
|
||||
if !ready {
|
||||
continue;
|
||||
}
|
||||
candidates.push((self.distance(x, z), x, z, sections));
|
||||
}
|
||||
// Nearest first, and only as many sections as the workers can chew.
|
||||
// Every ready column used to be submitted at once, so at a wide
|
||||
// render distance the queue — and `meshing`, and each job's 3x3
|
||||
// region snapshot — grew with the number of loaded columns and the
|
||||
// jobs went stale long before a worker reached them (#3629). What
|
||||
// stays dirty is asked for again, from the current centre, as
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -2051,10 +2143,112 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
}
|
||||
*queue = BinaryHeap::from(jobs);
|
||||
}
|
||||
/// Whether the 3x3 a light job for (x, z) reads is all there: every
|
||||
/// neighbour a finished column, or a decorated proto chunk past the area
|
||||
/// (the last FULL ring lights against neighbours at FEATURES, as
|
||||
/// `Streaming::light_region` reads them).
|
||||
fn light_region_ready(
|
||||
world: &World,
|
||||
in_range: impl Fn(i32, i32) -> bool,
|
||||
decorated: &FxHashSet<(i32, i32)>,
|
||||
protos: &FxHashMap<(i32, i32), Option<Proto>>,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> bool {
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
let n = (x + dx - 1, z + dz - 1);
|
||||
if world.chunks.contains_key(&n)
|
||||
|| (!in_range(n.0, n.1)
|
||||
&& decorated.contains(&n)
|
||||
&& matches!(protos.get(&n), Some(Some(_))))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The nearest `room` of the `candidates`, ordered: the light and mesh
|
||||
/// stages submit bounded work, chosen from everything that is ready each
|
||||
/// `update`, so what runs is always the nearest of what is left — chosen
|
||||
/// again, from the current centre, as capacity frees, the way vanilla's
|
||||
/// chunk task queues follow the current ticket levels. The selection is
|
||||
/// O(n); the sort orders only what is submitted.
|
||||
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
|
||||
if candidates.len() > room {
|
||||
candidates.select_nth_unstable(room);
|
||||
candidates.truncate(room);
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
|
||||
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
|
||||
mesh.block_entities.push(([1, 2, 3], 7));
|
||||
mesh.visibility = 0;
|
||||
mesh.sides = crate::mesher::Sides::unsorted(16);
|
||||
let capacities = mesh.capacity_bytes();
|
||||
let pointers = (mesh.solid.as_ptr(), mesh.translucent.as_ptr());
|
||||
let mut pool = MeshPool::default();
|
||||
assert!(pool.put(mesh).is_none());
|
||||
assert_eq!(pool.bytes, capacities);
|
||||
let reused = pool.take();
|
||||
assert_eq!(reused.capacity_bytes(), capacities);
|
||||
assert_eq!(
|
||||
pointers,
|
||||
(reused.solid.as_ptr(), reused.translucent.as_ptr())
|
||||
);
|
||||
assert!(reused.solid.is_empty());
|
||||
assert!(reused.translucent.is_empty());
|
||||
assert!(reused.block_entities.is_empty());
|
||||
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
|
||||
assert_eq!(reused.sides, crate::mesher::Sides::default());
|
||||
assert_eq!(pool.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_pool_caps_retained_count_and_bytes() {
|
||||
let mut pool = MeshPool::default();
|
||||
for _ in 0..MeshPool::COUNT {
|
||||
let mut mesh = SectionMesh::default();
|
||||
mesh.solid.reserve_exact(1);
|
||||
assert!(pool.put(mesh).is_none());
|
||||
}
|
||||
let mut extra = SectionMesh::default();
|
||||
extra.solid.reserve_exact(1);
|
||||
assert!(pool.put(extra).is_some());
|
||||
for _ in 0..MeshPool::COUNT {
|
||||
pool.take();
|
||||
}
|
||||
assert_eq!(pool.bytes, 0);
|
||||
for i in 0..2 {
|
||||
let mut mesh = SectionMesh::default();
|
||||
mesh.solid.reserve_exact(
|
||||
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() / 2 + 1,
|
||||
);
|
||||
assert_eq!(pool.put(mesh).is_some(), i == 1);
|
||||
assert!(pool.bytes <= MeshPool::BYTES);
|
||||
assert_eq!(pool.meshes.len(), 1);
|
||||
}
|
||||
pool.take();
|
||||
assert_eq!(pool.bytes, 0);
|
||||
let mut huge = SectionMesh::default();
|
||||
huge.solid.reserve_exact(
|
||||
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() + 1,
|
||||
);
|
||||
assert!(pool.put(huge).is_some());
|
||||
assert!(pool.meshes.is_empty());
|
||||
assert_eq!(pool.bytes, 0);
|
||||
}
|
||||
|
||||
/// The frame never waits for a worker: while one holds the mailbox, the
|
||||
/// frame takes nothing and finds the results, in order, the next time.
|
||||
#[test]
|
||||
@@ -2140,6 +2334,79 @@ mod tests {
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
/// The light and mesh stages submit bounded work, the nearest first:
|
||||
/// what exceeds the room stays dirty, to be chosen again — from the
|
||||
/// current centre — as capacity frees. Submitted whole, the queue once
|
||||
/// held every ready column's jobs and grew with the render distance
|
||||
/// (#3629); ties break by position, so the choice is deterministic.
|
||||
#[test]
|
||||
fn bounded_submission_takes_the_nearest_first() {
|
||||
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
|
||||
.map(|i| {
|
||||
let (x, z) = (i / 8 - 2, i % 8 - 3);
|
||||
(
|
||||
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
|
||||
x,
|
||||
z,
|
||||
1 << (i % 24),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut expected = candidates.clone();
|
||||
expected.sort_unstable();
|
||||
let mut none = candidates.clone();
|
||||
nearest_first(&mut none, 0);
|
||||
assert!(none.is_empty());
|
||||
let mut nearest = candidates;
|
||||
nearest_first(&mut nearest, 5);
|
||||
assert_eq!(nearest, expected[..5]);
|
||||
let mut past = expected.clone();
|
||||
nearest_first(&mut past, expected.len() + 10);
|
||||
assert_eq!(past, expected);
|
||||
}
|
||||
|
||||
/// What the scheduler counts as ready to light: every neighbour a
|
||||
/// finished column, or a decorated proto chunk past the area — the
|
||||
/// walk `light_region` makes without its snapshot.
|
||||
#[test]
|
||||
fn light_readiness_follows_the_region_a_light_job_reads() {
|
||||
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let chunk = Arc::new(Chunk::empty(0, 0));
|
||||
let nine = (-1..=1).flat_map(|dz| (-1..=1).map(move |dx| (dx, dz)));
|
||||
for key in nine {
|
||||
world.chunks.insert(key, chunk.clone());
|
||||
}
|
||||
// The kept area is the cross around (0, 0); the corners of the
|
||||
// 3x3 light reads lie past it.
|
||||
let in_range = |x: i32, z: i32| x.abs() + z.abs() <= 1;
|
||||
let none = FxHashSet::<(i32, i32)>::default();
|
||||
let empty = FxHashMap::<(i32, i32), Option<Proto>>::default();
|
||||
assert!(light_region_ready(&world, in_range, &none, &empty, 0, 0));
|
||||
// A corner is missing, and nothing stands in for it yet.
|
||||
world.chunks.remove(&(1, 1));
|
||||
assert!(!light_region_ready(&world, in_range, &none, &empty, 0, 0));
|
||||
// A decorated, checked-in proto chunk past the area lights against.
|
||||
let decorated: FxHashSet<(i32, i32)> = [(1, 1)].into_iter().collect();
|
||||
let mut protos: FxHashMap<(i32, i32), Option<Proto>> = Default::default();
|
||||
protos.insert((1, 1), Some(Box::new(()) as Proto));
|
||||
assert!(light_region_ready(
|
||||
&world, in_range, &decorated, &protos, 0, 0
|
||||
));
|
||||
// The proto is checked out to a job: it cannot be read.
|
||||
protos.insert((1, 1), None);
|
||||
assert!(!light_region_ready(
|
||||
&world, in_range, &decorated, &protos, 0, 0
|
||||
));
|
||||
// A neighbour inside the area waits for the chunk itself.
|
||||
world.chunks.remove(&(1, 0));
|
||||
let mut inside = decorated.clone();
|
||||
inside.insert((1, 0));
|
||||
protos.insert((1, 0), Some(Box::new(()) as Proto));
|
||||
assert!(!light_region_ready(
|
||||
&world, in_range, &inside, &protos, 0, 0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_distance_is_vanillas_shape() {
|
||||
// ChunkTrackingView.isInViewDistance at render distance 12: a column
|
||||
|
||||
@@ -27,6 +27,9 @@ struct Globals {
|
||||
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
|
||||
// triangle's volume with the camera; then each rim vertex's normal against the apex.
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
|
||||
// in texels (vanilla's TextureSize).
|
||||
filtering: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -45,14 +48,12 @@ struct Globals {
|
||||
// which then samples exactly as before.
|
||||
override SPRITE_ARRAYS: u32 = 1u;
|
||||
override SPRITE_SPLIT: u32 = 2048u;
|
||||
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSample(sprites, sprite_sampler, uv, i32(layer));
|
||||
}
|
||||
// The array is chosen per fragment: sample with the derivatives taken here.
|
||||
let dx = dpdx(uv);
|
||||
let dy = dpdy(uv);
|
||||
// A sprite sampled with the given gradients, from whichever array holds its layer.
|
||||
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
|
||||
let l = i32(layer % SPRITE_SPLIT);
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy);
|
||||
}
|
||||
switch layer / SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy); }
|
||||
case 1u: { return textureSampleGrad(sprites_1, sprite_sampler, uv, l, dx, dy); }
|
||||
@@ -64,6 +65,78 @@ fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
default: { return textureSampleGrad(sprites_7, sprite_sampler, uv, l, dx, dy); }
|
||||
}
|
||||
}
|
||||
// The same at an explicit mip level.
|
||||
fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
|
||||
let l = i32(layer % SPRITE_SPLIT);
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSampleLevel(sprites, sprite_sampler, uv, l, lod);
|
||||
}
|
||||
switch layer / SPRITE_SPLIT {
|
||||
case 0u: { return textureSampleLevel(sprites, sprite_sampler, uv, l, lod); }
|
||||
case 1u: { return textureSampleLevel(sprites_1, sprite_sampler, uv, l, lod); }
|
||||
case 2u: { return textureSampleLevel(sprites_2, sprite_sampler, uv, l, lod); }
|
||||
case 3u: { return textureSampleLevel(sprites_3, sprite_sampler, uv, l, lod); }
|
||||
case 4u: { return textureSampleLevel(sprites_4, sprite_sampler, uv, l, lod); }
|
||||
case 5u: { return textureSampleLevel(sprites_5, sprite_sampler, uv, l, lod); }
|
||||
case 6u: { return textureSampleLevel(sprites_6, sprite_sampler, uv, l, lod); }
|
||||
default: { return textureSampleLevel(sprites_7, sprite_sampler, uv, l, lod); }
|
||||
}
|
||||
}
|
||||
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
|
||||
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
|
||||
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
|
||||
texel_screen: vec2<f32>) -> vec4<f32> {
|
||||
let texel_coords = uv / pixel;
|
||||
let center = round(texel_coords) - 0.5;
|
||||
var offset = texel_coords - center;
|
||||
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
|
||||
offset = clamp(offset, vec2(0.0), vec2(1.0));
|
||||
return sprite_grad((center + offset) * pixel, layer, du, dv);
|
||||
}
|
||||
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
|
||||
// shrink below two screen pixels, over sample_nearest.
|
||||
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
|
||||
texel_screen: vec2<f32>) -> vec4<f32> {
|
||||
let max_texel = max(texel_screen.x, texel_screen.y);
|
||||
let min_pixel = min(pixel.x, pixel.y);
|
||||
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
|
||||
let du_len = length(du);
|
||||
let dv_len = length(dv);
|
||||
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
|
||||
let exact = max(0.0, log2(effective / min_pixel));
|
||||
let low = floor(exact);
|
||||
let mip_blend = fract(exact);
|
||||
var offsets = array<vec2<f32>, 4>(
|
||||
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
|
||||
var sum_low = vec4(0.0);
|
||||
var sum_high = vec4(0.0);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
let p = uv + offsets[i] * pixel;
|
||||
sum_low += sprite_level(p, layer, low);
|
||||
sum_high += sprite_level(p, layer, low + 1.0);
|
||||
}
|
||||
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
|
||||
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
|
||||
}
|
||||
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
|
||||
let mode = u32(globals.filtering.x);
|
||||
if mode != 0u {
|
||||
// Texture Filtering Anisotropic or RGSS: terrain.fsh's two sampling paths.
|
||||
let du = dpdx(uv);
|
||||
let dv = dpdy(uv);
|
||||
let pixel = vec2(1.0 / globals.filtering.y);
|
||||
let texel_screen = sqrt(du * du + dv * dv);
|
||||
if mode == 1u {
|
||||
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
|
||||
}
|
||||
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
|
||||
}
|
||||
if SPRITE_ARRAYS == 1u {
|
||||
return textureSample(sprites, sprite_sampler, uv, i32(layer));
|
||||
}
|
||||
// The array is chosen per fragment: sample with the derivatives taken here.
|
||||
return sprite_grad(uv, layer, dpdx(uv), dpdy(uv));
|
||||
}
|
||||
|
||||
struct TerrainOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@@ -302,7 +375,7 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
|
||||
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
|
||||
// Moller-Trumbore from the origin along d, over normals built once a frame
|
||||
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
|
||||
fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
fn sunrise_weight_full(d: vec3<f32>) -> f32 {
|
||||
if globals.sunrise.a <= 0.001 { return 0.0; }
|
||||
var best = 0.0;
|
||||
var k0 = dot(d, globals.sunrise_fan[16].xyz);
|
||||
@@ -322,6 +395,50 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// The rim's YZ projection is an ellipse. Comparing its unsquashed X/Y
|
||||
// magnitudes with tan(PI/8) selects one of sixteen sectors without atan2.
|
||||
fn sunrise_triangle_weight(d: vec3<f32>, i: u32) -> f32 {
|
||||
let k0 = dot(d, globals.sunrise_fan[16u+i].xyz);
|
||||
let k1 = dot(d, globals.sunrise_fan[16u+(i+1u)%16u].xyz);
|
||||
let triangle = globals.sunrise_fan[i];
|
||||
let apex = dot(d, triangle.xyz);
|
||||
let s = apex + k1 - k0;
|
||||
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0
|
||||
&& k1 * s >= 0.0 && k0 * s <= 0.0 {
|
||||
return apex / s;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
fn sunrise_weight(d: vec3<f32>) -> f32 {
|
||||
if globals.sunrise.a <= 0.001 { return 0.0; }
|
||||
if dot(d.yz, d.yz) < 1e-10 { return sunrise_weight_full(d); }
|
||||
// The camera lies in the rim plane. A forward ray can reach the fan
|
||||
// only on the apex side. Keep a guard for rounded coplanar normals.
|
||||
let apex = dot(d, globals.sunrise_fan[0].xyz);
|
||||
if apex * globals.sunrise_fan[0].w < -1e-4 { return 0.0; }
|
||||
let y = dot(d, globals.sunrise_fan[16].xyz);
|
||||
let x = -dot(d, globals.sunrise_fan[20].xyz);
|
||||
let ax = abs(x);
|
||||
let ay = abs(y);
|
||||
let tangent = 0.414213562373095;
|
||||
let quadrant_sector = select(select(0u, 1u, ay > ax * tangent),
|
||||
select(2u, 3u, ax < ay * tangent), ay > ax);
|
||||
let sector = select(select(8u + quadrant_sector, 7u - quadrant_sector, y >= 0.0),
|
||||
select(15u - quadrant_sector, quadrant_sector, y >= 0.0), x >= 0.0);
|
||||
var best = max(sunrise_triangle_weight(d, sector),
|
||||
sunrise_triangle_weight(d, (sector + 8u) % 16u));
|
||||
// Test adjacent sectors only at a rounded boundary; their exact rim
|
||||
// normals remain the authority for hit tests and interpolation weights.
|
||||
let k0 = dot(d, globals.sunrise_fan[16u+sector].xyz);
|
||||
let k1 = dot(d, globals.sunrise_fan[16u+(sector+1u)%16u].xyz);
|
||||
if min(abs(k0), abs(k1)) < max(ax, ay) * 0.001 {
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 15u) % 16u));
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 1u) % 16u));
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 7u) % 16u));
|
||||
best = max(best, sunrise_triangle_weight(d, (sector + 9u) % 16u));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
|
||||
// gfx/native_sky.rs builds that stage from this section).
|
||||
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -307,6 +307,13 @@ pub fn text_scaled(
|
||||
pub fn hovered(cursor: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
|
||||
cursor.0 >= x && cursor.0 < x + w && cursor.1 >= y && cursor.1 < y + h
|
||||
}
|
||||
/// `TextAlignment.CENTER.calculateLeft` at the box's integer centre:
|
||||
/// `x + w / 2 - width / 2`. Flooring `(w - width) / 2` sits odd-width labels
|
||||
/// one pixel left of vanilla when `w` is even.
|
||||
fn centered_left(x: f32, w: f32, width: f32) -> f32 {
|
||||
x + (w / 2.0).floor() - (width / 2.0).floor()
|
||||
}
|
||||
|
||||
/// Centered text with a shadow, scrolled when wider than `w` (AbstractWidget.renderScrollingString).
|
||||
pub fn centered_text(
|
||||
batch: &mut HudBatch,
|
||||
@@ -320,14 +327,7 @@ pub fn centered_text(
|
||||
let text = text.resolved();
|
||||
let width = atlas.font.width_text(&text);
|
||||
if width <= w {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
x + ((w - width) / 2.0).floor(),
|
||||
y,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
batch.text_styled(atlas, &text, centered_left(x, w, width), y, color, true);
|
||||
} else {
|
||||
// Clip by characters (vanilla scrolls; we show the part that fits).
|
||||
let mut shown = Text::empty();
|
||||
@@ -1289,6 +1289,9 @@ impl EditBox {
|
||||
batch.text(atlas, "_", cursor_x, ty, color, true);
|
||||
}
|
||||
}
|
||||
if self.focused {
|
||||
crate::preedit::report_caret(cursor_x, ty, 10.0);
|
||||
}
|
||||
}
|
||||
/// Character index under GUI x (mouse click).
|
||||
pub fn index_at(&self, atlas: &HudAtlas, x: f32) -> usize {
|
||||
@@ -1351,6 +1354,14 @@ mod tests {
|
||||
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn centered_label_matches_vanilla_integer_center() {
|
||||
// Even box, odd text: (w - width) / 2 floors one pixel left of w/2 - width/2.
|
||||
assert_eq!(centered_left(0.0, 200.0, 9.0), 96.0);
|
||||
assert_eq!(centered_left(0.0, 200.0, 8.0), 96.0);
|
||||
assert_eq!(centered_left(10.0, 196.0, 9.0), 104.0);
|
||||
assert_eq!(centered_left(2.0, 196.0, 7.0), 97.0);
|
||||
}
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
assert_eq!(track, "gui/sprites/widget/slider");
|
||||
|
||||
@@ -40,7 +40,7 @@ impl Zip {
|
||||
let Some(name) = archive.name_for_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.replace('\\', "/");
|
||||
let name = name.map_err(invalid)?.replace('\\', "/");
|
||||
match name.strip_suffix('/') {
|
||||
Some(folder) => folders.insert(folder.to_owned(), i),
|
||||
None => entries.insert(name, i),
|
||||
@@ -374,7 +374,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let entry = archive.by_index_raw(index).unwrap();
|
||||
(
|
||||
entry.data_start() as usize,
|
||||
entry.data_start().unwrap() as usize,
|
||||
entry.compressed_size() as usize,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#[path = "../src/gfx/sunrise_fan.rs"]
|
||||
mod sunrise_fan;
|
||||
|
||||
#[path = "../src/gfx/wgsl_prefix.rs"]
|
||||
mod wgsl_prefix;
|
||||
|
||||
fn sunrise_shader() -> String {
|
||||
let terrain = include_str!("../src/terrain.wgsl");
|
||||
let start = terrain.find("fn sunrise_weight_full").unwrap();
|
||||
let end = terrain[start..].find("// The sky's colour").unwrap() + start;
|
||||
let mut source = String::from(
|
||||
"struct Globals { sunrise: vec4<f32>, sunrise_fan: array<vec4<f32>,32> }\n\
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;\n\
|
||||
@group(0) @binding(1) var<storage,read> rays: array<vec4<f32>>;\n\
|
||||
@group(0) @binding(2) var<storage,read_write> results: array<vec2<f32>>;\n",
|
||||
);
|
||||
source.push_str(&terrain[start..end]);
|
||||
source.push_str(
|
||||
"@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {\n\
|
||||
if id.x < arrayLength(&rays) {\n\
|
||||
let d = rays[id.x].xyz;\n\
|
||||
results[id.x] = vec2(sunrise_weight_full(d), sunrise_weight(d));\n\
|
||||
}\n\
|
||||
}\n",
|
||||
);
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_sunrise_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(&sunrise_shader()).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Execute the actual optimized and reference WGSL on a host GPU.
|
||||
// This opt-in test compares shipped WGSL with the full fan search, not FPS.
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn sunrise_sectors_match_reference_on_real_gpu() {
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Sunrise GPU correctness: {}", adapter.get_info().name);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(sunrise_shader().into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut rays = Vec::<[f32; 4]>::new();
|
||||
for yaw in 0..360 {
|
||||
for pitch in -89..90 {
|
||||
let (sy, cy) = (yaw as f32).to_radians().sin_cos();
|
||||
let (sp, cp) = (pitch as f32).to_radians().sin_cos();
|
||||
rays.push([sy * cp, sp, cy * cp, 0.]);
|
||||
}
|
||||
}
|
||||
rays.extend([
|
||||
[1., 0., 0., 0.],
|
||||
[-1., 0., 0., 0.],
|
||||
[0., 1., 0., 0.],
|
||||
[0., -1., 0., 0.],
|
||||
]);
|
||||
let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&rays),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let size = (rays.len() * 8) as u64;
|
||||
let output = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
for alpha in [0.01, 0.2, 0.65, 1.] {
|
||||
for hemisphere in [-1., 1.] {
|
||||
let mut globals = vec![[1., 1., 1., alpha]];
|
||||
globals.extend(sunrise_fan::normals(
|
||||
alpha,
|
||||
glam::Vec3::new(0., 0., hemisphere),
|
||||
));
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let bindings = [&uniform, &input, &output];
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &bindings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: b.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups((rays.len() as u32).div_ceil(64), 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&output, 0, &read, 0, size);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
{
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let pairs: &[[f32; 2]] = bytemuck::cast_slice(&mapped);
|
||||
for (i, pair) in pairs.iter().enumerate() {
|
||||
assert!(
|
||||
(pair[0] - pair[1]).abs() < 1e-4,
|
||||
"alpha={alpha} hemisphere={hemisphere} ray={:?} values={pair:?}",
|
||||
rays[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
read.unmap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pack_sky_keeps_sunrise_helpers_private() {
|
||||
use wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
// Use the same complete terrain sections and prefixer as native_sky.rs.
|
||||
let sky = prefixed(
|
||||
&[
|
||||
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
|
||||
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
|
||||
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
|
||||
]
|
||||
.concat(),
|
||||
"horizon_sky_",
|
||||
);
|
||||
for name in [
|
||||
"sunrise_weight",
|
||||
"sunrise_weight_full",
|
||||
"sunrise_triangle_weight",
|
||||
] {
|
||||
assert!(sky.contains(&format!("fn horizon_sky_{name}(")));
|
||||
}
|
||||
// A native pack may declare the same helper names as the renderer.
|
||||
let own = "fn sunrise_weight(d: vec3<f32>) -> f32 { return d.x; }\n\
|
||||
fn sunrise_weight_full(d: vec3<f32>) -> f32 { return d.y; }\n\
|
||||
fn sunrise_triangle_weight(d: vec3<f32>, i: u32) -> f32 { return d.z + f32(i); }\n";
|
||||
let module = naga::front::wgsl::parse_str(&format!("{own}\n{sky}")).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_terrain_shader_still_validates_for_fallback_sky() {
|
||||
let module = naga::front::wgsl::parse_str(wgsl_prefix::TERRAIN).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -56,7 +56,7 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
@@ -64,10 +64,11 @@ horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
|
||||
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
|
||||
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
|
||||
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -96,6 +97,10 @@ audio = ["horizon-client-render/audio"]
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# 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.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
windows-segment-heap = []
|
||||
|
||||
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
|
||||
[[example]]
|
||||
|
||||
Loaded 100 of 681 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user